Added support for marking obj.prop and obj.prop() to be the same in templates (and equally obj["prop"], and obj["prop"]()). Made Java zero argument methods to be such properties by default, if incompatibleImprovements is at least 2.3.33. Added ZeroArgumentNonVoidMethodPolicy, and BeansWrapperConfiguration.nonRecordZeroArgumentNonVoidMethodPolicy, and recordZeroArgumentNonVoidMethodPolicy to implement these. Also,added GenericObjectModel which implements MethodCallAwareTemplateHashModel, and create that in BeansWrapper instead of StringModel.
diff --git a/build.gradle.kts b/build.gradle.kts
index 114ac08..82f8917 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -61,7 +61,7 @@
     configureSourceSet("jython20")
     configureSourceSet("jython22")
     configureSourceSet("jython25") { enableTests() }
-    configureSourceSet("core16", "16")
+    configureSourceSet("core16", "16") { enableTests() }
 
     configureGeneratedSourceSet("jakartaServlet") {
         val jakartaSourceGenerators = generateJakartaSources("javaxServlet")
diff --git a/freemarker-core/src/main/java/freemarker/core/Configurable.java b/freemarker-core/src/main/java/freemarker/core/Configurable.java
index fc98db5..6eaf8ed 100644
--- a/freemarker-core/src/main/java/freemarker/core/Configurable.java
+++ b/freemarker-core/src/main/java/freemarker/core/Configurable.java
@@ -2553,7 +2553,7 @@
      *      <p>If you have no constructor arguments and property setters, and the <code><i>className</i></code> class has
      *      a public static {@code INSTANCE} field, the value of that filed will be the value of the expression, and
      *      the constructor won't be called. Note that if you use the backward compatible
-     *      syntax, where these's no parenthesis after the class name, then it will not look for {@code INSTANCE}.
+     *      syntax, where there's no parenthesis after the class name, then it will not look for {@code INSTANCE}.
      *   </li>
      *   <li>
      *      <p>If there exists a class named <code><i>className</i>Builder</code>, then that class will be instantiated
diff --git a/freemarker-core/src/main/java/freemarker/core/Dot.java b/freemarker-core/src/main/java/freemarker/core/Dot.java
index 54bae57..a2d2f27 100644
--- a/freemarker-core/src/main/java/freemarker/core/Dot.java
+++ b/freemarker-core/src/main/java/freemarker/core/Dot.java
@@ -27,20 +27,29 @@
  * The dot operator. Used to reference items inside a
  * <code>TemplateHashModel</code>.
  */
-final class Dot extends Expression {
+class Dot extends Expression {
     private final Expression target;
-    private final String key;
+    protected final String key;
 
     Dot(Expression target, String key) {
         this.target = target;
         this.key = key;
     }
 
+    /**
+     * Shallow copy constructor
+     */
+    Dot(Dot dot) {
+        this(dot.target, dot.key);
+        this.constantValue = dot.constantValue; // Probably always will be null here
+        copyFieldsFrom(dot);
+    }
+
     @Override
     TemplateModel _eval(Environment env) throws TemplateException {
         TemplateModel leftModel = target.eval(env);
         if (leftModel instanceof TemplateHashModel) {
-            return ((TemplateHashModel) leftModel).get(key);
+            return evalOnHash((TemplateHashModel) leftModel);
         }
         if (leftModel == null && env.isClassicCompatible()) {
             return null; // ${noSuchVar.foo} has just printed nothing in FM 1.
@@ -48,6 +57,10 @@
         throw new NonHashException(target, leftModel, env);
     }
 
+    protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws TemplateException {
+        return leftModel.get(key);
+    }
+
     @Override
     public String getCanonicalForm() {
         return target.getCanonicalForm() + getNodeTypeSymbol() + _CoreStringUtils.toFTLIdentifierReferenceAfterDot(key);
diff --git a/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java
new file mode 100644
index 0000000..17ae026
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import freemarker.ext.beans.BeansWrapper;
+import freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy;
+import freemarker.template.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateModel;
+
+/**
+ * Like {@link Dot}, but when used before method call (but as of 2.3.33, before 0-argument calls only), as in
+ * {@code obj.key()}. The reason it's only used before 0-argument calls (as of 2.3.33 at least) is that it adds some
+ * overhead, and this {@link Dot} subclass was added to implement
+ * {@link ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}
+ * (via {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}). We don't
+ * necessarily want to go beyond that hack, as we don't have separate method namespace in the template language.
+ */
+class DotBeforeMethodCall extends Dot {
+    public DotBeforeMethodCall(Dot dot) {
+        super(dot);
+    }
+
+    @Override
+    protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws TemplateException {
+        if (leftModel instanceof MethodCallAwareTemplateHashModel) {
+            try {
+                return ((MethodCallAwareTemplateHashModel) leftModel).getBeforeMethodCall(key);
+            } catch (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
+                String hint = e.getHint();
+                throw new NonMethodException(
+                        this,
+                        e.getActualValue(),
+                        hint != null ? new String[] { hint } : null,
+                        Environment.getCurrentEnvironment());
+            }
+        } else {
+            return super.evalOnHash(leftModel);
+        }
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java b/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
index f8cef3a..d2fa822 100644
--- a/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
+++ b/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
@@ -43,7 +43,7 @@
  * {@code target[keyExpression]}, where, in FM 2.3, {@code keyExpression} can be string, a number or a range,
  * and {@code target} can be a hash or a sequence.
  */
-final class DynamicKeyName extends Expression {
+class DynamicKeyName extends Expression {
 
     private static final int UNKNOWN_RESULT_SIZE = -1;
 
@@ -58,6 +58,13 @@
         target.enableLazilyGeneratedResult();
     }
 
+    DynamicKeyName(DynamicKeyName dynamicKeyName) {
+        this(dynamicKeyName.target, dynamicKeyName.keyExpression);
+        this.lazilyGeneratedResultEnabled = dynamicKeyName.lazilyGeneratedResultEnabled;
+        this.constantValue = dynamicKeyName.constantValue; // Probably always will be null here
+        copyFieldsFrom(dynamicKeyName);
+    }
+
     @Override
     TemplateModel _eval(Environment env) throws TemplateException {
         TemplateModel targetModel = target.eval(env);
@@ -163,11 +170,16 @@
     private TemplateModel dealWithStringKey(TemplateModel targetModel, String key, Environment env)
         throws TemplateException {
         if (targetModel instanceof TemplateHashModel) {
-            return((TemplateHashModel) targetModel).get(key);
+            return getFromHashModelWithStringKey((TemplateHashModel) targetModel, key);
         }
         throw new NonHashException(target, targetModel, env);
     }
 
+    protected TemplateModel getFromHashModelWithStringKey(TemplateHashModel targetModel, String key)
+            throws TemplateException {
+        return targetModel.get(key);
+    }
+
     private TemplateModel dealWithRangeKey(TemplateModel targetModel, RangeModel range, Environment env)
     throws TemplateException {
         // We can have 3 kind of left hand operands ("targets"): sequence, lazily generated sequence, string
diff --git a/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java b/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java
new file mode 100644
index 0000000..f9678ca
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import freemarker.template.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateModel;
+
+class DynamicKeyNameBeforeMethodCall extends DynamicKeyName {
+    DynamicKeyNameBeforeMethodCall(DynamicKeyName dynamicKeyName) {
+        super(dynamicKeyName);
+    }
+
+    @Override
+    protected TemplateModel getFromHashModelWithStringKey(TemplateHashModel targetModel, String key)
+            throws TemplateException {
+        if (targetModel instanceof MethodCallAwareTemplateHashModel) {
+            try {
+                return ((MethodCallAwareTemplateHashModel) targetModel).getBeforeMethodCall(key);
+            } catch (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
+                String hint = e.getHint();
+                throw new NonMethodException(
+                        this,
+                        e.getActualValue(),
+                        hint != null ? new String[] { hint } : null,
+                        Environment.getCurrentEnvironment());
+            }
+        } else {
+            return super.getFromHashModelWithStringKey(targetModel, key);
+        }
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/core/MethodCall.java b/freemarker-core/src/main/java/freemarker/core/MethodCall.java
index 2ece2a0..1eebbe5 100644
--- a/freemarker-core/src/main/java/freemarker/core/MethodCall.java
+++ b/freemarker-core/src/main/java/freemarker/core/MethodCall.java
@@ -66,6 +66,9 @@
         } else {
             throw new NonMethodException(target, targetModel, true, false, null, env);
         }
+        // ATTENTION! If you add support for calling any new type, ensure that
+        // freemarker.ext.beans.BeanModel.invokeThroughDescriptor sees that type as callable too,
+        // where it deals with the beforeMethodCall logic!
     }
 
     @Override
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
index 4580ac8..2bc875e 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
@@ -19,6 +19,10 @@
 
 package freemarker.ext.beans;
 
+import freemarker.template.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
 /**
  * Exposes the Java API (and properties) of an object.
  * 
@@ -32,7 +36,7 @@
  * 
  * @since 2.3.22
  */
-final class APIModel extends BeanModel {
+final class APIModel extends BeanModel implements MethodCallAwareTemplateHashModel {
 
     APIModel(Object object, BeansWrapper wrapper) {
         super(object, wrapper, false);
@@ -41,5 +45,10 @@
     protected boolean isMethodsShadowItems() {
         return true;
     }
-    
+
+    @Override
+    public TemplateModel getBeforeMethodCall(String key) throws TemplateModelException,
+            ShouldNotBeGetAsMethodException {
+        return super.getBeforeMethodCall(key);
+    }
 }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
index 79110b9..c983ae0 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
@@ -30,7 +30,9 @@
 import java.util.Map;
 import java.util.Set;
 
+import freemarker.core.BugException;
 import freemarker.core.CollectionAndSequence;
+import freemarker.core.Macro;
 import freemarker.core._DelayedFTLTypeDescription;
 import freemarker.core._DelayedJQuote;
 import freemarker.core._TemplateModelException;
@@ -38,16 +40,20 @@
 import freemarker.ext.util.WrapperTemplateModel;
 import freemarker.log.Logger;
 import freemarker.template.AdapterTemplateModel;
+import freemarker.template.MethodCallAwareTemplateHashModel;
 import freemarker.template.ObjectWrapper;
 import freemarker.template.SimpleScalar;
 import freemarker.template.SimpleSequence;
 import freemarker.template.TemplateCollectionModel;
 import freemarker.template.TemplateHashModelEx;
+import freemarker.template.TemplateMethodModel;
+import freemarker.template.TemplateMethodModelEx;
 import freemarker.template.TemplateModel;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateModelIterator;
 import freemarker.template.TemplateModelWithAPISupport;
 import freemarker.template.TemplateScalarModel;
+import freemarker.template.utility.CollectionUtils;
 import freemarker.template.utility.StringUtil;
 
 /**
@@ -134,21 +140,55 @@
      * then {@code non-void-return-type get(java.lang.Object)}, or 
      * alternatively (if the wrapped object is a resource bundle) 
      * {@code Object getObject(java.lang.String)}.
+     *
+     * <p>As of 2.3.33, the default implementation of this method delegates to {@link #get(String, boolean)}. It's
+     * better to override that, instead of this method. Otherwise, unwanted behavior can arise if the model class also
+     * implements {@link MethodCallAwareTemplateHashModel}, as that will certainly call {@link #get(String, boolean)}
+     * internally, and not the overridden version of this method.
+     *
      * @throws TemplateModelException if there was no property nor method nor
      * a generic {@code get} method to invoke.
      */
     @Override
-    public TemplateModel get(String key)
-        throws TemplateModelException {
+    public TemplateModel get(String key) throws TemplateModelException {
+        try {
+            return get(key, false);
+        } catch (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
+            throw new BugException(e);
+        }
+    }
+
+    /**
+     * Override this if you want to customize the behavior of {@link #get(String)}.
+     * In standard implementations at least, this is what {@link #get(String)}, and
+     * {@link MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} delegates to.
+     *
+     * @param key
+     *      Same as the parameter of {@link #get(String)}.
+     * @param beforeMethodCall
+     *      This is a hint that tells that the returned value will be called in the template. This was added to
+     *      implement {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}.
+     *      This parameter is {@code false} when {@link #get(String)} is called, and
+     *      {@code true} when {@link MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} is called.
+     *      If this is {@code true}, this method should return a {@link TemplateMethodModelEx}, or {@code null},
+     *      or fail with {@link MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException}.
+     *
+     * @since 2.3.33
+     */
+    // Before calling this from FreeMarker classes, consider that some users may have overridden {@link #get(String)}
+    // instead, as this class didn't exist before 2.3.33. So with incompatibleImprovements before that, that should be
+    // the only place where this gets called, or else the behavior of the model will be inconsistent.
+    protected TemplateModel get(String key, boolean beforeMethodCall)
+            throws TemplateModelException, MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
         Class<?> clazz = object.getClass();
         Map<Object, Object> classInfo = wrapper.getClassIntrospector().get(clazz);
         TemplateModel retval = null;
-        
+
         try {
             if (wrapper.isMethodsShadowItems()) {
                 Object fd = classInfo.get(key);
                 if (fd != null) {
-                    retval = invokeThroughDescriptor(fd, classInfo);
+                    retval = invokeThroughDescriptor(fd, classInfo, beforeMethodCall);
                 } else {
                     retval = invokeGenericGet(classInfo, clazz, key);
                 }
@@ -160,7 +200,7 @@
                 }
                 Object fd = classInfo.get(key);
                 if (fd != null) {
-                    retval = invokeThroughDescriptor(fd, classInfo);
+                    retval = invokeThroughDescriptor(fd, classInfo, beforeMethodCall);
                     if (retval == UNKNOWN && model == nullModel) {
                         // This is the (somewhat subtle) case where the generic get() returns null
                         // and we have no bean info, so we respect the fact that
@@ -178,7 +218,7 @@
                 retval = wrapper.wrap(null);
             }
             return retval;
-        } catch (TemplateModelException e) {
+        } catch (TemplateModelException | MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
             throw e;
         } catch (Exception e) {
             throw new _TemplateModelException(e,
@@ -189,6 +229,24 @@
         }
     }
 
+    /**
+     * Can be overridden to be public, to implement {@link MethodCallAwareTemplateHashModel}. We don't implement that
+     * in {@link BeanModel} for backward compatibility, but the functionality is present. If you expose this method by
+     * implementing {@link MethodCallAwareTemplateHashModel}, then be sure that {@link #get(String)} is
+     * not overridden in custom subclasses; if it is, then those subclasses should be modernized to override
+     * {@link #get(String, boolean)} instead.
+     *
+     * @since 2.3.33
+     */
+    protected TemplateModel getBeforeMethodCall(String key)
+            throws TemplateModelException, MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
+        TemplateModel result = get(key, true);
+        if (result instanceof  TemplateMethodModelEx || result == null) {
+            return result;
+        }
+        throw new MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException(result, null);
+    }
+
     private void logNoSuchKey(String key, Map<?, ?> keyMap) {
         LOG.debug("Key " + StringUtil.jQuoteNoXSS(key) + " was not found on instance of " + 
             object.getClass().getName() + ". Introspection information for " +
@@ -203,8 +261,9 @@
         return wrapper.getClassIntrospector().get(object.getClass()).get(ClassIntrospector.GENERIC_GET_KEY) != null;
     }
     
-    private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, Object> classInfo)
-            throws IllegalAccessException, InvocationTargetException, TemplateModelException {
+    private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, Object> classInfo, boolean beforeMethodCall)
+            throws IllegalAccessException, InvocationTargetException, TemplateModelException,
+            MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
         // See if this particular instance has a cached implementation for the requested feature descriptor
         TemplateModel cachedModel;
         synchronized (this) {
@@ -215,6 +274,9 @@
             return cachedModel;
         }
 
+        // ATTENTION! As the value of beforeMethodCall is not part of the cache lookup key, it's very important that we
+        // don't cache the value for desc-s where beforeMethodCall can have influence on the result!
+
         TemplateModel resultModel = UNKNOWN;
         if (desc instanceof FastPropertyDescriptor) {
             FastPropertyDescriptor pd = (FastPropertyDescriptor) desc;
@@ -229,8 +291,30 @@
                                 ClassIntrospector.getArgTypes(classInfo, indexedReadMethod), wrapper);
                 }
             } else {
-                resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null);
-                // cachedModel remains null, as we don't cache these
+                // cachedModel must remains null in this branch, because the result is influenced by beforeMethodCall,
+                // which wasn't part of the cache key!
+
+                if (!beforeMethodCall) {
+                    resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null);
+                    // cachedModel remains null, as we don't cache these
+                } else {
+                    if (pd.isMethodInsteadOfPropertyValueBeforeCall()) {
+                        // Do not cache this result! See comments earlier!
+                        resultModel = new SimpleMethodModel(
+                                object, pd.getReadMethod(), CollectionUtils.EMPTY_CLASS_ARRAY, wrapper);
+                    } else {
+                        resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null);
+
+                        // Checks if freemarker.core.MethodCall would accept this result:
+                        if (!(resultModel instanceof TemplateMethodModel || resultModel instanceof Macro)) {
+                            throw new MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException(
+                                    resultModel,
+                                    "This member of the parent object is seen by templates as a property of it "
+                                            + "(with other words, an attribute, or a field), not a method of it. "
+                                            + "Thus, to get its value, it must not be called as a method.");
+                        }
+                    }
+                }
             }
         } else if (desc instanceof Field) {
             resultModel = wrapper.readField(object, (Field) desc);
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
index be2455e..0e20266 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
@@ -19,6 +19,7 @@
 
 package freemarker.ext.beans;
 
+import java.beans.IntrospectionException;
 import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
 import java.lang.reflect.AccessibleObject;
@@ -153,7 +154,7 @@
      * performance. In theory that's not needed, but apps might fail to keep the rules.
      */
     private ClassIntrospector classIntrospector;
-    
+
     /**
      * {@link String} class name to {@link StaticModel} cache.
      * This object only belongs to a single {@link BeansWrapper}.
@@ -193,9 +194,10 @@
     private boolean simpleMapWrapper;  // initialized from the BeansWrapperConfiguration
     private boolean strict;  // initialized from the BeansWrapperConfiguration
     private boolean preferIndexedReadMethod; // initialized from the BeansWrapperConfiguration
-    
+
     private final Version incompatibleImprovements;
-    
+
+
     /**
      * Creates a new instance with the incompatible-improvements-version specified in
      * {@link Configuration#DEFAULT_INCOMPATIBLE_IMPROVEMENTS}.
@@ -262,6 +264,16 @@
      *       The default of the {@link #setPreferIndexedReadMethod(boolean) preferIndexedReadMethod} setting changes
      *       from {@code true} to {@code false}.
      *     </li>  
+     *     <li>
+     *       <p>2.3.33 (or higher):
+     *       The default of {@link BeansWrapper#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}
+     *       has changed to {@link ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}, from
+     *       {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}. This means that Java records public methods with
+     *       0-arguments and non-void return type are now exposed both as properties, and as methods, while earlier they
+     *       were only exposed as methods. That is, if in a record you have {@code public String name()}, now in
+     *       templates the value can be accessed both as {@code obj.name} (like a property), and as {@code obj.name()}
+     *       (for better backward compatibility only - it's bad style).
+     *     </li>
      *   </ul>
      *   
      *   <p>Note that the version will be normalized to the lowest version where the same incompatible
@@ -289,7 +301,7 @@
     }
     
     /**
-     * Initializes the instance based on the the {@link BeansWrapperConfiguration} specified.
+     * Initializes the instance based on the {@link BeansWrapperConfiguration} specified.
      * 
      * @param writeProtected Makes the instance's configuration settings read-only via
      *     {@link WriteProtectable#writeProtect()}; this way it can use the shared class introspection cache.
@@ -320,7 +332,7 @@
                 }
             } catch (Throwable e) {
                 // The security manager sometimes doesn't allow this
-                LOG.info("Failed to check if finetuneMethodAppearance is overidden in " + thisClass.getName()
+                LOG.info("Failed to check if finetuneMethodAppearance is overridden in " + thisClass.getName()
                         + "; acting like if it was, but this way it won't utilize the shared class introspection "
                         + "cache.",
                         e);
@@ -353,7 +365,7 @@
         defaultDateType = bwConf.getDefaultDateType();
         outerIdentity = bwConf.getOuterIdentity() != null ? bwConf.getOuterIdentity() : this;
         strict = bwConf.isStrict();
-        
+
         if (!writeProtected) {
             // As this is not a read-only BeansWrapper, the classIntrospector will be possibly replaced for a few times,
             // but we need to use the same sharedInrospectionLock forever, because that's what the model factories
@@ -367,7 +379,7 @@
             classIntrospector = _BeansAPI.getClassIntrospectorBuilder(bwConf).build();
             sharedIntrospectionLock = classIntrospector.getSharedLock(); 
         }
-        
+
         falseModel = new BooleanModel(Boolean.FALSE, this);
         trueModel = new BooleanModel(Boolean.TRUE, this);
         
@@ -633,7 +645,45 @@
             replaceClassIntrospector(builder);
         }
     }
-    
+
+    /**
+     * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are not Java records;
+     * defaults to {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}.
+     *
+     * <p>Note that methods in this class are inherited by {@link DefaultObjectWrapper}, which is what you normally use.
+     *
+     * @since 2.3.33
+     */
+    public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) {
+        checkModifiable();
+
+        if (classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy() != nonRecordZeroArgumentNonVoidMethodPolicy) {
+            ClassIntrospectorBuilder builder = classIntrospector.createBuilder();
+            builder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy);
+            replaceClassIntrospector(builder);
+        }
+    }
+
+    /**
+     * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are Java records; if the
+     * {@code BeansWrapper#BeansWrapper(Version) incompatibleImprovements} of the object wrapper is at least 2.3.33,
+     * then this defaults to {@link ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}, otherwise this defaults
+     * to {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}.
+     *
+     * <p>Note that methods in this class are inherited by {@link DefaultObjectWrapper}, which is what you normally use.
+     *
+     * @since 2.3.33
+     */
+    public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) {
+        checkModifiable();
+
+        if (classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy() != recordZeroArgumentNonVoidMethodPolicy) {
+            ClassIntrospectorBuilder builder = classIntrospector.createBuilder();
+            builder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy);
+            replaceClassIntrospector(builder);
+        }
+    }
+
     /**
      * Returns whether exposure of public instance fields of classes is 
      * enabled. See {@link #setExposeFields(boolean)} for details.
@@ -651,7 +701,25 @@
     public boolean getTreatDefaultMethodsAsBeanMembers() {
         return classIntrospector.getTreatDefaultMethodsAsBeanMembers();
     }
-    
+
+    /**
+     * See {@link #setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link #setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
     public MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return classIntrospector.getMethodAppearanceFineTuner();
     }
@@ -865,7 +933,7 @@
     /**
      * Returns the version given with {@link #BeansWrapper(Version)}, normalized to the lowest version where a change
      * has occurred. Thus, this is not necessarily the same version than that was given to the constructor.
-     * 
+     *
      * @since 2.3.21
      */
     public Version getIncompatibleImprovements() {
@@ -894,7 +962,8 @@
      */
     protected static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) {
         _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements);
-        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 ? Configuration.VERSION_2_3_27
+        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33
+                : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 ? Configuration.VERSION_2_3_27
                 : incompatibleImprovements.intValue() == _VersionInts.V_2_3_26 ? Configuration.VERSION_2_3_26
                 : is2324Bugfixed(incompatibleImprovements) ? Configuration.VERSION_2_3_24
                 : is2321Bugfixed(incompatibleImprovements) ? Configuration.VERSION_2_3_21
@@ -937,7 +1006,7 @@
      * <li>if the object is an Iterator, returns a {@link IteratorModel} for it
      * <li>if the object is an Enumeration, returns a {@link EnumerationModel} for it
      * <li>if the object is a String, returns a {@link StringModel} for it
-     * <li>otherwise, returns a generic {@link StringModel} for it.
+     * <li>otherwise, returns a {@link GenericObjectModel} for it.
      * </ul>
      */
     @Override
@@ -1033,7 +1102,7 @@
         if (clazz.isArray()) {
             return ArrayModel.FACTORY;
         }
-        return StringModel.FACTORY;
+        return GenericObjectModel.FACTORY;
     }
 
     /**
@@ -1855,15 +1924,33 @@
      */
     static public final class MethodAppearanceDecision {
         private PropertyDescriptor exposeAsProperty;
+        private boolean methodInsteadOfPropertyValueBeforeCall;
         private boolean replaceExistingProperty;
         private String exposeMethodAs;
         private boolean methodShadowsProperty;
-        
-        void setDefaults(Method m) {
-            exposeAsProperty = null;
-            replaceExistingProperty = false;
-            exposeMethodAs = m.getName();
+
+        /**
+         * @param appliedZeroArgumentNonVoidMethodPolicy
+         *      {@code null} if this is not a zero argument method with non-void return type.
+         */
+        void setDefaults(Method m, ZeroArgumentNonVoidMethodPolicy appliedZeroArgumentNonVoidMethodPolicy) {
+            if (appliedZeroArgumentNonVoidMethodPolicy != null
+                    && appliedZeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY) {
+                try {
+                    exposeAsProperty = new PropertyDescriptor(m.getName(), m, null);
+                } catch (IntrospectionException e) {
+                    throw new BugException("Failed to create PropertyDescriptor for " + m, e);
+                }
+                methodInsteadOfPropertyValueBeforeCall = appliedZeroArgumentNonVoidMethodPolicy ==
+                        ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD;
+            } else {
+                exposeAsProperty = null;
+                methodInsteadOfPropertyValueBeforeCall = false;
+            }
+            exposeMethodAs = appliedZeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY
+                    ? m.getName() : null;
             methodShadowsProperty = true;
+            replaceExistingProperty = false;
         }
         
         /**
@@ -1935,6 +2022,23 @@
             this.methodShadowsProperty = shadowEarlierProperty;
         }
 
+        /**
+         * See in the documentation of {@link MethodAppearanceFineTuner#process}.
+         *
+         * @since 2.3.33
+         */
+        public boolean isMethodInsteadOfPropertyValueBeforeCall() {
+            return methodInsteadOfPropertyValueBeforeCall;
+        }
+
+        /**
+         * See in the documentation of {@link MethodAppearanceFineTuner#process}.
+         *
+         * @since 2.3.33
+         */
+        public void setMethodInsteadOfPropertyValueBeforeCall(boolean methodInsteadOfPropertyValueBeforeCall) {
+            this.methodInsteadOfPropertyValueBeforeCall = methodInsteadOfPropertyValueBeforeCall;
+        }
     }
     
     /**
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
index 5daaa90..a49fabb 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
@@ -251,6 +251,42 @@
         classIntrospectorBuilder.setTreatDefaultMethodsAsBeanMembers(treatDefaultMethodsAsBeanMembers);
     }
 
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospectorBuilder.getNonRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link BeansWrapper#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * <p>Note that methods in this class are inherited by {@link DefaultObjectWrapperBuilder}, which is what you normally use.
+     *
+     * @since 2.3.33
+     */
+    public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) {
+        classIntrospectorBuilder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy);
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospectorBuilder.getRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link BeansWrapper#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}
+     *
+     * <p>Note that methods in this class are inherited by {@link DefaultObjectWrapperBuilder}, which is what you normally use.
+     *
+     * @since 2.3.33
+     */
+    public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) {
+        classIntrospectorBuilder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy);
+    }
+
     public MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return classIntrospectorBuilder.getMethodAppearanceFineTuner();
     }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
