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>
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-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:
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.AdvanceRecordexcludes 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-skippedelseat:758-803falls through to the same commit tail). - The only
return falsepaths between:371and:840are 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
IsPlayerGuidtest. The only two guid tests inTickare 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, thenSetPosition@0x00516420; arg4 != 0andplayer_distance < 96→InterpolateTo@0x005163AF(enqueue only —CPhysicsObj::InterpolateTo@0x005104F0is two lines:MakePositionManagerthenPositionManager::InterpolateTo);arg4 != 0andplayer_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 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 (
:2944skip); - 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 aRemoteMotionwithLastShadowSyncPos = Zero/LastShadowSyncOrientation = Identity, drives oneTick, and asserts the shadow entry moved andLastShadowSyncOrientationwas re-stamped. That is precisely the stale-bookkeeping → republish path #316 relies on, exercised end-to-end against a realShadowObjectRegistry. RemotePhysicsUpdaterTests.cs:534,557additionally 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 withRemotePhysicsUpdaterTests'BindRemote+Tick, and assert the positive outcome: after the landing packet the shadow entry is at the pre-snap pose (today's..._316Preservedassertion, unchanged), and after one subsequentTickthe same entry is within 1 cm ofrm.Body.Positionandrm.LastShadowSyncPosequals 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:
- Launch Release against local ACE with
ACDREAM_PROBE_REMOTE_LANDING=1plusACDREAM_PROBE_RESOLVE=1, piping tolaunch.log. - Second client (retail or a second acdream) on a player character within ~20 m; have it jump repeatedly in view.
- Sample: for each
[remote-landing] site=controller guid=0x50......line, find the nextsite=per-tickline for the same guid and the intervening[resolve]lines naming that guid as the responsible entity. - Cosmetic iff every
controllerline is followed by aper-tickline 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. - #184-class iff a
controllerline for a player guid is followed by100 ms with no
per-tickline 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)
-
A possible masking hole in the canonical placement commit.
src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:5037-5038stampsremote.LastShadowSyncPosition = result.Positionbefore theIsCanonicalPlacementCommitCurrentguard at:5138-5146, which canreturn falsebeforeShadowObjects.CommitSetPositionat:5148ever 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 fromresult.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. -
The
RemoteContactArm.AirborneSnapname is misleading. Its predicate is!Body.InContact, so it is the whole airborne period, not the landing edge. Every comment inLiveEntityNetworkUpdateController.csstill 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.