diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6e28f872..964dbd29 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,84 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply + +**Status:** OPEN +**Severity:** MEDIUM (no observed symptom; reachability against ACE is +unmeasured — see below. The behaviour when reached is four simultaneous +divergences, not one.) +**Filed:** 2026-08-05 at the C5b closeout, with register row **AP-148** +**Component:** physics / inbound timestamp gate / local-player force position + +**Description.** Retail's `SmartBox::HandleReceivedPosition` Gate A — the +local-player FORCE_POSITION self-echo shortcut — takes its shortcut iff the +wire TELEPORT_TS is **not older** than the stored one, i.e. equal *or newer*. +acdream requires exact equality +(`PhysicsTimestampGate.TryAcceptPositionEvent:199`, +`teleport == _timestamps[Teleport]`), so acdream's `ForcePosition` +disposition is a strict subset of retail's Gate A set. + +The disassembly, the byte-level reasoning, and why two review rounds of +reading Binary Ninja pseudo-C recorded the term backwards are in AP-148 and +in `docs/research/2026-08-05-c5b-contract.md` §15.1. Short version: +0x0045402B–0x00454054 materialises the carry of a wrap-safe 16-bit compare +with `sbb eax,eax / neg eax` and skips Gate A on CF, where CF means "wire +strictly older"; Binary Ninja drops the flag test and renders the whole +thing as `if (-((eax_7 - eax_7)) == 0)`, which is always true. + +**What the misroute does.** The excluded packet falls through to `Apply` +with `advancesTeleport` true, which is four behaviour changes at once: + +1. the wire heading is applied instead of the body's being preserved + (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856` is + `ForcePosition`-gated); +2. the entity is unparented, and may have a placement frame installed + (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations`); +3. local velocity is zeroed (`:882-885`, the `TeleportAdvanced` arm); +4. TELEPORT_TS advances and `OfferTeleportDestination` is called, starting + teleport/portal presentation for a packet retail never starts it for. + +Retail's Gate A deliberately lets a force ride *past* a pending teleport +advance without consuming it — it returns @0x0045409D, before +`newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158 — leaving the ordinary +Position channel to process that teleport. + +**This is NOT a one-line comparison swap, and the fix must not be attempted +as one.** Three things have to be decided together: + +1. **The predicate exists twice.** Besides `PhysicsTimestampGate.cs:199`, + `RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority` + independently requires + `authority.PreviousTeleportSequence == authority.AcceptedTeleportSequence` + for a `ForcePosition` — the same narrowing, encoded downstream. Widening + one without the other turns the newly-admitted packets into + `RejectedAuthority` routes, which is a third behaviour, worse than either. +2. **TELEPORT_TS's disposition on the Gate A path.** acdream's Gate A branch + already returns without advancing TELEPORT_TS, which matches retail — + but it has never had to do so while the wire stamp was *newer*. After + widening, `AcceptedPhysicsTimestamps.PreviousTeleport` and `.Teleport` + would be equal and STALE while the wire carried a newer value: a shape no + consumer has seen. `IsFreshTeleportStart`, the drive controller's + `previousTeleport` argument, and J6.3's F751/Position teleport + correlation all read that pair. +3. **Reachability has to be established before the fix, not assumed.** ACE's + two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, + `Player_Tick.cs:488` z-hack correction) do not themselves bump the + teleport sequence — but `PositionPack` serialises the *current* teleport + sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent + window on its next force. Whether that lag is reachable in practice is + unmeasured. A cdb trace or a wire capture answers it; guessing does not. + +The correct predicate already exists verbatim one file away: +`PhysicsTimestampGate.IsFreshTeleportStart:163` is +`!IsNewer(teleport, _timestamps[Teleport])`, which is exactly retail's Gate A +term. + +**Acceptance:** both encodings widened together; the newly-admitted shape +covered by a discriminating test at the disposition boundary AND at the +classifier's authority validation; the three consumers in item 2 checked +against a stale-but-equal teleport pair; AP-148 retired in the same commit. + ## #324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2056a808..ef494fe3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -152,15 +152,15 @@ readiness/requeue adaptation. See | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | -| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). LEGACY HALF RETIRED 2026-08-05 (C5b, #275); row REWRITTEN rather than deleted, because wire-cell channels SURVIVE outside the merge and a silent whole-row deletion would hide them.** No Position merge commits residency any more: both the executor's `ApplyPositionAction` and the steady-state `RuntimeEntityObjectLifetime.TryApplyPosition` refresh `canonical.Snapshot.Position` with the wire pose while withholding the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`; the steady-state site is the `RefreshSnapshot` call in `TryApplyPosition`, cite by symbol — the row's former `:1338` and the C5 scoping's `:1918` were both stale). Only a Runtime `SetPosition` commit or a simulation full-cell commit may change residency inside the merge. **The precise surviving claim: a wire Position never makes a record resident INSIDE THE MERGE OR AHEAD OF CLASSIFICATION.** Two steady-state wire-cell writers deliberately remain downstream of it, and are separately filed: **(W2)** the `OnPosition` prologue rebucket (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket` → `SetFullCell`), which runs for every classification reaching the generic tail and is ALSO the local player's own cell-freshness path — cross-filed at AP-146/#320, and deliberately NOT gated, since gating it would freeze the player's canonical cell between teleports and #319's child-cell equality would inherit the freeze; and **(W3)** the post-routing wire-cell adopt for non-placing arms (`TryAdoptWireCellAfterRouting`), filed at AP-135. Packets that return BEFORE W2 — the local force path, the missile arm, and (added at the C5b architecture review's D1 fix) the local `ChildUnparentDisposition` Superseded/Pending arm — are placement-receipt-authoritative for residency, or unchanged at the last commit on a refused/contended force (AD-62's shapes), which is retail's own body-keeps-its-last-placed-cell behaviour. **CORRECTED 2026-08-05 at the D1 fix: that enumeration was presented as exhaustive and was not — the ENTIRE no-window host belonged in it.** W2 and W3 both live in `AcDream.App`, and the two hosts run parallel, non-shared inbound routes (`LiveEntitySessionController`/`LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated`), so `AcDream.Headless` had NO post-merge cell writer at all: every remote's `FullCellId` was written at create/placement and then frozen for the session, and the local player lost this row's own inbound-Position refresh edge (AP-146/#320). Fixed in that commit by giving the no-window route its own W2 over a NEW shared Runtime owner for the committed value, `RuntimeEntityObjectLifetime.CommitWireCellRebucket` — which also retires this row's layering inversion, since it no longer has to document itself by naming an App class its own assembly cannot reference. The no-window host has no W3 analogue and needs none: it performs no remote contact routing, so there is no post-routing arm to adopt a wire cell into. The duplicated REACHABILITY decision the fix leaves behind is filed at AD-64. **AMENDED 2026-08-05 at the C5b architecture review (finding L1/L2), with a measurement the C5b commit did not have: on the ordinary remote tail W2 and W3 are REDUNDANT, not complementary.** Sabotaging W2 alone — either making it adopt the committed cell instead of the wire cell, or skipping the rebucket outright — leaves the entire `LiveEntityNetworkOnPositionCollapseMatrixTests` file green, because W3's `RemoteMotion.CellId` write reads through to canonical `FullCellId` via `RuntimePhysicsState.CommitCanonicalCell`, whose graphical `CellCommitted` recovery also re-installs the render bucket. Only removing BOTH channels goes red, and then exactly one test does: `LiveEntityNetworkOnPositionCollapseMatrixTests.WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell`, added at that review because C5b shipped its "production installs the bucket at W2 in the same call" claim untested (the commit message's "no fixture covers pickup at that layer" was inaccurate — that file drives the real `OnPosition` at ~26 call sites). The practical consequence: this row's W2/W3 enumeration is correct as a list of surviving channels, but neither one individually is load-bearing on the remote tail, so a future change that retires one of them will not be caught by anything except that test. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`, the same comment; `CommitWireCellRebucket`, the shared committed-value owner added at the D1 fix); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`, the graphical W2 caller); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, the no-window W2) | Matches retail exactly: `HandleReceivedPosition` @0x00453FD0 reads the wire `objcell_id` into a LOCAL @0x00453FE3 and never assigns the object's cell — `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. C5b evidence: `RuntimeSteadyStatePositionMergeTests.AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary` (asserted at the merge boundary, never at `OnPosition` level, where W2 legitimately re-stamps), `…ConservesOneRebucketAndOneChildPropagation` (both parent classes), and `RuntimeAcceptedPositionDriveControllerTests.ContendedForcePosition_WritesNoResidencyAnywhere`; all sabotage-verified. D1-fix evidence for the no-window half: `RuntimeLiveEntitySessionControllerTests` — `AcceptedRemotePosition_AdvancesCanonicalResidencyInANoWindowHost`, `AcceptedLocalPlayerPosition_AdvancesCanonicalResidencyInANoWindowHost`, `WireCellCommit_HonoursRejection_Residence_AndTheLandblockPreserveRule`, `BoundProjectilePosition_CommitsNoWireCell_UnboundMissileDoes`; `HeadlessSessionIsolationTests.RemoteSteadyStatePositionAdvancesTheBotVisibleCell` (end to end through a real `HeadlessSessionHost`); and the two-direction `HeadlessSessionHostTests.LocalForcePosition_CommitsTheWireCellOnlyWhenTheDriveDeclined` theory, whose handled arm discriminates on a measured resolved cell (0xA9B4001C) that is neither the wire cell nor the spawn cell. Eight sabotages verified, each red on at least one of these; the shared derivation's sabotage additionally reddens the graphical `LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell`, which is what establishes that the extracted rule is the same rule both hosts run. | If a future change passes `refreshPosition: true` at either site, a wire Position would make a cellless canonical body resident without any placement/collision commit — the classic AP-1-shaped bug this campaign closed. Conversely, gating or deleting W2 for "symmetry" freezes the local player's canonical cell between teleports. And a host without W2 at all freezes EVERY entity's cell after its placement — the D1 defect: a bot's `RuntimeEntitySnapshot.CellId` never advances, and `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies against a landblock they left. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00453FE3 the local read); `CPhysicsObj::SetPositionInternal` 0x00515BD0 → `set_cell`; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | +| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). LEGACY HALF RETIRED 2026-08-05 (C5b, #275); row REWRITTEN rather than deleted, because wire-cell channels SURVIVE outside the merge and a silent whole-row deletion would hide them.** No Position merge commits residency any more: both the executor's `ApplyPositionAction` and the steady-state `RuntimeEntityObjectLifetime.TryApplyPosition` refresh `canonical.Snapshot.Position` with the wire pose while withholding the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`; the steady-state site is the `RefreshSnapshot` call in `TryApplyPosition`, cite by symbol — the row's former `:1338` and the C5 scoping's `:1918` were both stale). Only a Runtime `SetPosition` commit or a simulation full-cell commit may change residency inside the merge. **The precise surviving claim: a wire Position never makes a record resident INSIDE THE MERGE OR AHEAD OF CLASSIFICATION.** Two steady-state wire-cell writers deliberately remain downstream of it, and are separately filed: **(W2)** the `OnPosition` prologue rebucket (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket` → `SetFullCell`), which runs for every classification reaching the generic tail and is ALSO the local player's own cell-freshness path — cross-filed at AP-146/#320, and deliberately NOT gated, since gating it would freeze the player's canonical cell between teleports and #319's child-cell equality would inherit the freeze; and **(W3)** the post-routing wire-cell adopt for non-placing arms (`TryAdoptWireCellAfterRouting`), filed at AP-135. Packets that return BEFORE W2 — the local force path, the missile arm, and (added at the C5b architecture review's D1 fix) the local `ChildUnparentDisposition` Superseded/Pending arm — are placement-receipt-authoritative for residency, or unchanged at the last commit on a refused/contended force (AD-62's shapes), which is retail's own body-keeps-its-last-placed-cell behaviour. **CORRECTED 2026-08-05 at the D1 fix: that enumeration was presented as exhaustive and was not — the ENTIRE no-window host belonged in it.** W2 and W3 both live in `AcDream.App`, and the two hosts run parallel, non-shared inbound routes (`LiveEntitySessionController`/`LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated`), so `AcDream.Headless` had NO post-merge cell writer at all: every remote's `FullCellId` was written at create/placement and then frozen for the session, and the local player lost this row's own inbound-Position refresh edge (AP-146/#320). Fixed in that commit by giving the no-window route its own W2 over a NEW shared Runtime owner for the committed value, `RuntimeEntityObjectLifetime.CommitWireCellRebucket` — which also retires this row's layering inversion, since it no longer has to document itself by naming an App class its own assembly cannot reference. The no-window host has no W3 analogue and needs none: it performs no remote contact routing, so there is no post-routing arm to adopt a wire cell into. The duplicated REACHABILITY decision the fix leaves behind is filed at AD-64. **AMENDED 2026-08-05 at the C5b architecture review (finding L1/L2), with a measurement the C5b commit did not have: on the ordinary remote tail W2 and W3 are REDUNDANT, not complementary.** Sabotaging W2 alone — either making it adopt the committed cell instead of the wire cell, or skipping the rebucket outright — leaves the entire `LiveEntityNetworkOnPositionCollapseMatrixTests` file green, because W3's `RemoteMotion.CellId` write reads through to canonical `FullCellId` via `RuntimePhysicsState.CommitCanonicalCell`, whose graphical `CellCommitted` recovery also re-installs the render bucket. Only removing BOTH channels goes red, and then exactly one test does: `LiveEntityNetworkOnPositionCollapseMatrixTests.WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell`, added at that review because C5b shipped its "production installs the bucket at W2 in the same call" claim untested (the commit message's "no fixture covers pickup at that layer" was inaccurate — that file drives the real `OnPosition` at ~26 call sites). The practical consequence: this row's W2/W3 enumeration is correct as a list of surviving channels, but neither one individually is load-bearing on the remote tail, so a future change that retires one of them will not be caught by anything except that test. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`, the same comment; `CommitWireCellRebucket`, the shared committed-value owner added at the D1 fix); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`, the graphical W2 caller); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, the no-window W2) | **Scoped claim (tightened 2026-08-05 at the C5b closeout — the former bare "Matches retail exactly" opener over-read a row whose own body documents two channels that do NOT match retail exactly).** The WITHHOLD matches retail exactly: `HandleReceivedPosition` @0x00453FD0 reads the wire `objcell_id` into a LOCAL @0x00453FE3 and never assigns the object's cell — `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. C5b evidence: `RuntimeSteadyStatePositionMergeTests.AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary` (asserted at the merge boundary, never at `OnPosition` level, where W2 legitimately re-stamps), `…ConservesOneRebucketAndOneChildPropagation` (both parent classes), and `RuntimeAcceptedPositionDriveControllerTests.ContendedForcePosition_WritesNoResidencyAnywhere`; all sabotage-verified. D1-fix evidence for the no-window half: `RuntimeLiveEntitySessionControllerTests` — `AcceptedRemotePosition_AdvancesCanonicalResidencyInANoWindowHost`, `AcceptedLocalPlayerPosition_AdvancesCanonicalResidencyInANoWindowHost`, `WireCellCommit_HonoursRejection_Residence_AndTheLandblockPreserveRule`, `BoundProjectilePosition_CommitsNoWireCell_UnboundMissileDoes`; `HeadlessSessionIsolationTests.RemoteSteadyStatePositionAdvancesTheBotVisibleCell` (end to end through a real `HeadlessSessionHost`); and the two-direction `HeadlessSessionHostTests.LocalForcePosition_CommitsTheWireCellOnlyWhenTheDriveDeclined` theory, whose handled arm discriminates on a measured resolved cell (0xA9B4001C) that is neither the wire cell nor the spawn cell. Eight sabotages verified, each red on at least one of these; the shared derivation's sabotage additionally reddens the graphical `LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell`, which is what establishes that the extracted rule is the same rule both hosts run. | If a future change passes `refreshPosition: true` at either site, a wire Position would make a cellless canonical body resident without any placement/collision commit — the classic AP-1-shaped bug this campaign closed. Conversely, gating or deleting W2 for "symmetry" freezes the local player's canonical cell between teleports. And a host without W2 at all freezes EVERY entity's cell after its placement — the D1 defect: a bot's `RuntimeEntitySnapshot.CellId` never advances, and `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies against a landblock they left. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00453FE3 the local read); `CPhysicsObj::SetPositionInternal` 0x00515BD0 → `set_cell`; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | | AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-62 | **Filed 2026-08-03 (C4 route 2, round 2); rewritten round 3.** General rule: an accepted local-player ForcePosition that this route does not carry through to a committed canonical placement is never re-applied. That half matches retail — `SmartBox::BlipPlayer` attempts the placement exactly once and never retries. What diverges is that acdream has non-commit outcomes retail cannot reach at all, because retail's world is fully resident and its placement synchronous. Round 3 narrowed the loss to the re-apply alone wherever the packet's placement was actually BEGUN: the retail position event now fires at that packet's terminal outcome whether or not the placement committed (`SettlePending`'s `positionEventOwed` path), matching `BlipPlayer` discarding `SetPositionSimple`'s `enum SetPositionError` and `HandleReceivedPosition` acking unconditionally @0x00454091. Shapes losing ONLY the re-apply: (i) the destination landblock's collision generation is unpublished so the placement parks (`DeferredCell`) and is then retired by a non-position cause (collision-generation retirement, the lost-cell deadline, `ParkCollisionResidents`) with the accepted authority unmoved — the funnel's EQUAL branch; (ii) the same park superseded by a newer ordinary `Apply` Position which now owns the pose — the ADVANCED+ordinary branch; (iii) any OTHER `PositionAuthorityVersion` advance moving the record out from under the funnel's re-issue test — `TryApplyPickup` (`RuntimeEntityObjectLifetime.cs:1116`), `CommitPositionChannelUpdate` (`:2041`), `AdvanceCreateAuthority` (`:2466`) — effectively unreachable for a live local player, but they fail silently in the same direction and the funnel cannot tell them from (ii). Shapes still losing BOTH the re-apply and the ack because no placement was ever begun for that packet: (iv) a `Contention` whose blocking operation is EXTERNAL to this drive (a concurrent portal/teleport placement owns the entity) — nothing is recorded in `_pending`, so nothing pumps it and the packet is dropped outright; (vi) a re-issue retry marker whose re-issue never manages to begin before the funnel clears it. Losing BOTH for a DIFFERENT reason — the placement WAS begun, but the descriptor was displaced before reaching its own terminal settle: (v) a packet superseded by a newer force whose own placement begins cleanly — `SettlePending` opens by nulling `_pending` without reading it, so the older descriptor's owed ack is discarded. Replaying it would be worse than losing it (a stale-sequence report carrying the newer packet's committed pose), and the displacing packet always acks, so ACE always receives a report for the newest force. The `DeferredCell` park is NOT a precondition of this row: shapes (iv)-(vi) never park. In every shape the body stays where the last successful placement left it and the next accepted Position (ACE broadcasts at 5-10 Hz) carries the corrected pose forward. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`SettlePending` — the single terminal-outcome funnel: its `positionEventOwed` ack and its two non-reissuing branches; and `TryExecuteAcceptedLocalPosition`'s `Contention` return) | Retail has no park and no external placement authority: `SmartBox::BlipPlayer` runs synchronously against a fully resident world, so "arrived but not yet placeable" and "another placement owns this entity" are both unrepresentable there. Those are our async collision-publication and single-placement-authority adaptations. Re-issuing a retired force instead would be worse than not: shape (ii) would stamp the force route's `Teleport\|Slide` flags and an unconditional ack onto an ordinary echo's pose while skipping the `ConstrainTo` the ordinary branch runs (`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`), and shape (i) can re-issue into the same persistent cancellation cause indefinitely. The drive still owns at most one in-flight placement and still re-issues whenever the newest accepted event IS a still-unserved ForcePosition. | A server correction whose destination collision is slow to publish, or which lands while another placement authority owns the entity, can be silently skipped: the player stays at the pre-correction pose for one broadcast interval (~100-200 ms). Sustained (a slow-publishing destination correcting repeatedly) this reads as rubber-banding that does not take. In shapes (iv)-(vi) ACE additionally receives one fewer `AutonomousPosition` than retail would have sent, so the server cannot tell its force was not applied. | `SmartBox::HandleReceivedPosition` @0x00453FD0 FORCE_POSITION branch (`SendPositionEvent` @0x00454091, early return @0x0045409D); `SmartBox::BlipPlayer` @0x00453940 (discards the error, returns void); `CPhysicsObj::SetPositionSimple` @0x005162B0 (returns `enum SetPositionError`; other callers test `== OK_SPE` @0x0055605D/@0x00556021); `CommandInterpreter::SendPositionEvent` @0x006B4770 | | AD-63 | **Filed 2026-08-04 (cancelled-park presentation rollback).** When a cancelled restorable park is rolled back, the entity's presentation is restored EXCEPT the player's selection. `ParkDeferred`'s Withdraw receipt makes the host sink clear the selection if the parked entity was the selected object (`_clearSelectionForUnavailableEntity`), and the `WithdrawalRestored` receipt that rolls that withdrawal back deliberately does not re-select it. Every other registration the withdrawal removed — the graphical bucket, projection visibility, plugin world state, the world-event replay set, the effect-pose registry, the local-player shadow, the presentation visibility sinks — IS restored exactly. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryApplyWithdrawalRestoration` vs `TryPublishWithdrawal`'s `_clearSelectionForUnavailableEntity` call) | Selection is user intent, not a projection registration. Retail clears the selection when its target becomes unavailable (`SelectionChangeReason.SelectedObjectRemoved` is acdream's name for the same edge) and never re-selects on the object's behalf; re-selecting here would invent input the player did not give. Retail also cannot reach this state at all — it has no cancel for a lost-cell park (AP-136) — so there is no retail behaviour to match, only two acdream choices, and "do not act for the player" is the conservative one. | The player loses their target for the ~150 ms park window if the selected object happened to park, and must re-click it. No other state is affected: the object is visible, on the radar, collidable, and assessable again as soon as the restoration receipt drains. Retire together with AP-136 by making the park survive cancellation (issue #309), which removes the withdrawal — and therefore the selection clear — entirely. | AP-136 (the park rollback this rides on); no retail anchor — retail has no cancellable lost-cell park | -| AD-64 | **Filed 2026-08-05 at the C5b architecture review's D1 fix.** The graphical and no-window hosts run parallel, non-shared inbound entity routes — `LiveEntitySessionController` → `LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated` — and AD-60's W2 wire-cell commit is therefore expressed TWICE. The committed VALUE is shared exactly (one owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, including the landblock-vs-cell preserve branch); what is duplicated is the REACHABILITY decision — which packets may reach it. The graphical host encodes that decision implicitly, as the set of early returns strewn through a 400-line `OnPosition` (authority gate on `Rejected`, the local force arm on every drive status except `NotApplicable`, the missile arm, the `ChildUnparentDisposition` Superseded/Pending arm, the initial-create residence gate inside `RebucketLiveEntity`). The no-window host encodes it explicitly, in one method, `TryCommitAcceptedWireCell`, whose gates were derived from those returns one by one. Two of the graphical gates have no no-window analogue and are deliberately absent rather than reproduced: the `ChildUnparentDisposition` arm is presentation recovery this host does not perform, and the residence gate's `MaterializationResidence is AwaitRuntimePlacement` half is App presentation bookkeeping whose no-window equivalent is unconditionally true for a residence-backed record. The no-window host also has no W3 (`TryAdoptWireCellAfterRouting`) analogue and needs none — it performs no remote contact routing at all. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the implicit gate set); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`'s residence early return); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, `IsMissilePacket`) | Retail has one client and therefore one route; there is no retail shape to match, only acdream's own two-host structure. The alternative — unifying the two session controllers so the decision exists once — is the genuinely correct fix and is filed as issue #324, but it is campaign-sized: it has to reconcile presentation recovery, hydration, the equipped-child renderer, and the remote routing arms that only one of the two hosts has. Duplicating a small, individually test-gated decision is the cheaper correct thing meanwhile; duplicating it SILENTLY, which is what the pre-D1 state amounted to (one host simply had none of it), is what this row exists to stop. | The two decisions can drift: a future change to one host's reachability rules will not be caught by the other host's tests. Concretely, if the graphical route later adds an early return, the no-window host keeps committing on that packet shape, and vice versa. Bounded by the eight-sabotage gate the D1 fix left behind — every arm of `TryCommitAcceptedWireCell` and both directions of the force rule are individually red-verified — so drift shows up as a test that must be deliberately changed, not as a silent divergence. Retire with #324. | No retail anchor — acdream-only host-structure deviation. Adjacent rows: AD-60 (the W2/W3 channel list), AP-146/#320 (the local player's cell edges) | +| AD-64 | **Filed 2026-08-05 at the C5b architecture review's D1 fix.** The graphical and no-window hosts run parallel, non-shared inbound entity routes — `LiveEntitySessionController` → `LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated` — and AD-60's W2 wire-cell commit is therefore expressed TWICE. The committed VALUE is shared exactly (one owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, including the landblock-vs-cell preserve branch); what is duplicated is the REACHABILITY decision — which packets may reach it. The graphical host encodes that decision implicitly, as the set of early returns strewn through a 400-line `OnPosition` (authority gate on `Rejected`, the local force arm on every drive status except `NotApplicable`, the missile arm, the `ChildUnparentDisposition` Superseded/Pending arm, the initial-create residence gate inside `RebucketLiveEntity`). The no-window host encodes it explicitly, in one method, `TryCommitAcceptedWireCell`, whose gates were derived from those returns one by one. Two of the graphical gates have no no-window analogue and are deliberately absent rather than reproduced: the `ChildUnparentDisposition` arm is presentation recovery this host does not perform, and the residence gate's `MaterializationResidence is AwaitRuntimePlacement` half is App presentation bookkeeping whose no-window equivalent is unconditionally true for a residence-backed record. **CORRECTED 2026-08-05 at the C5b closeout (architecture finding L-B): "deliberately absent" was presented as the complete list of differences and it was not — there are three more, and the row's own "derived from those returns one by one" phrasing was the claim that made them invisible.** (a) **The residence gate is WEAKER than the merge's own.** Both hosts' wire-cell commits gate on `TryGetInitialCreateResidence` (= `RuntimeInitialCreateResidenceState.TryGetCurrent`), while `RuntimeEntityObjectLifetime.TryApplyPosition`'s FIFO enqueue branch gates on `TryGetPendingInitialResidence` (= `TryGetTransaction` = `TryGetCurrent` OR a completed-but-unretired lease, `RuntimeInitialCreateResidenceState.cs:729-748`). In that window the merge enqueues the packet as a continuation while the commit reads "no residence" and writes the wire cell AHEAD of the continuation that will replay it. Host-symmetric and pre-existing — the graphical `RebucketLiveEntity` has the identical pair — but this row previously claimed the `AwaitRuntimePlacement` half was the only deliberately-absent piece of the residence gate, which is false. (b) **The missile gates are two different expressions.** The graphical route PREFERS `earlyRemoteRoute.OperationKind is RuntimeSetPositionOperationKind.ProjectileAuthoritative` and falls back to the `Missile`-flag / bound-projectile conjunction only when the classification is null; the no-window route ALWAYS uses the conjunction, because it classifies nothing for a remote. They agree today (the conjunction is what the classifier's own projectile test is built from), but they are separately maintained and only the conjunction is reachable on one side — a change to the classifier's projectile predicate moves one host and not the other. (c) **The pre-merge PAYLOAD gate was absent entirely, and is now present.** The graphical route validates the wire payload before the merge (`LiveEntityNetworkUpdateController.OnPosition`'s `payloadIsValid` from `ProjectileController.CanAcceptPositionPayload` — despite the name, not projectile-scoped; it runs for every guid — consumed by `LiveEntityInboundAuthorityGate.TryAcceptPosition`'s `!payloadIsValid` return). The no-window route had no equivalent, so since D1 an unvalidated `update.Position.LandblockId` reached `CommitWireCellRebucket`, whose own doc calls `0` "the withdrawal shape" (cell 0 + landblock 0) — silently de-residencing the entity in the exact field `RuntimeEntityObjectViews.Snapshot` hands every bot as `CellId` and `RuntimeSetPositionState.IsAffectedCollisionResident` reads. Fixed at the closeout by applying the same predicate at the same point: `RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition` plus the finite-velocity term, the pair `RuntimeEntityObjectLifetime.TryApplyPosition` already applies on its initial-residence branch. Rejecting BEFORE the merge (not merely before the commit) is what makes the hosts symmetric — neither lets an invalid payload advance the timestamp gate — and is pinned by `RuntimeLiveEntitySessionControllerTests.InvalidPositionPayload_IsRefusedBeforeTheMerge_InANoWindowHost`, sabotage-verified in both directions (gate removed -> red at the withdrawal-shape assertion; gate moved to guard only the commit -> red at the pose assertion). The no-window host also has no W3 (`TryAdoptWireCellAfterRouting`) analogue and needs none — it performs no remote contact routing at all. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the implicit gate set); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`'s residence early return); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, `IsMissilePacket`) | Retail has one client and therefore one route; there is no retail shape to match, only acdream's own two-host structure. The alternative — unifying the two session controllers so the decision exists once — is the genuinely correct fix and is filed as issue #324, but it is campaign-sized: it has to reconcile presentation recovery, hydration, the equipped-child renderer, and the remote routing arms that only one of the two hosts has. Duplicating a small, individually test-gated decision is the cheaper correct thing meanwhile; duplicating it SILENTLY, which is what the pre-D1 state amounted to (one host simply had none of it), is what this row exists to stop. | The two decisions can drift: a future change to one host's reachability rules will not be caught by the other host's tests. Concretely, if the graphical route later adds an early return, the no-window host keeps committing on that packet shape, and vice versa. Bounded by the eight-sabotage gate the D1 fix left behind, plus the closeout's ninth (the payload gate, red in both directions) — every arm of `TryCommitAcceptedWireCell` and both directions of the force rule are individually red-verified — so drift shows up as a test that must be deliberately changed, not as a silent divergence. **That bound does NOT cover the three differences added at the closeout**: the weaker residence predicate (a) and the missile-expression split (b) have no discriminating test on either side, because in both cases the two hosts currently AGREE and the divergence is structural rather than behavioural. They are recorded here precisely because nothing else will catch them. Retire with #324. | No retail anchor — acdream-only host-structure deviation. Adjacent rows: AD-60 (the W2/W3 channel list), AP-146/#320 (the local player's cell edges) | --- -## 3. Documented approximation (AP) — 101 active rows (AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 102 active rows (AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -173,7 +173,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-143 | **Filed 2026-08-04 (C4 route 7 D5, headless parent-realize drive). AMENDED 2026-08-04 at the retail-conformance review round (R7 MINOR) — this row originally described only ONE of the three checks the drive skips. Line citations corrected at the round-3 review (N3).** The graphical `EquippedChildRenderController.ValidateParentProjection` performs three retail-anchored checks before accepting a parent-attach request: (1) self-parenting rejection (`relation.ParentGuid == relation.ChildGuid`, `:915-916`); (2) the parent must have a constructed part array (`parent.HasPartArray`, `:920` — the closest acdream analogue to retail's `part_array != 0` guard, AP-142 clause d); (3) `Setup.HoldingLocations` validates the specific holding location (`CSetup::GetHoldingLocation` @0x0050F896, via `PartArray::add_child`). `AcDream.Headless`/`AcDream.Runtime`'s direct-host parent-realize drive (`RuntimeLiveEntitySessionController.ResolveAndCommitChildAttachment`) performs NONE of the three — it commits on the POSITION_TS gate acceptance and relation resolution alone. (1) is inert by construction: D1's re-cell gate reads `parent.FullCellId == 0` (the child was just zeroed by the cell-less edge before D1 runs), and D2's skip-on-equal terminates the resulting one-node cycle — a self-parent headless commits the relation but never observably re-cells through it. (2) has no headless analogue at all (see AP-142 clause d — `HasPartArray` is populated only by the graphical mesh pipeline, never headless, for ANY entity). (3) has no prepared-content surface (repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `Setup.HoldingLocations`). | `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Precedent: the content-less host already accepts reduced fidelity elsewhere (`RuntimeLiveEntitySessionController:108-117`'s documented content-less registration). A server-sent self-parent, part-array-less parent, or invalid holding location is unreachable against a well-behaved ACE (ACE only emits `ParentEvent` for a location its own `Player_Inventory`/wield validation already accepted), so this is a defense-in-depth gap, not a live-play one. | A malicious or buggy server could attach a child headless where retail and the graphical host would both reject it — inert against ACE today for all three. Retiring (3) means extending the prepared-content bake format with `Setup.HoldingLocations`, deliberately NOT done in this slice (route 7 contract §4 D5); (2) has no retiring action available until acdream's canonical layer gains its own construction-time part-array concept (a larger architectural question, out of scope here). | `PartArray::add_child` (`CSetup::GetHoldingLocation` 0x0050F896); `CPhysicsObj::enter_cell` 0x00510ed8 (the `part_array` guard); `EquippedChildRenderController.ValidateParentProjection` (graphical port, all three checks) | | AP-144 | **Filed 2026-08-05 (C4 route 3, round-3 review R7). Register discipline finding, not an implementer's disposition** — CLAUDE.md's register rule binds regardless of whether the gap has a live symptom yet. `RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal`'s teleport-arrival movement-event send gates on `!RuntimeCharacterState.UsePositionFromServer` — retail's `CommandInterpreter::UsePositionFromServer` @0x006B3B40, which is `autonomy_level != 2`. But the retail function that ACTUALLY gates this send is a different one: `CommandInterpreter::SendMovementEvent` @0x006B4680 (the `PlayerTeleported` tail-jump), which gates on `autonomy_level != 0` — the LOOSER test, excluding only level 0, satisfied by BOTH level 1 and level 2. acdream's gate reuses the STRICTER `UsePositionFromServer` test (excluding two of the three levels, 0 AND 1), built from the wrong retail function, so it sends only at level 2 and wrongly suppresses at level 1. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`ReconcileAndAcknowledgePortal`, the `!_usePositionFromServer()` guard around `TrySendMovement`); `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`UsePositionFromServer`, `AutonomyLevel`) | The two gates agree at level 0 (both suppress) and level 2 (both send); they diverge only at level 1. `RuntimeCharacterState.TrySetAutonomyLevel` has zero production callers today, so no live code path can ever reach `AutonomyLevel == 1` — the divergence is filed for completeness, not because it is currently reachable. | The instant a future feature calls `TrySetAutonomyLevel(1)` (a partial-autonomy mode, if one is ever built), a portal-arrival movement-event ACE expects to receive at level 1 is silently dropped, until this row's fix threads the raw `AutonomyLevel` through the constructor (touching both host compositions) and gates on `!= 0` directly instead of reusing `UsePositionFromServer`. | `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (`autonomy_level != 2`); `CommandInterpreter::SendMovementEvent` @0x006B4680 (`autonomy_level != 0`, the `PlayerTeleported` tail-jump call site) | | AP-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition` generic tail's prologue rebucket after an accepted inbound Position (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket`; **amended 2026-08-05 by C5b/#275** — this writer was `RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`, i.e. the merge itself, until C5b made the merge withhold the wire cell per AD-60; a ForcePosition, which returns before this tail, is now placement-receipt-authoritative instead), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. **AMENDED 2026-08-05 at the C5b architecture review's D1 fix: this three-edge enumeration was written from the graphical host and silently assumed both hosts shared it.** They do not — the two run parallel, non-shared inbound routes — and the second edge (the `OnPosition` prologue rebucket) lived in `AcDream.App`, so the no-window host had only TWO of the three, the login activation and the teleport/portal commit. It now has all three: `RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` commits the same value through the same shared owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`. The no-window host reaches that edge on the local ordinary (`Apply`) Position and on a `ForcePosition` the accepted-Position drive declined (`NotApplicable`), mirroring the graphical route exactly — a force the drive HANDLED stays placement-receipt-authoritative. This row's COARSENESS claim is unchanged and applies identically to both hosts: the preserve branch now lives in `CommitWireCellRebucket` rather than at `LiveEntityRuntime.cs:935-938`, and the no-window host does not even have the per-frame landblock-shaped caller that motivates it. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitWireCellRebucket` — the landblock-preserve branch, moved here verbatim from `LiveEntityRuntime.cs:935-938` at the D1 fix so both hosts share one rule); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell` — the no-window host's inbound-Position edge); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. **The D1 fix narrows that open item's URGENCY without answering it**: before the fix a no-window bot was strictly worse than the graphical client here, because it lacked the inbound-Position edge entirely — a bot running A→B without teleporting kept `FullCellId` at A for the whole session, so retiring A parked a body physically in B, and retiring B missed it. Both hosts now refresh on every accepted Position; what remains open is the same question this row always asked, at ACE's 5-10 Hz cadence rather than never. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | -| AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | +| AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. **AMENDED 2026-08-05 at the C5b closeout (bookkeeping only — nothing in this row was false, it was un-updated).** This row was written from the graphical host at a moment when it was the only host producing the two-delta stream at all: pre-D1 the no-window host had no post-merge cell writer, so its accepted Position published `[Updated]` alone and simply LOST the `Rebucketed`. D1 gave that host its own `CommitWireCellRebucket` caller, so both hosts now produce `[Updated, Rebucketed]` with the same torn intermediate. The row's analysis, its "no production consumer identified today" verdict, and its retirement condition are unchanged; what changed is the population — a headless bot's event log is now a REAL instance of the "future consumer that SNAPSHOTS a delta" this row warns about, not a hypothetical one, because the no-window host is the one whose consumers are event streams by construction. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | +| AP-148 | **Filed 2026-08-05 at the C5b closeout, from disassembly of the PDB-paired binary — NOT from the pseudo-C, which cannot show it.** acdream's local-player Gate A (the FORCE_POSITION self-echo shortcut) requires the wire TELEPORT_TS to be EXACTLY EQUAL to the stored one; retail requires only that it not be OLDER, so equal AND newer both take the shortcut. `SmartBox::HandleReceivedPosition` @0x0045402B-54 loads `player->update_times[4]` (TELEPORT_TS; base 0x164, 2 bytes/entry, confirmed by the POSITION_TS store `mov word [edx+0x164], ax` @0x00454084 and `acclient.h:6090`), takes `abs(stored - wire)`, picks a wrapped or unwrapped 16-bit compare on `> 0x7fff`, materialises the carry with `sbb eax,eax / neg eax`, and SKIPS Gate A on CF — where CF means the wire stamp is strictly older. It is `CPhysicsObj::newer_event` @0x00451B10's identical idiom with the compare operands swapped. **Binary Ninja drops the flag test and renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)`, vacuously true**, which is why two C5b review rounds read this function carefully and both recorded the term backwards (`docs/research/2026-08-05-c5b-contract.md` §1 said first "teleport must NOT be newer", then "TELEPORT_TS equal"; both corrected at §15). **Consequence:** acdream's `ForcePosition` disposition is a strict SUBSET of retail's Gate A set. A local ForcePosition carrying a NEWER teleport stamp is misrouted into a full `Apply`, which is four separate behaviour changes at once — it takes the WIRE heading instead of preserving the body's (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856`, force-gated), it UNPARENTS and may install a placement frame (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations` — C5b's own truth table), it sets `TeleportAdvanced` and therefore ZEROES local velocity (`:882-885`), and it advances TELEPORT_TS and calls `OfferTeleportDestination`, starting teleport/portal presentation for a packet retail never starts it for. Retail's Gate A deliberately lets a force ride PAST a pending teleport advance without consuming it (it returns @0x0045409D before `newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158); the ordinary Position channel is what processes that teleport. **Not fixed in the filing commit**, deliberately: see issue #325 for why it is not a one-line comparison swap. **C5b made this marginally BETTER, not worse** — `clearParent` was unconditionally `true` pre-C5b and is unchanged for the misrouted packet, and `installPlacementFrame` went unconditional-`true` to `!force && !hasAnimations`, i.e. toward retail's "Gate A never reaches `SetPlacementFrame`". | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptPositionEvent:199`, the `teleport == _timestamps[Teleport]` term); `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` (`ValidAcceptedAuthority`, the `PreviousTeleportSequence == AcceptedTeleportSequence` term — the SAME predicate encoded a second time, and the reason the fix is not one line) | None argued — this is an unintended narrowing found at a closeout, not a chosen approximation. It is filed as an approximation rather than a defect only because the resulting behaviour is a strictly SMALLER shortcut set, i.e. more packets take the fully-processed path rather than fewer, which fails safe for pose correctness even where it is wrong about heading, parent, velocity, and presentation. The exact retail predicate already exists verbatim in the same file — `IsFreshTeleportStart:163` is `!IsNewer(teleport, _timestamps[Teleport])` — so the correction itself is trivial; the consumers are not. | A server correction that arrives while the client's TELEPORT_TS is behind ACE's (a teleport whose Position packet was lost, or arrived after the force) is promoted from "blip me in place" to a full teleporting apply: the player's facing snaps to the wire heading instead of staying where the mouse left it, local velocity is zeroed mid-stride, an equipped child is unparented, and the portal/transit presentation owner is offered a destination for a packet that is not a teleport. Reachability against ACE is UNMEASURED — ACE's two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, `Player_Tick.cs:488` z-hack correction) do not themselves bump the teleport sequence, but `PositionPack` serialises the CURRENT teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (Gate A's teleport test @0x0045402B-0x00454054; the return @0x0045409D; the TELEPORT_TS advance it skips @0x00454158); `CPhysicsObj::newer_event` 0x00451B10 (the same idiom, operands unswapped); `acclient.h:6090` (`update_times[4] == TELEPORT_TS`) | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | diff --git a/docs/research/2026-08-05-c4-closeout-handoff.md b/docs/research/2026-08-05-c4-closeout-handoff.md index 67719dbc..c4b69e99 100644 --- a/docs/research/2026-08-05-c4-closeout-handoff.md +++ b/docs/research/2026-08-05-c4-closeout-handoff.md @@ -2,6 +2,27 @@ Written at C4's implementation closeout. **Read this before touching anything.** +> ## ⚠ BISECT HAZARD — commits `735f0a72..23aa62f2` +> +> Added 2026-08-05 at the C5b closeout. **A `git bisect` that lands anywhere +> in that three-commit range will hit a live, unrelated headless defect.** +> `735f0a72` (C5b) made the steady-state Position merge withhold the wire +> cell and relied on a replacement writer that lives in `AcDream.App`; the +> no-window host has no analogue, so across that range **every remote +> entity's `FullCellId` is frozen at its placement value for the whole +> session** in `AcDream.Headless`, and the local player loses one of +> AP-146's three cell-refresh edges. Nothing throws; no test in the range +> fails. A bot's `RuntimeEntitySnapshot.CellId` simply stops advancing and +> `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies against +> a landblock they have left. Fixed at `ff100cf3`. The range is exactly +> `735f0a72`, `ed806997`, `23aa62f2`. +> +> If you are bisecting a headless cell/residency symptom, treat any `bad` +> verdict inside that range as suspect and re-test with `ff100cf3`'s +> `RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` cherry-picked +> on top. Structural cause: issue **#324**. Residual duplication: **AD-64**. +> Full write-up: `docs/research/2026-08-05-c5b-contract.md` §15.3. + ## Where the branch is - Worktree `C:\Users\erikn\source\repos\acdream\.claude\worktrees\peaceful-visvesvaraya-e0a196` diff --git a/docs/research/2026-08-05-c5b-contract.md b/docs/research/2026-08-05-c5b-contract.md index a9b3e68b..be1d2f16 100644 --- a/docs/research/2026-08-05-c5b-contract.md +++ b/docs/research/2026-08-05-c5b-contract.md @@ -38,7 +38,10 @@ shape, and the (test-only) missile arm. The contract pins all of this. 00453fe3 objcell_id = arg3->objcell_id // wire cell read into a LOCAL 00453ff4 Frame::operator=(&var_40, &arg3->frame) // wire frame copied into local Position var_48 0045400c GATE A: if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9)): -00454044 wrapped-compare update_times[4] (TELEPORT_TS) vs arg8 — teleport must NOT be newer +0045402b-54 wrapped-compare update_times[4] (TELEPORT_TS) vs arg8 — the wire + teleport stamp must NOT be OLDER (equal or newer both pass). + CORRECTED 2026-08-05, see §15 — this line read "must NOT be + newer" and it was backwards. 00454056-68 get_heading / Frame::set_heading(&var_40) // preserve body heading 00454074 SmartBox::BlipPlayer(this, &var_48) 00454079 player->update_times[0] = arg7 // stamp POSITION_TS @@ -70,16 +73,24 @@ Five facts decide C5b: rule AD-60's executor half already encodes ("a wire position never directly makes the record resident"). 2. **Gate A (@0x0045400C) is decided on data that exists before any merge:** - the entity is the player, FORCE_POSITION_TS advanced, TELEPORT_TS equal. - It returns @0x0045409D **before `unset_parent` @0x00454129 and before the - `SetPlacementFrame` gate @0x00454137** — a ForcePosition never unparents - and never installs a placement frame. acdream's exact analog exists - upstream: `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces - the `ForcePosition` disposition only when `isLocalPlayer && - IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]` — - retail's Gate A pair, verified at HEAD. **A remote entity can never - receive the `ForcePosition` disposition** (`isLocalPlayer` guard at - `:190`), so the flag truth table below needs no entity-kind term. + the entity is the player, FORCE_POSITION_TS advanced, and the wire + TELEPORT_TS is **not older** than the stored one. It returns @0x0045409D + **before `unset_parent` @0x00454129 and before the `SetPlacementFrame` + gate @0x00454137** — a ForcePosition never unparents and never installs + a placement frame. acdream's analog exists upstream: + `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces the + `ForcePosition` disposition when `isLocalPlayer && + IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]`. + **CORRECTED 2026-08-05 (see §15): that third term is retail's Gate A pair + NARROWED, not matched.** Retail's test is `teleport` equal-or-newer; + acdream's is equal only. The narrowing is filed at **AP-148 / issue + #325**. Everything C5b decides from Gate A is unaffected — the + disposition C5b reads is the same one it always was, and the narrowing + makes the `ForcePosition` set strictly SMALLER, so no packet C5b's truth + table classifies as force would have been classified otherwise. + **A remote entity can never receive the `ForcePosition` disposition** + (`isLocalPlayer` guard at `:190`), so the flag truth table below needs no + entity-kind term. 3. **The two pre-placement writes are gated on exactly two facts.** `unset_parent` @0x00454129 runs for every accepted non-Gate-A Position (including packets `MoveOrTeleport` will return 0 for — retail unparents @@ -337,7 +348,12 @@ For every path this slice touches, including every refusal and rejection: stays upstream (`PhysicsTimestampGate.cs:190-217`; a force with a teleport advance falls through to `Apply`, and `ValidAcceptedAuthority`'s `PreviousTeleport == AcceptedTeleport` rule - handles the classified side). + handles the classified side). **CORRECTED 2026-08-05 (§15): that + fallthrough is not retail's shape.** Retail's Gate A accepts a force + carrying a NEWER teleport stamp and returns before ever advancing + TELEPORT_TS. Both acdream sites named in this item encode the narrower + equality; both are filed at AP-148 / #325, and both stay untouched by + C5b — closing them is its own slice with its own gate. 7. **The OnPosition routing/arms are untouched** — generic render-pose gate, W2, the arm dispatch, W3, the unified tail, AP-87's catch-up, AP-139's landing clear, AP-140's `InContact` gates. @@ -779,3 +795,124 @@ dropped item at W2 rests on the code reading above plus the two hydration tests now driving recovery-then-W2 in production order. §9's connected gate recipe item 1 (pickup-then-drop, several times) is the positive evidence for it and has not been run. + +--- + +## 15. Corrections and hazards found at the C5b closeout (2026-08-05) + +Both C5b re-reviews (retail-conformance and architecture) returned PASS on +`02578441..ff100cf3`. This section carries what they left behind, plus one +correction neither of them made and both of them missed twice. + +### 15.1 §1's Gate A teleport test was WRONG on primary source + +This document's §1 stated retail's Gate A teleport term two ways, and both +were wrong: the trace line read "teleport must NOT be newer", and fact 2 +read "TELEPORT_TS equal". It then blessed acdream's +`teleport == _timestamps[Teleport]` (`PhysicsTimestampGate.cs:199`) as +"retail's Gate A pair, verified at HEAD". + +**Verified against the PDB-paired binary** (`acclient.exe` v11.4186, +CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, `check_exe_pdb.py` +reports MATCH), disassembled with capstone at +`SmartBox::HandleReceivedPosition` 0x0045402B–0x00454054: + +``` +00453fff mov esi, [esp+0x68] ; arg2 (the object) +00454003 cmp esi, ecx ; == this->player ? +00454005 mov ebx, [esp+0x80] ; ebx = arg8 = WIRE teleport stamp +0045401c call 0x451b10 ; newer_event(player, 6=FORCE_POSITION_TS, arg9) +0045402b mov bp, word [ecx+0x16c] ; bp = player->update_times[4] == TELEPORT_TS + ; (base 0x164, 2 bytes/entry -> index 4) +00454032 movzx edx, bx ; wire +00454035 movzx eax, bp ; stored +00454038 sub eax, edx / cdq / xor eax,edx / sub eax,edx ; abs(stored - wire) +0045403f cmp eax, 0x7fff +00454044 jg 0x45404b ; far apart -> wrapped compare +00454046 cmp bx, bp ; unwrapped: CF <=> wire < stored +00454049 jmp 0x45404e +0045404b cmp bp, bx ; wrapped: CF <=> stored < wire +0045404e sbb eax, eax / neg eax ; eax = CF +00454052 test eax, eax +00454054 jne 0x4540a0 ; CF set (wire strictly OLDER) -> SKIP Gate A +``` + +The shortcut is taken **iff the wire teleport stamp is equal or newer** +(wrap-safe) — "not older". It is `CPhysicsObj::newer_event` @0x00451B10's +identical idiom with the two compare operands swapped: `newer_event`'s +unwrapped compare is `cmp si(stored), di(incoming)` and returns 1 on CF, +i.e. "incoming is newer"; Gate A's is `cmp bx(wire), bp(stored)` and skips +on CF, i.e. "wire is older". `acclient.h:6090` confirms +`update_times[4] == TELEPORT_TS`, and 0x00454084's +`mov word [edx+0x164], ax` confirms the array base via the POSITION_TS +stamp. + +**Why two review rounds missed it.** Binary Ninja drops the flag test and +renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)` — vacuously +true. Reading the pseudo-C, at any level of care, cannot recover this. +Reading the bytes takes five minutes. This is the same class as the PE +byte-decode finding in `claude-memory/reference_pe_byte_decode.md`: when a +decomp renders a comparison as a tautology, that is a decompiler artifact +signature, not a retail fact. + +**Consequence.** acdream's `ForcePosition` disposition is a strict SUBSET +of retail's Gate A set. Filed as **AP-148** with issue **#325**. Not fixed +here: see the AP row and the issue for why it is not a one-line comparison +swap. + +**C5b's own effect on this row is marginally POSITIVE, not negative.** +`clearParent` was unconditionally `true` before C5b and is `!force` after — +unchanged for the misrouted `Apply`. `installPlacementFrame` went +unconditional `true` → `!force && !hasAnimations`, i.e. toward retail's +"Gate A never reaches `SetPlacementFrame`" for the animated half. C5b +neither introduced nor widened the narrowing; it narrowed the damage. + +### 15.2 The no-window route had no pre-merge payload validation + +Retail finding F2 / architecture finding L-A, found independently by both +re-reviewers. Fixed in the closeout commit rather than documented: the +no-window route (`RuntimeLiveEntitySessionController.OnPositionUpdated`) +now applies the same predicate at the same point as the graphical route's +`payloadIsValid` gate. See AD-64 and the method's own comment. The choice +was the root fix rather than a documented asymmetry because the fix is five +lines, reuses an existing Runtime predicate verbatim +(`RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition` +plus the finite-velocity term — the exact pair +`RuntimeEntityObjectLifetime.TryApplyPosition` already applies on its +initial-residence branch), and leaving it would have left two written +claims falsified by the code (the `TryCommitAcceptedWireCell` doc's "under +the same reachability rules the graphical `OnPosition` route applies" and +AD-64's "whose gates were derived from those returns one by one"). + +**It is a behaviour change, and its blast radius is bounded by argument, +not by a gate.** Headless now drops packets it previously merged. Against +ACE the set is empty: ACE resolves cell 0 at `Position` construction and +never serializes a NaN/Inf frame, and `PositionPack` writes a real +`ObjCellId`. The shape is unreachable in the same way it has always been +unreachable for the graphical host, which has carried this gate since it +was written. Two test fixtures did carry illegal cell ids (low word `0x41` +and `0x51`, both above `LandDefs.CellLowInRange`'s `0x40` landcell +ceiling); their constants were corrected, their assertions were not. + +### 15.3 BISECT HAZARD — `735f0a72..23aa62f2` contain a live headless defect + +**A `git bisect` that lands anywhere in this range will hit a real, +unrelated defect.** `735f0a72` (the C5b implementation) made the +steady-state Position merge withhold the wire cell, and the replacement +writer it relied on — the `OnPosition` prologue rebucket — lives in +`AcDream.App`. The no-window host has no analogue, so across that range +**every remote entity's `FullCellId` is frozen at its placement value for +the whole session** in `AcDream.Headless`, and the local player loses one +of AP-146's three cell-refresh edges. Nothing throws and no test in that +range fails; a bot's `RuntimeEntitySnapshot.CellId` simply stops advancing, +and `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies +against a landblock they have left. + +Fixed at `ff100cf3` (the D1 fix). The range is three commits: +`735f0a72`, `ed806997`, `23aa62f2`. + +If you are bisecting a headless cell/residency symptom, treat any `bad` +verdict inside that range as suspect and re-test with `ff100cf3`'s +`RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` cherry-picked +on top. Structural cause: issue **#324** (two parallel, non-shared inbound +routes); residual duplication: **AD-64**. diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index bb0c195d..5472883e 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -2092,6 +2092,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable uint spatialCellOrLandblockId, Action? acknowledgeProjection = null) { + // Noted at the C5b closeout, unreachable in production and left as + // is: this null check runs BEFORE CommitRebucket's EnsureNotDisposed, + // so a call that is both disposed AND null throws + // ArgumentNullException where the same call straight to + // CommitRebucket throws ObjectDisposedException. No caller can + // produce that pair — both hosts' callers resolve `canonical` from a + // live TryGetActive on the same thread — and reordering would put a + // disposed-instance check in front of a derivation that touches no + // instance state. ArgumentNullException.ThrowIfNull(canonical); uint committedFullCell = (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index f3e4cd35..cd3b66b7 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; using AcDream.Runtime.World; namespace AcDream.Runtime.Session; @@ -223,6 +224,42 @@ public sealed class RuntimeLiveEntitySessionController private void OnPositionUpdated( WorldSession.EntityPositionUpdate update) { + // C5b follow-up (2026-08-05), retail finding F2 / architecture + // finding L-A, found independently by both re-reviewers. The + // graphical route validates the wire payload BEFORE the merge — + // LiveEntityNetworkUpdateController.OnPosition computes + // `payloadIsValid` from ProjectileController.CanAcceptPositionPayload + // (retail Position::IsValid @0x005A9480 composed with Frame::IsValid + // @0x00534ED0, plus finite origin/velocity) and + // LiveEntityInboundAuthorityGate.TryAcceptPosition returns false on + // it, ahead of the timestamp gate and every wire-cell writer. Despite + // its name that check is not projectile-scoped; it runs for every + // guid. This route had no equivalent, so an invalid payload merged + // here and then — since D1 — fed its unvalidated LandblockId into + // CommitWireCellRebucket, whose own doc calls a 0 landblock "the + // withdrawal shape": cell 0 + landblock 0, silently de-residencing + // the entity in the exact field every bot reads as + // RuntimeEntitySnapshot.CellId. + // + // The predicate is not re-derived here. It is + // RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition + // plus the finite-velocity term — literally the pair + // RuntimeEntityObjectLifetime.TryApplyPosition already applies on its + // initial-residence branch, and the same composition the graphical + // gate applies. Rejecting BEFORE the merge (rather than before the + // cell commit alone) is what makes the two hosts genuinely + // symmetric: neither one lets an invalid payload advance the + // timestamp gate. + if (!RuntimeAuthoritativePositionRouteClassifier + .IsValidCreateWirePosition(update.Position) + || update.Velocity is { } wireVelocity + && !(float.IsFinite(wireVelocity.X) + && float.IsFinite(wireVelocity.Y) + && float.IsFinite(wireVelocity.Z))) + { + return; + } + bool isLocal = update.Guid == _runtime.PlayerIdentity.ServerGuid; PlayerMovementController? localController = @@ -358,9 +395,12 @@ public sealed class RuntimeLiveEntitySessionController /// /// D1 (C5b architecture review): commits the accepted wire cell to - /// canonical residency for a no-window host, under the same - /// reachability rules the graphical OnPosition route applies to - /// AD-60's W2. The committed VALUE is + /// canonical residency for a no-window host, under reachability rules + /// derived one by one from the graphical OnPosition route's own + /// early returns for AD-60's W2. They are NOT identical, and AD-64 + /// enumerates every place they differ — the two absent gates, the + /// residence gate's weaker predicate, and the missile gate's structural + /// drift risk. The committed VALUE is /// 's — /// one rule, shared by both hosts, including its landblock-vs-cell /// branch. @@ -387,6 +427,27 @@ public sealed class RuntimeLiveEntitySessionController /// operation kind. Committing a wire cell for a projectile here would /// invent residency a placement route owns. /// + /// + /// + /// Two known imprecisions, both host-symmetric and both pre-existing; + /// filed at AD-64 rather than papered over here. (1) The residence + /// gate uses TryGetInitialCreateResidence (TryGetCurrent), + /// while RuntimeEntityObjectLifetime.TryApplyPosition's own FIFO + /// branch uses the strictly WEAKER TryGetPendingInitialResidence + /// (TryGetTransaction = current OR a completed-but-unretired + /// lease). In that window the merge enqueues the packet as a + /// continuation while this gate reads "no residence" and commits the + /// wire cell ahead of the continuation that will replay it. The + /// graphical route's RebucketLiveEntity reads the same weaker + /// predicate, so both hosts have it identically. (2) The missile + /// predicate below is the graphical route's FALLBACK conjunction; that + /// route PREFERS earlyRemoteRoute.OperationKind is + /// ProjectileAuthoritative and drops to the conjunction only when + /// the classification is null. The two agree today — the conjunction is + /// what the classifier's own projectile test is built from — but they + /// are separate expressions and only one of them is reachable here, + /// because this route classifies nothing for a remote. + /// /// private void TryCommitAcceptedWireCell( WorldSession.EntityPositionUpdate update) @@ -400,6 +461,17 @@ public sealed class RuntimeLiveEntitySessionController return; } + // The bool is discarded, where the graphical caller + // (LiveEntityRuntime.RebucketLiveEntity) treats false as + // ThrowAfterCommittedProjectionChange. That is not a suppressed + // failure: false means `Entities.IsCurrent(canonical)` went stale, and + // TryGetActive above returned the CURRENT record synchronously three + // statements earlier on the same thread, with nothing in between that + // can retire it. The graphical caller needs the test because it has + // already published spatial/presentation changes by that point and a + // stale canonical would leave them orphaned; this route publishes + // nothing ahead of the commit, so there is no half-applied state to + // detect. Asserting on it would be asserting on an unreachable value. _ = Entities.CommitWireCellRebucket( canonical, update.Position.LandblockId); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 96a0eb29..0ee3bd0e 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -659,9 +659,16 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.NotEqual(0u, placedCell); // (a) A stale position sequence is Rejected by the timestamp gate. + // The cell is 0x…0011 rather than the 0x…0041 this fixture used + // before the C5b follow-up: a low word above 0x40 is not a legal + // landcell (LandDefs.CellLowInRange, retail + // LandDefs::inbound_valid_cellid @0x004979A0), so once this route + // gained the graphical route's payload gate the packet would have + // been refused for its CELL and this assertion would have passed + // without the timestamp gate ever being consulted. sink.PositionUpdated(PositionUpdate( spawn.Guid, - 0x01010041u, + 0x01010011u, positionX: 13f, positionSequence: 1)); Assert.Equal(placedCell, remote.FullCellId); @@ -674,6 +681,110 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.Equal(0x0202FFFFu, remote.CanonicalLandblockId); } + /// + /// C5b follow-up (retail F2 / architecture L-A): the no-window route + /// refuses an invalid wire payload with the same rule, at the same + /// point, as the graphical route's payloadIsValid gate + /// (LiveEntityNetworkUpdateController.OnPosition → + /// ProjectileController.CanAcceptPositionPayload → + /// LiveEntityInboundAuthorityGate's !payloadIsValid + /// return). + /// + /// + /// The three shapes are the three terms of that rule. LandblockId + /// 0 is the one that mattered: LandDefs.InboundValidCellId rejects + /// a 0 low word, and since D1 an unvalidated 0 flowed straight into + /// CommitWireCellRebucket, whose own doc calls cell 0 + landblock + /// 0 "the withdrawal shape" — silently de-residencing the entity in the + /// field RuntimeEntityObjectViews.Snapshot hands every bot as + /// CellId. + /// + /// + /// + /// The last assertion is the discriminating one, and it is what makes + /// this a BEFORE-THE-MERGE test rather than a before-the-commit test: a + /// valid packet at the SAME position sequence the three refused packets + /// carried still applies. If the refusal sat anywhere downstream of + /// TryApplyPosition, the timestamp gate would have consumed that + /// sequence and the follow-up would be Rejected. + /// + /// + [Fact] + public void InvalidPositionPayload_IsRefusedBeforeTheMerge_InANoWindowHost() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + SpawnAt(0x70000051u, incarnation: 1, 0x01010001u); + + sink.Spawned(spawn); + DrainFirstEntry(runtime, drive); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord remote)); + uint placedCell = remote.FullCellId; + uint placedLandblock = remote.CanonicalLandblockId; + float placedX = remote.Snapshot.Position!.Value.PositionX; + Assert.NotEqual(0u, placedCell); + + const ushort refusedSequence = 7; + + // (a) LandblockId 0 — the withdrawal shape. + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + 0u, + positionX: 20f, + positionSequence: refusedSequence)); + Assert.Equal(placedCell, remote.FullCellId); + Assert.Equal(placedLandblock, remote.CanonicalLandblockId); + Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX); + + // (b) A NaN origin component — retail Frame::IsValid @0x00534ED0. + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + 0x01010031u, + positionX: float.NaN, + positionSequence: refusedSequence)); + Assert.Equal(placedCell, remote.FullCellId); + Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX); + + // (c) A non-finite velocity — the graphical gate's third term. + WorldSession.EntityPositionUpdate infiniteVelocity = PositionUpdate( + spawn.Guid, + 0x01010031u, + positionX: 22f, + positionSequence: refusedSequence) with + { + Velocity = new System.Numerics.Vector3( + float.PositiveInfinity, + 0f, + 0f), + }; + sink.PositionUpdated(infiniteVelocity); + Assert.Equal(placedCell, remote.FullCellId); + Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX); + + // The discriminator: POSITION_TS was never consumed. + const uint movedCell = 0x01010031u; + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + movedCell, + positionX: 23f, + positionSequence: refusedSequence)); + Assert.Equal(movedCell, remote.FullCellId); + Assert.Equal(23f, remote.Snapshot.Position!.Value.PositionX); + } + /// /// D1's missile gate. The graphical route sends a BOUND projectile's /// accepted Position to the canonical projectile placement owner and @@ -737,7 +848,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.NotNull(bound.Projectile); Assert.Null(unbound.Projectile); - const uint movedCell = 0x01010051u; + // 0x…0012, not the 0x…0051 this fixture used before the C5b + // follow-up: a low word above 0x40 is not a legal landcell + // (LandDefs.CellLowInRange), so the payload gate this route now + // shares with the graphical host would refuse BOTH packets and the + // missile distinction under test would never be reached. + const uint movedCell = 0x01010012u; sink.PositionUpdated(PositionUpdate( boundGuid, movedCell,