index bf86781..bc4a5dd 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
@@ -48,11 +48,13 @@
 import java.util.concurrent.ConcurrentHashMap;
 
 import freemarker.core.BugException;
+import freemarker.core._JavaVersions;
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecision;
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecisionInput;
 import freemarker.ext.util.ModelCache;
 import freemarker.log.Logger;
 import freemarker.template.Version;
+import freemarker.template.utility.CollectionUtils;
 import freemarker.template.utility.NullArgumentException;
 import freemarker.template.utility.SecurityUtilities;
 
@@ -82,7 +84,7 @@
     private static final ExecutableMemberSignature GET_OBJECT_SIGNATURE =
             new ExecutableMemberSignature("get", new Class[] { Object.class });
     private static final ExecutableMemberSignature TO_STRING_SIGNATURE =
-            new ExecutableMemberSignature("toString", new Class[0]);
+            new ExecutableMemberSignature("toString", CollectionUtils.EMPTY_CLASS_ARRAY);
 
     /**
      * When this property is true, some things are stricter. This is mostly to catch suspicious things in development
@@ -151,6 +153,9 @@
     final MethodAppearanceFineTuner methodAppearanceFineTuner;
     final MethodSorter methodSorter;
     final boolean treatDefaultMethodsAsBeanMembers;
+    final ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy;
+    final ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy;
+    final private boolean recordAware;
     final Version incompatibleImprovements;
 
     /** See {@link #getHasSharedInstanceRestrictions()} */
