acdream/docs/research/2026-08-05-316-investigation.md
Erik 429775d4c4 docs: #316 investigation — verdict COSMETIC, resolved without a live gate
Report-only per CLAUDE.md's investigation rule; no fix applied and none
approved. Committed so the evidence is not lost.

Verdict: the player arm's airborne-snap block skips the collision-shadow
publish, but the stale shadow self-heals within one object quantum
(~33 ms). Not the #184 invisible-but-solid class.

The reasoning is structural rather than incidental, which is why it
resolved offline instead of needing a connected sample. The per-tick gate
at RuntimeRemotePhysicsUpdater.cs:840 compares the body against
LastShadowSyncPos/Orientation, and those fields are stamped ONLY inside
SyncRemoteShadowToBody immediately after a publish. They therefore record
where the shadow actually is, which makes the gate an invariant check
("is the shadow more than 1 cm / 0.51 degrees from the body?") rather than
a change-detector. The snap's two raw field writes leave that invariant
violated and untouched, so the next quantum sees the full delta and
republishes.

Three findings beyond the question asked:

- One residual does NOT self-heal: past 96 m the activity gate deactivates
  the remote while OnPosition is not distance-gated, so a distant
  player-remote's render entity moves and its shadow does not, until it
  re-enters the bubble. Unobservable in practice — everything that could
  sweep against it is gated by the same rule.
- The "LANDING TRANSITION" naming throughout the file is stale: the
  predicate is !Body.InContact, the whole airborne period, so it fires on
  every airborne update rather than once at the landing edge.
- RuntimeSetPositionState.cs:5037 stamps LastShadowSyncPosition before a
  guard at :5138 that can return ahead of the publish at :5148 — a
  possible masking hole, deliberately not folded in.

Retail note: retail has no separate shadow at all — SetPositionInternal
0x00515330 calls remove_shadows_from_cells/add_shadows_to_cells in the
same transaction, so the skip is a real divergence, just a 33 ms one.

Recommended next step (NOT approved): an offline two-step test composing
the collapse-matrix player-guid landing fixture with one Tick, asserting
the shadow converges. Strictly stronger than a connected sample, which
could only show that nobody noticed 33 ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 06:25:27 +02:00

17 KiB

#316 — severity investigation: the player-guid AirborneSnap arm's skipped shadow publish

Mode: REPORT-ONLY. No production file was modified; this document is the only write. No build and no test run was performed — see §8 for why. Worktree: .claude/worktrees/peaceful-visvesvaraya-e0a196, branch claude/acdream-physics-divergence-5aa784, HEAD 9ee9c1a1. Date: 2026-08-05.


1. Verdict

COSMETIC — bounded-transient, self-healing, with one narrow non-healing residual that is unobservable in the domain where collision matters.

Not the #184 class. #184 was a persistent divergence (shadow parked at the raw server position while the body sat at the resolved one, forever). #316 is a latency: the collision shadow trails the body by at most one retail object quantum (PhysicsBody.MinQuantum = 1/30 s ≈ 33.3 ms, src/AcDream.Core/Physics/PhysicsBody.cs:138), and then heals without any further packet.

The reason it heals is structural, not incidental, and it is the single most important finding in this report:

The per-tick shadow gate compares the body against the shadow's actual last-published pose, not against the previous tick's body pose.

So any out-of-band body write — from any source, including a raw field assignment that bypasses every publisher — leaves a delta that the next tick observes and repairs. There is no "the snap updated the body, so the next tick sees no change" failure mode. That was the crux the issue asked about, and it resolves in the safe direction.

One residual that does NOT self-heal (§5): a player-remote more than 96 m from the local player is gated out of the per-tick sweep entirely, so its shadow can stay at the pre-snap pose indefinitely while its render entity moves. This is real, is player-guid-only, and has no bound. It is nonetheless not worth an urgent fix: nothing that can collide with it is itself ticking at that range, and re-entering the 96 m bubble heals it within two frames.

No live measurement is required. §7 proposes a deterministic offline proof instead, which is strictly stronger evidence than a connected sample.


2. Verification of the issue's claims at HEAD

