# #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-2945` — `if (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`: ```csharp 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: ```csharp 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 == 0` → `teleport_hook` @`0x005163EF`, then `SetPosition` @`0x00516420`; - `arg4 != 0` and `player_distance < 96` → `InterpolateTo` @`0x005163AF` (enqueue only — `CPhysicsObj::InterpolateTo` @`0x005104F0` is two lines: `MakePositionManager` then `PositionManager::InterpolateTo`); - `arg4 != 0` and `player_distance >= 96` → `StopInterpolating` + `SetPositionSimple` @`0x005163D9`; - `arg4 == 0` → `return 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 default`s (`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). --- ## 7. Recommended next step — an offline proof, not a connected session 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. --- ## 10. Recommended fix shape — **NOT YET APPROVED** 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.