@@ -192,6 +197,14 @@
         this.methodAppearanceFineTuner = builder.getMethodAppearanceFineTuner();
         this.methodSorter = builder.getMethodSorter();
         this.treatDefaultMethodsAsBeanMembers = builder.getTreatDefaultMethodsAsBeanMembers();
+        this.nonRecordZeroArgumentNonVoidMethodPolicy = builder.getNonRecordZeroArgumentNonVoidMethodPolicy();
+        this.recordZeroArgumentNonVoidMethodPolicy = builder.getRecordZeroArgumentNonVoidMethodPolicy();
+        this.recordAware = nonRecordZeroArgumentNonVoidMethodPolicy != recordZeroArgumentNonVoidMethodPolicy;
+        if (recordAware && _JavaVersions.JAVA_16 == null) {
+            throw new IllegalArgumentException(
+                    "nonRecordZeroArgumentNonVoidMethodPolicy != recordZeroArgumentNonVoidMethodPolicy, " +
+                    "but Java 16 support is not available.");
+        }
         this.incompatibleImprovements = builder.getIncompatibleImprovements();
 
         this.sharedLock = sharedLock;
@@ -329,13 +342,26 @@
             Map<ExecutableMemberSignature, List<Method>> accessibleMethods,
             ClassMemberAccessPolicy effClassMemberAccessPolicy) throws IntrospectionException {
         BeanInfo beanInfo = Introspector.getBeanInfo(clazz);
+
+        boolean treatClassAsRecord = recordAware && _JavaVersions.JAVA_16.isRecord(clazz);
+        ZeroArgumentNonVoidMethodPolicy zeroArgumentNonVoidMethodPolicy = treatClassAsRecord
+                ? recordZeroArgumentNonVoidMethodPolicy
+                : nonRecordZeroArgumentNonVoidMethodPolicy;
+
+        // For real Java Beans properties only, used to exclude them from creating fake properties based on ZeroArgumentNonVoidMethod.
+        Set<String> beanPropertyReadMethodNameCollector = zeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY
+                ? new HashSet<>()
+                : null;
+
         List<PropertyDescriptor> pdas = getPropertyDescriptors(beanInfo, clazz);
         int pdasLength = pdas.size();
         // Reverse order shouldn't mater, but we keep it to not risk backward incompatibility.
         for (int i = pdasLength - 1; i >= 0; --i) {
             addPropertyDescriptorToClassIntrospectionData(
-                    introspData, pdas.get(i),
-                    accessibleMethods, effClassMemberAccessPolicy);
+                    introspData, pdas.get(i), false,
+                    accessibleMethods,
+                    beanPropertyReadMethodNameCollector,
+                    effClassMemberAccessPolicy);
         }
 
         if (exposureLevel < BeansWrapper.EXPOSE_PROPERTIES_ONLY) {
@@ -348,7 +374,11 @@
             for (int i = mdsSize - 1; i >= 0; --i) {
                 final Method method = getMatchingAccessibleMethod(mds.get(i).getMethod(), accessibleMethods);
                 if (method != null && effClassMemberAccessPolicy.isMethodExposed(method)) {
-                    decision.setDefaults(method);
+                    ZeroArgumentNonVoidMethodPolicy appliedZeroArgumentNonVoidMethodPolicy =
+                            getAppliedZeroArgumentNonVoidMethodPolicy(
+                                    method, beanPropertyReadMethodNameCollector, zeroArgumentNonVoidMethodPolicy);
+
+                    decision.setDefaults(method, appliedZeroArgumentNonVoidMethodPolicy);
                     if (methodAppearanceFineTuner != null) {
                         if (decisionInput == null) {
                             decisionInput = new MethodAppearanceDecisionInput();
@@ -359,24 +389,31 @@
                         methodAppearanceFineTuner.process(decisionInput, decision);
                     }
 
+                    String exposedMethodName = decision.getExposeMethodAs();
+
                     PropertyDescriptor propDesc = decision.getExposeAsProperty();
                     if (propDesc != null &&
                             (decision.getReplaceExistingProperty()
                                     || !(introspData.get(propDesc.getName()) instanceof FastPropertyDescriptor))) {
+                        boolean methodInsteadOfPropertyValueBeforeCall = decision.isMethodInsteadOfPropertyValueBeforeCall();
                         addPropertyDescriptorToClassIntrospectionData(
-                                introspData, propDesc, accessibleMethods, effClassMemberAccessPolicy);
+                                introspData, propDesc, methodInsteadOfPropertyValueBeforeCall,
+                                accessibleMethods, null, effClassMemberAccessPolicy);
+                        if (methodInsteadOfPropertyValueBeforeCall
+                                && exposedMethodName != null && exposedMethodName.equals(propDesc.getName())) {
+                            exposedMethodName = null; // We have already exposed this as property with the method name
+                        }
                     }
 
-                    String methodKey = decision.getExposeMethodAs();
-                    if (methodKey != null) {
-                        Object previous = introspData.get(methodKey);
+                    if (exposedMethodName != null) {
+                        Object previous = introspData.get(exposedMethodName);
                         if (previous instanceof Method) {
                             // Overloaded method - replace Method with a OverloadedMethods
                             OverloadedMethods overloadedMethods =
                                     new OverloadedMethods(is2321Bugfixed());
                             overloadedMethods.addMethod((Method) previous);
                             overloadedMethods.addMethod(method);
-                            introspData.put(methodKey, overloadedMethods);
+                            introspData.put(exposedMethodName, overloadedMethods);
                             // Remove parameter type information (unless an indexed property reader needs it):
                             if (argTypesUsedByIndexerPropReaders == null
                                     || !argTypesUsedByIndexerPropReaders.containsKey(previous)) {
@@ -388,7 +425,7 @@
                         } else if (decision.getMethodShadowsProperty()
                                 || !(previous instanceof FastPropertyDescriptor)) {
                             // Simple method (so far)
-                            introspData.put(methodKey, method);
+                            introspData.put(exposedMethodName, method);
                             Class<?>[] replaced = getArgTypesByMethod(introspData).put(method,
                                     method.getParameterTypes());
                             if (replaced != null) {
@@ -404,6 +441,16 @@
         } // end if (exposureLevel < EXPOSE_PROPERTIES_ONLY)
     }
 
+    private static ZeroArgumentNonVoidMethodPolicy getAppliedZeroArgumentNonVoidMethodPolicy(Method method, Set<String> beanPropertyReadMethodNameCollector, ZeroArgumentNonVoidMethodPolicy zeroArgumentNonVoidMethodPolicy) {
+        if (method.getParameterCount() == 0 && method.getReturnType() != void.class) {
+            return beanPropertyReadMethodNameCollector != null && beanPropertyReadMethodNameCollector.contains(method.getName())
+                    ? ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY
+                    : zeroArgumentNonVoidMethodPolicy;
+        } else {
+            return null;
+        }
+    }
+
     /**
      * Very similar to {@link BeanInfo#getPropertyDescriptors()}, but can deal with Java 8 default methods too.
      */
@@ -673,8 +720,9 @@
     }
 
     private void addPropertyDescriptorToClassIntrospectionData(Map<Object, Object> introspData,
-            PropertyDescriptor pd,
+            PropertyDescriptor pd, boolean methodInsteadOfPropertyValueBeforeCall,
             Map<ExecutableMemberSignature, List<Method>> accessibleMethods,
+            Set<String> beanPropertyReadMethodNameCollector,
             ClassMemberAccessPolicy effClassMemberAccessPolicy) {
         Method readMethod = getMatchingAccessibleMethod(pd.getReadMethod(), accessibleMethods);
         if (readMethod != null && !effClassMemberAccessPolicy.isMethodExposed(readMethod)) {
@@ -697,7 +745,13 @@
         }
         
         if (readMethod != null || indexedReadMethod != null) {
-            introspData.put(pd.getName(), new FastPropertyDescriptor(readMethod, indexedReadMethod));
+            introspData.put(pd.getName(), new FastPropertyDescriptor(
+                    readMethod, indexedReadMethod,
+                    methodInsteadOfPropertyValueBeforeCall));
+        }
+
+        if (readMethod != null && beanPropertyReadMethodNameCollector != null) {
+            beanPropertyReadMethodNameCollector.add(readMethod.getName());
         }
     }
 
@@ -1076,6 +1130,14 @@
         return treatDefaultMethodsAsBeanMembers;
     }
 
+    ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() {
+        return recordZeroArgumentNonVoidMethodPolicy;
+    }
+
     MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return methodAppearanceFineTuner;
     }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
index 76e4231..24ad273 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
@@ -26,6 +26,7 @@
 import java.util.Iterator;
 import java.util.Map;
 
+import freemarker.core._JavaVersions;
 import freemarker.template.Configuration;
 import freemarker.template.Version;
 import freemarker.template._TemplateAPI;
@@ -46,6 +47,8 @@
     private boolean exposeFields;
     private MemberAccessPolicy memberAccessPolicy;
     private boolean treatDefaultMethodsAsBeanMembers;
+    private ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy;
+    private ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy;
     private MethodAppearanceFineTuner methodAppearanceFineTuner;
     private MethodSorter methodSorter;
     // Attention:
@@ -60,6 +63,8 @@
         exposeFields = ci.exposeFields;
         memberAccessPolicy = ci.memberAccessPolicy;
         treatDefaultMethodsAsBeanMembers = ci.treatDefaultMethodsAsBeanMembers;
+        nonRecordZeroArgumentNonVoidMethodPolicy = ci.nonRecordZeroArgumentNonVoidMethodPolicy;
+        recordZeroArgumentNonVoidMethodPolicy = ci.recordZeroArgumentNonVoidMethodPolicy;
         methodAppearanceFineTuner = ci.methodAppearanceFineTuner;
         methodSorter = ci.methodSorter;
     }
@@ -69,15 +74,18 @@
         // change in the BeansWrapper.normalizeIncompatibleImprovements results. That is, this class may don't react
         // to some version changes that affects BeansWrapper, but not the other way around.
         this.incompatibleImprovements = normalizeIncompatibleImprovementsVersion(incompatibleImprovements);
-        treatDefaultMethodsAsBeanMembers
-                = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_26;
+        treatDefaultMethodsAsBeanMembers = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_26;
+        nonRecordZeroArgumentNonVoidMethodPolicy = ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY;
+        recordZeroArgumentNonVoidMethodPolicy = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 && _JavaVersions.JAVA_16 != null
+                ? ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD : ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY;
         memberAccessPolicy = DefaultMemberAccessPolicy.getInstance(this.incompatibleImprovements);
     }
 
     private static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) {
         _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements);
         // All breakpoints here must occur in BeansWrapper.normalizeIncompatibleImprovements!
