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>
This commit is contained in:
Erik 2026-08-06 06:25:27 +02:00
parent 3aab05b0cc
commit 429775d4c4

View file

@ -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.