Issue claim Status at 9ee9c1a1
The player-guid airborne arm does not publish the shadow TRUE. src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2944-2945if (arm is not RemoteContactArm.AirborneSnap || !IsPlayerGuid(update.Guid)) wraps the sole LiveEntityShadowPublisher.TryPublishRemote call in the tail.
The NPC-guid equivalent DOES publish TRUE. Same predicate: an NPC guid on the same arm falls through to the publish at :2947-2957.
The render entity is still committed TRUE. :2941-2943 (entity.SetPosition / ParentCellId / Rotation) sits outside the guard.
The block "returns" without publishing STALE. Pre-collapse phrasing. Post-collapse it is a guid-gated skip at the unified tail, exactly as the issue's own 2026-08-04 update already records.
C5b (735f0a72..9ee9c1a1) may have touched this NO. git log 735f0a72..9ee9c1a1 -- over LiveEntityNetworkUpdateController.cs, RuntimeRemotePhysicsUpdater.cs, and LiveEntityShadowPublisher.cs returns empty. The issue text is current.
Covered by the two named matrix tests TRUE, and both exist: LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved:222 and LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared:271 in tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs. Neither ticks afterwards, so neither answers the severity question (§6).

One further staleness worth recording: the issue calls this the "LANDING TRANSITION" block. Since AP-140 retired the walkability gate, the arm's predicate is !remote.Body.InContact (LiveEntityNetworkUpdateController.cs:1266) — any out-of-contact body, not just the touchdown packet. A player-remote therefore takes this arm on every UpdatePosition for the whole airborne period of a jump or fall, not once at landing. The per-occurrence severity is unchanged; the frequency is higher than the issue's wording implies.


3. Evidence chain — why it heals

3.1 What the arm writes

src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1266-1292:

if (!remote.Body.InContact)
{
    remote.Body.Position = worldPos;
    remote.Body.Orientation = rotation;
    return new RemoteContactRouting(RemoteContactArm.AirborneSnap, Placement: null);
}

Two raw field writes. Nothing else is touched — in particular not remote.LastShadowSyncPos / LastShadowSyncOrientation.

3.2 What the per-tick commit compares

src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:840-852, inside the SetPositionInternal commit tail:

if (ShouldSynchronizeShadow(
        cellChanged,
        rm.Body.Position,          // current body
        rm.Body.Orientation,
        rm.LastShadowSyncPos,      // where the SHADOW actually is
        rm.LastShadowSyncOrientation))
{
    SyncRemoteShadowToBody(localEntityId, rm, liveCenterX, liveCenterY);
}

ShouldSynchronizeShadowPose (:1132-1161) fires on DistanceSquared > 1e-4 (> 1 cm) or normalized quaternion dot < 0.99999 (> ≈0.51°).

SyncRemoteShadowToBody (:1115-1130) publishes and then re-stamps LastShadowSyncPosition/Orientation from the body. The stamp is downstream of the publish, never independent of it.

Therefore LastShadowSyncPos is a faithful record of the shadow's registered pose, and the gate is an invariant check ("is the shadow more than 1 cm from the body?"), not a change-detector. The AirborneSnap leaves that invariant violated; the next tick that reaches line 840 restores it.

3.3 Nothing masks the delta

I enumerated every writer of LastShadowSyncPos in the tree:

Site Effect Masking risk
RuntimeRemotePhysicsUpdater.cs:1128-1129 Stamped immediately after publishing None
RuntimeSetPositionState.cs:4649-4650 Reset to Vector3.Zero None — forces a sync
RuntimeSetPositionState.cs:5037-5038 Canonical placement commit Paired with ShadowObjects.CommitSetPosition at :5148 — see §9 for one narrow caveat
RemoteMotion construction Default Vector3.Zero None — forces the first sync

The acknowledgeProjection callback the App supplies (src/AcDream.App/Physics/RemotePhysicsUpdater.cs:234-242) writes only the render entity. It cannot stamp the shadow bookkeeping.

3.4 The tick is reachable for player-guid remotes

  • LiveEntityAnimationScheduler.AdvanceRecord excludes only the local player (src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs:186). Remote players take the ordinary path.
  • The shadow gate sits inside if (rm.CellId != 0 && _physics.Engine.LandblockCount > 0) (RuntimeRemotePhysicsUpdater.cs:371) and is reached whether or not the collision sweep ran that tick (the sweep-skipped else at :758-803 falls through to the same commit tail).
  • The only return false paths between :371 and :840 are the ownership re-checks at :612, :643, :821, :833. All four mean this incarnation was superseded, in which case a replacement owner registers its own shadow.
  • The gate carries no IsPlayerGuid test. The only two guid tests in Tick are the stale-velocity watchdog (:198) and the mover flags (:438).

3.5 Timing bound

