diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java index 45867d56089..d49c8677dcf 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. IdRef resolved from a tracker that wasn't 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 e378f36734e..6f3bef24bcb 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 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 a37025ab7db..1867ba3b943 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 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 e56ec35785b..499710135a1 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectDecoder.java @@ -1,6 +1,7 @@ package forge.gamemodes.net; import forge.gui.GuiBase; +import forge.trackable.Tracker; import forge.util.IHasForgeLog; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; @@ -15,16 +16,17 @@ public class CompatibleObjectDecoder extends LengthFieldBasedFrameDecoder implements IHasForgeLog { private final ClassResolver classResolver; - - public CompatibleObjectDecoder(ClassResolver classResolver) { - this(1048576, classResolver); - } + private volatile Tracker tracker; public CompatibleObjectDecoder(int maxObjectSize, ClassResolver classResolver) { super(maxObjectSize, 0, 4, 0, 4); 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 +36,15 @@ 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); + + ObjectInputStream ois; + if (GuiBase.hasPropertyConfig()) { + 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, tracker); + } Object var5 = null; try { @@ -55,4 +63,5 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception return var5; } + } 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..ae03f1923d3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java @@ -1,6 +1,8 @@ package forge.gamemodes.net; +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; @@ -14,22 +16,40 @@ public class CompatibleObjectEncoder extends MessageToByteEncoder implements IHasForgeLog { 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. + 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 TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), tracker) + : new ObjectOutputStream(new LZ4BlockOutputStream(bout)); + } else { + oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace); + } oout.writeObject(msg); oout.flush(); } finally { @@ -53,4 +73,24 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) netLog.info("Encoded {} bytes (compressed) for {}", msgSize, msg.getClass().getSimpleName()); } } + + /** + * Determines whether TrackableObject references should be replaced with + * IdRef markers for this message. Excludes only: + * - setGameView/openView: carry full state to populate the client's Tracker + * + * 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; + } + return true; + } + } 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 c0378477175..00000000000 --- a/forge-gui/src/main/java/forge/gamemodes/net/GameEventProxy.java +++ /dev/null @@ -1,269 +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; - } - } - - /** - * 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; - - 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..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); - // 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={}", - resolvedEvents.size(), packet.getSequenceNumber()); + boolean hasEvents = packet.hasEvents(); + if (hasEvents) { + netLog.info("[DeltaSync] {} events received with delta seq={}", + 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 new file mode 100644 index 00000000000..6321bf1f0be --- /dev/null +++ b/forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java @@ -0,0 +1,264 @@ +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; +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.*; +import java.util.ArrayList; +import java.util.List; + +/** + * 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; + } + } + + /** + * 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) { + 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; + } + } + + /** + * 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; + } + + 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 replace(obj, tracker); + } + } + + 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/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index 9436ae73bf7..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 @@ -3,7 +3,7 @@ import forge.game.*; import forge.game.card.CardView; import forge.game.player.PlayerView; -import forge.gamemodes.net.GameEventProxy; +import forge.gamemodes.net.CompatibleObjectDecoder; import forge.gamemodes.net.GameProtocolHandler; import forge.util.IHasForgeLog; import forge.gamemodes.net.IRemote; @@ -62,6 +62,16 @@ 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 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); + } if (gameView.getGameLog() == null) { gameView.initGameLog(); } @@ -167,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..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 @@ -1,9 +1,12 @@ package forge.gamemodes.net.server; -import forge.util.IHasForgeLog; +import forge.gamemodes.net.CompatibleObjectDecoder; +import forge.gamemodes.net.CompatibleObjectEncoder; 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.atomic.AtomicInteger; @@ -81,6 +84,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..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 @@ -15,23 +15,21 @@ 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; import java.util.Map; @@ -48,6 +46,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 +181,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 +199,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 +245,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 +514,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(TrackableSerializer.wrapEvents(events, gameView.getTracker())); 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 +546,9 @@ public void handleGameEvents(List events) { } } else { updateGameView(false); - sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(proxied)); + GameView gameView = getGameView(); + forge.trackable.Tracker tracker = gameView != null ? gameView.getTracker() : null; + sender.send(ProtocolMethod.applyDelta, DeltaPacket.eventsOnly(TrackableSerializer.wrapEvents(events, tracker))); } }