diff --git a/docs/research/2026-08-05-316-investigation.md b/docs/research/2026-08-05-316-investigation.md new file mode 100644 index 00000000..7a150902 --- /dev/null +++ b/docs/research/2026-08-05-316-investigation.md @@ -0,0 +1,343 @@ +# #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.