From a04b7e0860b9429ee90d6d6f039144859d6b9dae Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 5 Apr 2026 07:54:27 +0930 Subject: [PATCH 1/8] Add ID-reference replacement for TrackableObjects in protocol args Protocol methods were serializing the entire CardView/PlayerView object graph for each argument. Add IdRef replacement at the Netty encoder/decoder level: the server encoder swaps CardView/PlayerView with lightweight IdRef(typeTag, id) markers, and the client decoder resolves them from the Tracker. setGameView and openView are excluded (they carry the full state). Extract shared ID-mapping primitives (IdRef, type tags, typeTagFor, trackableTypeFor) from GameEventProxy into TrackableRef so both the encoder/decoder path and GameEventProxy can reuse them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gamemodes/net/CObjectInputStream.java | 13 +++- .../gamemodes/net/CObjectOutputStream.java | 10 ++- .../net/CompatibleObjectDecoder.java | 39 ++++++++-- .../net/CompatibleObjectEncoder.java | 34 ++++++++- .../forge/gamemodes/net/GameEventProxy.java | 5 -- .../forge/gamemodes/net/TrackableRef.java | 74 +++++++++++++++++++ .../net/client/GameClientHandler.java | 6 ++ 7 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java b/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java index e378f36734e..540b5a7d1c5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java @@ -1,15 +1,21 @@ package forge.gamemodes.net; +import forge.trackable.Tracker; import io.netty.handler.codec.serialization.ClassResolver; import java.io.*; public class CObjectInputStream extends ObjectInputStream { private final ClassResolver classResolver; + private final Tracker tracker; - CObjectInputStream(InputStream in, ClassResolver classResolver) throws IOException { + CObjectInputStream(InputStream in, ClassResolver classResolver, Tracker tracker) throws IOException { super(in); this.classResolver = classResolver; + this.tracker = tracker; + if (tracker != null) { + enableResolveObject(true); + } } @Override @@ -35,4 +41,9 @@ protected Class resolveClass(ObjectStreamClass desc) throws IOException, Clas } return clazz; } + + @Override + protected Object resolveObject(Object obj) throws IOException { + return TrackableRef.resolve(obj, tracker); + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java index a37025ab7db..acfc2d83520 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java @@ -8,8 +8,11 @@ public class CObjectOutputStream extends ObjectOutputStream { static final int TYPE_THIN_DESCRIPTOR = 1; - CObjectOutputStream(OutputStream out) throws IOException { + CObjectOutputStream(OutputStream out, boolean replaceTrackables) throws IOException { super(out); + if (replaceTrackables) { + enableReplaceObject(true); + } } @Override @@ -18,4 +21,9 @@ protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException { write(TYPE_THIN_DESCRIPTOR); writeUTF(desc.getName()); } + + @Override + protected Object replaceObject(Object obj) throws IOException { + return TrackableRef.replace(obj); + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java index e56ec35785b..dfd4c0f48b2 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java @@ -1,7 +1,7 @@ package forge.gamemodes.net; import forge.gui.GuiBase; -import forge.util.IHasForgeLog; +import forge.trackable.Tracker; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.channel.ChannelHandlerContext; @@ -9,12 +9,15 @@ import io.netty.handler.codec.serialization.ClassResolver; import net.jpountz.lz4.LZ4BlockInputStream; +import java.io.IOException; +import java.io.InputStream; import java.io.ObjectInputStream; import java.io.StreamCorruptedException; -public class CompatibleObjectDecoder extends LengthFieldBasedFrameDecoder implements IHasForgeLog { +public class CompatibleObjectDecoder extends LengthFieldBasedFrameDecoder implements IHasNetLog { private final ClassResolver classResolver; + private volatile Tracker tracker; public CompatibleObjectDecoder(ClassResolver classResolver) { this(1048576, classResolver); @@ -25,6 +28,10 @@ public CompatibleObjectDecoder(int maxObjectSize, ClassResolver classResolver) { this.classResolver = classResolver; } + public void setTracker(Tracker tracker) { + this.tracker = tracker; + } + @Override protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { int frameStart = in.readerIndex(); @@ -34,9 +41,16 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception } int frameSize = frame.readableBytes(); long startMs = System.currentTimeMillis(); - ObjectInputStream ois = GuiBase.hasPropertyConfig() ? - new ObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true))): - new CObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)),this.classResolver); + + Tracker currentTracker = this.tracker; + ObjectInputStream ois; + if (GuiBase.hasPropertyConfig()) { + ois = currentTracker != null + ? new ResolvingObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), currentTracker) + : new ObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true))); + } else { + ois = new CObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), this.classResolver, currentTracker); + } Object var5 = null; try { @@ -55,4 +69,19 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception return var5; } + + private static class ResolvingObjectInputStream extends ObjectInputStream { + private final Tracker tracker; + + ResolvingObjectInputStream(InputStream in, Tracker tracker) throws IOException { + super(in); + this.tracker = tracker; + enableResolveObject(true); + } + + @Override + protected Object resolveObject(Object obj) { + return TrackableRef.resolve(obj, tracker); + } + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index 41d816dfc5e..ffa4b44062b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -1,5 +1,6 @@ package forge.gamemodes.net; +import forge.gamemodes.net.event.GuiGameEvent; import forge.gui.GuiBase; import forge.util.IHasForgeLog; import io.netty.buffer.ByteBuf; @@ -8,13 +9,14 @@ import io.netty.handler.codec.MessageToByteEncoder; import net.jpountz.lz4.LZ4BlockOutputStream; +import java.io.IOException; import java.io.ObjectOutputStream; +import java.io.OutputStream; import java.io.Serializable; public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasForgeLog { private static final byte[] LENGTH_PLACEHOLDER = new byte[4]; - private final NetworkByteTracker byteTracker; public CompatibleObjectEncoder(NetworkByteTracker byteTracker) { @@ -27,9 +29,17 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) ByteBufOutputStream bout = new ByteBufOutputStream(out); ObjectOutputStream oout = null; + boolean replace = shouldReplaceTrackables(msg); + try { bout.write(LENGTH_PLACEHOLDER); - oout = GuiBase.hasPropertyConfig() ? new ObjectOutputStream(new LZ4BlockOutputStream(bout)) : new CObjectOutputStream(new LZ4BlockOutputStream(bout)); + if (GuiBase.hasPropertyConfig()) { + oout = replace + ? new ReplacingObjectOutputStream(new LZ4BlockOutputStream(bout)) + : new ObjectOutputStream(new LZ4BlockOutputStream(bout)); + } else { + oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace); + } oout.writeObject(msg); oout.flush(); } finally { @@ -53,4 +63,24 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) netLog.info("Encoded {} bytes (compressed) for {}", msgSize, msg.getClass().getSimpleName()); } } + + private static boolean shouldReplaceTrackables(Serializable msg) { + if (msg instanceof GuiGameEvent event) { + ProtocolMethod method = event.getMethod(); + return method != ProtocolMethod.setGameView && method != ProtocolMethod.openView; + } + return false; + } + + private static class ReplacingObjectOutputStream extends ObjectOutputStream { + ReplacingObjectOutputStream(OutputStream out) throws IOException { + super(out); + enableReplaceObject(true); + } + + @Override + protected Object replaceObject(Object obj) { + return TrackableRef.replace(obj); + } + } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java b/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java index c0378477175..bf87783c9ea 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java @@ -198,11 +198,6 @@ protected Object replaceObject(Object obj) { } } - /** - * ObjectInputStream that resolves IdRef markers back to TrackableObjects - * from the Tracker. Tracks whether any resolution failed so callers - * can drop damaged events. - */ private static class IdResolvingInputStream extends ObjectInputStream { private final Tracker tracker; private boolean unresolvedRefs; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java new file mode 100644 index 00000000000..97b71a7f13b --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java @@ -0,0 +1,74 @@ +package forge.gamemodes.net; + +import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.trackable.TrackableObject; +import forge.trackable.TrackableTypes; +import forge.trackable.TrackableTypes.TrackableType; +import forge.trackable.Tracker; + +import org.tinylog.Logger; + +import java.io.Serializable; + +/** + * Shared primitives for replacing CardView/PlayerView references with + * lightweight {@link IdRef} markers during network serialization. + * Used by both {@link GameEventProxy} (game events) and the Netty + * encoder/decoder (protocol method args). + */ +final class TrackableRef { + static final byte TYPE_CARD_VIEW = 0; + static final byte TYPE_PLAYER_VIEW = 1; + + static final class IdRef implements Serializable { + private static final long serialVersionUID = 1L; + final byte typeTag; + final int id; + + IdRef(byte typeTag, int id) { + this.typeTag = typeTag; + this.id = id; + } + } + + static byte typeTagFor(TrackableObject obj) { + if (obj instanceof CardView) return TYPE_CARD_VIEW; + if (obj instanceof PlayerView) return TYPE_PLAYER_VIEW; + return -1; + } + + static TrackableType trackableTypeFor(byte typeTag) { + switch (typeTag) { + case TYPE_CARD_VIEW: return TrackableTypes.CardViewType; + case TYPE_PLAYER_VIEW: return TrackableTypes.PlayerViewType; + default: return null; + } + } + + static Object replace(Object obj) { + if (obj instanceof TrackableObject trackable) { + byte tag = typeTagFor(trackable); + if (tag >= 0) { + return new IdRef(tag, trackable.getId()); + } + } + return obj; + } + + static Object resolve(Object obj, Tracker tracker) { + if (obj instanceof IdRef ref) { + TrackableType type = trackableTypeFor(ref.typeTag); + if (type != null) { + Object resolved = tracker.getObj(type, ref.id); + if (resolved == null) { + Logger.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag, ref.id); + } + return resolved; + } + } + return obj; + } + + private TrackableRef() {} +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index 9436ae73bf7..41884bc4c39 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -3,6 +3,7 @@ import forge.game.*; import forge.game.card.CardView; import forge.game.player.PlayerView; +import forge.gamemodes.net.CompatibleObjectDecoder; import forge.gamemodes.net.GameEventProxy; import forge.gamemodes.net.GameProtocolHandler; import forge.util.IHasForgeLog; @@ -62,6 +63,11 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod if (args.length > 0 && args[0] instanceof GameView gameView) { if (this.tracker == null) { this.tracker = new Tracker(); + // Set tracker on decoder for IdRef resolution in protocol args + CompatibleObjectDecoder decoder = ctx.pipeline().get(CompatibleObjectDecoder.class); + if (decoder != null) { + decoder.setTracker(this.tracker); + } if (gameView.getGameLog() == null) { gameView.initGameLog(); } From 3ba2a59c8d5a9bf9beadc745b67e63fe17fda60a Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:05:05 +0930 Subject: [PATCH 2/8] =?UTF-8?q?Enable=20encoder-level=20IdRef=20replacemen?= =?UTF-8?q?t=20for=20client=E2=86=92server=20protocol=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends encoder replacement (previously server→client only) to the client→server path. The client encoder now replaces CardView/PlayerView with lightweight IdRef markers in protocol method args like selectCard, and the server decoder resolves them from the tracker. Testing revealed that after a zone change (e.g. playing a land from hand), the server tracker holds a stale CardView with the old zone, causing Game.findByView to search the wrong zone and return null. Added a fallback global search in findByView when the zone-specific search misses, rather than modifying tracker update behavior which has wide blast radius. Also removes GameEventProxy — raw GameEvent objects travel inside DeltaPacket, with replacement handled by the encoder/decoder. Confirmed by batch testing (0 dropped events). Co-Authored-By: Claude Opus 4.6 (1M context) --- forge-game/src/main/java/forge/game/Game.java | 13 +- .../gamemodes/net/CObjectInputStream.java | 2 +- .../gamemodes/net/CObjectOutputStream.java | 2 +- .../net/CompatibleObjectDecoder.java | 2 +- .../net/CompatibleObjectEncoder.java | 42 ++- .../java/forge/gamemodes/net/DeltaPacket.java | 18 +- .../forge/gamemodes/net/GameEventProxy.java | 264 ------------------ .../forge/gamemodes/net/NetworkGuiGame.java | 12 +- .../forge/gamemodes/net/TrackableRef.java | 74 ----- .../gamemodes/net/TrackableSerializer.java | 203 ++++++++++++++ .../net/client/GameClientHandler.java | 10 +- .../net/server/GameServerHandler.java | 2 +- .../gamemodes/net/server/RemoteClient.java | 26 +- .../net/server/RemoteClientGuiGame.java | 57 ++-- 14 files changed, 326 insertions(+), 401 deletions(-) delete mode 100644 forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java delete mode 100644 forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java create mode 100644 forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java index 45867d56089..1e7eb547c2b 100644 --- a/forge-game/src/main/java/forge/game/Game.java +++ b/forge-game/src/main/java/forge/game/Game.java @@ -694,8 +694,17 @@ public Card findByView(CardView view) { if (ZoneType.Stack.equals(view.getZone())) { visit.visitAll(getStackZone()); } else if (view.getController() != null && view.getZone() != null) { - visit.visitAll(getPlayer(view.getController()).getZone(view.getZone())); - } else { // fallback if view doesn't has controller or zone set for some reason + Player p = getPlayer(view.getController()); + if (p != null) { + visit.visitAll(p.getZone(view.getZone())); + } + } else { + forEachCardInGame(visit); + } + // Zone-specific search may miss if the view has stale zone info + // (e.g. from encoder IdRef resolution using a tracker that hasn't + // been updated after a zone change). Fall back to global search. + if (visit.getFound() == null) { forEachCardInGame(visit); } return visit.getFound(); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java b/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java index 540b5a7d1c5..6f3bef24bcb 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CObjectInputStream.java @@ -44,6 +44,6 @@ protected Class resolveClass(ObjectStreamClass desc) throws IOException, Clas @Override protected Object resolveObject(Object obj) throws IOException { - return TrackableRef.resolve(obj, tracker); + return TrackableSerializer.resolve(obj, tracker); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java index acfc2d83520..aebf245ce54 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java @@ -24,6 +24,6 @@ protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException { @Override protected Object replaceObject(Object obj) throws IOException { - return TrackableRef.replace(obj); + return TrackableSerializer.replace(obj); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java index dfd4c0f48b2..2c271b0996a 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java @@ -81,7 +81,7 @@ private static class ResolvingObjectInputStream extends ObjectInputStream { @Override protected Object resolveObject(Object obj) { - return TrackableRef.resolve(obj, tracker); + return TrackableSerializer.resolve(obj, tracker); } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index ffa4b44062b..3668fc90f83 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -2,7 +2,7 @@ import forge.gamemodes.net.event.GuiGameEvent; import forge.gui.GuiBase; -import forge.util.IHasForgeLog; +import forge.trackable.Tracker; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufOutputStream; import io.netty.channel.ChannelHandlerContext; @@ -14,28 +14,40 @@ import java.io.OutputStream; import java.io.Serializable; -public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasForgeLog { +public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasNetLog { private static final byte[] LENGTH_PLACEHOLDER = new byte[4]; private final NetworkByteTracker byteTracker; + private volatile Tracker tracker; public CompatibleObjectEncoder(NetworkByteTracker byteTracker) { this.byteTracker = byteTracker; } + public void setTracker(Tracker tracker) { + this.tracker = tracker; + } + @Override protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) throws Exception { int startIdx = out.writerIndex(); ByteBufOutputStream bout = new ByteBufOutputStream(out); ObjectOutputStream oout = null; + // Replacement modes: + // - Server encoder (tracker set by RemoteClientGuiGame.setGameView): + // verified replacement with stale CardView detection. + // - Client encoder (no tracker): simple IdRef replacement. No stale + // detection — would create StaleCardRef markers that the server + // resolves as detached CardViews, breaking game object identity. + Tracker currentTracker = this.tracker; boolean replace = shouldReplaceTrackables(msg); try { bout.write(LENGTH_PLACEHOLDER); if (GuiBase.hasPropertyConfig()) { oout = replace - ? new ReplacingObjectOutputStream(new LZ4BlockOutputStream(bout)) + ? new ReplacingObjectOutputStream(new LZ4BlockOutputStream(bout), currentTracker) : new ObjectOutputStream(new LZ4BlockOutputStream(bout)); } else { oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace); @@ -64,23 +76,39 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) } } + /** + * Determines whether TrackableObject references should be replaced with + * IdRef markers for this message. Excludes: + * - setGameView/openView: carry full state to populate the client's Tracker + * - applyDelta: DeltaPacket contains property maps with carefully constructed + * values that must arrive intact (new objects may reference other new objects + * not yet in the tracker at decode time) + */ private static boolean shouldReplaceTrackables(Serializable msg) { + // DIAG: re-enabled for investigation if (msg instanceof GuiGameEvent event) { ProtocolMethod method = event.getMethod(); - return method != ProtocolMethod.setGameView && method != ProtocolMethod.openView; + return method != ProtocolMethod.setGameView + && method != ProtocolMethod.openView + && method != ProtocolMethod.applyDelta; } - return false; + return true; } private static class ReplacingObjectOutputStream extends ObjectOutputStream { - ReplacingObjectOutputStream(OutputStream out) throws IOException { + private final Tracker tracker; + + ReplacingObjectOutputStream(OutputStream out, Tracker tracker) throws IOException { super(out); + this.tracker = tracker; enableReplaceObject(true); } @Override protected Object replaceObject(Object obj) { - return TrackableRef.replace(obj); + return tracker != null + ? TrackableSerializer.replace(obj, tracker) + : TrackableSerializer.replace(obj); } } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/DeltaPacket.java b/forge-gui/src/main/java/forge/gamemodes/net/DeltaPacket.java index e25c80aae7c..0fe54a7c856 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/DeltaPacket.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/DeltaPacket.java @@ -32,7 +32,7 @@ public final class DeltaPacket implements NetEvent { private final Map> newObjects; private final int checksum; private final int[] checksumProperties; - private List proxiedEvents; + private List events; public static final int TYPE_CARD_VIEW = 0; public static final int TYPE_PLAYER_VIEW = 1; @@ -114,9 +114,9 @@ public CombatData(List> bandAttackerIds, List bandDefenderR } /** Create an events-only DeltaPacket with no state deltas (seq=-1 means no ack needed). */ - public static DeltaPacket eventsOnly(List proxiedEvents) { + public static DeltaPacket eventsOnly(List events) { DeltaPacket packet = new DeltaPacket(-1L, null, null, 0, null); - packet.setProxiedEvents(proxiedEvents); + packet.setEvents(events); return packet; } @@ -159,19 +159,19 @@ public boolean isEmpty() { return objectDeltas.isEmpty() && newObjects.isEmpty() && !hasEvents() && !hasChecksum(); } - public void setProxiedEvents(List events) { - this.proxiedEvents = events; + public void setEvents(List events) { + this.events = events; } - public List getProxiedEvents() { - return proxiedEvents; + public List getEvents() { + return events; } public boolean hasEvents() { - return proxiedEvents != null && !proxiedEvents.isEmpty(); + return events != null && !events.isEmpty(); } - /** Return a shallow copy without proxied events, for state-only size measurement. */ + /** Return a shallow copy without events, for state-only size measurement. */ public DeltaPacket withoutEvents() { return new DeltaPacket(sequenceNumber, objectDeltas, newObjects, checksum, checksumProperties); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java b/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java deleted file mode 100644 index bf87783c9ea..00000000000 --- a/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java +++ /dev/null @@ -1,264 +0,0 @@ -package forge.gamemodes.net; - -import forge.game.card.CardView; -import forge.game.event.GameEvent; -import forge.trackable.TrackableObject; -import forge.trackable.TrackableProperty; -import forge.trackable.TrackableTypes.TrackableType; -import forge.trackable.Tracker; -import forge.util.IHasForgeLog; - -import java.io.*; -import java.util.ArrayList; -import java.util.List; - -public class GameEventProxy implements Serializable, IHasForgeLog { - private static final long serialVersionUID = 1L; - - private final byte[] eventData; - - private GameEventProxy(byte[] eventData) { - this.eventData = eventData; - } - - /** - * Wraps a {@link GameEvent} by serializing it into a byte array with - * {@link TrackableObject} references replaced by lightweight {@link IdRef} - * markers. - * Returns null if verification fails. - * - *

This avoids Java serialization expanding TrackableObject references into - * the full game state object graph when events are sent over the network. - */ - public static GameEventProxy wrap(GameEvent event, Tracker tracker) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(256); - try (IdReplacingOutputStream out = new IdReplacingOutputStream(baos, tracker)) { - out.writeObject(event); - if (out.hasUnresolvableRefs()) { - return null; - } - } - return new GameEventProxy(baos.toByteArray()); - } - - /** - * Unwraps the proxy by deserializing the event with IdRef markers - * resolved to TrackableObjects from the given Tracker. Returns null - * if any IdRef could not be resolved (the event would have null - * fields where TrackableObjects were expected). - */ - public GameEvent unwrap(Tracker tracker) throws IOException, ClassNotFoundException { - ByteArrayInputStream bais = new ByteArrayInputStream(eventData); - try (IdResolvingInputStream in = new IdResolvingInputStream(bais, tracker)) { - GameEvent event = (GameEvent) in.readObject(); - if (in.hasUnresolvedRefs()) { - return null; - } - return event; - } - } - - /** - * Wraps a list of events. Events with unresolvable references are dropped - * rather than sent (the client would fail to resolve them too). - */ - public static List wrapAll(List events, Tracker tracker) { - List result = new ArrayList<>(events.size()); - for (GameEvent event : events) { - try { - GameEventProxy proxy = wrap(event, tracker); - if (proxy != null) { - result.add(proxy); - } else { - netLog.debug("Dropped {} with unresolvable references", - event.getClass().getSimpleName()); - } - } catch (IOException e) { - netLog.warn("Failed to wrap {}, sending as-is: {}", - event.getClass().getSimpleName(), e.getMessage()); - result.add(event); - } - } - return result; - } - - /** - * Unwraps a list that may contain both GameEventProxy and plain GameEvent - * objects. Proxies are unwrapped; plain events pass through unchanged. - */ - public static List unwrapAll(List items, Tracker tracker) { - List result = new ArrayList<>(items.size()); - for (Object item : items) { - if (item instanceof GameEventProxy proxy) { - try { - GameEvent event = proxy.unwrap(tracker); - if (event != null) { - result.add(event); - } else { - netLog.debug("Dropped event with unresolved references"); - } - } catch (IOException | ClassNotFoundException e) { - netLog.warn("Failed to unwrap GameEventProxy: {}", e.getMessage()); - } - } else if (item instanceof GameEvent event) { - result.add(event); - } - } - return result; - } - - /** - * Lightweight serializable marker that replaces a TrackableObject reference - * during proxy serialization. - */ - static final class IdRef implements Serializable { - private static final long serialVersionUID = 1L; - final byte typeTag; - final int id; - - IdRef(byte typeTag, int id) { - this.typeTag = typeTag; - this.id = id; - } - } - - /** - * Marker for a stale CardView reference — the event holds a previous - * incarnation of a card (same ID, different Java object) that has since - * been replaced in the tracker by a zone-change copy. Carries the - * image key and name from the original so the client can construct a - * detached CardView with the correct display data. - */ - static final class StaleCardRef implements Serializable { - private static final long serialVersionUID = 1L; - final int id; - final String imageKey; - final String name; - - StaleCardRef(int id, String imageKey, String name) { - this.id = id; - this.imageKey = imageKey; - this.name = name; - } - } - - /** - * ObjectOutputStream that replaces TrackableObject with an IdRef. - * If a tracker is provided, verifies each ID is resolvable as a - * server-side sanity check. - */ - private static class IdReplacingOutputStream extends ObjectOutputStream { - private final Tracker tracker; - private boolean unresolvableRefs; - - IdReplacingOutputStream(OutputStream out, Tracker tracker) throws IOException { - super(out); - this.tracker = tracker; - enableReplaceObject(true); - } - - boolean hasUnresolvableRefs() { - return unresolvableRefs; - } - - @Override - protected Object replaceObject(Object obj) { - if (obj instanceof TrackableObject trackable) { - int tag = DeltaPacket.typeTagFor(trackable); - // Only CardView and PlayerView are replaced with IdRef markers. These two - // types carry the largest object graphs and are always present in the GameView - // (registered via updateObjLookup on the IO thread before proxy unwrapping). - // Other TrackableObject types (StackItemView, SpellAbilityView, CombatView) - // are either ephemeral or not reachable from GameView's property graph, so - // they serialize normally. - if (tag == DeltaPacket.TYPE_CARD_VIEW || tag == DeltaPacket.TYPE_PLAYER_VIEW) { - if (tracker != null) { - TrackableType type = DeltaPacket.trackableTypeFor(tag); - if (type != null) { - Object tracked = tracker.getObj(type, trackable.getId()); - if (tracked == null) { - netLog.debug("Server-side check: {} id={} not in tracker", - trackable.getClass().getSimpleName(), trackable.getId()); - unresolvableRefs = true; - } else if (tracked != trackable && tag == DeltaPacket.TYPE_CARD_VIEW) { - // Stale reference: the event holds a previous incarnation - // of this card (e.g. ability source that changed zones). - // Preserve the image key so the client displays correctly - CardView cv = (CardView) trackable; - String imgKey = cv.getCurrentState() != null - ? cv.getCurrentState().getImageKey(null) : null; - return new StaleCardRef(cv.getId(), imgKey, cv.getName()); - } - } - } - return new IdRef((byte) tag, trackable.getId()); - } - } - return obj; - } - } - - private static class IdResolvingInputStream extends ObjectInputStream { - private final Tracker tracker; - private boolean unresolvedRefs; - - IdResolvingInputStream(InputStream in, Tracker tracker) throws IOException { - super(in); - this.tracker = tracker; - enableResolveObject(true); - } - - boolean hasUnresolvedRefs() { - return unresolvedRefs; - } - - // needed for cross-platform play because Android implements Records via desugaring to regular classes, - // causing the serialVersionUID to auto-compute instead of default 0L - // (this approach avoids having to hardcode it on each individual GameEvent instead) - @Override - protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException { - ObjectStreamClass streamDesc = super.readClassDescriptor(); - try { - Class localClass = Class.forName(streamDesc.getName()); - ObjectStreamClass localDesc = ObjectStreamClass.lookup(localClass); - if (localDesc != null && streamDesc.getSerialVersionUID() != localDesc.getSerialVersionUID()) { - return localDesc; - } - } catch (ClassNotFoundException ignored) { - // Class not found locally — fall through to stream descriptor - } - return streamDesc; - } - - @Override - protected Object resolveObject(Object obj) { - if (obj instanceof StaleCardRef ref) { - // Create a detached CardView with the correct image key. - // Not registered in the tracker — used only for display - // (game log thumbnail) so it won't affect live game state - CardView detached = new CardView(ref.id, tracker); - if (ref.name != null) { - detached.set(TrackableProperty.Name, ref.name); - detached.getCurrentState().set(TrackableProperty.Name, ref.name); - } - if (ref.imageKey != null) { - detached.getCurrentState().set(TrackableProperty.ImageKey, ref.imageKey); - } - return detached; - } - if (obj instanceof IdRef ref) { - TrackableType type = DeltaPacket.trackableTypeFor(ref.typeTag); - if (type != null) { - Object resolved = tracker.getObj(type, ref.id); - if (resolved == null) { - netLog.debug("Could not resolve {} id={} from Tracker", - type.getClass().getSimpleName(), ref.id); - unresolvedRefs = true; - } - return resolved; - } - } - return obj; - } - } -} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index 6944cc164d9..3d9da097544 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -45,14 +45,14 @@ public void applyDelta(DeltaPacket packet) { netLog.info("[DeltaSync] === START applyDelta seq={} (Turn {}, {}, Active={}) ===", packet.getSequenceNumber(), getGameView().getTurn(), phaseName, activePlayerName); - // Resolve event card references BEFORE applying deltas — the tracker - // still has the pre-replacement CardView instances, so events get the - // correct references. Dispatch happens after deltas so event handlers - // read current game state. List resolvedEvents = null; if (packet.hasEvents()) { - resolvedEvents = GameEventProxy.unwrapAll(packet.getProxiedEvents(), tracker); - netLog.info("[DeltaSync] Pre-resolved {} events before delta seq={}", + List events = new java.util.ArrayList<>(packet.getEvents().size()); + for (Object item : packet.getEvents()) { + if (item instanceof GameEvent e) events.add(e); + } + resolvedEvents = events; + netLog.info("[DeltaSync] {} events received with delta seq={}", resolvedEvents.size(), packet.getSequenceNumber()); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java deleted file mode 100644 index 97b71a7f13b..00000000000 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableRef.java +++ /dev/null @@ -1,74 +0,0 @@ -package forge.gamemodes.net; - -import forge.game.card.CardView; -import forge.game.player.PlayerView; -import forge.trackable.TrackableObject; -import forge.trackable.TrackableTypes; -import forge.trackable.TrackableTypes.TrackableType; -import forge.trackable.Tracker; - -import org.tinylog.Logger; - -import java.io.Serializable; - -/** - * Shared primitives for replacing CardView/PlayerView references with - * lightweight {@link IdRef} markers during network serialization. - * Used by both {@link GameEventProxy} (game events) and the Netty - * encoder/decoder (protocol method args). - */ -final class TrackableRef { - static final byte TYPE_CARD_VIEW = 0; - static final byte TYPE_PLAYER_VIEW = 1; - - static final class IdRef implements Serializable { - private static final long serialVersionUID = 1L; - final byte typeTag; - final int id; - - IdRef(byte typeTag, int id) { - this.typeTag = typeTag; - this.id = id; - } - } - - static byte typeTagFor(TrackableObject obj) { - if (obj instanceof CardView) return TYPE_CARD_VIEW; - if (obj instanceof PlayerView) return TYPE_PLAYER_VIEW; - return -1; - } - - static TrackableType trackableTypeFor(byte typeTag) { - switch (typeTag) { - case TYPE_CARD_VIEW: return TrackableTypes.CardViewType; - case TYPE_PLAYER_VIEW: return TrackableTypes.PlayerViewType; - default: return null; - } - } - - static Object replace(Object obj) { - if (obj instanceof TrackableObject trackable) { - byte tag = typeTagFor(trackable); - if (tag >= 0) { - return new IdRef(tag, trackable.getId()); - } - } - return obj; - } - - static Object resolve(Object obj, Tracker tracker) { - if (obj instanceof IdRef ref) { - TrackableType type = trackableTypeFor(ref.typeTag); - if (type != null) { - Object resolved = tracker.getObj(type, ref.id); - if (resolved == null) { - Logger.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag, ref.id); - } - return resolved; - } - } - return obj; - } - - private TrackableRef() {} -} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java new file mode 100644 index 00000000000..349a94c3581 --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -0,0 +1,203 @@ +package forge.gamemodes.net; + +import forge.game.card.CardView; +import forge.game.player.PlayerView; +import forge.trackable.TrackableObject; +import forge.trackable.TrackableProperty; +import forge.trackable.TrackableTypes; +import forge.trackable.TrackableTypes.TrackableType; +import forge.trackable.Tracker; + +import org.tinylog.Logger; + +import net.jpountz.lz4.LZ4BlockOutputStream; + +import java.io.*; + +/** + * Handles serialization of {@link TrackableObject} references across the network. + * Replaces CardView/PlayerView with lightweight {@link IdRef} markers during + * encoding and resolves them back from the Tracker during decoding. + * + *

Used by the Netty encoder/decoder pipeline ({@link CompatibleObjectEncoder}, + * {@link CompatibleObjectDecoder}) and the mobile codec path + * ({@link CObjectOutputStream}, {@link CObjectInputStream}). + */ +public final class TrackableSerializer { + static final byte TYPE_CARD_VIEW = 0; + static final byte TYPE_PLAYER_VIEW = 1; + + /** + * Lightweight serializable marker that replaces a TrackableObject reference. + */ + static final class IdRef implements Serializable { + private static final long serialVersionUID = 1L; + final byte typeTag; + final int id; + + IdRef(byte typeTag, int id) { + this.typeTag = typeTag; + this.id = id; + } + } + + /** + * Marker for a stale CardView reference — the object holds a previous + * incarnation of a card (same ID, different Java object) that has since + * been replaced in the tracker by a zone-change copy. Carries the + * image key and name so the decoder can construct a detached CardView + * with the correct display data. + */ + static final class StaleCardRef implements Serializable { + private static final long serialVersionUID = 1L; + final int id; + final String imageKey; + final String name; + + StaleCardRef(int id, String imageKey, String name) { + this.id = id; + this.imageKey = imageKey; + this.name = name; + } + } + + static byte typeTagFor(TrackableObject obj) { + if (obj instanceof CardView) return TYPE_CARD_VIEW; + if (obj instanceof PlayerView) return TYPE_PLAYER_VIEW; + return -1; + } + + static TrackableType trackableTypeFor(byte typeTag) { + switch (typeTag) { + case TYPE_CARD_VIEW: return TrackableTypes.CardViewType; + case TYPE_PLAYER_VIEW: return TrackableTypes.PlayerViewType; + default: return null; + } + } + + /** + * Simple replacement — no tracker, no stale detection. + * Used by the client encoder (which has no game-state awareness). + */ + static Object replace(Object obj) { + if (obj instanceof TrackableObject trackable) { + byte tag = typeTagFor(trackable); + if (tag >= 0) { + return new IdRef(tag, trackable.getId()); + } + } + return obj; + } + + /** + * Verified replacement with stale detection. + * Used by the server encoder (which has the game's tracker). + * Returns {@link StaleCardRef} for stale CardViews, {@link IdRef} for + * current objects, or the original object for non-trackable types. + */ + static Object replace(Object obj, Tracker tracker) { + if (obj instanceof TrackableObject trackable) { + byte tag = typeTagFor(trackable); + if (tag >= 0) { + if (tracker != null) { + TrackableType type = trackableTypeFor(tag); + if (type != null) { + Object tracked = tracker.getObj(type, trackable.getId()); + if (tracked != trackable && tag == TYPE_CARD_VIEW && tracked != null) { + // Stale reference: previous incarnation of this card + CardView cv = (CardView) trackable; + String imgKey = cv.getCurrentState() != null + ? cv.getCurrentState().getImageKey(null) : null; + return new StaleCardRef(cv.getId(), imgKey, cv.getName()); + } + } + } + return new IdRef(tag, trackable.getId()); + } + } + return obj; + } + + /** + * Resolves {@link IdRef} and {@link StaleCardRef} markers back to + * TrackableObjects from the given Tracker. + */ + static Object resolve(Object obj, Tracker tracker) { + if (obj instanceof StaleCardRef ref) { + // Create a detached CardView with the correct image key. + // Not registered in the tracker — used only for display + // (game log thumbnail) so it won't affect live game state. + CardView detached = new CardView(ref.id, tracker); + if (ref.name != null) { + detached.set(TrackableProperty.Name, ref.name); + detached.getCurrentState().set(TrackableProperty.Name, ref.name); + } + if (ref.imageKey != null) { + detached.getCurrentState().set(TrackableProperty.ImageKey, ref.imageKey); + } + return detached; + } + if (obj instanceof IdRef ref) { + TrackableType type = trackableTypeFor(ref.typeTag); + if (type != null) { + Object resolved = tracker.getObj(type, ref.id); + if (resolved == null) { + Logger.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag, ref.id); + } + return resolved; + } + } + return obj; + } + + /** + * Measures LZ4-compressed serialized size with IdRef replacement, + * matching the encoder wire format for applyDelta messages. + */ + public static int measureReplacedSize(Object obj, Tracker tracker) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + LZ4BlockOutputStream lz4Out = new LZ4BlockOutputStream(baos); + ObjectOutputStream oos = new ReplacingOutputStream(lz4Out, tracker); + oos.writeObject(obj); + oos.close(); + return baos.size(); + } catch (Exception e) { + return 0; + } + } + + /** + * Measures LZ4-compressed serialized size without replacement, + * matching the encoder wire format for setGameView messages. + */ + public static int measurePlainSize(Object obj) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + LZ4BlockOutputStream lz4Out = new LZ4BlockOutputStream(baos); + ObjectOutputStream oos = new ObjectOutputStream(lz4Out); + oos.writeObject(obj); + oos.close(); + return baos.size(); + } catch (Exception e) { + return 0; + } + } + + private static class ReplacingOutputStream extends ObjectOutputStream { + private final Tracker tracker; + + ReplacingOutputStream(OutputStream out, Tracker tracker) throws IOException { + super(out); + this.tracker = tracker; + enableReplaceObject(true); + } + + @Override + protected Object replaceObject(Object obj) { + return tracker != null ? replace(obj, tracker) : replace(obj); + } + } + + private TrackableSerializer() {} +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index 41884bc4c39..0e71e7d39ad 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -4,7 +4,6 @@ import forge.game.card.CardView; import forge.game.player.PlayerView; import forge.gamemodes.net.CompatibleObjectDecoder; -import forge.gamemodes.net.GameEventProxy; import forge.gamemodes.net.GameProtocolHandler; import forge.util.IHasForgeLog; import forge.gamemodes.net.IRemote; @@ -63,7 +62,12 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod if (args.length > 0 && args[0] instanceof GameView gameView) { if (this.tracker == null) { this.tracker = new Tracker(); - // Set tracker on decoder for IdRef resolution in protocol args + // Set tracker on decoder for IdRef resolution in server messages. + // The client encoder does NOT get a tracker — it uses simple + // IdRef replacement without stale detection. Stale detection + // on the client would create StaleCardRef markers for cards + // updated by delta sync, causing the server to create detached + // CardViews that don't match real game objects. CompatibleObjectDecoder decoder = ctx.pipeline().get(CompatibleObjectDecoder.class); if (decoder != null) { decoder.setTracker(this.tracker); @@ -173,7 +177,7 @@ private void replicatePlayerView(final PlayerView newPlayerView) { * {@code updateObjLookup()} skips objects already in the tracker, so CardViews * registered on the first {@code setGameView} become stale — their zone, state, * and other properties no longer reflect the server's current game state. - * When {@link GameEventProxy} resolves IdRefs from the tracker, it gets these + * When the decoder resolves IdRefs from the tracker, it gets these * stale CardViews, causing issues like card-back images in the game log * (the stale zone is Library, so {@code canBeShownTo} returns false). */ diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java index 5d7fad56e06..72c7dc7a970 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java @@ -52,4 +52,4 @@ protected void beforeCall(final ChannelHandlerContext ctx, final ProtocolMethod } } -} \ No newline at end of file +} diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java index 6ba75c284d2..6650ecbcfa1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java @@ -1,14 +1,18 @@ package forge.gamemodes.net.server; -import forge.util.IHasForgeLog; +import forge.gamemodes.net.CompatibleObjectDecoder; +import forge.gamemodes.net.CompatibleObjectEncoder; +import forge.gamemodes.net.IHasNetLog; import forge.gamemodes.net.ReplyPool; +import forge.trackable.Tracker; import forge.gamemodes.net.event.IdentifiableNetEvent; import forge.gamemodes.net.event.NetEvent; import io.netty.channel.Channel; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; -public final class RemoteClient implements IToClient, IHasForgeLog { +public final class RemoteClient implements IToClient, IHasNetLog { /** Special value indicating the client hasn't been assigned a slot yet. */ public static final int UNASSIGNED_SLOT = -1; @@ -61,7 +65,7 @@ public void write(final NetEvent event) { } @Override - public Object sendAndWait(final IdentifiableNetEvent event) { + public Object sendAndWait(final IdentifiableNetEvent event) throws TimeoutException { replies.initialize(event.getId()); send(event); return replies.get(event.getId()); @@ -81,6 +85,22 @@ public void setIndex(final int index) { this.index = index; } + /** + * Set the tracker on the channel's encoder and decoder for IdRef + * replacement/resolution. Called when the game starts (before any + * client protocol messages arrive). + */ + public void setCodecTracker(Tracker tracker) { + CompatibleObjectEncoder encoder = channel.pipeline().get(CompatibleObjectEncoder.class); + if (encoder != null) { + encoder.setTracker(tracker); + } + CompatibleObjectDecoder decoder = channel.pipeline().get(CompatibleObjectDecoder.class); + if (decoder != null) { + decoder.setTracker(tracker); + } + } + public int getSendErrorCount() { return sendErrors.get(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index cf8a05e8361..71ffd44147d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -15,22 +15,22 @@ import forge.game.zone.ZoneType; import forge.gamemodes.net.NetworkGuiGame; import forge.gamemodes.net.DeltaPacket; -import forge.gamemodes.net.GameEventProxy; import forge.gamemodes.net.GameProtocolSender; import forge.util.IHasForgeLog; import forge.gamemodes.net.ProtocolMethod; +import forge.gamemodes.net.TrackableSerializer; import forge.gui.control.GameEventForwarder; import forge.item.PaperCard; import forge.localinstance.skin.FSkinProp; import forge.model.FModel; import forge.player.PlayerZoneUpdate; import forge.player.PlayerZoneUpdates; -import forge.trackable.Tracker; + import forge.trackable.TrackableCollection; import forge.util.FSerializableFunction; import forge.util.ITriggerEvent; -import net.jpountz.lz4.LZ4BlockOutputStream; + import java.util.Collection; import java.util.List; @@ -48,6 +48,7 @@ public class RemoteClientGuiGame extends NetworkGuiGame implements IHasForgeLog private boolean initialSyncSent = false; private boolean objectsRegistered = false; + private boolean codecTrackerSet = false; private boolean fallbackLogged = false; // Prevent duplicate fallback log messages private volatile boolean paused; private volatile boolean resyncPending; @@ -182,8 +183,8 @@ private void updateGameView(boolean flush) { } if (logBandwidth) { - int deltaSize = measureSerializedSize(delta); - int fullStateSize = measureSerializedSize(gameView); + int deltaSize = measureDeltaSize(delta); + int fullStateSize = measureFullStateSize(gameView); totalDeltaBytes += deltaSize; totalFullStateBytes += fullStateSize; @@ -200,22 +201,15 @@ private void updateGameView(boolean flush) { } } - /** - * Measure the serialized+compressed size of an object. - * Uses ObjectOutputStream + LZ4 — same pipeline as the network encoder — - * so delta and full-state measurements are directly comparable. - */ - private int measureSerializedSize(Object obj) { - try { - java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - LZ4BlockOutputStream lz4Out = new LZ4BlockOutputStream(baos); - java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(lz4Out); - oos.writeObject(obj); - oos.close(); - return baos.size(); - } catch (Exception e) { - return 0; - } + /** Measure serialized size with IdRef replacement (applyDelta wire format). */ + private int measureDeltaSize(Object obj) { + forge.trackable.Tracker tracker = getGameView() != null ? getGameView().getTracker() : null; + return TrackableSerializer.measureReplacedSize(obj, tracker); + } + + /** Measure serialized size without replacement (setGameView wire format). */ + private int measureFullStateSize(Object obj) { + return TrackableSerializer.measurePlainSize(obj); } /** @@ -253,6 +247,13 @@ public void sendFullState() { @Override public void setGameView(final GameView gameView) { super.setGameView(gameView); + // Set codec tracker before any client protocol messages arrive. + // setGameView is called before openView, and the client can't respond + // until after openView — so the encoder/decoder are ready in time. + if (!codecTrackerSet && gameView != null && gameView.getTracker() != null) { + client.setCodecTracker(gameView.getTracker()); + codecTrackerSet = true; + } updateGameView(); } @@ -515,24 +516,22 @@ public void handleGameEvents(List events) { } } } - Tracker tracker = getGameView() != null ? getGameView().getTracker() : null; - List proxied = GameEventProxy.wrapAll(events, tracker); if (useDeltaSync && initialSyncSent && objectsRegistered) { // Bundle events with delta so they're applied atomically: // delta properties first, then events forwarded. GameView gameView = getGameView(); if (gameView != null) { DeltaPacket delta = syncManager.collectDeltas(gameView); - delta.setProxiedEvents(proxied); + delta.setEvents(new java.util.ArrayList(events)); sender.send(ProtocolMethod.applyDelta, delta); if (logBandwidth) { - int deltaSize = measureSerializedSize(delta); - int eventsSize = measureSerializedSize(proxied); - int stateOnlyFullSize = measureSerializedSize(gameView); + int deltaSize = measureDeltaSize(delta); + int eventsSize = measureDeltaSize(events); + int stateOnlyFullSize = measureFullStateSize(gameView); int fullStateSize = stateOnlyFullSize + eventsSize; DeltaPacket stateOnly = delta.withoutEvents(); - int stateOnlyDeltaSize = measureSerializedSize(stateOnly); + int stateOnlyDeltaSize = measureDeltaSize(stateOnly); totalDeltaBytes += deltaSize; totalFullStateBytes += fullStateSize; @@ -549,7 +548,7 @@ public void handleGameEvents(List events) { } } else { updateGameView(false); - sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(proxied)); + sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(new java.util.ArrayList(events))); } } From c8d64696b98e9ae65df659366813517d9b063252 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:15:24 +0930 Subject: [PATCH 3/8] Cleanup: remove stale DIAG comment, fix FQN ArrayList usage Co-Authored-By: Claude Opus 4.6 (1M context) --- forge-game/src/main/java/forge/game/Game.java | 4 ++-- .../java/forge/gamemodes/net/CompatibleObjectEncoder.java | 1 - .../java/forge/gamemodes/net/server/RemoteClientGuiGame.java | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java index 1e7eb547c2b..d49c8677dcf 100644 --- a/forge-game/src/main/java/forge/game/Game.java +++ b/forge-game/src/main/java/forge/game/Game.java @@ -702,8 +702,8 @@ public Card findByView(CardView view) { forEachCardInGame(visit); } // Zone-specific search may miss if the view has stale zone info - // (e.g. from encoder IdRef resolution using a tracker that hasn't - // been updated after a zone change). Fall back to global search. + // (e.g. IdRef resolved from a tracker that wasn't updated after a + // zone change). Fall back to global search. if (visit.getFound() == null) { forEachCardInGame(visit); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index 3668fc90f83..69b4ace7b1e 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -85,7 +85,6 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) * not yet in the tracker at decode time) */ private static boolean shouldReplaceTrackables(Serializable msg) { - // DIAG: re-enabled for investigation if (msg instanceof GuiGameEvent event) { ProtocolMethod method = event.getMethod(); return method != ProtocolMethod.setGameView diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index 71ffd44147d..6a8636fc454 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -32,6 +32,7 @@ +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -522,7 +523,7 @@ public void handleGameEvents(List events) { GameView gameView = getGameView(); if (gameView != null) { DeltaPacket delta = syncManager.collectDeltas(gameView); - delta.setEvents(new java.util.ArrayList(events)); + delta.setEvents(new ArrayList<>(events)); sender.send(ProtocolMethod.applyDelta, delta); if (logBandwidth) { @@ -548,7 +549,7 @@ public void handleGameEvents(List events) { } } else { updateGameView(false); - sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(new java.util.ArrayList(events))); + sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(new ArrayList<>(events))); } } From d101b6118db9a9d38daaee2efcdf73e4dd0d2636 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:42:52 +0930 Subject: [PATCH 4/8] Fix NPE in event handling: resolve trackerless TrackableObjects in decoder Events embedded in DeltaPacket are excluded from encoder IdRef replacement (to protect state data), so their TrackableObject references arrive as raw deserialized instances with null trackers. When event handlers call TrackableTypes.lookup(), the null tracker causes NPE. Added a fallback in TrackableSerializer.resolve() that detects raw TrackableObjects with no tracker and resolves them to the canonical tracker instance, matching what GameEventProxy.unwrapAll previously did. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../forge/gamemodes/net/TrackableSerializer.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 349a94c3581..7d429893cba 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -147,6 +147,22 @@ static Object resolve(Object obj, Tracker tracker) { return resolved; } } + // Resolve raw TrackableObjects that were deserialized without IdRef + // replacement (e.g. events embedded in DeltaPacket, which excludes + // replacement to protect state data). Return the canonical tracker + // instance so downstream code can call getTracker() safely. + if (obj instanceof TrackableObject trackable && trackable.getTracker() == null) { + byte tag = typeTagFor(trackable); + if (tag >= 0) { + TrackableType type = trackableTypeFor(tag); + if (type != null) { + Object canonical = tracker.getObj(type, trackable.getId()); + if (canonical != null) { + return canonical; + } + } + } + } return obj; } From 5b4667d8c47a0798a21e0b4c03758267ca94a18b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:08:49 +0930 Subject: [PATCH 5/8] Restore event-level IdRef replacement for DeltaPacket events Events bundled in DeltaPacket were excluded from encoder-level IdRef replacement to avoid a decoder timing issue: the Netty decoder resolves IdRefs during deserialization, before applyDelta creates new objects in the client tracker. This caused events to travel as raw TrackableObjects, losing StaleCardRef detection and inflating wire size. Restore the wrap/unwrap pattern from the removed GameEventProxy, now in TrackableSerializer: events are serialized to byte arrays with IdRef replacement at DeltaPacket construction time (server), and deserialized with resolution after state application (client), when the tracker is fully populated. This gives events proper IdRef/StaleCardRef handling without the decoder timing problem. - Remove applyDelta exclusion from shouldReplaceTrackables - Remove raw-TrackableObject fallback from TrackableSerializer.resolve - Add wrapEvents/unwrapEvents to TrackableSerializer - Wrap events in both delta+events and events-only paths Co-Authored-By: Claude Opus 4.6 (1M context) --- .../net/CompatibleObjectEncoder.java | 13 +-- .../forge/gamemodes/net/NetworkGuiGame.java | 20 ++-- .../gamemodes/net/TrackableSerializer.java | 94 +++++++++++++++---- .../net/server/RemoteClientGuiGame.java | 7 +- 4 files changed, 98 insertions(+), 36 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index 69b4ace7b1e..297ad246c52 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -78,18 +78,19 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) /** * Determines whether TrackableObject references should be replaced with - * IdRef markers for this message. Excludes: + * IdRef markers for this message. Excludes only: * - setGameView/openView: carry full state to populate the client's Tracker - * - applyDelta: DeltaPacket contains property maps with carefully constructed - * values that must arrive intact (new objects may reference other new objects - * not yet in the tracker at decode time) + * + * applyDelta is NOT excluded: its property maps already use Integer IDs + * (via DeltaSyncManager.toNetworkValue), and bundled events are wrapped + * by TrackableSerializer.wrapEvents before entering the packet, so no + * raw TrackableObjects remain in the serialization graph. */ private static boolean shouldReplaceTrackables(Serializable msg) { if (msg instanceof GuiGameEvent event) { ProtocolMethod method = event.getMethod(); return method != ProtocolMethod.setGameView - && method != ProtocolMethod.openView - && method != ProtocolMethod.applyDelta; + && method != ProtocolMethod.openView; } return true; } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java index 3d9da097544..0a097ed5595 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkGuiGame.java @@ -45,15 +45,10 @@ public void applyDelta(DeltaPacket packet) { netLog.info("[DeltaSync] === START applyDelta seq={} (Turn {}, {}, Active={}) ===", packet.getSequenceNumber(), getGameView().getTurn(), phaseName, activePlayerName); - List resolvedEvents = null; - if (packet.hasEvents()) { - List events = new java.util.ArrayList<>(packet.getEvents().size()); - for (Object item : packet.getEvents()) { - if (item instanceof GameEvent e) events.add(e); - } - resolvedEvents = events; + boolean hasEvents = packet.hasEvents(); + if (hasEvents) { netLog.info("[DeltaSync] {} events received with delta seq={}", - resolvedEvents.size(), packet.getSequenceNumber()); + packet.getEvents().size(), packet.getSequenceNumber()); } int newObjectCount = 0; @@ -165,9 +160,12 @@ public void applyDelta(DeltaPacket packet) { packet.getSequenceNumber(), elapsed); } - // Dispatch pre-resolved events now that game state is current. - if (resolvedEvents != null && !resolvedEvents.isEmpty()) { - handleGameEvents(resolvedEvents); + // Unwrap and dispatch events now that new objects are in the tracker. + if (hasEvents) { + List resolvedEvents = TrackableSerializer.unwrapEvents(packet.getEvents(), tracker); + if (!resolvedEvents.isEmpty()) { + handleGameEvents(resolvedEvents); + } } // TODO shouldn't be needed if hands are ordered correctly diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index 7d429893cba..a0d0cda94da 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -1,6 +1,7 @@ package forge.gamemodes.net; import forge.game.card.CardView; +import forge.game.event.GameEvent; import forge.game.player.PlayerView; import forge.trackable.TrackableObject; import forge.trackable.TrackableProperty; @@ -13,6 +14,8 @@ import net.jpountz.lz4.LZ4BlockOutputStream; import java.io.*; +import java.util.ArrayList; +import java.util.List; /** * Handles serialization of {@link TrackableObject} references across the network. @@ -147,22 +150,6 @@ static Object resolve(Object obj, Tracker tracker) { return resolved; } } - // Resolve raw TrackableObjects that were deserialized without IdRef - // replacement (e.g. events embedded in DeltaPacket, which excludes - // replacement to protect state data). Return the canonical tracker - // instance so downstream code can call getTracker() safely. - if (obj instanceof TrackableObject trackable && trackable.getTracker() == null) { - byte tag = typeTagFor(trackable); - if (tag >= 0) { - TrackableType type = trackableTypeFor(tag); - if (type != null) { - Object canonical = tracker.getObj(type, trackable.getId()); - if (canonical != null) { - return canonical; - } - } - } - } return obj; } @@ -200,6 +187,66 @@ public static int measurePlainSize(Object obj) { } } + // ---- Event wrapping for DeltaPacket ---- + + /** + * Serializable wrapper for a GameEvent whose TrackableObject references + * have been replaced with IdRef/StaleCardRef markers. Stored in + * DeltaPacket.events so events travel as compact byte arrays rather than + * full object graphs. Unwrapped after delta state is applied, when the + * client tracker is populated. + */ + static final class WrappedEvent implements Serializable { + private static final long serialVersionUID = 1L; + final byte[] data; + WrappedEvent(byte[] data) { this.data = data; } + } + + /** + * Wraps GameEvents by serializing each with IdRef replacement. + * Events that fail to serialize are dropped (logged). + */ + public static List wrapEvents(List events, Tracker tracker) { + List wrapped = new ArrayList<>(events.size()); + for (GameEvent event : events) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(256); + try (ReplacingOutputStream out = new ReplacingOutputStream(baos, tracker)) { + out.writeObject(event); + } + wrapped.add(new WrappedEvent(baos.toByteArray())); + } catch (IOException e) { + Logger.warn("Failed to wrap event {}: {}", event.getClass().getSimpleName(), e.getMessage()); + } + } + return wrapped; + } + + /** + * Unwraps events by deserializing with IdRef resolution from the tracker. + * Called after delta state is applied so new objects are resolvable. + * Events that fail to unwrap are dropped (logged). + */ + public static List unwrapEvents(List items, Tracker tracker) { + List events = new ArrayList<>(items.size()); + for (Object item : items) { + if (item instanceof WrappedEvent we) { + try { + ByteArrayInputStream bais = new ByteArrayInputStream(we.data); + try (ResolvingInputStream in = new ResolvingInputStream(bais, tracker)) { + Object obj = in.readObject(); + if (obj instanceof GameEvent e) events.add(e); + } + } catch (IOException | ClassNotFoundException e) { + Logger.warn("Failed to unwrap event: {}", e.getMessage()); + } + } + } + return events; + } + + // ---- Stream implementations ---- + private static class ReplacingOutputStream extends ObjectOutputStream { private final Tracker tracker; @@ -215,5 +262,20 @@ protected Object replaceObject(Object obj) { } } + private static class ResolvingInputStream extends ObjectInputStream { + private final Tracker tracker; + + ResolvingInputStream(InputStream in, Tracker tracker) throws IOException { + super(in); + this.tracker = tracker; + enableResolveObject(true); + } + + @Override + protected Object resolveObject(Object obj) { + return resolve(obj, tracker); + } + } + private TrackableSerializer() {} } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index 6a8636fc454..3db1c187c5d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -32,7 +32,6 @@ -import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -523,7 +522,7 @@ public void handleGameEvents(List events) { GameView gameView = getGameView(); if (gameView != null) { DeltaPacket delta = syncManager.collectDeltas(gameView); - delta.setEvents(new ArrayList<>(events)); + delta.setEvents(TrackableSerializer.wrapEvents(events, gameView.getTracker())); sender.send(ProtocolMethod.applyDelta, delta); if (logBandwidth) { @@ -549,7 +548,9 @@ public void handleGameEvents(List events) { } } else { updateGameView(false); - sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(new ArrayList<>(events))); + GameView gameView = getGameView(); + forge.trackable.Tracker tracker = gameView != null ? gameView.getTracker() : null; + sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(TrackableSerializer.wrapEvents(events, tracker))); } } From 82ea75d4feb885b4b93f32a725bdcf199e6c9048 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:15:57 +0930 Subject: [PATCH 6/8] Consolidate TrackableSerializer streams; remove redundant replace() overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #17 review feedback from tool4ever: - Delete single-arg TrackableSerializer.replace(Object) — verified byte-equivalent to replace(obj, null) (the tracker null-check is the only difference, and skipping it falls through to the same IdRef return). Update CObjectOutputStream and TrackableSerializer's own ReplacingOutputStream to use the unified two-arg form. - Delete CompatibleObjectEncoder.ReplacingObjectOutputStream and CompatibleObjectDecoder.ResolvingObjectInputStream — exact duplicates of TrackableSerializer.ReplacingOutputStream/ResolvingInputStream modulo class qualifier. Make those inner classes package-private and reuse them directly from the encoder/decoder. --- .../gamemodes/net/CObjectOutputStream.java | 2 +- .../net/CompatibleObjectDecoder.java | 18 +----------- .../net/CompatibleObjectEncoder.java | 20 +------------ .../gamemodes/net/TrackableSerializer.java | 29 +++++-------------- 4 files changed, 11 insertions(+), 58 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java index aebf245ce54..1867ba3b943 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java @@ -24,6 +24,6 @@ protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException { @Override protected Object replaceObject(Object obj) throws IOException { - return TrackableSerializer.replace(obj); + return TrackableSerializer.replace(obj, null); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java index 2c271b0996a..66ad7869792 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java @@ -9,8 +9,6 @@ import io.netty.handler.codec.serialization.ClassResolver; import net.jpountz.lz4.LZ4BlockInputStream; -import java.io.IOException; -import java.io.InputStream; import java.io.ObjectInputStream; import java.io.StreamCorruptedException; @@ -46,7 +44,7 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception ObjectInputStream ois; if (GuiBase.hasPropertyConfig()) { ois = currentTracker != null - ? new ResolvingObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), currentTracker) + ? new TrackableSerializer.ResolvingInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), currentTracker) : new ObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true))); } else { ois = new CObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), this.classResolver, currentTracker); @@ -70,18 +68,4 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception return var5; } - private static class ResolvingObjectInputStream extends ObjectInputStream { - private final Tracker tracker; - - ResolvingObjectInputStream(InputStream in, Tracker tracker) throws IOException { - super(in); - this.tracker = tracker; - enableResolveObject(true); - } - - @Override - protected Object resolveObject(Object obj) { - return TrackableSerializer.resolve(obj, tracker); - } - } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index 297ad246c52..f81716057ec 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -9,9 +9,7 @@ import io.netty.handler.codec.MessageToByteEncoder; import net.jpountz.lz4.LZ4BlockOutputStream; -import java.io.IOException; import java.io.ObjectOutputStream; -import java.io.OutputStream; import java.io.Serializable; public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasNetLog { @@ -47,7 +45,7 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) bout.write(LENGTH_PLACEHOLDER); if (GuiBase.hasPropertyConfig()) { oout = replace - ? new ReplacingObjectOutputStream(new LZ4BlockOutputStream(bout), currentTracker) + ? new TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), currentTracker) : new ObjectOutputStream(new LZ4BlockOutputStream(bout)); } else { oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace); @@ -95,20 +93,4 @@ private static boolean shouldReplaceTrackables(Serializable msg) { return true; } - private static class ReplacingObjectOutputStream extends ObjectOutputStream { - private final Tracker tracker; - - ReplacingObjectOutputStream(OutputStream out, Tracker tracker) throws IOException { - super(out); - this.tracker = tracker; - enableReplaceObject(true); - } - - @Override - protected Object replaceObject(Object obj) { - return tracker != null - ? TrackableSerializer.replace(obj, tracker) - : TrackableSerializer.replace(obj); - } - } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index a0d0cda94da..b7f4f1b63df 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -79,24 +79,11 @@ static TrackableType trackableTypeFor(byte typeTag) { } /** - * Simple replacement — no tracker, no stale detection. - * Used by the client encoder (which has no game-state awareness). - */ - static Object replace(Object obj) { - if (obj instanceof TrackableObject trackable) { - byte tag = typeTagFor(trackable); - if (tag >= 0) { - return new IdRef(tag, trackable.getId()); - } - } - return obj; - } - - /** - * Verified replacement with stale detection. - * Used by the server encoder (which has the game's tracker). - * Returns {@link StaleCardRef} for stale CardViews, {@link IdRef} for - * current objects, or the original object for non-trackable types. + * Replaces TrackableObject references with {@link IdRef} markers, or + * {@link StaleCardRef} markers for CardViews whose tracker entry has + * been replaced by a zone-change copy. When {@code tracker} is null, + * stale detection is skipped (used by the client encoder, which has + * no game-state awareness). */ static Object replace(Object obj, Tracker tracker) { if (obj instanceof TrackableObject trackable) { @@ -247,7 +234,7 @@ public static List unwrapEvents(List items, Tracker tracker) // ---- Stream implementations ---- - private static class ReplacingOutputStream extends ObjectOutputStream { + static class ReplacingOutputStream extends ObjectOutputStream { private final Tracker tracker; ReplacingOutputStream(OutputStream out, Tracker tracker) throws IOException { @@ -258,11 +245,11 @@ private static class ReplacingOutputStream extends ObjectOutputStream { @Override protected Object replaceObject(Object obj) { - return tracker != null ? replace(obj, tracker) : replace(obj); + return replace(obj, tracker); } } - private static class ResolvingInputStream extends ObjectInputStream { + static class ResolvingInputStream extends ObjectInputStream { private final Tracker tracker; ResolvingInputStream(InputStream in, Tracker tracker) throws IOException { From d4771ab7bb7a74847c18b7053005e3c077d503b0 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 11 Apr 2026 20:15:13 +0200 Subject: [PATCH 7/8] Clean up --- .../gamemodes/net/CompatibleObjectDecoder.java | 14 +++++--------- .../gamemodes/net/CompatibleObjectEncoder.java | 6 +++--- .../forge/gamemodes/net/server/RemoteClient.java | 7 +++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java index 66ad7869792..499710135a1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java @@ -2,6 +2,7 @@ import forge.gui.GuiBase; import forge.trackable.Tracker; +import forge.util.IHasForgeLog; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.channel.ChannelHandlerContext; @@ -12,15 +13,11 @@ import java.io.ObjectInputStream; import java.io.StreamCorruptedException; -public class CompatibleObjectDecoder extends LengthFieldBasedFrameDecoder implements IHasNetLog { +public class CompatibleObjectDecoder extends LengthFieldBasedFrameDecoder implements IHasForgeLog { private final ClassResolver classResolver; private volatile Tracker tracker; - public CompatibleObjectDecoder(ClassResolver classResolver) { - this(1048576, classResolver); - } - public CompatibleObjectDecoder(int maxObjectSize, ClassResolver classResolver) { super(maxObjectSize, 0, 4, 0, 4); this.classResolver = classResolver; @@ -40,14 +37,13 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception int frameSize = frame.readableBytes(); long startMs = System.currentTimeMillis(); - Tracker currentTracker = this.tracker; ObjectInputStream ois; if (GuiBase.hasPropertyConfig()) { - ois = currentTracker != null - ? new TrackableSerializer.ResolvingInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), currentTracker) + ois = tracker != null + ? new TrackableSerializer.ResolvingInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), tracker) : new ObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true))); } else { - ois = new CObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), this.classResolver, currentTracker); + ois = new CObjectInputStream(new LZ4BlockInputStream(new ByteBufInputStream(frame, true)), this.classResolver, tracker); } Object var5 = null; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java index f81716057ec..ae03f1923d3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -3,6 +3,7 @@ import forge.gamemodes.net.event.GuiGameEvent; import forge.gui.GuiBase; import forge.trackable.Tracker; +import forge.util.IHasForgeLog; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufOutputStream; import io.netty.channel.ChannelHandlerContext; @@ -12,7 +13,7 @@ import java.io.ObjectOutputStream; import java.io.Serializable; -public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasNetLog { +public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasForgeLog { private static final byte[] LENGTH_PLACEHOLDER = new byte[4]; private final NetworkByteTracker byteTracker; @@ -38,14 +39,13 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) // - Client encoder (no tracker): simple IdRef replacement. No stale // detection — would create StaleCardRef markers that the server // resolves as detached CardViews, breaking game object identity. - Tracker currentTracker = this.tracker; boolean replace = shouldReplaceTrackables(msg); try { bout.write(LENGTH_PLACEHOLDER); if (GuiBase.hasPropertyConfig()) { oout = replace - ? new TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), currentTracker) + ? new TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), tracker) : new ObjectOutputStream(new LZ4BlockOutputStream(bout)); } else { oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java index 6650ecbcfa1..b5e30de314c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java @@ -2,17 +2,16 @@ import forge.gamemodes.net.CompatibleObjectDecoder; import forge.gamemodes.net.CompatibleObjectEncoder; -import forge.gamemodes.net.IHasNetLog; import forge.gamemodes.net.ReplyPool; import forge.trackable.Tracker; import forge.gamemodes.net.event.IdentifiableNetEvent; import forge.gamemodes.net.event.NetEvent; +import forge.util.IHasForgeLog; import io.netty.channel.Channel; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; -public final class RemoteClient implements IToClient, IHasNetLog { +public final class RemoteClient implements IToClient, IHasForgeLog { /** Special value indicating the client hasn't been assigned a slot yet. */ public static final int UNASSIGNED_SLOT = -1; @@ -65,7 +64,7 @@ public void write(final NetEvent event) { } @Override - public Object sendAndWait(final IdentifiableNetEvent event) throws TimeoutException { + public Object sendAndWait(final IdentifiableNetEvent event) { replies.initialize(event.getId()); send(event); return replies.get(event.getId()); From 16c6ff7523135af0b7b72f0bc01f3f1d7f8db9a7 Mon Sep 17 00:00:00 2001 From: tool4EvEr Date: Sat, 11 Apr 2026 20:20:27 +0200 Subject: [PATCH 8/8] Clean up --- .../main/java/forge/gamemodes/net/TrackableSerializer.java | 4 ---- .../java/forge/gamemodes/net/server/RemoteClientGuiGame.java | 2 -- 2 files changed, 6 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java index b7f4f1b63df..6321bf1f0be 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -174,8 +174,6 @@ public static int measurePlainSize(Object obj) { } } - // ---- Event wrapping for DeltaPacket ---- - /** * Serializable wrapper for a GameEvent whose TrackableObject references * have been replaced with IdRef/StaleCardRef markers. Stored in @@ -232,8 +230,6 @@ public static List unwrapEvents(List items, Tracker tracker) return events; } - // ---- Stream implementations ---- - static class ReplacingOutputStream extends ObjectOutputStream { private final Tracker tracker; diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index 3db1c187c5d..f30cb917040 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -30,8 +30,6 @@ import forge.util.FSerializableFunction; import forge.util.ITriggerEvent; - - import java.util.Collection; import java.util.List; import java.util.Map;