From 8f3391dce15b8dc5ea2274a1ce81f51eb1e487ab Mon Sep 17 00:00:00 2001 From: thkluge Date: Mon, 26 Oct 2026 21:34:41 +0100 Subject: [PATCH 1/9] added support for records serialization --- .../jme3/network/base/ConnectorAdapter.java | 2 +- .../com/jme3/network/base/KernelAdapter.java | 2 +- .../network/serializing/Serializable.java | 3 +- .../jme3/network/serializing/Serializer.java | 63 ++++++- .../serializers/RecordSerializer.java | 136 +++++++++++++++ .../serializing/RecordSerializerTest.java | 158 ++++++++++++++++++ 6 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java create mode 100644 jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java diff --git a/jme3-networking/src/main/java/com/jme3/network/base/ConnectorAdapter.java b/jme3-networking/src/main/java/com/jme3/network/base/ConnectorAdapter.java index 8a8ac275ab..d27c1273f9 100644 --- a/jme3-networking/src/main/java/com/jme3/network/base/ConnectorAdapter.java +++ b/jme3-networking/src/main/java/com/jme3/network/base/ConnectorAdapter.java @@ -201,7 +201,7 @@ public void run() Message m = null; while( (m = messageBuffer.pollMessage()) != null ) { - m.setReliable( reliable ); + m = m.setReliable( reliable ); dispatch( m ); } } diff --git a/jme3-networking/src/main/java/com/jme3/network/base/KernelAdapter.java b/jme3-networking/src/main/java/com/jme3/network/base/KernelAdapter.java index 3496ea0fbc..1fa403edaa 100644 --- a/jme3-networking/src/main/java/com/jme3/network/base/KernelAdapter.java +++ b/jme3-networking/src/main/java/com/jme3/network/base/KernelAdapter.java @@ -301,7 +301,7 @@ protected void createAndDispatch( Envelope env ) // Should be complete... and maybe we should check, but we don't. Message m = null; while( (m = protocol.pollMessage()) != null ) { - m.setReliable(reliable); + m = m.setReliable(reliable); dispatch(env.getSource(), m); } } diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java index 5535464638..4d5a2122f5 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java @@ -31,7 +31,6 @@ */ package com.jme3.network.serializing; -import com.jme3.network.serializing.serializers.FieldSerializer; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -48,7 +47,7 @@ * * @return the serializer class */ - Class serializer() default FieldSerializer.class; + Class serializer() default Serializer.class; /** * Returns the fixed serializer id for the annotated type, or {@code 0} for auto-assignment. * diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index 08d02c02bb..4c2542d002 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -38,6 +38,7 @@ import com.jme3.network.message.GZIPCompressedMessage; import com.jme3.network.message.ZIPCompressedMessage; import com.jme3.network.serializing.serializers.*; +import java.lang.reflect.Method; import java.io.File; import java.io.IOException; import java.net.URL; @@ -47,6 +48,7 @@ import java.util.logging.Level; import java.util.logging.Logger; + /** * The main serializer class, which will serialize objects such that * they can be sent across the network. Serializing classes should extend @@ -68,6 +70,9 @@ public abstract class Serializer { private static final Serializer fieldSerializer = new FieldSerializer(); private static final Serializer arraySerializer = new ArraySerializer(); + private static Serializer recordSerializer; + + private static final Method IS_RECORD_METHOD; private static short nextAvailableId = -2; // historically the first ID was always -2 @@ -84,6 +89,14 @@ public Serializer() { // Registers the classes we already have serializers for. static { + Method method = null; + try { + method = Class.class.getMethod("isRecord"); + } catch (NoSuchMethodException | SecurityException e) { + // Java 8 to 15: Records are not supported by the runtime + method = null; + } + IS_RECORD_METHOD = method; initialize(); } @@ -162,6 +175,13 @@ public static void initialize() { registerClass(ChannelInfoMessage.class); } + private static synchronized Serializer getRecordSerializer() { + if (recordSerializer == null) { + recordSerializer = new RecordSerializer(); + } + return recordSerializer; + } + /** * When set to true, classes that do not have intrinsic IDs in their * {@code @Serializable } will not be auto-registered during write. Defaults @@ -278,17 +298,31 @@ public static SerializerRegistration registerClass(Class cls, boolean failOnMiss short classId = serializable.id(); if (classId == 0) classId = nextId(); - Serializer serializer = getSerializer(serializerClass, false); - - if (serializer == null) serializer = fieldSerializer; + Serializer serializer = null; + + // If the annotation specifies the default (FieldSerializer.class or Serializer.class), + // or if no custom serializer was assigned, automatically route records to RecordSerializer + // and standard classes to FieldSerializer: + if (serializerClass == null || serializerClass == FieldSerializer.class || serializerClass == Serializer.class) { + serializer = isRecord(cls) ? getRecordSerializer() : fieldSerializer; + } else { + // A specific custom serializer was explicitly requested by the user + serializer = getSerializer(serializerClass, false); + if (serializer == null) { + try { + serializer = (Serializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to instantiate custom serializer: " + serializerClass, e); + serializer = isRecord(cls) ? getRecordSerializer() : fieldSerializer; + } + } + } SerializerRegistration existingReg = getExactSerializerRegistration(cls); - if (existingReg != null) classId = existingReg.getId(); SerializerRegistration reg = new SerializerRegistration(serializer, cls, classId); - - return registerClassForId( classId, cls, serializer ); + return registerClassForId(classId, cls, serializer); } if (failOnMiss) { throw new IllegalArgumentException( "Class is not marked @Serializable:" + cls ); @@ -465,7 +499,7 @@ public static SerializerRegistration getSerializerRegistration(Class cls, boolea if( failOnMiss ) { throw new IllegalArgumentException( "Class has not been registered:" + cls ); } - return registerClass(cls, fieldSerializer); + return registerClass(cls, isRecord(cls) ? getRecordSerializer() : fieldSerializer); } @@ -551,6 +585,21 @@ public static void writeClassAndObject(ByteBuffer buffer, Object object) throws reg.getSerializer().writeObject(buffer, object); } + + /** + * Safely checks if a class is a record using cached reflection. + * Compliant with Java 16+ JVM semantics while cleanly returning false on Java 8–15. + */ + private static boolean isRecord(Class cls) { + if (IS_RECORD_METHOD == null || cls == null) { + return false; + } + try { + return (Boolean) IS_RECORD_METHOD.invoke(cls); + } catch (Exception e) { + return false; + } + } /** * Read an object from the buffer, effectively deserializing it. diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java new file mode 100644 index 0000000000..7f9d70c890 --- /dev/null +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.network.serializing.serializers; + +import com.jme3.network.serializing.Serializer; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Serializer for Java {@code record} types. Uses cached reflection + * for performance supporting immutable records across modular Java runtimes. + * + * @author thkluge + */ +public class RecordSerializer extends Serializer { + + private static final Map, RecordCache> CACHE = new ConcurrentHashMap<>(); + + private static class RecordCache { + Constructor constructor; + Method[] accessors; + } + + /** + * Initializes and caches the record's constructor and component accessor methods. + * + * @param clazz The record class being registered. + */ + @Override + public void initialize(Class clazz) { + try { + RecordCache rc = new RecordCache(); + Method getRecordComponents = Class.class.getMethod("getRecordComponents"); + Object[] components = (Object[]) getRecordComponents.invoke(clazz); + + Class[] types = new Class[components.length]; + rc.accessors = new Method[components.length]; + + if (components.length > 0) { + Method getType = components[0].getClass().getMethod("getType"); + Method getAccessor = components[0].getClass().getMethod("getAccessor"); + + for (int i = 0; i < components.length; i++) { + types[i] = (Class) getType.invoke(components[i]); + rc.accessors[i] = (Method) getAccessor.invoke(components[i]); + rc.accessors[i].setAccessible(true); + } + } + + rc.constructor = clazz.getDeclaredConstructor(types); + rc.constructor.setAccessible(true); + CACHE.put(clazz, rc); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize RecordCache for: " + clazz.getName(), e); + } + } + + /** + * Reads record fields from the buffer and creates a new record instance using its constructor. + * + * @param data The buffer to read from. + * @param clazz The class of the record. + * @param The record type. + * @return The deserialized record instance. + * @throws IOException If deserialization fails. + */ + @Override + public T readObject(ByteBuffer data, Class cls) throws IOException { + try { + RecordCache rc = CACHE.get(cls); + Object[] args = new Object[rc.accessors.length]; + + for (int i = 0; i < rc.accessors.length; i++) { + args[i] = Serializer.readClassAndObject(data); + } + + return (T) rc.constructor.newInstance(args); + } catch (Exception e) { + throw new IOException("Failed to deserialize record: " + cls.getName(), e); + } + } + + /** + * Writes the record's field values to the buffer using cached accessors. + * + * @param buffer The buffer to write to. + * @param object The record object to serialize. + * @throws IOException If serialization fails. + */ + @Override + public void writeObject(ByteBuffer buffer, Object object) throws IOException { + try { + RecordCache rc = CACHE.get(object.getClass()); + + for (Method accessor : rc.accessors) { + Object value = accessor.invoke(object); + Serializer.writeClassAndObject(buffer, value); + } + } catch (Exception e) { + throw new IOException("Failed to serialize record: " + object.getClass().getName(), e); + } + } +} \ No newline at end of file diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java new file mode 100644 index 0000000000..665776a533 --- /dev/null +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.network.serializing; + +import com.jme3.network.Message; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.*; + +public class RecordSerializerTest { + + @Serializable + public record SimpleRecord(int id, String name, float value) {} + + @Serializable + public record NestedMessageRecord(SimpleRecord data, boolean isReliable) implements Message { + + // Convenience constructor + public NestedMessageRecord(SimpleRecord data) { + this(data, true); + } + + // Immutable pattern: returns a new instance instead of mutating 'this' + @Override + public Message setReliable(boolean f) { + return new NestedMessageRecord(this.data, f); + } + } + + @BeforeEach + public void setupSerializerRegistry() { + Serializer.initialize(); + Serializer.registerClass(SimpleRecord.class); + Serializer.registerClass(NestedMessageRecord.class); + } + + @Test + public void testBasicRecordSerialization() throws Exception { + SimpleRecord original = new SimpleRecord(42, "jMonkeyEngine", 3.14f); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof SimpleRecord); + + // Records automatically generate equals() evaluating all fields + assertEquals(original, result); + } + + @Test + public void testNestedRecordSerialization() throws Exception { + SimpleRecord inner = new SimpleRecord(99, "InnerData", 1.0f); + NestedMessageRecord original = new NestedMessageRecord(inner, false); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof NestedMessageRecord); + assertEquals(original, result); + } + + @Test + public void testRecordImmutabilityAndReliabilityReassignment() { + SimpleRecord inner = new SimpleRecord(1, "Test", 0f); + NestedMessageRecord original = new NestedMessageRecord(inner, true); + + // Simulate the internal KernelAdapter / ConnectorAdapter reassignment + Message modified = original.setReliable(false); + + // Verify the original object was untouched (strict immutability) + assertTrue(original.isReliable()); + + // Verify a new object was returned with the updated flag + assertFalse(modified.isReliable()); + assertNotSame(original, modified); + + // Verify the nested data remained perfectly intact + assertEquals(inner, ((NestedMessageRecord) modified).data()); + } + + @Test + public void testRegistryAssignsCorrectSerializerType() { + SerializerRegistration registration = Serializer.getExactSerializerRegistration(SimpleRecord.class); + + assertNotNull(registration); + // Ensure that the fallback did not assign the legacy FieldSerializer + assertEquals("RecordSerializer", registration.getSerializer().getClass().getSimpleName()); + } + + @Test + public void testRecordSerializationPerformance() throws Exception { + SimpleRecord original = new SimpleRecord(123, "PerformanceTestRecord", 999.99f); + ByteBuffer buffer = ByteBuffer.allocate(1024); + + // Warm up / populate cache + Serializer.writeClassAndObject(buffer, original); + buffer.clear(); + + int iterations = 50_000; + long startTime = System.nanoTime(); + + for (int i = 0; i < iterations; i++) { + buffer.clear(); + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + Object deserialized = Serializer.readClassAndObject(buffer); + assertEquals(original, deserialized); + } + + long durationMs = (System.nanoTime() - startTime) / 1_000_000; + + // Output result to console for visibility during local test runs + System.out.println("RecordSerializer Performance: " + iterations + " iterations took " + durationMs + " ms."); + + // Sanity check: 50,000 serializations with cached reflection should easily finish under 1 second + assertTrue(durationMs < 1000, "Performance test took too long, caching might be failing: " + durationMs + " ms"); + } +} \ No newline at end of file From 8636185b7ae94789d512148059ef9e17e2306306 Mon Sep 17 00:00:00 2001 From: thkluge Date: Mon, 26 Oct 2026 21:40:34 +0100 Subject: [PATCH 2/9] add regression test to ensure normal classes extending AbstractMessage are still working --- .../serializing/RecordSerializerTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java index 665776a533..0e340a586f 100644 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java @@ -31,6 +31,7 @@ */ package com.jme3.network.serializing; +import com.jme3.network.AbstractMessage; import com.jme3.network.Message; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -58,6 +59,28 @@ public Message setReliable(boolean f) { return new NestedMessageRecord(this.data, f); } } + + @Serializable + public static class LegacyPojoMessage extends AbstractMessage { + private int id; + private String text; + + // Required default constructor for FieldSerializer + public LegacyPojoMessage() {} + + public LegacyPojoMessage(int id, String text) { + this.id = id; + this.text = text; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LegacyPojoMessage that = (LegacyPojoMessage) o; + return id == that.id && java.util.Objects.equals(text, that.text); + } + } @BeforeEach public void setupSerializerRegistry() { @@ -155,4 +178,28 @@ public void testRecordSerializationPerformance() throws Exception { // Sanity check: 50,000 serializations with cached reflection should easily finish under 1 second assertTrue(durationMs < 1000, "Performance test took too long, caching might be failing: " + durationMs + " ms"); } + + @Test + public void testLegacyPojoSerializationRegression() throws Exception { + // Register the legacy POJO class + Serializer.registerClass(LegacyPojoMessage.class); + + // Verify it was correctly assigned the legacy FieldSerializer instead of RecordSerializer + SerializerRegistration registration = Serializer.getExactSerializerRegistration(LegacyPojoMessage.class); + assertNotNull(registration); + assertEquals("FieldSerializer", registration.getSerializer().getClass().getSimpleName()); + + // Verify read/write roundtrip works exactly as it always did + LegacyPojoMessage original = new LegacyPojoMessage(777, "BackwardCompatibilityTest"); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof LegacyPojoMessage); + assertEquals(original, result); + } } \ No newline at end of file From dbcd71722350e1d2140dd59e39d0e47d8df7a15c Mon Sep 17 00:00:00 2001 From: thkluge Date: Mon, 26 Oct 2026 22:22:03 +0100 Subject: [PATCH 3/9] fixed some AI recommendation --- .../jme3/network/serializing/Serializer.java | 3 +- .../serializers/RecordSerializer.java | 34 ++++++++--- .../serializing/RecordSerializerTest.java | 61 ++++++++++++++++++- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index 4c2542d002..3abdd6a443 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -307,7 +307,8 @@ public static SerializerRegistration registerClass(Class cls, boolean failOnMiss serializer = isRecord(cls) ? getRecordSerializer() : fieldSerializer; } else { // A specific custom serializer was explicitly requested by the user - serializer = getSerializer(serializerClass, false); + SerializerRegistration serializerReg = getExactSerializerRegistration(serializerClass); + serializer = serializerReg == null ? null : serializerReg.getSerializer(); if (serializer == null) { try { serializer = (Serializer) serializerClass.getDeclaredConstructor().newInstance(); diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java index 7f9d70c890..be74ba926b 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java @@ -35,6 +35,7 @@ import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.nio.ByteBuffer; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -45,6 +46,7 @@ * * @author thkluge */ +@SuppressWarnings("unchecked") public class RecordSerializer extends Serializer { private static final Map, RecordCache> CACHE = new ConcurrentHashMap<>(); @@ -52,6 +54,8 @@ public class RecordSerializer extends Serializer { private static class RecordCache { Constructor constructor; Method[] accessors; + Class[] componentTypes; + Serializer[] componentSerializers; } /** @@ -66,21 +70,27 @@ public void initialize(Class clazz) { Method getRecordComponents = Class.class.getMethod("getRecordComponents"); Object[] components = (Object[]) getRecordComponents.invoke(clazz); - Class[] types = new Class[components.length]; + rc.componentTypes = new Class[components.length]; rc.accessors = new Method[components.length]; + rc.componentSerializers = new Serializer[components.length]; if (components.length > 0) { Method getType = components[0].getClass().getMethod("getType"); Method getAccessor = components[0].getClass().getMethod("getAccessor"); for (int i = 0; i < components.length; i++) { - types[i] = (Class) getType.invoke(components[i]); + rc.componentTypes[i] = (Class) getType.invoke(components[i]); rc.accessors[i] = (Method) getAccessor.invoke(components[i]); rc.accessors[i].setAccessible(true); + + // Match FieldSerializer: resolve and register final/primitive/array types upfront during init + if (Modifier.isFinal(rc.componentTypes[i].getModifiers()) || rc.componentTypes[i].isPrimitive() || rc.componentTypes[i].isArray()) { + rc.componentSerializers[i] = Serializer.getSerializer(rc.componentTypes[i], false); + } } } - rc.constructor = clazz.getDeclaredConstructor(types); + rc.constructor = clazz.getDeclaredConstructor(rc.componentTypes); rc.constructor.setAccessible(true); CACHE.put(clazz, rc); } catch (Exception e) { @@ -104,10 +114,14 @@ public T readObject(ByteBuffer data, Class cls) throws IOException { Object[] args = new Object[rc.accessors.length]; for (int i = 0; i < rc.accessors.length; i++) { - args[i] = Serializer.readClassAndObject(data); + if (rc.componentSerializers[i] != null) { + args[i] = rc.componentSerializers[i].readObject(data, rc.componentTypes[i]); + } else { + args[i] = Serializer.readClassAndObject(data); + } } - return (T) rc.constructor.newInstance(args); + return cls.cast(rc.constructor.newInstance(args)); } catch (Exception e) { throw new IOException("Failed to deserialize record: " + cls.getName(), e); } @@ -125,9 +139,13 @@ public void writeObject(ByteBuffer buffer, Object object) throws IOException { try { RecordCache rc = CACHE.get(object.getClass()); - for (Method accessor : rc.accessors) { - Object value = accessor.invoke(object); - Serializer.writeClassAndObject(buffer, value); + for (int i = 0; i < rc.accessors.length; i++) { + Object value = rc.accessors[i].invoke(object); + if (rc.componentSerializers[i] != null) { + rc.componentSerializers[i].writeObject(buffer, value); + } else { + Serializer.writeClassAndObject(buffer, value); + } } } catch (Exception e) { throw new IOException("Failed to serialize record: " + object.getClass().getName(), e); diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java index 0e340a586f..0d684d5deb 100644 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java @@ -60,6 +60,12 @@ public Message setReliable(boolean f) { } } + @Serializable + public record RecordWithArray(int id, byte[] payload) {} + + @Serializable + public record UnregisteredNestedRecord(SimpleRecord nested, String label) {} + @Serializable public static class LegacyPojoMessage extends AbstractMessage { private int id; @@ -175,8 +181,6 @@ public void testRecordSerializationPerformance() throws Exception { // Output result to console for visibility during local test runs System.out.println("RecordSerializer Performance: " + iterations + " iterations took " + durationMs + " ms."); - // Sanity check: 50,000 serializations with cached reflection should easily finish under 1 second - assertTrue(durationMs < 1000, "Performance test took too long, caching might be failing: " + durationMs + " ms"); } @Test @@ -202,4 +206,57 @@ public void testLegacyPojoSerializationRegression() throws Exception { assertTrue(result instanceof LegacyPojoMessage); assertEquals(original, result); } + + @Test + public void testRecordWithByteArrayComponentUnderLockedRegistry() throws Exception { + // Register the record containing a byte[] array component + Serializer.registerClass(RecordWithArray.class); + + // Lock the registry (simulating server startup completion) + Serializer.setReadOnly(true); + + try { + RecordWithArray original = new RecordWithArray(101, new byte[]{1, 2, 3, 4, 5}); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof RecordWithArray); + assertArrayEquals(original.payload(), ((RecordWithArray) result).payload()); + } finally { + // Always unlock registry for subsequent tests + Serializer.setReadOnly(false); + } + } + + @Test + public void testNestedRecordAutoRegistrationUnderLockedRegistry() throws Exception { + // Register ONLY the outer record. The inner SimpleRecord should be + // automatically registered during initialization of UnregisteredNestedRecord. + Serializer.registerClass(UnregisteredNestedRecord.class); + + // Lock the registry + Serializer.setReadOnly(true); + + try { + SimpleRecord inner = new SimpleRecord(77, "NestedAuto", 2.5f); + UnregisteredNestedRecord original = new UnregisteredNestedRecord(inner, "OuterLabel"); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof UnregisteredNestedRecord); + assertEquals(original, result); + } finally { + Serializer.setReadOnly(false); + } + } } \ No newline at end of file From 9ab5da0bdcf0c466db089b26188f1651aba2f820 Mon Sep 17 00:00:00 2001 From: thkluge Date: Mon, 26 Oct 2026 22:33:21 +0100 Subject: [PATCH 4/9] added enum test to verify it works fixed nestedrecord test --- .../serializing/RecordSerializerTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java index 0d684d5deb..95dc67ae29 100644 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java @@ -62,6 +62,13 @@ public Message setReliable(boolean f) { @Serializable public record RecordWithArray(int id, byte[] payload) {} + + public enum TestStatus { + ACTIVE, PENDING, INACTIVE + } + + @Serializable + public record RecordWithEnum(int id, TestStatus status) {} @Serializable public record UnregisteredNestedRecord(SimpleRecord nested, String label) {} @@ -235,10 +242,20 @@ public void testRecordWithByteArrayComponentUnderLockedRegistry() throws Excepti @Test public void testNestedRecordAutoRegistrationUnderLockedRegistry() throws Exception { + // Reset registry to ensure SimpleRecord is NOT pre-registered + Serializer.initialize(); + + assertNull(Serializer.getExactSerializerRegistration(SimpleRecord.class), + "SimpleRecord should not be registered yet"); + // Register ONLY the outer record. The inner SimpleRecord should be // automatically registered during initialization of UnregisteredNestedRecord. Serializer.registerClass(UnregisteredNestedRecord.class); + // Verify that the inner record was successfully auto-registered as a side effect + assertNotNull(Serializer.getExactSerializerRegistration(SimpleRecord.class), + "SimpleRecord should have been auto-registered during outer record initialization"); + // Lock the registry Serializer.setReadOnly(true); @@ -257,6 +274,29 @@ public void testNestedRecordAutoRegistrationUnderLockedRegistry() throws Excepti assertEquals(original, result); } finally { Serializer.setReadOnly(false); + // Re-initialize for any subsequent tests sharing the @BeforeEach lifecycle + Serializer.initialize(); + Serializer.registerClass(SimpleRecord.class); + Serializer.registerClass(NestedMessageRecord.class); } } + + @Test + public void testRecordWithEnumComponentSerialization() throws Exception { + // Register only the outer record (TestStatus should be auto-registered during RecordSerializer init) + Serializer.registerClass(RecordWithEnum.class); + + RecordWithEnum original = new RecordWithEnum(500, TestStatus.PENDING); + ByteBuffer buffer = ByteBuffer.allocate(256); + + Serializer.writeClassAndObject(buffer, original); + buffer.flip(); + + Object result = Serializer.readClassAndObject(buffer); + + assertNotNull(result); + assertTrue(result instanceof RecordWithEnum); + assertEquals(original, result); + assertEquals(TestStatus.PENDING, ((RecordWithEnum) result).status()); + } } \ No newline at end of file From 1849eadc3dda21a6e2d3a6e82a9c23d64d18fce1 Mon Sep 17 00:00:00 2001 From: thkluge Date: Mon, 26 Oct 2026 22:47:29 +0100 Subject: [PATCH 5/9] fixed parameter name / java doc name --- .../jme3/network/serializing/serializers/RecordSerializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java index be74ba926b..5a8bc69498 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java @@ -102,7 +102,7 @@ public void initialize(Class clazz) { * Reads record fields from the buffer and creates a new record instance using its constructor. * * @param data The buffer to read from. - * @param clazz The class of the record. + * @param cls The class of the record. * @param The record type. * @return The deserialized record instance. * @throws IOException If deserialization fails. From 37d1cc52652fcca85803fd46e69c08231a35ef86 Mon Sep 17 00:00:00 2001 From: thkluge Date: Tue, 27 Oct 2026 08:32:41 +0100 Subject: [PATCH 6/9] reworked PR to use predict instead of heavy reflection removed records serializer as this can be implemented now vie predict --- .../network/serializing/Serializable.java | 3 +- .../jme3/network/serializing/Serializer.java | 92 +++--- .../serializers/RecordSerializer.java | 154 --------- .../serializing/PredicateSerializerTest.java | 120 +++++++ .../serializing/RecordSerializerTest.java | 302 ------------------ 5 files changed, 170 insertions(+), 501 deletions(-) delete mode 100644 jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java create mode 100644 jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java delete mode 100644 jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java index 4d5a2122f5..5535464638 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializable.java @@ -31,6 +31,7 @@ */ package com.jme3.network.serializing; +import com.jme3.network.serializing.serializers.FieldSerializer; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -47,7 +48,7 @@ * * @return the serializer class */ - Class serializer() default Serializer.class; + Class serializer() default FieldSerializer.class; /** * Returns the fixed serializer id for the annotated type, or {@code 0} for auto-assignment. * diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index 3abdd6a443..c0900bf51c 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -38,12 +38,12 @@ import com.jme3.network.message.GZIPCompressedMessage; import com.jme3.network.message.ZIPCompressedMessage; import com.jme3.network.serializing.serializers.*; -import java.lang.reflect.Method; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.ByteBuffer; import java.util.*; +import java.util.function.Predicate; import java.util.jar.Attributes; import java.util.logging.Level; import java.util.logging.Logger; @@ -70,9 +70,17 @@ public abstract class Serializer { private static final Serializer fieldSerializer = new FieldSerializer(); private static final Serializer arraySerializer = new ArraySerializer(); - private static Serializer recordSerializer; - private static final Method IS_RECORD_METHOD; + // Predicate-based registry for dynamic serializer resolution + private static class PredicateRegistration { + final Predicate> predicate; + final Serializer serializer; + PredicateRegistration(Predicate> p, Serializer s) { + this.predicate = p; + this.serializer = s; + } + } + private static final List predicateSerializers = new ArrayList<>(); private static short nextAvailableId = -2; // historically the first ID was always -2 @@ -89,14 +97,6 @@ public Serializer() { // Registers the classes we already have serializers for. static { - Method method = null; - try { - method = Class.class.getMethod("isRecord"); - } catch (NoSuchMethodException | SecurityException e) { - // Java 8 to 15: Records are not supported by the runtime - method = null; - } - IS_RECORD_METHOD = method; initialize(); } @@ -175,13 +175,6 @@ public static void initialize() { registerClass(ChannelInfoMessage.class); } - private static synchronized Serializer getRecordSerializer() { - if (recordSerializer == null) { - recordSerializer = new RecordSerializer(); - } - return recordSerializer; - } - /** * When set to true, classes that do not have intrinsic IDs in their * {@code @Serializable } will not be auto-registered during write. Defaults @@ -300,25 +293,22 @@ public static SerializerRegistration registerClass(Class cls, boolean failOnMiss Serializer serializer = null; - // If the annotation specifies the default (FieldSerializer.class or Serializer.class), - // or if no custom serializer was assigned, automatically route records to RecordSerializer - // and standard classes to FieldSerializer: - if (serializerClass == null || serializerClass == FieldSerializer.class || serializerClass == Serializer.class) { - serializer = isRecord(cls) ? getRecordSerializer() : fieldSerializer; - } else { - // A specific custom serializer was explicitly requested by the user - SerializerRegistration serializerReg = getExactSerializerRegistration(serializerClass); - serializer = serializerReg == null ? null : serializerReg.getSerializer(); - if (serializer == null) { - try { - serializer = (Serializer) serializerClass.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - log.log(Level.WARNING, "Failed to instantiate custom serializer: " + serializerClass, e); - serializer = isRecord(cls) ? getRecordSerializer() : fieldSerializer; + // if the default FieldSerializer is requested, check predicates first + if (serializerClass == FieldSerializer.class) { + for (PredicateRegistration pr : predicateSerializers) { + if (pr.predicate.test(cls)) { + serializer = pr.serializer; + break; } } } + // Fallback to standard resolution if no predicate matched + if (serializer == null) { + serializer = getSerializer(serializerClass, false); + if (serializer == null) serializer = fieldSerializer; + } + SerializerRegistration existingReg = getExactSerializerRegistration(cls); if (existingReg != null) classId = existingReg.getId(); @@ -500,7 +490,20 @@ public static SerializerRegistration getSerializerRegistration(Class cls, boolea if( failOnMiss ) { throw new IllegalArgumentException( "Class has not been registered:" + cls ); } - return registerClass(cls, isRecord(cls) ? getRecordSerializer() : fieldSerializer); + + // check predicates for auto-registration fallback + Serializer fallbackSerializer = null; + for (PredicateRegistration pr : predicateSerializers) { + if (pr.predicate.test(cls)) { + fallbackSerializer = pr.serializer; + break; + } + } + if (fallbackSerializer == null) { + fallbackSerializer = fieldSerializer; + } + + return registerClass(cls, fallbackSerializer); } @@ -588,18 +591,19 @@ public static void writeClassAndObject(ByteBuffer buffer, Object object) throws } /** - * Safely checks if a class is a record using cached reflection. - * Compliant with Java 16+ JVM semantics while cleanly returning false on Java 8–15. + * Registers a custom serializer that will be automatically selected for any class + * matching the given predicate, provided the class does not explicitly specify a serializer. + * This is useful for feature-specific serializers (like Java Records) without hardcoding + * them into the engine core. + * + * @param predicate The condition the class must meet. + * @param serializer The serializer to use. */ - private static boolean isRecord(Class cls) { - if (IS_RECORD_METHOD == null || cls == null) { - return false; - } - try { - return (Boolean) IS_RECORD_METHOD.invoke(cls); - } catch (Exception e) { - return false; + public static void registerSerializerPredicate(Predicate> predicate, Serializer serializer) { + if (locked) { + throw new RuntimeException("Serializer registry locked trying to register predicate"); } + predicateSerializers.add(new PredicateRegistration(predicate, serializer)); } /** diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java deleted file mode 100644 index 5a8bc69498..0000000000 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/serializers/RecordSerializer.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2009-2026 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.network.serializing.serializers; - -import com.jme3.network.serializing.Serializer; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.nio.ByteBuffer; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Serializer for Java {@code record} types. Uses cached reflection - * for performance supporting immutable records across modular Java runtimes. - * - * @author thkluge - */ -@SuppressWarnings("unchecked") -public class RecordSerializer extends Serializer { - - private static final Map, RecordCache> CACHE = new ConcurrentHashMap<>(); - - private static class RecordCache { - Constructor constructor; - Method[] accessors; - Class[] componentTypes; - Serializer[] componentSerializers; - } - - /** - * Initializes and caches the record's constructor and component accessor methods. - * - * @param clazz The record class being registered. - */ - @Override - public void initialize(Class clazz) { - try { - RecordCache rc = new RecordCache(); - Method getRecordComponents = Class.class.getMethod("getRecordComponents"); - Object[] components = (Object[]) getRecordComponents.invoke(clazz); - - rc.componentTypes = new Class[components.length]; - rc.accessors = new Method[components.length]; - rc.componentSerializers = new Serializer[components.length]; - - if (components.length > 0) { - Method getType = components[0].getClass().getMethod("getType"); - Method getAccessor = components[0].getClass().getMethod("getAccessor"); - - for (int i = 0; i < components.length; i++) { - rc.componentTypes[i] = (Class) getType.invoke(components[i]); - rc.accessors[i] = (Method) getAccessor.invoke(components[i]); - rc.accessors[i].setAccessible(true); - - // Match FieldSerializer: resolve and register final/primitive/array types upfront during init - if (Modifier.isFinal(rc.componentTypes[i].getModifiers()) || rc.componentTypes[i].isPrimitive() || rc.componentTypes[i].isArray()) { - rc.componentSerializers[i] = Serializer.getSerializer(rc.componentTypes[i], false); - } - } - } - - rc.constructor = clazz.getDeclaredConstructor(rc.componentTypes); - rc.constructor.setAccessible(true); - CACHE.put(clazz, rc); - } catch (Exception e) { - throw new RuntimeException("Failed to initialize RecordCache for: " + clazz.getName(), e); - } - } - - /** - * Reads record fields from the buffer and creates a new record instance using its constructor. - * - * @param data The buffer to read from. - * @param cls The class of the record. - * @param The record type. - * @return The deserialized record instance. - * @throws IOException If deserialization fails. - */ - @Override - public T readObject(ByteBuffer data, Class cls) throws IOException { - try { - RecordCache rc = CACHE.get(cls); - Object[] args = new Object[rc.accessors.length]; - - for (int i = 0; i < rc.accessors.length; i++) { - if (rc.componentSerializers[i] != null) { - args[i] = rc.componentSerializers[i].readObject(data, rc.componentTypes[i]); - } else { - args[i] = Serializer.readClassAndObject(data); - } - } - - return cls.cast(rc.constructor.newInstance(args)); - } catch (Exception e) { - throw new IOException("Failed to deserialize record: " + cls.getName(), e); - } - } - - /** - * Writes the record's field values to the buffer using cached accessors. - * - * @param buffer The buffer to write to. - * @param object The record object to serialize. - * @throws IOException If serialization fails. - */ - @Override - public void writeObject(ByteBuffer buffer, Object object) throws IOException { - try { - RecordCache rc = CACHE.get(object.getClass()); - - for (int i = 0; i < rc.accessors.length; i++) { - Object value = rc.accessors[i].invoke(object); - if (rc.componentSerializers[i] != null) { - rc.componentSerializers[i].writeObject(buffer, value); - } else { - Serializer.writeClassAndObject(buffer, value); - } - } - } catch (Exception e) { - throw new IOException("Failed to serialize record: " + object.getClass().getName(), e); - } - } -} \ No newline at end of file diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java new file mode 100644 index 0000000000..f62e650684 --- /dev/null +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.network.serializing; + +import com.jme3.network.serializing.serializers.FieldSerializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.*; + +public class PredicateSerializerTest { + + // A dummy message representing a custom data structure (e.g., mimicking a Record or Kotlin Data Class) + @Serializable + public static class SpecialMessage { + public String data; + public SpecialMessage() {} + } + + // A standard message that should fall back to the default FieldSerializer + @Serializable + public static class StandardMessage { + public int id; + public StandardMessage() {} + } + + // A mock custom serializer to test the routing mechanism + public static class MockCustomSerializer extends Serializer { + @Override + public T readObject(ByteBuffer data, Class c) { + return null; // Mock implementation + } + + @Override + public void writeObject(ByteBuffer buffer, Object object) { + // Mock implementation + } + } + + @BeforeEach + public void setupRegistry() { + Serializer.initialize(); + + // Register the predicate: any class matching SpecialMessage gets the MockCustomSerializer + Serializer.registerSerializerPredicate( + cls -> cls == SpecialMessage.class, + new MockCustomSerializer() + ); + } + + @Test + public void testPredicateSerializerSelectionOverridesDefault() { + // Register the class + Serializer.registerClass(SpecialMessage.class); + + SerializerRegistration registration = Serializer.getExactSerializerRegistration(SpecialMessage.class); + + assertNotNull(registration, "Registration should not be null"); + // Verify the predicate successfully intercepted the registration and applied the mock serializer + assertEquals(MockCustomSerializer.class, registration.getSerializer().getClass(), + "SpecialMessage should be routed to MockCustomSerializer via predicate"); + } + + @Test + public void testStandardClassFallsBackToFieldSerializer() { + // Register a normal POJO + Serializer.registerClass(StandardMessage.class); + + SerializerRegistration registration = Serializer.getExactSerializerRegistration(StandardMessage.class); + + assertNotNull(registration, "Registration should not be null"); + // Verify classes that do not match the predicate still receive the default FieldSerializer + assertEquals(FieldSerializer.class, registration.getSerializer().getClass(), + "StandardMessage should fall back to the default FieldSerializer"); + } + + @Test + public void testPredicateAutoRegistrationFallback() { + // Clear registrations + Serializer.initialize(); + Serializer.registerSerializerPredicate(cls -> cls == SpecialMessage.class, new MockCustomSerializer()); + + // Fetch the registration using the auto-registration fallback mechanism (passing failOnMiss=false implicitly or explicitly) + SerializerRegistration registration = Serializer.getSerializerRegistration(SpecialMessage.class, false); + + assertNotNull(registration, "Auto-registration should successfully create a registration"); + assertEquals(MockCustomSerializer.class, registration.getSerializer().getClass(), + "Auto-registration should respect the registered predicates"); + } +} \ No newline at end of file diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java deleted file mode 100644 index 95dc67ae29..0000000000 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/RecordSerializerTest.java +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright (c) 2009-2026 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.network.serializing; - -import com.jme3.network.AbstractMessage; -import com.jme3.network.Message; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.nio.ByteBuffer; - -import static org.junit.jupiter.api.Assertions.*; - -public class RecordSerializerTest { - - @Serializable - public record SimpleRecord(int id, String name, float value) {} - - @Serializable - public record NestedMessageRecord(SimpleRecord data, boolean isReliable) implements Message { - - // Convenience constructor - public NestedMessageRecord(SimpleRecord data) { - this(data, true); - } - - // Immutable pattern: returns a new instance instead of mutating 'this' - @Override - public Message setReliable(boolean f) { - return new NestedMessageRecord(this.data, f); - } - } - - @Serializable - public record RecordWithArray(int id, byte[] payload) {} - - public enum TestStatus { - ACTIVE, PENDING, INACTIVE - } - - @Serializable - public record RecordWithEnum(int id, TestStatus status) {} - - @Serializable - public record UnregisteredNestedRecord(SimpleRecord nested, String label) {} - - @Serializable - public static class LegacyPojoMessage extends AbstractMessage { - private int id; - private String text; - - // Required default constructor for FieldSerializer - public LegacyPojoMessage() {} - - public LegacyPojoMessage(int id, String text) { - this.id = id; - this.text = text; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - LegacyPojoMessage that = (LegacyPojoMessage) o; - return id == that.id && java.util.Objects.equals(text, that.text); - } - } - - @BeforeEach - public void setupSerializerRegistry() { - Serializer.initialize(); - Serializer.registerClass(SimpleRecord.class); - Serializer.registerClass(NestedMessageRecord.class); - } - - @Test - public void testBasicRecordSerialization() throws Exception { - SimpleRecord original = new SimpleRecord(42, "jMonkeyEngine", 3.14f); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof SimpleRecord); - - // Records automatically generate equals() evaluating all fields - assertEquals(original, result); - } - - @Test - public void testNestedRecordSerialization() throws Exception { - SimpleRecord inner = new SimpleRecord(99, "InnerData", 1.0f); - NestedMessageRecord original = new NestedMessageRecord(inner, false); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof NestedMessageRecord); - assertEquals(original, result); - } - - @Test - public void testRecordImmutabilityAndReliabilityReassignment() { - SimpleRecord inner = new SimpleRecord(1, "Test", 0f); - NestedMessageRecord original = new NestedMessageRecord(inner, true); - - // Simulate the internal KernelAdapter / ConnectorAdapter reassignment - Message modified = original.setReliable(false); - - // Verify the original object was untouched (strict immutability) - assertTrue(original.isReliable()); - - // Verify a new object was returned with the updated flag - assertFalse(modified.isReliable()); - assertNotSame(original, modified); - - // Verify the nested data remained perfectly intact - assertEquals(inner, ((NestedMessageRecord) modified).data()); - } - - @Test - public void testRegistryAssignsCorrectSerializerType() { - SerializerRegistration registration = Serializer.getExactSerializerRegistration(SimpleRecord.class); - - assertNotNull(registration); - // Ensure that the fallback did not assign the legacy FieldSerializer - assertEquals("RecordSerializer", registration.getSerializer().getClass().getSimpleName()); - } - - @Test - public void testRecordSerializationPerformance() throws Exception { - SimpleRecord original = new SimpleRecord(123, "PerformanceTestRecord", 999.99f); - ByteBuffer buffer = ByteBuffer.allocate(1024); - - // Warm up / populate cache - Serializer.writeClassAndObject(buffer, original); - buffer.clear(); - - int iterations = 50_000; - long startTime = System.nanoTime(); - - for (int i = 0; i < iterations; i++) { - buffer.clear(); - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - Object deserialized = Serializer.readClassAndObject(buffer); - assertEquals(original, deserialized); - } - - long durationMs = (System.nanoTime() - startTime) / 1_000_000; - - // Output result to console for visibility during local test runs - System.out.println("RecordSerializer Performance: " + iterations + " iterations took " + durationMs + " ms."); - - } - - @Test - public void testLegacyPojoSerializationRegression() throws Exception { - // Register the legacy POJO class - Serializer.registerClass(LegacyPojoMessage.class); - - // Verify it was correctly assigned the legacy FieldSerializer instead of RecordSerializer - SerializerRegistration registration = Serializer.getExactSerializerRegistration(LegacyPojoMessage.class); - assertNotNull(registration); - assertEquals("FieldSerializer", registration.getSerializer().getClass().getSimpleName()); - - // Verify read/write roundtrip works exactly as it always did - LegacyPojoMessage original = new LegacyPojoMessage(777, "BackwardCompatibilityTest"); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof LegacyPojoMessage); - assertEquals(original, result); - } - - @Test - public void testRecordWithByteArrayComponentUnderLockedRegistry() throws Exception { - // Register the record containing a byte[] array component - Serializer.registerClass(RecordWithArray.class); - - // Lock the registry (simulating server startup completion) - Serializer.setReadOnly(true); - - try { - RecordWithArray original = new RecordWithArray(101, new byte[]{1, 2, 3, 4, 5}); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof RecordWithArray); - assertArrayEquals(original.payload(), ((RecordWithArray) result).payload()); - } finally { - // Always unlock registry for subsequent tests - Serializer.setReadOnly(false); - } - } - - @Test - public void testNestedRecordAutoRegistrationUnderLockedRegistry() throws Exception { - // Reset registry to ensure SimpleRecord is NOT pre-registered - Serializer.initialize(); - - assertNull(Serializer.getExactSerializerRegistration(SimpleRecord.class), - "SimpleRecord should not be registered yet"); - - // Register ONLY the outer record. The inner SimpleRecord should be - // automatically registered during initialization of UnregisteredNestedRecord. - Serializer.registerClass(UnregisteredNestedRecord.class); - - // Verify that the inner record was successfully auto-registered as a side effect - assertNotNull(Serializer.getExactSerializerRegistration(SimpleRecord.class), - "SimpleRecord should have been auto-registered during outer record initialization"); - - // Lock the registry - Serializer.setReadOnly(true); - - try { - SimpleRecord inner = new SimpleRecord(77, "NestedAuto", 2.5f); - UnregisteredNestedRecord original = new UnregisteredNestedRecord(inner, "OuterLabel"); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof UnregisteredNestedRecord); - assertEquals(original, result); - } finally { - Serializer.setReadOnly(false); - // Re-initialize for any subsequent tests sharing the @BeforeEach lifecycle - Serializer.initialize(); - Serializer.registerClass(SimpleRecord.class); - Serializer.registerClass(NestedMessageRecord.class); - } - } - - @Test - public void testRecordWithEnumComponentSerialization() throws Exception { - // Register only the outer record (TestStatus should be auto-registered during RecordSerializer init) - Serializer.registerClass(RecordWithEnum.class); - - RecordWithEnum original = new RecordWithEnum(500, TestStatus.PENDING); - ByteBuffer buffer = ByteBuffer.allocate(256); - - Serializer.writeClassAndObject(buffer, original); - buffer.flip(); - - Object result = Serializer.readClassAndObject(buffer); - - assertNotNull(result); - assertTrue(result instanceof RecordWithEnum); - assertEquals(original, result); - assertEquals(TestStatus.PENDING, ((RecordWithEnum) result).status()); - } -} \ No newline at end of file From 0107f013709ebf528c06bc8707e01bfd732c4d97 Mon Sep 17 00:00:00 2001 From: thkluge Date: Tue, 27 Oct 2026 08:47:15 +0100 Subject: [PATCH 7/9] clear predicateSerializers during initialize --- .../jme3/network/serializing/Serializer.java | 3 +- .../serializing/PredicateSerializerTest.java | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index c0900bf51c..1ed839c426 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -108,7 +108,8 @@ public static void initialize() { // Reset all of the indices and tracking variables just in case idRegistrations.clear(); classRegistrations.clear(); - registrations.clear(); + registrations.clear(); + predicateSerializers.clear(); nextAvailableId = -2; // historically the first ID was always -2 diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java index f62e650684..e6e8e08766 100644 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java @@ -53,6 +53,13 @@ public static class StandardMessage { public int id; public StandardMessage() {} } + + public static class SecondMockSerializer extends Serializer { + @Override + public T readObject(ByteBuffer data, Class c) { return null; } + @Override + public void writeObject(ByteBuffer buffer, Object object) {} + } // A mock custom serializer to test the routing mechanism public static class MockCustomSerializer extends Serializer { @@ -117,4 +124,30 @@ public void testPredicateAutoRegistrationFallback() { assertEquals(MockCustomSerializer.class, registration.getSerializer().getClass(), "Auto-registration should respect the registered predicates"); } + + @Test + public void testInitializeClearsPredicateRegistrations() { + // @BeforeEach already called initialize() and registered MockCustomSerializer for SpecialMessage. + + // Re-initialize the registry. This MUST clear the predicateSerializers list. + Serializer.initialize(); + + // Register the exact same predicate condition, but with a different serializer (SecondMockSerializer) + Serializer.registerSerializerPredicate( + cls -> cls == SpecialMessage.class, + new SecondMockSerializer() + ); + + // Register the class + Serializer.registerClass(SpecialMessage.class); + + SerializerRegistration registration = Serializer.getExactSerializerRegistration(SpecialMessage.class); + + assertNotNull(registration, "Registration should not be null"); + + // If initialize() failed to clear the list, MockCustomSerializer would still be first and win. + // If it correctly cleared the list, SecondMockSerializer should be assigned. + assertEquals(SecondMockSerializer.class, registration.getSerializer().getClass(), + "initialize() failed to clear predicateSerializers, causing a stale serializer to be selected."); + } } \ No newline at end of file From 2aacb9ad2dcfd3670c62f6e568cfe689790e3ced Mon Sep 17 00:00:00 2001 From: thkluge Date: Tue, 27 Oct 2026 10:54:05 +0100 Subject: [PATCH 8/9] fix in Serializer.java: @Serializable defaults to FieldSerializer.class, which causes annotated classes to bypass predicate selection --- .../jme3/network/serializing/Serializer.java | 18 +++++-- .../serializing/PredicateSerializerTest.java | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index 1ed839c426..7d0808533c 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -294,8 +294,18 @@ public static SerializerRegistration registerClass(Class cls, boolean failOnMiss Serializer serializer = null; - // if the default FieldSerializer is requested, check predicates first - if (serializerClass == FieldSerializer.class) { + // ignore both FieldSerializer and Serializer defaults. + if (serializerClass != null && serializerClass != FieldSerializer.class && serializerClass != Serializer.class) { + try { + // Directly instantiate the custom serializer + serializer = (Serializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + log.log(Level.SEVERE, "Could not instantiate explicit custom serializer: " + serializerClass, e); + } + } + + // no explicit custom serializer was requested + if (serializer == null) { for (PredicateRegistration pr : predicateSerializers) { if (pr.predicate.test(cls)) { serializer = pr.serializer; @@ -306,14 +316,12 @@ public static SerializerRegistration registerClass(Class cls, boolean failOnMiss // Fallback to standard resolution if no predicate matched if (serializer == null) { - serializer = getSerializer(serializerClass, false); - if (serializer == null) serializer = fieldSerializer; + serializer = fieldSerializer; } SerializerRegistration existingReg = getExactSerializerRegistration(cls); if (existingReg != null) classId = existingReg.getId(); - SerializerRegistration reg = new SerializerRegistration(serializer, cls, classId); return registerClassForId(classId, cls, serializer); } if (failOnMiss) { diff --git a/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java index e6e8e08766..6a83f24c41 100644 --- a/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java +++ b/jme3-networking/src/test/java/com/jme3/network/serializing/PredicateSerializerTest.java @@ -54,6 +54,17 @@ public static class StandardMessage { public StandardMessage() {} } + @Serializable(serializer = Serializer.class) + public static class SpecialMessageWithAlternateDefault { + public SpecialMessageWithAlternateDefault() {} + } + + // Simulates a class where the user EXPLICITLY requested a custom serializer + @Serializable(serializer = SecondMockSerializer.class) + public static class ExplicitCustomMessage { + public ExplicitCustomMessage() {} + } + public static class SecondMockSerializer extends Serializer { @Override public T readObject(ByteBuffer data, Class c) { return null; } @@ -150,4 +161,43 @@ public void testInitializeClearsPredicateRegistrations() { assertEquals(SecondMockSerializer.class, registration.getSerializer().getClass(), "initialize() failed to clear predicateSerializers, causing a stale serializer to be selected."); } + + @Test + public void testPredicateEvaluatesForAlternateDefaultSerializer() { + Serializer.initialize(); + Serializer.registerSerializerPredicate( + cls -> cls == SpecialMessageWithAlternateDefault.class, + new MockCustomSerializer() + ); + + Serializer.registerClass(SpecialMessageWithAlternateDefault.class); + SerializerRegistration registration = Serializer.getExactSerializerRegistration(SpecialMessageWithAlternateDefault.class); + + assertNotNull(registration); + + // FAILS HERE in the flawed implementation: + // The registry sees `Serializer.class`, skips the predicate loop, + // and falls back to `FieldSerializer`, ignoring your mock! + assertEquals(MockCustomSerializer.class, registration.getSerializer().getClass(), + "Predicate should intercept the alternate default (Serializer.class)"); + } + + @Test + public void testExplicitCustomSerializerOverridesPredicate() { + Serializer.initialize(); + // Register a predicate that TRIES to intercept this class + Serializer.registerSerializerPredicate( + cls -> cls == ExplicitCustomMessage.class, + new MockCustomSerializer() + ); + + // Because the user explicitly annotated this with `@Serializable(serializer = SecondMockSerializer.class)`, + // the explicit request must win, and the predicate must be ignored. + Serializer.registerClass(ExplicitCustomMessage.class); + SerializerRegistration registration = Serializer.getExactSerializerRegistration(ExplicitCustomMessage.class); + + assertNotNull(registration); + assertEquals(SecondMockSerializer.class, registration.getSerializer().getClass(), + "Explicit custom serializer must override predicate selection"); + } } \ No newline at end of file From 82ac5b5fd1a799c79621534a184df567f078519a Mon Sep 17 00:00:00 2001 From: thkluge Date: Tue, 27 Oct 2026 11:22:30 +0100 Subject: [PATCH 9/9] added some java doc for registerSerializerPredicate --- .../com/jme3/network/serializing/Serializer.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java index 7d0808533c..96ccb17360 100644 --- a/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java +++ b/jme3-networking/src/main/java/com/jme3/network/serializing/Serializer.java @@ -600,13 +600,15 @@ public static void writeClassAndObject(ByteBuffer buffer, Object object) throws } /** - * Registers a custom serializer that will be automatically selected for any class - * matching the given predicate, provided the class does not explicitly specify a serializer. - * This is useful for feature-specific serializers (like Java Records) without hardcoding - * them into the engine core. + * Registers a predicate-based dynamic serializer. + *

+ * Important Ordering Behavior: Predicates are evaluated in registration order + * using a first-match-wins strategy. Furthermore, predicates only affect classes + * registered afterwards; any classes already registered prior to this call will + * not be re-evaluated against the predicate. * - * @param predicate The condition the class must meet. - * @param serializer The serializer to use. + * @param predicate the condition used to match classes dynamically + * @param serializer the serializer instance to assign when the predicate matches */ public static void registerSerializerPredicate(Predicate> predicate, Serializer serializer) { if (locked) {