The scheduler admits work only when the retail object clock yields a quantum (LiveEntityAnimationScheduler.cs:248-250), and RetailObjectQuantumClock.Advance yields nothing until accumulated time exceeds MinQuantum = 1/30 s. So the worst-case staleness window is one object quantum ≈ 33.3 ms, exactly the figure the issue guessed.

Within that window, a remote player's shadow is behind its body by the size of that packet's airborne correction — the divergence between acdream's dead-reckoned arc and ACE's authoritative position. That is a sub-metre quantity in ordinary play, and it is a lag along the trajectory, not a phantom in an unrelated place.


4. Retail check

CPhysicsObj::MoveOrTeleport 0x00516330 (docs/research/named-retail/acclient_2013_pseudo_c.txt:284304) has no airborne arm at all. Its structure is:

  • teleport-timestamp / cell == 0teleport_hook @0x005163EF, then SetPosition @0x00516420;
  • arg4 != 0 and player_distance < 96InterpolateTo @0x005163AF (enqueue only — CPhysicsObj::InterpolateTo @0x005104F0 is two lines: MakePositionManager then PositionManager::InterpolateTo);
  • arg4 != 0 and player_distance >= 96StopInterpolating + SetPositionSimple @0x005163D9;
  • arg4 == 0return 0, writing nothing.

An airborne retail remote near the player therefore takes the enqueue branch, and InterpolationManager::adjust_offset 0x00555D30 no-ops on the queue while transient_state & CONTACT_TS == 0 (the gate at 0x00555D52, acclient.h:3690). Retail never hard-snaps an out-of-contact remote's body at UpdatePosition cadence. acdream's AirborneSnap is an acdream-only arm — the existing register rows AP-87 / AP-140 cover that family; it is not new here.

The decisive retail fact for #316 is that retail has no separate "shadow" to fall behind. Every retail position write routes through CPhysicsObj::SetPositionInternal 0x00515330, which calls remove_shadows_from_cells @0x0051553E and add_shadows_to_cells @0x0051554C in the same transaction (see also the 0x00515215/0x00515221 and 0x00515313/0x0051531F pairs). Collision presence and render pose are the same object and cannot diverge by construction. So acdream's skip is a divergence from retail — but a 33 ms one, which retail's own 30 Hz frame budget would not resolve either.

Binary-Ninja artifact noted, not load-bearing here. MoveOrTeleport shows the same dropped-flag-test artifact the task warned about: if (-((eax_4 - eax_4)) == 0) at 0x00516364, where the real wrap-safe timestamp compare is set up across 0x0051634A-0x0051635B. It gates the teleport branch, not the shadow question, so no disassembly of C:\Users\erikn\Downloads\acclient.exe was needed for this verdict. Anyone re-deriving acdream's timestamp routing from that line must disassemble first.


5. The one residual that does not self-heal

RetailObjectActivityGate.Evaluate (src/AcDream.Core/Physics/RetailObjectActivityGate.cs, retail CPhysicsObj::update_object 0x00515D10) deactivates any object with a PartArray beyond MaxPhysicsDistance = 96f from the local player, and the scheduler then return defaults (LiveEntityAnimationScheduler.cs:245-246) — no tick, no shadow gate.

OnPosition is not distance-gated. So for a player-remote beyond 96 m:

  • the render entity moves (LiveEntityNetworkUpdateController.cs:2941);
  • the shadow does not (:2944 skip);
  • nothing later repairs it while the remote stays outside the bubble.

NPC guids are immune — their packet tail publishes. This is genuinely unbounded in time, and it is the only part of #316 that is not a 33 ms latency.

Why it still does not rise to #184: the stale shadow is ≥96 m from the local player, and every other object that could sweep against it is itself gated by the same 96 m rule relative to the player, so the reachable band is a thin annulus of mutually-near remotes straddling the bubble edge. On re-entry the clock returns Reactivated, which the scheduler also treats as non-Active (:245), so the heal lands on the second frame inside the bubble — still ≲ 2 frames before any close-range collision query can see it.


6. Existing test coverage

  • Nothing covers the heal. The two #316 matrix tests assert only the packet frame's shadow state.
  • The mechanism is already proven, on the other guid class. tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs:54 (Tick_InPlaceCompleteRootTurn_UpdatesOffsetCollisionShadow) constructs a RemoteMotion with LastShadowSyncPos = Zero / LastShadowSyncOrientation = Identity, drives one Tick, and asserts the shadow entry moved and LastShadowSyncOrientation was re-stamped. That is precisely the stale-bookkeeping → republish path #316 relies on, exercised end-to-end against a real ShadowObjectRegistry.
  • RemotePhysicsUpdaterTests.cs:534,557 additionally pins the negative case (a superseded owner must not move the source shadow).

