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:
parent
3aab05b0cc
commit
429775d4c4
1 changed files with 343 additions and 0 deletions
343
docs/research/2026-08-05-316-investigation.md
Normal file
343
docs/research/2026-08-05-316-investigation.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue