Fix CardView IdRef substitution / retire UI_NETPLAY_COMPAT#10662
Conversation
The pref gated two outer-codec branches that had been functionally equivalent for production traffic since 2020. Reference compression runs at the inner layer (DeltaSyncManager.toNetworkValue, wrapEvents) regardless of the outer codec, and a pref-mismatch between endpoints could corrupt the stream on the first class descriptor. Apply tracker-aware IdRef substitution from <!-- -->Card-Forge#10644 on the surviving codec, gated on per-client DeltaSyncManager consumerId rather than server-tracker presence: the server's tracker holds CardViews that never reach the client (Card.fromPaperCard choice copies registered as a side effect of SpellAbilityView.updateHostCard during buildAbilities), so trackable.hasConsumer(consumerId) is the correct "client knows" predicate. Ephemerals fall through to inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
TLDR: Netplay is currently working and ephemerals are resolving, but that's because all CardView protocol args are inadvertently being serialized inline rather than using IdRef substitution - the server tracker method attempted in #10644 doesn't work properly but this proposes a different solution that seems to. While it's annoying this was missed, I note it was fairly difficult for the AI to find - took it a few goes to work out where #10644 had gone wrong while investigating UI_NETPLAY_COMPAT even with max effort. The two different codec paths seem to throw it pretty badly because they branch in non-obvious ways. |
|
Sigh, my love/hate relationship with these complex PR doesn't get a break 😜 tbh understandable AI missed this first, the history how this was implemented is pretty messy as far as I can tell:
I think this has the side effect that if we need to test with |
| } | ||
| } | ||
|
|
||
| static class ResolvingInputStream extends ObjectInputStream { |
There was a problem hiding this comment.
I'll probably merge this soon after I finish testing, so can you check for a follow-up if there's still a point to keep these classes instead of simply reusing the CObject* ones?
and as bonus the Android workaround for records would then no longer be needed, right?
Summary
Fixes a CardView IdRef substitution bug that #10644 partially fixed, and retires the
UI_NETPLAY_COMPATtoggle. The host's tracker isn't a reliable proxy for what the client knows about — e.g. Jhoira's random choice copies are in the host tracker as a side effect ofbuildAbilitiesbut never broadcast — so IdRef substitution now gates on per-clientDeltaSyncManagerconsumer registration instead.Background
UI_NETPLAY_COMPATtoggled two outer-codec serialization paths: the default ("OFF") path usedCObjectInputStream/OutputStreamwith thin class descriptors (sender writes the class name only, receiver looks up its own); the opt-in ("ON") path used standardObjectStreamClassdescriptors viaTrackableSerializer'sReplacingOutputStream/ResolvingInputStream. It shipped in April 2020 as a fallback safety valve and has persisted for six years.Scope error in #10644
Tracing how the two paths actually differed surfaced an inadvertent scope error in #10644 ("Serialize CardViews missing from host tracker").
Pre-#10644, both paths used a blanket "always IdRef" rule for CardView refs in non-event mode: tracked cards resolved fine, ephemerals didn't (the bug). #10644 replaced it with a tracker-aware rule — IdRef if the tracker has the id, fall through to inline otherwise — and added encoder-side
setTrackerwiring.Both changes were meant universally, but the new logic was inadvertently consumed by the ON path's
ReplacingOutputStreamalone.CObjectOutputStream.replaceObjecton the (default) OFF path kept hardcodingTrackableSerializer.replace(obj, null, false), so the encoder's tracker landed in a field the stream never read. With null tracker plus the new fall-through, every CardView on the default path serialized inline — ephemerals safely, but tracked CardViews lost the IdRef compression they previously had. This is the maintenance cost of carrying two parallel codec paths: a universal fix quietly covered only one, and the gap went unnoticed because the orphaned path was the default.Investigation: ephemerals broke when we threaded the tracker
Threading
gameView.getTracker()throughCObjectOutputStreamso the surviving codec applied the same substitution as the ON path produced an NPE inListChooserand threeCould not resolve IdRefwarnings under manual test. The repro: Jhoira of the Ghitu Avatar's "copy three random sorceries, pick one" ability. Three CardViews the server had in its tracker were not in the client's.Diagnostic logging on
Tracker.putObjtraced each Jhoira choice copy's registration back through:Card.fromPaperCardconstructs the Card and immediately runsbuildAbilities, which createsSpellAbilityViewinstances. EachSpellAbilityView.updateHostCarddoesset(HostCard, cardView), and the property-set cascade registers the CardView in the tracker'sCardViewTypelookup as a side effect.So the server's tracker contains everything the server happens to know about, including transient CardViews created mid-resolution. The client's tracker contains what was broadcast via
setGameVieworapplyDelta(newObjects). These are not the same set.Solution: gate on per-client consumer registration
The host's tracker isn't a safe indicator of what the client knows — what is?
DeltaSyncManageralready maintains a per-clientconsumerId. When it walks the GameView graph inwalkAndRegisteror registers a new/replacement object incollectDeltas, it callsobj.registerConsumer(consumerId)— the same registration that drives inclusion in the delta packet'snewObjectsmap. So consumer presence is a faithful predicate for "the client has been told the properties of this CardView."TrackableSerializer.replacenow accepts aconsumerId. When ≥ 0 (server-side encoders) the CardView gate usestrackable.hasConsumer(consumerId); when < 0 (client-side encoders) it falls back to tracker presence — sound there because the client's tracker is what it knows.Wiring:
TrackableObjectexposeshasConsumer(int).DeltaSyncManagerexposesgetConsumerId().CObjectOutputStreamandCompatibleObjectEncoderplumb the id through.RemoteClient.setCodecTracker(Tracker, int)wires both onto the pipeline (reapplied onswapChannel).RemoteClientGuiGamepasses the id at firstsetGameView.For Jhoira's choices the copies are never reachable from the GameView graph, so
walkAndRegisternever visits them, the consumer is never registered,hasConsumerreturns false, andreplacefalls through to inline. Cards in real zones still IdRef. PlayerView IdRef stays unconditional — PlayerViews are stable fromopenView.Why the
UI_NETPLAY_COMPATtoggle was redundantWith the IdRef bug resolved, the rest of the PR retires
UI_NETPLAY_COMPATitself. The toggle is safe to remove because:DeltaSyncManager.toNetworkValueconverts CardView/PlayerView refs in property maps to Integer IDs, andTrackableSerializer.wrapEventswraps bundled events with IdRef/EventCardRef markers. Both layers run regardless of the pref, and both outer paths carry the same opaque bytes for those packets.ObjectStreamClassblock. Removing the toggle removes the ability to fall into that state.Other cleanup bundled
CompatibleObjectEncoder/Decoderno longer branch onGuiBase.hasPropertyConfig.ProtocolMethod.checkArgsunconditionally; it only logs, mirrors the always-oncheckReturnValue, and replaces a genericMethod.invoke"argument type mismatch" with a structured log line.var5→result,bout/oout→byteOut/objectOut; deadframeStartremoved.FPref.UI_NETPLAY_COMPAT,GuiBase.propertyConfig/hasPropertyConfig/enablePropertyConfig, startup wiring inFControlandForge.getApp, the desktop and mobile settings UI, the mobile launcher'spropertyConfigargument, andlblExperimentalNetworkCompatibility/nlExperimentalNetworkCompatibilityacross nine language files.🤖 Generated with Claude Code