A live sample is not required and would be weaker evidence. The heal is a deterministic two-step over state that both existing fixtures already build. Recommended (NOT YET APPROVED — do not implement without the user's word):

Compose LiveEntityNetworkOnPositionCollapseMatrixTests' player-guid landing fixture with RemotePhysicsUpdaterTests' BindRemote + Tick, and assert the positive outcome: after the landing packet the shadow entry is at the pre-snap pose (today's ..._316Preserved assertion, unchanged), and after one subsequent Tick the same entry is within 1 cm of rm.Body.Position and rm.LastShadowSyncPos equals it. Add the creature-guid half asserting the shadow was already correct at step 1 and unchanged at step 2.

This produces positive evidence in both directions and satisfies the campaign's "absence-of-signal is a weak criterion" rule, which a connected probe sample would not: a connected run can only show that nobody noticed a 33 ms lag.

If the user nonetheless wants the connected sample, the executable recipe:

  1. Launch Release against local ACE with ACDREAM_PROBE_REMOTE_LANDING=1 plus ACDREAM_PROBE_RESOLVE=1, piping to launch.log.
  2. Second client (retail or a second acdream) on a player character within ~20 m; have it jump repeatedly in view.
  3. Sample: for each [remote-landing] site=controller guid=0x50...... line, find the next site=per-tick line for the same guid and the intervening [resolve] lines naming that guid as the responsible entity.
  4. Cosmetic iff every controller line is followed by a per-tick line for the same guid within 40 ms, with no intervening [resolve] line reporting a collision responsible-entity match against that guid at the pre-snap pose.
  5. #184-class iff a controller line for a player guid is followed by

    100 ms with no per-tick line for that guid while the guid remains inside the 96 m bubble — i.e. the gate genuinely never re-fires.

Note that step 5's condition is exactly the >96 m residual of §5 inverted; if it ever trips inside the bubble, §3 is wrong and the verdict must be revisited.


8. Why no build or test run

The worktree has 13 modified and 4 untracked files from a concurrently running implementer (SessionPlayerComposition.cs, StreamingController.cs, WorldRevealCoordinator.cs, PhysicsEngine.cs, RuntimeWorldTransitState.cs, and seven test files). Building would compile their in-progress edits, could fail for reasons unrelated to #316, and would lock output assemblies underneath them. None of the modified files is on any path in this report's evidence chain, so the source read is unaffected.


9. Adjacent observations (NOT #316 — filed here so they are not lost)

  1. A possible masking hole in the canonical placement commit. src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:5037-5038 stamps remote.LastShadowSyncPosition = result.Position before the IsCanonicalPlacementCommitCurrent guard at :5138-5146, which can return false before ShadowObjects.CommitSetPosition at :5148 ever runs. On that path the bookkeeping claims a pose the shadow never took, which would mask the §3.2 invariant check until the body drifts >1 cm from result.Position. Reachability is unproven — the guard fires only when the commit was superseded, which normally implies a replacement owner — but the write ordering is the wrong way round regardless. Worth its own five-minute read; do not fold it into a #316 fix.

  2. The RemoteContactArm.AirborneSnap name is misleading. Its predicate is !Body.InContact, so it is the whole airborne period, not the landing edge. Every comment in LiveEntityNetworkUpdateController.cs still calls it "the dissolved LANDING TRANSITION block". That is the stale-comment class the campaign has hit repeatedly.


If the user wants #316 closed rather than downgraded, the minimal change is to delete the guid predicate at LiveEntityNetworkUpdateController.cs:2944-2945, making the publish unconditional across arms and guids — which is what the file's own #184 Slice 2b comments already claim happens, what retail's SetPositionInternal does by construction (§4), and what the NPC half already does today. It is strictly additive: a publish that would have happened 33 ms later happens now, and LiveEntityShadowPublisher.CanPublish (src/AcDream.App/Physics/LiveEntityShadowPublisher.cs:46-55) already gates it on Hidden / entity identity / position authority / spatial-motion currency.

That would also close the §5 residual, which is the only part with no bound.

Per the issue's own resolution note this must be its own commit with a dual-guid test, and it retires the two matrix tests' _316Preserved half. It must not be smuggled into any refactor.

Given the measured severity, filing it behind #280 / AP-22 / AD-10 rather than ahead of them is defensible.