Skip to content
Open
13 changes: 11 additions & 2 deletions forge-game/src/main/java/forge/game/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to investigate if we'd also hit this locally:
i.e. if there are situations (viewing face down stuff etc.) where we don't want to always find newer instance!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI analysis:

I don't think the fallback can actually return a "different" Card instance, but it's worth walking through why.

Lookup is purely by ID. CardIdVisitor (Game.java:677) matches on card.getId() only — no zone/visibility/face-down logic. The fallback is semantically equivalent to findById(view.getId()), which already exists and is used elsewhere in this file.

Card IDs are unique among Cards reachable by forEachCardInGame. That walker visits Graveyard, Hand, Library, Battlefield, MeldedCards, Exile, Command, InboundTokens, Stack. The only construct in the codebase that creates a second Card with the same ID is CardCopyService.getLKICopy (CardCopyService.java:230), and LKI snapshots live in transient trigger/replacement maps — never in zones — so the walker never encounters them. Face-down is a property toggle on a single Card instance, not a distinct Card, so face-down/face-up doesn't produce two ID-matching candidates either.

So when both paths succeed, they return the same Card. The only behavioral difference is null (old) vs. the real Card (new) when the view's zone is stale — never wrong Card vs. right Card.

Local play: The miss path shouldn't normally fire locally because the local UI's CardView is the live tracker's CardView and stays in sync via TrackableProperty.changed. The bug this fallback addresses is network-specific (server tracker holds a CardView with a stale zone because updateObjLookup skips already-registered IDs). If local ever does hit it, the outcome is the same — find the Card by ID — just via the longer path.

One potential downside is that silent recovery masks bugs. If local play ever starts hitting the fallback, nobody notices. This could be addressed by adding a one-line WARN log inside the fallback branch so we'd see it in logs if it ever fires locally.

forEachCardInGame(visit);
}
return visit.getFound();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand All @@ -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 {
Expand All @@ -55,4 +63,5 @@ protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception

return var5;
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,22 +16,40 @@
public class CompatibleObjectEncoder extends MessageToByteEncoder<Serializable> 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 {
Expand All @@ -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;
}

}
18 changes: 9 additions & 9 deletions forge-gui/src/main/java/forge/gamemodes/net/DeltaPacket.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public final class DeltaPacket implements NetEvent {
private final Map<Integer, Map<TrackableProperty, Object>> newObjects;
private final int checksum;
private final int[] checksumProperties;
private List<Object> proxiedEvents;
private List<Object> events;

public static final int TYPE_CARD_VIEW = 0;
public static final int TYPE_PLAYER_VIEW = 1;
Expand Down Expand Up @@ -114,9 +114,9 @@ public CombatData(List<List<Integer>> bandAttackerIds, List<int[]> bandDefenderR
}

/** Create an events-only DeltaPacket with no state deltas (seq=-1 means no ack needed). */
public static DeltaPacket eventsOnly(List<Object> proxiedEvents) {
public static DeltaPacket eventsOnly(List<Object> events) {
DeltaPacket packet = new DeltaPacket(-1L, null, null, 0, null);
packet.setProxiedEvents(proxiedEvents);
packet.setEvents(events);
return packet;
}

Expand Down Expand Up @@ -159,19 +159,19 @@ public boolean isEmpty() {
return objectDeltas.isEmpty() && newObjects.isEmpty() && !hasEvents() && !hasChecksum();
}

public void setProxiedEvents(List<Object> events) {
this.proxiedEvents = events;
public void setEvents(List<Object> events) {
this.events = events;
}

public List<Object> getProxiedEvents() {
return proxiedEvents;
public List<Object> 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);
}
Expand Down
Loading
Loading