-        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 ? Configuration.VERSION_2_3_30
+        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33
+                : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 ? Configuration.VERSION_2_3_30
                 : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_21 ? Configuration.VERSION_2_3_21
                 : Configuration.VERSION_2_3_0;
     }
@@ -98,6 +106,8 @@
         result = prime * result + incompatibleImprovements.hashCode();
         result = prime * result + (exposeFields ? 1231 : 1237);
         result = prime * result + (treatDefaultMethodsAsBeanMembers ? 1231 : 1237);
+        result = prime * result + nonRecordZeroArgumentNonVoidMethodPolicy.hashCode();
+        result = prime * result + recordZeroArgumentNonVoidMethodPolicy.hashCode();
         result = prime * result + exposureLevel;
         result = prime * result + memberAccessPolicy.hashCode();
         result = prime * result + System.identityHashCode(methodAppearanceFineTuner);
@@ -115,6 +125,8 @@
         if (!incompatibleImprovements.equals(other.incompatibleImprovements)) return false;
         if (exposeFields != other.exposeFields) return false;
         if (treatDefaultMethodsAsBeanMembers != other.treatDefaultMethodsAsBeanMembers) return false;
+        if (nonRecordZeroArgumentNonVoidMethodPolicy != other.nonRecordZeroArgumentNonVoidMethodPolicy) return false;
+        if (recordZeroArgumentNonVoidMethodPolicy != other.recordZeroArgumentNonVoidMethodPolicy) return false;
         if (exposureLevel != other.exposureLevel) return false;
         if (!memberAccessPolicy.equals(other.memberAccessPolicy)) return false;
         if (methodAppearanceFineTuner != other.methodAppearanceFineTuner) return false;
@@ -153,6 +165,36 @@
         this.treatDefaultMethodsAsBeanMembers = treatDefaultMethodsAsBeanMembers;
     }
 
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) {
+        NullArgumentException.check(nonRecordZeroArgumentNonVoidMethodPolicy);
+        this.nonRecordZeroArgumentNonVoidMethodPolicy = nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() {
+        return recordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) {
+        NullArgumentException.check(recordZeroArgumentNonVoidMethodPolicy);
+        this.recordZeroArgumentNonVoidMethodPolicy = recordZeroArgumentNonVoidMethodPolicy;
+    }
+
     public MemberAccessPolicy getMemberAccessPolicy() {
         return memberAccessPolicy;
     }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
index 12d43de..d66f06d 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
@@ -23,16 +23,20 @@
 
 /**
  * Used instead of {@link PropertyDescriptor}, because the methods of that are synchronized.
+ * Also, we use this for "fake" Java Beans properties too (see {@link BeansWrapper.MethodAppearanceDecision}).
  * 
  * @since 2.3.27
  */
 final class FastPropertyDescriptor {
     private final Method readMethod;
     private final Method indexedReadMethod;
-    
-    public FastPropertyDescriptor(Method readMethod, Method indexedReadMethod) {
+    private final boolean methodInsteadOfPropertyValueBeforeCall;
+
+    public FastPropertyDescriptor(
+            Method readMethod, Method indexedReadMethod, boolean methodInsteadOfPropertyValueBeforeCall) {
         this.readMethod = readMethod;
         this.indexedReadMethod = indexedReadMethod;
+        this.methodInsteadOfPropertyValueBeforeCall = methodInsteadOfPropertyValueBeforeCall;
     }
 
     public Method getReadMethod() {
@@ -42,5 +46,14 @@
     public Method getIndexedReadMethod() {
         return indexedReadMethod;
     }
-    
+
+    /**
+     * If this is true, and the property value is referred directly before it's called in a template, then
+     * instead of the property value, the value should be the read method.
+     *
+     * @since 2.3.33
+     */
+    public boolean isMethodInsteadOfPropertyValueBeforeCall() {
+        return methodInsteadOfPropertyValueBeforeCall;
+    }
 }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java
new file mode 100644
index 0000000..bb1723f
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.ext.beans;
+
+import java.util.Collection;
+import java.util.Map;
+
+import freemarker.ext.util.ModelFactory;
+import freemarker.template.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
+/**
+ * This is used for wrapping objects that has no special treatment (unlike {@link Map}-s, {@link Collection}-s,
+ * {@link Number}-s, {@link Boolean}-s, and some more, which have), hence they are just "generic" Java
+ * objects. Users usually just want to call the public Java methods on such objects.
+ * These objects can also be used as string values in templates, and that value is provided by
+ * the {@link Object#toString()} method of the wrapped object.
+ *
+ * <p>This extends {@link StringModel} for backward compatibility, as now {@link BeansWrapper} returns instances of
+ * {@link GenericObjectModel} instead of {@link StringModel}-s, but user code may have {@code insteanceof StringModel},
+ * or casing to {@link StringModel}. {@link StringModel} served the same purpose as this class, but didn't implement
+ * {@link MethodCallAwareTemplateHashModel}.
+ *
+ * @since 2.3.33
+ */
+public class GenericObjectModel extends StringModel implements MethodCallAwareTemplateHashModel {
+    static final ModelFactory FACTORY = (object, wrapper) -> new GenericObjectModel(object, (BeansWrapper) wrapper);
+
+    /**
+     * Creates a new model that wraps the specified object with BeanModel + scalar functionality.
+     *
+     * @param object
+     *         the object to wrap into a model.
+     * @param wrapper
+     *         the {@link BeansWrapper} associated with this model. Every model has to have an associated
+     *         {@link BeansWrapper} instance. The model gains many attributes from its wrapper, including the caching
+     *         behavior, method exposure level, method-over-item shadowing policy etc.
+     */
+    public GenericObjectModel(Object object, BeansWrapper wrapper) {
+        super(object, wrapper);
+    }
+
+    // Made this final, to ensure that users override get(key, boolean) instead.
+    @Override
+    public final TemplateModel get(String key) throws TemplateModelException {
+        return super.get(key);
+    }
+
+    @Override
+    public TemplateModel getBeforeMethodCall(String key) throws TemplateModelException,
+            ShouldNotBeGetAsMethodException {
+        return super.getBeforeMethodCall(key);
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
index 98c6416..bfa6bc9 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
@@ -53,7 +53,10 @@
      *   <li>Show the method with a different name in the data-model than its
      *     real name by calling
      *     {@link MethodAppearanceDecision#setExposeMethodAs(String)}
-     *     with non-{@code null} parameter.
+     *     with non-{@code null} parameter. Also, if set to {@code null}, the method won't be exposed.
+     *     The default is the name of the method. Note that if {@code methodInsteadOfPropertyValueBeforeCall} is
+     *     {@code true}, the method is not exposed if the method name set here is the same as the name of the property
+     *     set for this method with {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}.
      *   <li>Create a fake JavaBean property for this method by calling
      *     {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}.
      *     For example, if you have {@code int size()} in a class, but you
@@ -76,6 +79,21 @@
      *     of the same name was already assigned earlier, it won't be
      *     replaced by the new one by default, however this can be changed with
      *     {@link MethodAppearanceDecision#setReplaceExistingProperty(boolean)}.
+     *   <li>If something is exposed as property via
+     *     {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)} (and not only because it's a real
+     *     JavaBeans property), and you also want the property value to be accessible in templates as the return value
+     *     of a 0-argument method of the same name, then call
+     *     {@link MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} with {@code true}.
+     *     Here's an example to explain that. Let's say, you have a class that contains "public String name()", and you
+     *     exposed that as a property via {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}. So
+     *     far, you can access the property value from templates as {@code user.name}, but {@code user.name()} will
+     *     fail, saying that you try to call a {@code String} (because you apply the {@code ()} operator on the result
+     *     of {@code user.name}). But with
+     *     {@link MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} {@code true},
+     *     both {@code user.name}, and {@code user.name()} will do the same.
+     *     The default of this is influenced by
+     *     {@link BeansWrapperConfiguration#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)},
+     *     {@link BeansWrapperConfiguration#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
      *   <li>Prevent the method to hide a JavaBeans property (fake or real) of
      *     the same name by calling
      *     {@link MethodAppearanceDecision#setMethodShadowsProperty(boolean)}
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
index b53872d..7936fca 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
@@ -20,25 +20,20 @@
 package freemarker.ext.beans;
 
 import freemarker.ext.util.ModelFactory;
-import freemarker.template.ObjectWrapper;
-import freemarker.template.TemplateModel;
+import freemarker.template.MethodCallAwareTemplateHashModel;
 import freemarker.template.TemplateScalarModel;
 
 /**
  * Subclass of {@link BeanModel} that exposes the return value of the {@link
  * java.lang.Object#toString()} method through the {@link TemplateScalarModel}
  * interface.
+ *
+ * @deprecated Use {@link GenericObjectModel} instead, which implements {@link MethodCallAwareTemplateHashModel}.
  */
+@Deprecated
 public class StringModel extends BeanModel
 implements TemplateScalarModel {
-    static final ModelFactory FACTORY =
-        new ModelFactory()
-        {
-            @Override
-            public TemplateModel create(Object object, ObjectWrapper wrapper) {
-                return new StringModel(object, (BeansWrapper) wrapper);
-            }
-        };
+    static final ModelFactory FACTORY = (object, wrapper) -> new StringModel(object, (BeansWrapper) wrapper);
 
     // Package visible for testing
     static final String TO_STRING_NOT_EXPOSED = "[toString not exposed]";
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
new file mode 100644
index 0000000..add78f0
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.ext.beans;
+
+import freemarker.template.DefaultObjectWrapper;
+
+/**
+ * How to show 0 argument non-void public methods to templates, which are not standard Java Beans read methods.
+ * Used in {@link BeansWrapper}, and therefore in {@link DefaultObjectWrapper}.
+ * This policy doesn't apply to methods that Java Beans introspector discovers as a property read method (which
+ * typically look like {@code getSomething()}, or {@code isSomething()}). It's only applicable to methods like
+ * {@code something()}, including the component read methods of Java records.
+ *
+ * @see BeansWrapperConfiguration#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)
+ * @see BeansWrapperConfiguration#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)
+ * @see BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)
+ *
+ * @since 2.3.33
+ */
+public enum ZeroArgumentNonVoidMethodPolicy {
+
+    /**
+     * Both {@code obj.m}, and {@code obj.m()} gives back the value that the {@code m} Java method returns, and it's
+     * not possible to get the method itself.
+     *
+     * <p>This is a parse-time trick that only works when the result of the dot operator is called immediately in a
+     * template (and therefore the dot operator knows that you will call the result of it). The practical reason for
+     * this feature is that the convention of having {@code SomeType something()} instead of
+     * {@code SomeType getSomething()} spreads in the Java ecosystem (and is a standard in some other JVM languages),
+     * and thus we can't tell anymore if {@code SomeType something()} just reads a value, and hence should be accessed
+     * like {@code obj.something}, or it's more like an operation that has side effect, and therefore should be
+     * accessed like {@code obj.something()}. So with allowing both, the template author is free to decide which is
+     * the more fitting. Also, for accessing Java records components, the proper way is {@code obj.something}, but
+     * before FreeMarker was aware of records (and hence that those methods are like property read methods), the
+     * only way that worked was {@code obj.something()}, so to be more backward compatible, we have to support both.
+     */
+    BOTH_PROPERTY_AND_METHOD,
+
+    /**
+     * Only {@code obj.m()} gives back the value, {@code obj.m} just gives the method itself.
+     */
+    METHOD_ONLY,
+
+    /**
+     * {@code obj.m} in gives back the value, and the method itself can't be get.
+     */
+    PROPERTY_ONLY
+}
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
index 64d9797..a8a6c77 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
@@ -116,7 +116,7 @@
             
             packedArgs = new Object[fixedArgCnt + 1]; 
             for (int i = 0; i < fixedArgCnt; i++) {
-                packedArgs[i] = args[i];
+packedArgs[i] = args[i];
             }
             
             final Class<?> compType = paramTypes[fixedArgCnt].getComponentType();
@@ -226,5 +226,5 @@
     public static ClassIntrospectorBuilder getClassIntrospectorBuilder(BeansWrapperConfiguration bwc) {
         return bwc.getClassIntrospectorBuilder();
     }
-    
+
 }
diff --git a/freemarker-core/src/main/java/freemarker/template/MethodCallAwareTemplateHashModel.java b/freemarker-core/src/main/java/freemarker/template/MethodCallAwareTemplateHashModel.java
new file mode 100644
index 0000000..a3e8056
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/template/MethodCallAwareTemplateHashModel.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.template;
+
+import java.util.Collection;
+import java.util.Map;
+
+import freemarker.core.Macro;
+import freemarker.core.NonMethodException;
+import freemarker.ext.beans.BeansWrapper;
+import freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy;
+import freemarker.template.utility.NullArgumentException;
+
+/**
+ * Adds an extra getter method to {@link TemplateHashModel} that can return different result than {@link #get(String)},
+ * knowing that the result of it will be called as a method. At least as of 2.3.33, this is only utilized by the
+ * template language for 0-argument non-void method calls directly after the dot operator and the key (like
+ * {@code obj.m()}), or for the equivalent with square brackets ({@code obj["m"]()}). For example, if in the
+ * template you have {@code someRecord.someComponent()}, and there {@code someRecord} was wrapped by the
+ * {@link ObjectWrapper} into a {@link TemplateHashModel} that also implements this interface, then the dot operator
+ * will call {@link #getBeforeMethodCall(String) getBeforeMethodCall("someComponent")}, rather than
+ * {@link #get(String) get("someComponent")}. This is needed to implement subtle features like
+ * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)},
+ * which is needed to implement {@link ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}.
+ *
+ * <p>While technically we could do the same for method calls with more the 0 arguments, as of 2.3.33 at least, we
+ * don't want to generalize this to that case. The FreeMarker 2.x template language doesn't have separated namespace for
+ * methods, so this is already a hack as is, but we had to address the issue with Java records (see that at
+ * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}).
+ *
+ * <p>Objects wrapped with {@link BeansWrapper}, and hence with {@link DefaultObjectWrapper}, will implement this
+ * interface, when they are "generic" objects (that is, when they are not classes with special wrapper, like
+ * {@link Map}-s, {@link Collection}-s, {@link Number}-s, etc.).
+ *
+ * @since 2.3.33
+ */
+public interface MethodCallAwareTemplateHashModel extends TemplateHashModel {
+
+    /**
+     * This is called instead of {@link #get(String)}, if we know that the return value should be callable like a
+     * method. The advantage of this is that we can coerce the value to a method when desirable, and otherwise can give
+     * a more specific error message in the resulting exception than the standard {@link NonMethodException} would.
+     *
+     * @param key
+     *      Same as for {@link #get(String)}
+     *
+     * @return
+     *      Same as for just like {@link #get(String)}, except it should return a
+     *      {@link TemplateMethodModelEx}, or a {@link TemplateMethodModel}, or in very rare case a {@link Macro}
+     *      that was created with the {@code function} directive. Or, {@code null} in the same case as
+     *      {@link #get(String)}. The method should never return something that's not callable in the template language
+     *      as a method or function.
+     *
+     * @throws ShouldNotBeGetAsMethodException
+     *      If the value for the given key exists, but it shouldn't be coerced something callable as a method. This will
+     *      be converted to {@link NonMethodException} by the engine, but in this exception you can optionally give a
+     *      more specific explanation, and that will be added to the resulting {@link NonMethodException} as a hint to
+     *      the user.
+     */
+    TemplateModel getBeforeMethodCall(String key)
+            throws TemplateModelException, ShouldNotBeGetAsMethodException;
+
+    /**
+     * Thrown by {@link #getBeforeMethodCall(String)}; see there.
+     */
+    final class ShouldNotBeGetAsMethodException extends Exception {
+        private final TemplateModel actualValue;
+        private final String hint;
+
+        /**
+         * Same as {@link ShouldNotBeGetAsMethodException(TemplateModel, String, Throwable)}, with {@code null}
+         * cause exception argument.
+         */
+        public ShouldNotBeGetAsMethodException(TemplateModel actualValue, String hint) {
+            this(actualValue, hint, null);
+        }
+
+        /**
+         * @param actualValue
+         *      The actual value we got instead of a method; can't be {@code null}!
+         * @param hint
+         *      Hint for the user, that's added to the error message; {@code null} if you just want the plain
+         *      {@link NonMethodException} error message.
+         * @param cause
+         *      Can be {@code null}.
+         */
+        public ShouldNotBeGetAsMethodException(TemplateModel actualValue, String hint, Throwable cause) {
+            super(null, cause, true, false);
+            NullArgumentException.check(actualValue);
+            this.actualValue = actualValue;
+            this.hint = hint;
+        }
+
+        /**
+         * The actual value we got instead of a method; not {@code null}.
+         */
+        public TemplateModel getActualValue() {
+            return actualValue;
+        }
+
+        /**
+         * Additional hint for the user; maybe {@code null}.
+         */
+        public String getHint() {
+            return hint;
+        }
+    }
+}
diff --git a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
index f439086..e495043 100644
--- a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
+++ b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
@@ -2467,6 +2467,13 @@
         end = <CLOSE_PAREN>
         {
             args.trimToSize();
+            if (args.isEmpty()) {
+                if (exp instanceof Dot) {
+                    exp = new DotBeforeMethodCall((Dot) exp);
+                } else if (exp instanceof DynamicKeyName) {
+                    exp = new DynamicKeyNameBeforeMethodCall((DynamicKeyName) exp);
+                }
+            }
             MethodCall result = new MethodCall(exp, args);
             result.setLocation(template, exp, end);
             return result;
diff --git a/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java b/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
index 6a08ea9..4f4e6f9 100644
--- a/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
+++ b/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
@@ -90,10 +90,10 @@
 import freemarker.core.XSCFormat;
 import freemarker.core._CoreStringUtils;
 import freemarker.ext.beans.BeansWrapperBuilder;
+import freemarker.ext.beans.GenericObjectModel;
 import freemarker.ext.beans.LegacyDefaultMemberAccessPolicy;
 import freemarker.ext.beans.MemberAccessPolicy;
 import freemarker.ext.beans.MemberSelectorListMemberAccessPolicy;
-import freemarker.ext.beans.StringModel;
 import freemarker.ext.beans.WhitelistMemberAccessPolicy;
 import freemarker.template.utility.DateUtil;
 import freemarker.template.utility.NullArgumentException;
@@ -1316,7 +1316,7 @@
         {
             TemplateScalarModel aVal = (TemplateScalarModel) cfg.getSharedVariable("a");
             assertEquals("aa", aVal.getAsString());
-            assertEquals(StringModel.class, aVal.getClass());
+            assertEquals(GenericObjectModel.class, aVal.getClass());
             
             TemplateScalarModel bVal = (TemplateScalarModel) cfg.getSharedVariable("b");
             assertEquals("bbLegacy", bVal.getAsString());
diff --git a/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
new file mode 100644
index 0000000..ee834f8
--- /dev/null
+++ b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
@@ -0,0 +1,370 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.ext.beans;
+
+import static freemarker.template.Configuration.*;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.ObjectWrapper;
+import freemarker.template.SimpleHash;
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+public class TestZeroArgumentNonVoidMethodPolicy extends TemplateTest {
+    @Override
+    protected Configuration createConfiguration() throws Exception {
+        Configuration cfg = super.createConfiguration();
+        // Don't use default, as then the object wrapper is a shared static mutable object:
+        cfg.setIncompatibleImprovements(Configuration.VERSION_2_3_32);
+        cfg.setAPIBuiltinEnabled(true);
+        return cfg;
+    }
+
+    @Test
+    public void testDefaultWithHighIncompatibleImprovements() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)){
+            setupDataModel(
+                    () -> new DefaultObjectWrapper(VERSION_2_3_33),
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithLowIncompatibleImprovements() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> new DefaultObjectWrapper(VERSION_2_3_32),
+                    cacheTopLevelVars);
+            assertRecIsMethodOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithLowIncompatibleImprovements2() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
+                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(
+                                ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithRecordsPropertyOnly() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
+                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsPropertyOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithRecordsPropertyOnly2() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_33);
+                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsPropertyOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithNonRecordsPropertyOnly() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_32);
+                        beansWrapper.setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsMethodOnly();
+            assertNrcIsPropertyOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithBothPropertyAndMethod() throws TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new DefaultObjectWrapper(VERSION_2_3_33);
+                        beansWrapper.setNonRecordZeroArgumentNonVoidMethodPolicy(
+                                ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsBothPropertyAndMethod();
+        }
+    }
+
+    @Test
+    public void testSettings() throws TemplateException, IOException {
+            getConfiguration().setSetting(
+                    "objectWrapper",
+                    "DefaultObjectWrapper(2.3.33, nonRecordZeroArgumentNonVoidMethodPolicy=freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD)");
+            setupDataModel(() -> getConfiguration().getObjectWrapper(), false);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsBothPropertyAndMethod();
+    }
+
+    private void setupDataModel(Supplier<? extends ObjectWrapper> objectWrapperSupplier, boolean cacheTopLevelVars) {
+        ObjectWrapper objectWrapper = objectWrapperSupplier.get();
+        getConfiguration().setObjectWrapper(objectWrapper);
+
+        setDataModel(cacheTopLevelVars ? new SimpleHash(objectWrapper) : new HashMap<>());
+
+        addToDataModel("rec", new TestRecord(1, "S"));
+        addToDataModel("nrc", new TestNonRecord());
+    }
+
+    private void assertRecIsBothPropertyAndMethod() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${rec.x}", tempMods), "1");
+            assertOutput(modifyTemplate("${rec.x()}", tempMods), "1");
+            assertOutput(modifyTemplate("${rec.s}", tempMods), "S");
+            assertOutput(modifyTemplate("${rec.s()}", tempMods), "S");
+            assertOutput(modifyTemplate("${rec.y}", tempMods), "2");
+            assertOutput(modifyTemplate("${rec.y()}", tempMods), "2");
+            assertOutput(modifyTemplate("${rec.tenX}", tempMods), "10");
+            assertOutput(modifyTemplate("${rec.tenX()}", tempMods), "10");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecIsMethodOnly() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertErrorContains(modifyTemplate("${rec.x}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${rec.x()}", tempMods), "1");
+            assertErrorContains(modifyTemplate("${rec.s}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${rec.s()}", tempMods), "S");
+            assertErrorContains(modifyTemplate("${rec.y}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${rec.y()}", tempMods), "2");
+            assertErrorContains(modifyTemplate("${rec.tenX}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${rec.tenX()}", tempMods), "10");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecIsPropertyOnly() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${rec.x}", tempMods), "1");
+            assertErrorContains(modifyTemplate("${rec.x()}", tempMods), "SimpleNumber", "must not be called as a method");
+            assertOutput(modifyTemplate("${rec.s}", tempMods), "S");
+            assertErrorContains(modifyTemplate("${rec.s()}", tempMods), "SimpleScalar");
+            assertOutput(modifyTemplate("${rec.y}", tempMods), "2");
+            assertErrorContains(modifyTemplate("${rec.y()}", tempMods), "SimpleNumber");
+            assertOutput(modifyTemplate("${rec.tenX}", tempMods), "10");
+            assertErrorContains(modifyTemplate("${rec.tenX()}", tempMods), "SimpleNumber");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecPolicyIndependentMembers() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${rec.z}", tempMods), "3");
+            assertErrorContains(modifyTemplate("${rec.z()}", tempMods), "SimpleNumber");
+            assertOutput(modifyTemplate("${rec.getZ()}", tempMods), "3");
+            assertOutput(modifyTemplate("${rec.xTimes(5)}", tempMods), "5");
+            assertErrorContains(modifyTemplate("${rec.xTimes}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${rec.voidMethod()}", tempMods), "");
+            assertErrorContains(modifyTemplate("${rec.voidMethod}", tempMods), "SimpleMethodModel");
+        }
+    }
+
+    private void assertNrcIsMethodOnly() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertErrorContains(modifyTemplate("${nrc.x}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${nrc.x()}", tempMods), "1");
+            assertErrorContains(modifyTemplate("${nrc.y}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${nrc.y()}", tempMods), "2");
+            assertErrorContains(modifyTemplate("${nrc.tenX}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${nrc.tenX()}", tempMods), "10");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcIsBothPropertyAndMethod() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${nrc.x}", tempMods), "1");
+            assertOutput(modifyTemplate("${nrc.x()}", tempMods), "1");
+            assertOutput(modifyTemplate("${nrc.y}", tempMods), "2");
+            assertOutput(modifyTemplate("${nrc.y()}", tempMods), "2");
+            assertOutput(modifyTemplate("${nrc.tenX}", tempMods), "10");
+            assertOutput(modifyTemplate("${nrc.tenX()}", tempMods), "10");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcIsPropertyOnly() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${nrc.x}", tempMods), "1");
+            assertErrorContains(modifyTemplate("${nrc.x()}", tempMods), "SimpleNumber", "must not be called as a method");
+            assertOutput(modifyTemplate("${nrc.y}", tempMods), "2");
+            assertErrorContains(modifyTemplate("${nrc.y()}", tempMods), "SimpleNumber");
+            assertOutput(modifyTemplate("${nrc.tenX}", tempMods), "10");
+            assertErrorContains(modifyTemplate("${nrc.tenX()}", tempMods), "SimpleNumber");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcPolicyIndependentMembers() throws IOException, TemplateException {
+        for (TemplateModifications tempMods : TemplateModifications.values()) {
+            assertOutput(modifyTemplate("${nrc.z}", tempMods), "3");
+            assertErrorContains(modifyTemplate("${nrc.z()}", tempMods), "SimpleNumber");
+            assertOutput(modifyTemplate("${nrc.getZ()}", tempMods), "3");
+            assertOutput(modifyTemplate("${nrc.xTimes(5)}", tempMods), "5");
+            assertErrorContains(modifyTemplate("${nrc.xTimes}", tempMods), "SimpleMethodModel");
+            assertOutput(modifyTemplate("${nrc.voidMethod()}", tempMods), "");
+            assertErrorContains(modifyTemplate("${nrc.voidMethod}", tempMods), "SimpleMethodModel");
+        }
+    }
+
+    public interface TestInterface {
+        int y();
+
+        /**
+         * Defines a real JavaBeans property, "z", so the {@link ZeroArgumentNonVoidMethodPolicy} shouldn't affect this
+         */
+        int getZ();
+    }
+
+    /**
+     * Defines record component readers for "x" and "s", and some other non-record-component methods that are still
+     * potentially exposed as if there were properties.
+     */
+    public record TestRecord(int x, String s) implements TestInterface {
+        @Override
+        public int y() {
+            return 2;
+        }
+
+        @Override
+        public int getZ() {
+            return 3;
+        }
+
+        public int tenX() {
+            return x * 10;
+        }
+
+        /**
+         * Has an argument, so this never should be exposed as property.
+         */
+        public int xTimes(int m) {
+            return x * m;
+        }
+
+        /**
+         * Has a void return type, so this never should be exposed as property.
+         */
+        public void voidMethod() {
+            // do nothing
+        }
+    }
+
+    public static class TestNonRecord implements TestInterface {
+        public int x() {
+            return 1;
+        }
+
+        @Override
+        public int y() {
+            return 2;
+        }
+
+        @Override
+        public int getZ() {
+            return 3;
+        }
+
+        public int tenX() {
+            return x() * 10;
+        }
+
+        public int xTimes(int m) {
+            return x() * m;
+        }
+
+        /**
+         * Has a void return type, so this never should be exposed as property.
+         */
+        public void voidMethod() {
+            // do nothing
+        }
+    }
+
+    private static final Pattern DOT_TO_SQUARE_BRACKETS_REPLACEMENT_PATTERN = Pattern.compile("\\.(\\w+)");
+    
+    private static String modifyTemplate(String s, TemplateModifications tempMods) {
+        if (tempMods.useApi) {
+            s = s.replace(".", "?api.");
+        }
+        if (tempMods.doToSquareBrackets) {
+            s = DOT_TO_SQUARE_BRACKETS_REPLACEMENT_PATTERN.matcher(s).replaceFirst(key -> "['" + key.group(1) + "']");
+        }
+        return s;
+    }
+
+    enum TemplateModifications {
+        DOT(true, false), SQUARE_BRACKETS(false, false),
+        API_DOT(true, true), API_SQUARE_BRACKETS(false, true);
+
+        private final boolean doToSquareBrackets;
+        private final boolean useApi;
+
+        TemplateModifications(boolean doToSquareBrackets, boolean useApi) {
+            this.doToSquareBrackets = doToSquareBrackets;
+            this.useApi = useApi;
+        }
+    }
+
+
+}
diff --git a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
index d67d195..5115299 100644
--- a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
+++ b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
@@ -104,7 +104,7 @@
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.30
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.31
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.32
-        expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.33
+        expected.add(Configuration.VERSION_2_3_33);
 
         List<Version> actual = new ArrayList<>();
         for (int i = _VersionInts.V_2_3_0; i <= Configuration.getVersion().intValue(); i++) {
@@ -383,7 +383,7 @@
             assertTrue(ow.getUseAdaptersForContainers());
             assertTrue(ow.getForceLegacyNonListCollections());
         }
-        
+
         try {
             new DefaultObjectWrapper(new Version(99, 9, 9));
             fail();
diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml
index 06fa9f9..5675058 100644
--- a/freemarker-manual/src/main/docgen/en_US/book.xml
+++ b/freemarker-manual/src/main/docgen/en_US/book.xml
@@ -20,7 +20,10 @@
 <book conformance="docgen" version="5.0" xml:lang="en"
       xmlns="http://docbook.org/ns/docbook"
       xmlns:xlink="http://www.w3.org/1999/xlink"
->
+      xmlns:ns5="http://www.w3.org/1999/xhtml"
+      xmlns:ns4="http://www.w3.org/2000/svg"
+      xmlns:ns3="http://www.w3.org/1998/Math/MathML"
+      xmlns:ns="http://docbook.org/ns/docbook">
   <info>
     <title>Apache FreeMarker Manual</title>
 
@@ -30128,6 +30131,18 @@
           <itemizedlist>
             <listitem>
               <para><link
+              xlink:href="https://issues.apache.org/jira/browse/FREEMARKER-183">FREEMARKER-183</link>:
+              If FreeMarker is configured like so, values in Java records can
+              now be referred like <literal>obj.price</literal>, instead of
+              like <literal>obj.price()</literal>. Furthermore, FreeMarker can
+              now be configured to allow this for all 0-argument
+              non-<literal>void</literal> methods. See more details in the
+              <link linkend="version_hisotry_freemarker_183_java_side">Changes
+              on the Java side</link> section below.</para>
+            </listitem>
+
+            <listitem>
+              <para><link
               xlink:href="https://github.com/apache/freemarker/pull/87">GitHub
               PR 87</link> Comparing strings is now way faster, if the <link
               linkend="pgui_config_incompatible_improvements_how_to_set"><literal>incompatible_improvements</literal>
@@ -30229,12 +30244,123 @@
           </itemizedlist>
         </section>
 
-        <section>
+        <section xml:id="version_hisotry_freemarker_183_java_side">
           <title>Changes on the Java side</title>
 
           <itemizedlist>
             <listitem>
               <para><link
+              xlink:href="https://issues.apache.org/jira/browse/FREEMARKER-183">FREEMARKER-183</link>:
+              Better support for Java records, if you set the <link
+              linkend="pgui_config_incompatible_improvements_how_to_set"><literal>incompatible_improvements</literal>
+              setting</link> to 2.3.33 or higher (or if you create your own
+              <literal>ObjectWrapper</literal>, then set its
+              <literal>incompatible_improvements</literal>, or just its
+              <literal>recordZeroArgumentNonVoidMethodPolicy</literal>
+              property to <literal>BOTH_PROPERTY_AND_METHOD</literal>). If in
+              a Java record you have something like <literal>int
+              price()</literal>, earlier you could only read the value in
+              templates as <literal>obj.price()</literal>. With this
+              improvement <literal>obj.price</literal> will do the same (and
+              similarly, <literal>obj["price"]()</literal>, and
+              <literal>obj["price"]</literal> will do the same). This has
+              always worked for JavaBeans properties, like <literal>int
+              getPrice()</literal> could always be used in templates as
+              <literal>obj.price</literal>, in additionally to as
+              <literal>obj.getPrice()</literal>. Now this also works for Java
+              records, as there we simply treat all methods that has 0
+              arguments, and non-<literal>void</literal> return type as if it
+              was a JavaBean property read method. Except, here the name of
+              the method is exactly the same as the name of the faked
+              JavaBeans property (<literal>price</literal>), while with real
+              JavaBeans the read method name typically would be
+              <literal>getPrice</literal>, and the property name would be
+              <literal>price</literal> (so we have two separate names). There
+              are some strange technical tricks involved for the same name to
+              be usable in both ways, but as far as most users care, it just
+              works.</para>
+
+              <para>Some more technical changes:</para>
+
+              <itemizedlist>
+                <listitem>
+                  <para>Added two new settings to
+                  <literal>BeansWrapper</literal>, and therefore
+                  <literal>DefaultObjectWrapper</literal>:
+                  <literal>recordZeroArgumentNonVoidMethodPolicy</literal>,
+                  and
+                  <literal>nonRecordZeroArgumentNonVoidMethodPolicy</literal>.
+                  Each has enum type
+                  <literal>freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy</literal>,
+                  that can be <literal>METHOD_ONLY</literal>,
+                  <literal>PROPERTY_ONLY</literal>, or
+                  <literal>BOTH_PROPERTY_AND_METHOD</literal>.
+                  Therefore:</para>
+
+                  <itemizedlist>
+                    <listitem>
+                      <para>Note that with
+                      <literal>nonRecordZeroArgumentNonVoidMethodPolicy</literal>
+                      you can set similar behavior to non-records. That is,
+                      you can call 0 argument non-void methods without
+                      <literal>()</literal>, if you want. It's only meant to
+                      be used for methods that are mere value readers, and has
+                      no side effect.</para>
+                    </listitem>
+
+                    <listitem>
+                      <para>For records, you can enforce proper style with
+                      setting
+                      <literal>recordZeroArgumentNonVoidMethodPolicy</literal>
+                      to <literal>PROPERTY_ONLY</literal>. The default with
+                      <literal>incompatible_improvements</literal> 2.3.33 is
+                      more lenient, as there using <literal>()</literal> is
+                      allowed (for backward compatibility, and because people
+                      often just use the Java syntax).</para>
+                    </listitem>
+                  </itemizedlist>
+                </listitem>
+
+                <listitem>
+                  <para>Added new interface,
+                  <literal>freemarker.template.MethodCallAwareTemplateHashModel</literal>,
+                  which adds <literal>getBeforeMethodCall(String
+                  key)</literal>. If you have something like
+                  <literal>obj.price()</literal> in a template, where
+                  <literal>obj</literal> (after wrapping) implements that
+                  interface, then
+                  <literal>getBeforeMethodCall("price")</literal> called
+                  instead of
+                  <literal>TemplateHashModel.get("price")</literal>. This is
+                  needed for
+                  <literal>ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD</literal>
+                  to work.</para>
+                </listitem>
+
+                <listitem>
+                  <para>Added <literal>GenericObjectModel</literal>, which
+                  extends <literal>StringModel</literal> with implementing
+                  <literal>MethodCallAwareTemplateHashModel</literal>, and has
+                  a more telling name. <literal>BeansWrapper</literal>, and
+                  therefore <literal>DefaultObjectWrapper</literal> now
+                  creates <literal>GenericObjectModel</literal>-s instead of
+                  <literal>StringModel</literal>-s. This is like so regardless
+                  of any setting, like regardless of
+                  <literal>incompatible_improvements</literal>.</para>
+                </listitem>
+
+                <listitem>
+                  <para>You shouldn't override
+                  <literal>BeanModel.get(String)</literal> anymore, but
+                  <literal>BeanModel.get(String, boolean)</literal>. If you
+                  have overridden <literal>get</literal>, then see in the
+                  Javadoc for more.</para>
+                </listitem>
+              </itemizedlist>
+            </listitem>
+
+            <listitem>
+              <para><link
               xlink:href="https://github.com/apache/freemarker/pull/88">GitHub
               PR 88</link>: Added a new possible value for the
               <literal>auto_escaping_policy</literal> configuration setting,
diff --git a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
index a730b3e..1ab7af4 100644
--- a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
+++ b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
@@ -42,6 +42,7 @@
 import freemarker.cache.TemplateLoader;
 import freemarker.core.ParseException;
 import freemarker.template.Configuration;
+import freemarker.template.SimpleHash;
 import freemarker.template.Template;
 import freemarker.template.TemplateException;
 import freemarker.template.utility.StringUtil;
@@ -146,7 +147,8 @@
     
     protected String getOutput(Template t) throws TemplateException, IOException {
         StringWriter out = new StringWriter();
-        t.process(getDataModel(), new FilterWriter(out) {
+        Object dataModelObject = getDataModel();
+        t.process(dataModelObject, new FilterWriter(out) {
             private boolean closed;
 
             @Override
@@ -197,6 +199,11 @@
         }
         return dataModel;
     }
+
+    protected void setDataModel(Object dataModel) {
+        this.dataModel = dataModel;
+        dataModelCreated = true;
+    }
     
     protected Object createDataModel() {
         return null;
@@ -248,6 +255,7 @@
         }
     }
 
+    @SuppressWarnings({"unchecked", "rawtypes"})
     protected void addToDataModel(String name, Object value) {
         Object dm = getDataModel();
         if (dm == null) {
@@ -256,6 +264,9 @@
         }
         if (dm instanceof Map) {
             ((Map) dm).put(name, value);
+        } else if (dm instanceof SimpleHash) {
+            // SimpleHash is interesting, as it caches the top-level TemplateDateModel-s
+            ((SimpleHash) dm).put(name, value);
         } else {
             throw new IllegalStateException("Can't add to non-Map data-model: " + dm);
         }
@@ -289,7 +300,7 @@
                 t = new Template("adhoc", ftl, getConfiguration());
             }
             t.process(getDataModel(), new StringWriter());
-            fail("The tempalte had to fail");
+            fail("The template had to fail");
             return null;
         } catch (TemplateException e) {
             if (exceptionClass != null) {