From 735f0a72af6eb4562b09f80a6646ad29512e8718 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 21:17:20 +0200 Subject: [PATCH] fix(physics): classify before merge on every steady-state Position (C5b, #275, AP-131/AD-60) The steady-state accepted-Position merge did two things retail never does, on every single Position packet: it installed the wire placement frame and unparented unconditionally, and it derived the record's FullCellId from bare wire acceptance. Both are now correct, and they land together - a half-flipped intermediate (classified flags with the wire stamp, or vice versa) is exactly the mixed-residency state this campaign keeps paying for. WHY the flags need no route. SmartBox::HandleReceivedPosition @0x00453FD0 decides both pre-placement writes BEFORE MoveOrTeleport is consulted: Gate A @0x0045400C returns @0x0045409D ahead of unset_parent @0x00454129 and ahead of the HasAnims SetPlacementFrame gate @0x00454137. Neither gate reads the near/far/teleport classification. So the two flags are a pure function of (disposition, hasAnimations) and are computable inside the merge, pre-merge, with no signature change, no route construction and no playerDistance - the scoping's ~150-400-line route-plumbing estimate over-counted because it did not see this. That truth table IS RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's own ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows; the classifier stays the oracle and the equality is pinned by test, not by a shared path, so each computation remains separately sabotage-verifiable. WHY the cell is withheld. HandleReceivedPosition reads the wire objcell_id into a LOCAL @0x00453FE3 and hands it only to BlipPlayer / TeleportPlayer / MoveOrTeleport / ConstrainTo; it never assigns the object's cell. The object's cell moves inside the placement family (SetPositionInternal @0x00515BD0 to set_cell, enter_world) or per-frame transit, and nowhere else. The continuation executor has encoded that rule since the executor slice; this caller now matches it verbatim. WHAT DELIBERATELY SURVIVES. Two steady-state wire-cell writers stay, downstream of the merge and outside the classification window: the OnPosition prologue rebucket (W2, into CommitRebucket), which is also the local player's own cell-freshness path, and the post-routing wire-cell adopt for non-placing arms (W3, AP-135). Gating W2 "for symmetry" would freeze the player's canonical cell between teleports and #319's child-cell equality would inherit the freeze. AD-60's rewrite names both so the retirement cannot be misread as "wire acceptance never changes residency anywhere". REGISTER. AP-131 RETIRED - the unconditional literals no longer exist; the caller was corrected, not deleted, so the row's own "deleted at the production cutover" framing is overtaken. AD-60's legacy half RETIRED and the row REWRITTEN rather than deleted, naming W2/W3 (route 4b-3's D8 precedent: a silent whole-row deletion would hide surviving channels). AP-130 amended - the merge consumes the same static HasAnimations proxy, deliberately not escalated to a live animation-queue read. AP-146 and #320 amended - their "accepted inbound Position (RefreshSnapshot into RuntimeEntityRecord.cs:234)" local-player cell writer is now the generic tail's CommitRebucket, and a ForcePosition (which returns before that tail) is placement-receipt-authoritative. #275 closed. HEADLINE BEHAVIOURAL DELTA, stated once: a refused or contended local ForcePosition now leaves FullCellId at the last committed cell where the merge used to stamp the refused packet's wire cell. Retail cannot refuse (AD-62) and its body keeps its last placed cell, so the new shape is the retail-reachable one. THREE CONSUMER SITES THE CONTRACT'S BLAST-RADIUS SURVEY MISSED, all D2-caused, all found by the suite rather than by reading, all intended semantics rather than regressions (recorded in the contract's new section 14): (1) DatLiveEntityProjectionMaterializer's self-projection branch reads FullCellId inside OnPosition's prologue recovery, ahead of W2. It now correctly declines to project from an unplaced wire claim; production installs the bucket at W2 in the same call (verified: no return between the recovery call and W2 is conditioned on IsSpatiallyProjected or FullCellId). Two hydration tests asserted the bucket at the recovery boundary and now drive the production W2 step - the same shape as trap T2, one layer up. (2) ProjectileController.SyncPresentationFromResolvedBody writes ParentCellId = record.FullCellId. On a refused missile placement that is now the committed source cell. The MAJOR-1 invariant is unchanged and is now asserted as the identity it always meant rather than as a wire-cell constant. (3) The merge's Rebucketed ternary does NOT become always-Updated as the contract predicted, and is deliberately kept: the Forget(restoreCancelledPark: true) above it can roll a wakeable lost-cell park back, and RestoreParkWithdrawal restores canonical residency. That is a real cell edge produced inside this method by a placement owner. TEST-COUNT RECONCILIATION. Baseline measured at this HEAD by stashing the change: Runtime.Tests 1176, App.Tests 4135 (4132 passed / 3 skipped), solution 11,106 passed / 4 skipped - matching the recorded figure at 6921a027 exactly. Post-change: Runtime.Tests 1195, App.Tests 4135 unchanged, solution 11,125 passed / 4 skipped / 0 failed. Net +19, entirely new Runtime tests: 3 facts plus a 12-row matrix theory in InboundPhysicsStateControllerTests, 1 fact plus a 2-row theory in the new RuntimeSteadyStatePositionMergeTests, and 1 fact in RuntimeAcceptedPositionDriveControllerTests. No test was deleted; five existing tests were rewritten in place, never delete-only. No new skip; none of #302/#308/#321 appeared. SABOTAGE VERIFICATIONS (each new discriminating test, both directions; production line broken, suite run, line restored): installPlacementFrame (!force && !hasAnimations) to (!force) 5 fail: ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame plus the 4 animated non-force matrix rows. installPlacementFrame to false 6 fail: ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame, PositionPlacementAbsentAndPresentZeroBothApplyRetailZero plus the 4 non-animated non-force matrix rows. clearParent (!force) to true 3 fail: ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment plus the 2 force+parented matrix rows. clearParent (!force) to false 4 fail: the 4 Apply+parented matrix rows. refreshPosition false to acceptedPosition 4 fail: AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary, ContendedForcePosition_WritesNoResidencyAnywhere, ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta, MissileFarRefused_...ParentCellIdAgreesWithCommittedCell. Confirmed a second time by the baseline measurement above, where the withhold test was the sole red. CommitRebucket publishes Updated instead of Rebucketed 2 fail: both parent classes of CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation. RuntimeEntityDirectory.SetFullCell drops PropagateFullCellToChildren 2 fail: the same two rows. T4 respected: the ForcePosition placement-frame half is inert (appliedPlacement keeps old.PlacementId under either flag value), so the force row's discriminating assertion is parent retention, never the frame. NOT DONE, deliberately: the executor is still not wired into the steady-state path (#275's alternative branch); W2/W3 are untouched; no probe added or stripped; AP-130's proxy not escalated; no while-here unification of the two merge callsites. No automated OnPosition-level test drives the full pickup / drop / reproject sequence (no fixture covers pickup at that layer); the contract's connected gate recipe item 1 is the positive evidence for it and has NOT been run - this commit is not connected-gated. Contract: docs/research/2026-08-05-c5b-contract.md (committed here, with its section 14 implementation outcome appended). Co-Authored-By: Claude Opus 4.8 --- docs/ISSUES.md | 43 +- .../retail-divergence-register.md | 9 +- docs/research/2026-08-05-c5b-contract.md | 781 ++++++++++++++++++ .../Entities/InboundPhysicsStateController.cs | 107 ++- .../Entities/RuntimeEntityObjectLifetime.cs | 60 +- ...ityNetworkOnPositionCollapseMatrixTests.cs | 39 +- .../LiveEntityHydrationControllerTests.cs | 23 + .../InboundPhysicsStateControllerTests.cs | 333 +++++++- .../RuntimeSteadyStatePositionMergeTests.cs | 297 +++++++ .../Physics/RuntimeSetPositionStateTests.cs | 11 +- ...imeAcceptedPositionDriveControllerTests.cs | 75 ++ 11 files changed, 1715 insertions(+), 63 deletions(-) create mode 100644 docs/research/2026-08-05-c5b-contract.md create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 3a29bbbb..5e64dfa6 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -35,9 +35,11 @@ question is real and unresolved **Description.** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: -login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound -Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → -`RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit +login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition` +generic tail's prologue rebucket after an accepted inbound Position +(`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` +→ `RuntimeEntityObjectLifetime.CommitRebucket`), 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 @@ -47,6 +49,18 @@ and mostly-frozen between teleports — see the register row this issue's fix commit files (AP-146) for the full citation and the argument that this is currently safe for every EXISTING consumer. +**Amended 2026-08-05 by C5b (#275).** The second edge above used to be the +accepted-Position MERGE itself (`RuntimeEntityDirectory.RefreshSnapshot` → +`RuntimeEntityRecord.cs:234`). C5b made that merge withhold the wire cell +(AD-60), so the writer is now the `OnPosition` prologue rebucket's +`CommitRebucket` — one step later in the same call, same value. Nothing about +this issue's substance changes: the coarse landblock-preserve branch at +`LiveEntityRuntime.cs:935-938` is unchanged and is still what makes the +player's cell mostly-frozen. One shape DID change and belongs to this issue's +survey: a local **ForcePosition** returns before that tail, so its residency +is now placement-receipt-authoritative — a refused or contended force writes +no cell at all (retail's own shape; AD-62). + **Why this is not #319's blast radius.** #319's fix makes a player-parented equipped child inherit the parent's (the player's) canonical cell EXACTLY — an equality invariant, not a freshness one. The child is stale-but-equal @@ -1648,7 +1662,8 @@ Copy this block when adding a new issue: ## #275 — Unify the legacy Position wire path onto the executor's route classifier -**Status:** OPEN — post-cutover (physics campaign, filed 2026-08-02) +**Status:** CLOSED 2026-08-05 (C5b) — behaviour unified; the remaining +structural item is tracked below, not by this issue **Severity:** LOW (internal refactor debt; not a retail divergence) **Component:** Runtime / inbound Position @@ -1663,6 +1678,26 @@ in, unify the legacy caller onto the same classifier (or delete it with the route) and retire AP-131/AD-60's legacy halves. See `InboundPhysicsStateController.TryApplyPosition` remarks. +**Resolution (C5b, 2026-08-05, `docs/research/2026-08-05-c5b-contract.md`).** +The steady-state merge was CORRECTED rather than deleted — it is still the only +production Position wire caller, and the issue's alternative branch ("or delete +it with the route") was not taken. Two behaviour changes landed atomically: +`TryApplyPosition` now computes `installPlacementFrame`/`clearParent` pre-merge +from `(disposition, hasAnimations(old))`, which is exactly the classifier's own +two rows because retail decides both writes ahead of `MoveOrTeleport`; and the +merge stops deriving `FullCellId` from bare wire acceptance +(`refreshPosition: false`). AP-131 retired, AD-60's legacy half retired and +its row rewritten to name the surviving wire-cell channels (W2 the prologue +rebucket, W3 the post-routing adopt). + +**What deliberately remains, and is NOT this issue.** The two computations are +still separate small pure expressions in two places rather than one shared code +path — they are pinned equal by +`InboundPhysicsStateControllerTests.MergedPrePlacementFieldsMatchTheClassifiedRouteFlags`, +which uses the production classifier as the oracle. Wiring the continuation +executor into the steady-state path is a separate structural decision that no +longer has any behavioural motivation behind it. + --- ## #274 — Restricted/barred-house entry needs a connected retail comparison diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c51ce46c..ca224c34 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -152,14 +152,14 @@ 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).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `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 — 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. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`, the same comment) | 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. | 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. | `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 | --- -## 3. Documented approximation (AP) — 101 active rows (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) — 100 active rows (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 @@ -171,7 +171,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-142 | **Filed 2026-08-04 (C4 route 7, pickup/parent/delete). AMENDED 2026-08-04 at the dual-Opus retail-conformance/architecture review round (R1/A8 MAJOR+LOW; R10 MINOR) — clause (d) added, clause (b) corrected. AMENDED AGAIN 2026-08-04 at the round-3 dual review (N1/N2/N4, B3) — clause (d)'s reasoning corrected and its risk-column scope widened; clause (e) RETIRED — the depth cap it described is deleted outright, replaced by an iterative worklist with no depth concept at all. AMENDED AGAIN 2026-08-05 at the #319 fix — clause (f) added. AMENDED AGAIN 2026-08-05 at the #319 dual-review round (retail PASS, architecture FAIL/6 MAJORs) — clause (f) rewritten: the tripwire moved above the canonical commit and no longer throws (A1), and the deferred late-bind queue A1's fix text originally described was deleted per A6 (both reviews proved it production-unreachable for both producers).** acdream collapses retail's `CPhysicsObj` pair — a `cell` pointer plus a separately-written `objcell_id` — into ONE canonical `RuntimeEntityRecord.FullCellId`, which is also the residency/liveness predicate acdream reads at 45+ sites. Four consequences, all intentional: (a) the removal path propagates ZERO to a subtree's children (withdrawal, delete, `EndGeneration`), where retail's `leave_cell` recursion nulls only each child's `cell` pointer and leaves a STALE non-zero `objcell_id` (`change_cell`'s removal tail @0x005133C1 never touches a child's id) — reproducing that stale-id residue would leave a child "resident" per every acdream predicate while retail's own gating field (`cell == nullptr`) says it is not; (b) retail's same-cell depth-1 per-tick `objcell_id` refresh (`SetPositionInternal` @0x0051539c-@0x005153d8, gated on the parent NOT crossing a cell) is subsumed by the value-idempotent propagation chokepoint (`RuntimeEntityDirectory.SetFullCell`'s "skip a child whose `FullCellId` already equals the target" guard) rather than ported as a separate tick loop — a same-value restamp is unobservable with one field playing both retail roles. **Correction (R10): this is a clean equivalence only on the REMOVAL side.** The skip ALSO prunes the child's whole subtree on a same-value WRITE, which retail's `enter_cell` does not do — it recurses over children unconditionally (@0x00510f03); only `leave_cell` prunes (@0x00510f5b, on `cell != 0`). Currently unreachable-by-construction (after D4 nothing writes a grandchild's cell independently of its own committed parent), but it is an asymmetry, not a proven equivalence; (c) the sustaining propagation itself: retail re-cells children when the parent crosses a cell, recursively, on EVERY `SetPositionInternal`/`change_cell` (@0x00515372/@0x00513390), not only at attach — acdream ports this as a single hook every canonical cell-write funnels through, so an attach-only write (the pre-existing shape) is deliberately NOT what shipped. **(d) retail's `enter_cell` gates its ENTIRE body — the write AND the recursion into children — on `this->part_array != 0` (@0x00510ed8); a child with a null part array receives nothing and its whole subtree is skipped. acdream's propagation has NO analogue and writes unconditionally. CORRECTED reasoning (round-3 review, N1/N2): the original draft of this clause argued acdream's `HasPartArray` means something semantically different from retail's `part_array` (a "renderer built a mesh" flag vs. "this CPhysicsObj has any part array"). That framing is WRONG — retail's `part_array` has exactly ONE assignment site, `CPhysicsObj::makeAnimObject` @0x0050e930 → `CPartArray::CreateSetup`, assigned @0x0050e94d, so retail's flag is ALSO a mesh-construction product; the two are near-synonyms, not different concepts. The REAL reason acdream cannot gate the canonical D1/D2 write on `HasPartArray` is LAYERING, not semantics: Slice J made the Runtime canonical layer presentation-independent by design (`docs/research/2026-07-25-slice-j1-runtime-contract-closeout.md` and the Slice J campaign generally), and `HasPartArray` is populated exclusively by App/graphical code (`EquippedChildRenderController.cs:609`, `DatLiveEntityProjectionMaterializer.cs:203`) — the canonical layer structurally cannot depend on a flag only the presentation layer ever writes, headless or not. CORRECTED scope (round-3 review): this is NOT headless-only. `PrepareAndTryRealize` calls `CommitAcceptedParentCellless` (hence D1's re-cell) BEFORE `TryRealize` sets `HasPartArray = true` at `:609` — so at the exact moment D1 runs, `child.HasPartArray` is FALSE in the GRAPHICAL host too, and gating on it would break attach there as well, not just headless. Retail has no equivalent window at all: `part_array` is assigned once at construction and `enter_cell`'s guard reads that same, already-settled field.** The guard is deliberately NOT reproduced at the canonical layer. **(e) RETIRED 2026-08-04 (round-3 review, N4/B3 — both reviews independently found the same defect).** Previously: recursion depth capped at 64 levels as hostile/buggy-server hardening. The cap's actual failure mode was worse than what it guarded against: a subtree beyond the cap was left at its PRIOR — on the withdraw path, STALE NONZERO — cell PERMANENTLY, logged only under a probe flag nobody runs by default. On the withdraw path that is the #184 shape verbatim: an entity every acdream residency predicate calls resident that retail (and clause (a) above) says is not. Shipping that inside the slice whose headline is fixing exactly this class was unacceptable. Retired by deleting the cap outright and replacing the recursion with an iterative worklist (`RuntimeEntityDirectory._propagationWorklist`), which has no stack-frame-bounded depth at all — the only limit is the number of committed relations actually in the system, matching retail's own genuinely unbounded recursion with no acdream-only cap and therefore no register row for one. **(f) Filed 2026-08-05 (#319 fix).** A CreateObject-carried parent relation (the raw spawn's `Physics.Parent` field, and the same-generation `CreateParentUpdate` envelope) names the parent's GUID and location only — neither wire shape carries a parent instance sequence, matching retail's own GUID-only attach (`PhysicsDesc::get_parent_id` @0x00558a18 → `CObjectMaint::GetObjectA` @0x00558a2d → `CPhysicsObj::set_parent` @0x00558a3e; the reverse `CObjectMaint::SetChildren` @0x00509370 hash-walks by guid with a `GetNullObject` placeholder @0x005093e6 — no instance-sequence field or comparison exists anywhere in either direction). acdream's committed-relation table is nonetheless keyed by (guid, incarnation) (clause (c)'s D1/D2 requirement), so a CreateObject-carried relation must adopt SOME incarnation to file under; it now LATE-BINDS to the parent's LIVE incarnation at accept time (`EquippedChildRenderController.AcceptLateBoundCreateObjectRelation`, both the raw-CreateObject and same-generation `CreateParentUpdate` producers) rather than the previously-hardcoded 0, which silently mis-keyed every player-parented CreateObject relation (a player's `ObjectInstance` is `Character.TotalLogins`, never 0) and defeated D1/D2 for the local player's own login equipment and every remote player's observed equipment (#319). A commit-time tripwire (`ParentAttachmentState.CanCommitIncarnation`, checked BEFORE either half of the commit mutates state — architecture review A1, 2026-08-05, moved it there after the original throw-after-canonical-commit shape was shown to tear the transaction it was built to protect) refuses (logs, returns false, never throws) rather than silently filing a relation under a mismatched incarnation whenever the parent is currently addressable. **A1 also settled A6's design question**: an initial revision queued a relation whose parent was not yet addressable through a deferred/late-bind retry mechanism; both reviews independently proved that queue was structurally unreachable in production for BOTH producers (`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate` gate defers the ENTIRE CreateObject, for both wire shapes, before either producer ever runs) while carrying three latent defects of its own (a missing child POSITION_TS gate, a placeholder-incarnation collision with the generation filters, unbounded accumulation) — it was deleted rather than fixed in place; the unaddressable-parent case now logs and refuses outright, matching the invariant the layer above already enforces. | `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` (`SetFullCell`, `PropagateFullCellToChildren`, `RefreshSnapshot`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitAcceptedParentCellless`'s D1 half, `WithdrawCommittedChildrenToCellless`); `src/AcDream.Runtime/Entities/ParentAttachmentState.cs` (`TryGetCommittedParent`, `CanCommitIncarnation`, `CommitProjection`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`); `src/AcDream.App/Rendering/EquippedChildRenderController.cs` (`AcceptLateBoundCreateObjectRelation`, `OnSpawn`, `OnCreateParentAccepted`, `PrepareAndTryRealize`); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Reproducing retail's pointer/id split would require a second field acdream's 45+ liveness call sites would then have to be individually audited for which half they mean — the single-field model is a stated, load-bearing simplification, not an oversight; see `docs/research/2026-08-04-retail-parent-cell-propagation.md` and `docs/research/2026-08-04-c4-route-7-contract.md` D2/D3/D9. Clause (f) is retail-faithful for the identical reason clauses (a)-(d) are: retail's attach has no incarnation gate on this path at all, so adopting the current holder of the guid IS the retail behavior, not an approximation of it. | A future consumer that expects retail's exact stale-`objcell_id`-under-a-null-`cell` shape (none identified) would see a fully cell-less child instead. (d)'s risk: acdream celling a child retail would leave nowhere — none identified in play against a well-behaved ACE, since a server-authored equip always names a real, DAT-resolvable Setup, and the graphical host's own brief pre-`TryRealize` window is bridged by D1 running inside the same synchronous transaction as the rest of the attach commit, not by `HasPartArray` being true. (f)'s risk: none identified against a well-behaved ACE — a CreateObject's parent guid always names the entity that currently holds it by construction. | `CPhysicsObj::change_cell` 0x00513390 (@0x005133C1 removal tail); `CPhysicsObj::enter_cell` 0x00510ed0 (@0x00510ed8 the `part_array` guard); `CPhysicsObj::leave_cell` 0x00510f50; `CPhysicsObj::SetPositionInternal` 0x00515330 (@0x0051536d branch, @0x0051539c-@0x005153d8 same-cell loop, @0x00515372 cell-change branch); `CPhysicsObj::makeAnimObject` 0x0050e930 (`CPartArray::CreateSetup` assignment @0x0050e94d); `PhysicsDesc::get_parent_id` 0x00558a18; `CObjectMaint::GetObjectA` 0x00558a2d; `CPhysicsObj::set_parent` 0x00558a3e; `CObjectMaint::SetChildren` 0x00509370 (`GetNullObject` placeholder @0x005093e6) | | 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`), an accepted inbound Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`), 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. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.App/World/LiveEntityRuntime.cs:935-938` (the landblock-preserve branch); `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. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | +| 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. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.App/World/LiveEntityRuntime.cs:935-938` (the landblock-preserve branch); `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. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | | ~~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 | @@ -282,8 +282,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | -| AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | -| AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) | +| AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. **Amended 2026-08-05 (C5b, #275):** the steady-state merge now consumes the SAME static proxy, computed from the pre-merge snapshot with the identical expression, so this row covers both callers. The proxy is deliberately not escalated to a live animation-queue read in that slice. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local); `src/AcDream.Runtime/Physics/RuntimeAcceptedPositionRouteRequests.cs` (`Build`, `hasAnimations` local); `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | | AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). AMENDED 2026-08-05 at the #319 fix — clarifying sentence added distinguishing this row's producer from the CreateObject producer AP-142 clause (f) covers.** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. **This row's incarnation gate applies ONLY to the `ParentEvent` wire producer, which NAMES a specific parent incarnation on the wire (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. The CreateObject producer (AP-142 clause (f)) is different in kind: neither a raw CreateObject's `Physics.Parent` nor the same-generation `CreateParentUpdate` envelope carries a parent instance sequence AT ALL, so there is no wire-named value to gate against; that producer LATE-BINDS to the parent's live incarnation instead of gating on a wire value, which is the same "honor what the server actually sent" principle applied to a message that sent no incarnation.** | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | | AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | | AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` | diff --git a/docs/research/2026-08-05-c5b-contract.md b/docs/research/2026-08-05-c5b-contract.md new file mode 100644 index 00000000..a9b3e68b --- /dev/null +++ b/docs/research/2026-08-05-c5b-contract.md @@ -0,0 +1,781 @@ +# C5b — classify-before-merge on every steady-state Position (#275): pinned contract (2026-08-05) + +Written at HEAD `02578441` (branch `claude/acdream-physics-divergence-5aa784`; +C5a landed at `6921a027`, #319 at `392c1e22`, AP-145/#318 at `f8e55ba5`). +Every symbol below was re-verified **by symbol at this HEAD**, not inherited; +§12 lists what the C5 scoping (`2026-08-05-c5-scoping.md`, written at +`52175aa1`) got wrong. Inputs: the scoping's C5b section, register rows +AP-131 / AD-60 / AP-130 / AP-135 / AP-146, the 4b-3 contract +(`2026-08-04-c4-route-4b-3-contract.md` — D1 is C5b's direct ancestor), the +C4 closeout handoff's seven process findings, the C5a contract +(`2026-08-05-c5a-contract.md`), and issue #275. + +**One-paragraph verdict:** C5b is smaller than the scoping feared, because of +a fact the scoping did not state: the merge's two flags +(`installPlacementFrame` / `clearParent`) are a pure function of the +**timestamp disposition** and the **hasAnimations proxy** — both available +inside the merge itself, pre-merge, with no routing outcome and no +`playerDistance`. Retail runs `unset_parent` and the `SetPlacementFrame` gate +BEFORE `MoveOrTeleport` is consulted, so the flags are upstream of +classification proper. C5b therefore needs **no route plumbing, no signature +changes, no App→Runtime threading** — it replaces two `true` literals with a +four-row truth table the classifier already encodes, and flips one +`refreshPosition:` argument to `false`. The blast radius is correspondingly +narrower than "45+ sites mis-reading a withheld wire cell", for a second +reason the scoping missed entirely: **the merge stamp is one of THREE +steady-state wire-cell writers, and the other two stay** (§5, W2/W3). What +genuinely changes is the classification window, the refused-ForcePosition +shape, and the (test-only) missile arm. The contract pins all of this. + +--- + +## 1. Retail ground truth — Gate A and the pre-placement sequence, verified in `acclient_2013_pseudo_c.txt` + +`SmartBox::HandleReceivedPosition` @0x00453FD0 (pseudo-C lines +92896–93051). The exact order, with addresses: + +``` +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 +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 +00454091 cmdinterp->SendPositionEvent() +0045409d return // BEFORE unset_parent / SetPlacementFrame +004540b7 if (!newer_event(arg2, POSITION_TS, arg7)) → return (the 004540e6 teleport-regression + quirk stamps update_times[0] and returns @004540f6) +004540f9-11e if (parented && parent->id != player_id): weenie->SetParentedState(0) notification +00454129 CPhysicsObj::unset_parent(arg2) // UNCONDITIONAL on this path +00454137 if (CPhysicsObj::HasAnims(arg2) == 0): +00454142 CPhysicsObj::SetPlacementFrame(arg2, arg4, 1) +0045414d if (arg2 != player): // REMOTE branch +00454254 if (MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0): +00454272 ConstrainTo(arg2, &arg2->m_position, …) // post-placement anchor +0045415f else if TELEPORT_TS newer: TeleportPlayer @00454168, ConstrainTo @0045418a + anchored at the WIRE &var_48, set_velocity(0) @004541b4 + else: ConstrainTo @004541ec; if UsePositionFromServer && arg5: InterpolateTo @0045422c +``` + +Five facts decide C5b: + +1. **The wire cell is never written to the object.** `arg3->objcell_id` is + read into a local @0x00453FE3 and flows only into the local `var_48` + Position handed to `BlipPlayer` / `TeleportPlayer` / `MoveOrTeleport` / + `ConstrainTo`. Nowhere in this function is the object's cell assigned. + The object's `cell` changes only inside the placement family + (`SetPositionInternal` @0x00515BD0 → `set_cell`; `enter_world` + @0x00516310/0x00516170) or per-frame movement transit. This is the retail + 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. +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 + BEFORE learning the routing outcome). `SetPlacementFrame` @0x00454142 is + gated on `HasAnims(arg2) == 0` alone. **Neither gate reads the + near/far/teleport classification** — which is why acdream's merge flags + are a function of (disposition, hasAnimations) and nothing else. +4. **`MoveOrTeleport` @0x00516330 reads the BODY's own state at entry:** + TELEPORT_TS @0x00516375 and `this_1->cell == 0` @0x00516386 — the + committed cell, read before any placement — then contact `arg4` + @0x0051638E. Route 4b-3's D1 fed the classifier the PRE-merge committed + cell for exactly this reason; C5b's withhold (D2) makes that pre-merge + value structurally equal to the record's `FullCellId` in the + classification window (§4, "strengthened invariant"). +5. **`store_position` @0x00515CE2 is `SetPositionInternal`'s no-transition + branch** (placement ran, no walkable transition → `GotoLostCell` + @0x00515CF2), not a `MoveOrTeleport` path. A retail placement that fails + leaves the wire pose in `m_position` and the object HIDDEN in the lost + cell — acdream's surviving wire-cell-on-non-commit behaviour (W2, §5) is + the visible variant of this, already filed across AP-136/AP-138/#309. + C5b does not re-litigate it. + +## 2. The two scoping-identified sites, re-verified at HEAD `02578441` + +### S1 — the steady-state merge's unconditional flags + +`InboundPhysicsStateController.TryApplyPosition` +(`src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs:610`), +hardcoding `installPlacementFrame: true, clearParent: true` at **`:662-663`** +(scoping exact). Production chain at HEAD, every hop verified: +`LiveEntityNetworkUpdateController.OnPosition:1948` → +`LiveEntityInboundAuthorityGate.TryAcceptPosition:161` +(`src/AcDream.App/Physics/` — the scoping gave no directory) → +`LiveEntityRuntime.TryApplyPosition:2550` → +`RuntimeEntityObjectLifetime.TryApplyPosition:1789` (scoping said `:1781`) → +`RuntimeEntityDirectory.TryApplyPosition:654` → the S1 overload. + +The route-flag-threaded sibling (`ApplyAcceptedPositionSnapshot:673`, +flags at `:681-682`) already exists and is what the continuation executor +uses (`RuntimeInitialCreateContinuationExecutor.cs:1983-1993`, classified +flags at `:1991-1992`, withhold at `:1996`) — scoping §1b verified exact. + +**What changes semantically (not "what gets deleted"):** the merged +snapshot's `Physics.AnimationFrame` / `PlacementId` and its +`ParentGuid` / `ParentLocation` / `Physics.Parent` stop being +unconditionally wire-installed / wire-cleared and start obeying retail's +two gates: + +| disposition | installPlacementFrame | clearParent | classifier row (cite, don't restate) | +|---|---|---|---| +| `Rejected` | unread (timestamp-only path `:793-794`) | unread | — | +| `ForcePosition` (local player only, teleport-equal) | **false** | **false** | `RuntimeAuthoritativePositionRouteClassifier.cs:311-333` (Gate A) | +| `Apply` | **`!hasAnimations`** | **true** (unchanged) | classifier `:336-465` — every accepted non-force route carries `UnparentBeforeRouting: true, ApplyPlacementFrameBeforeRouting: !request.HasAnimations` | + +`hasAnimations` is the AP-130 static proxy, computed from the PRE-merge +snapshot `old` with the identical expression +`RuntimeAcceptedPositionRouteRequests.cs:99-101` uses +(`old.MotionTableId ?? old.Physics?.MotionTableId) is { } id && id != 0u`). +`WorldSession.EntitySpawn.MotionTableId` exists (`WorldSession.cs:103`); the +inbound controller's `_snapshots[guid]` and `canonical.Snapshot` are the +same data pre-merge, so the two computations cannot diverge. + +Two observability notes that decide the test plan (§8): + +- The `ForcePosition` placement-frame half is **inert**: for + `disposition is not Apply`, `appliedPlacement` keeps `old.PlacementId` + under either flag value (`:815-819`). Gate A's force half discriminates + ONLY through parent retention (`:855-857`). +- The `Apply` placement-frame half is live exactly when + `hasAnimations == true`: today the wire frame (`update.PlacementId ?? 0u`) + is stamped onto an animated entity's snapshot where retail's `HasAnims` + gate skips `SetPlacementFrame`. That is AP-131's animation snap/reset + half. + +### S2 — the wire-acceptance-derived `FullCellId` stamp + +`RuntimeEntityObjectLifetime.cs:1923-1926` — +`Entities.RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` +(argument at `:1926`, matching the C5a contract's corrected citation; the +register's `:1338` and the scoping's `:1918` are both stale — cite by +symbol). `refreshPosition: true` routes through +`RuntimeEntityRecord.RefreshDerivedState:230-237` → +`SetFullCell(position.LandblockId, …)` — the wire cell, stamped on bare +acceptance, plus `PropagateFullCellToChildren` +(`RuntimeEntityDirectory.cs:240-246`) when it changes. + +**What changes semantically:** `refreshPosition: false`. The merge stops +being a residency writer. In the window between the merge and the OnPosition +prologue rebucket, `canonical.FullCellId` IS the pre-merge committed cell — +which is exactly what 4b-3's D1 threads out-of-band today +(`timestamps.PreMergeCommittedCellId`, `:1904-1907`). Post-D2, 4b-3's +convention becomes a structural property of the record. + +**The executor's analogue is unchanged and untouched** +(`refreshPosition: false` at executor `:1996` — its CANONICAL CELL SEMANTICS +comment at `:1966-1976` is the model for S2's new comment). + +## 3. Design decisions — pinned, not open for redesign + +### D1 — the merge flags become the classifier's truth table, computed pre-merge inside the merge + +In S1's `TryApplyPosition`, replace the two literals at `:662-663` with the +table in §2-S1, computed from `disposition` and `hasAnimations(old)`. No +signature changes; no new parameters; no route construction in the merge; +`playerDistance` is never needed (the full route's near/far decision is +downstream of retail's unset_parent/SetPlacementFrame order and stays +post-merge in `ClassifyRemoteAcceptedPosition`). + +The post-merge classification (`LiveEntityNetworkUpdateController.cs:2167-2175` +→ `RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition` → +`RuntimeAcceptedPositionRouteRequests.Build`) is **untouched**: it computes +the full route from the same retained inputs, so its +`ApplyPlacementFrameBeforeRouting` / `UnparentBeforeRouting` fields equal +the merge's flags by construction. The consistency is pinned by test (§8 +test 4), not by a shared code path — the two computations are small, pure, +and separately sabotage-verifiable. + +Doc comments that must be made true in the same commit (process rule (c)): +the `:595-608` "Round 4 R4-15 … no HasContact or route-classification +concept" remarks; the `:657-661` "Legacy immediate-apply reproduces EXACT +prior behavior" comment; `ApplyAcceptedPosition`'s `:772-781` two-caller +doc; the `:809-814` PositionPack comment's "retail skips it entirely while +HasAnimations is true" (already true — now load-bearing for BOTH callers). + +### D2 — the steady-state merge withholds the wire cell (`refreshPosition: false`) + +One argument flip at S2 plus the comment rewrite. The 4b-3 D1 comment at +`:1894-1907` is extended, not deleted: it currently explains why the +classifier needs the threaded pre-merge value; it must add that after this +change the post-merge record CARRIES that same value through the +classification window, making the threading a belt-and-suspenders duplicate +that stays (the threaded value remains the classifier's input of record — +process finding (b), read the observable once). + +**Two publish-side effects, both pinned (trap T3):** + +1. The merge's publish-kind ternary (`:1948`, + `beforeCell != canonical.FullCellId ? Rebucketed : Updated`) becomes + always-`Updated` — the merge can no longer change the cell. The + `Rebucketed` edge for a wire-cell-changing packet moves to the prologue + rebucket's `CommitRebucket` (`RuntimeEntityObjectLifetime.cs:1956-1987`), + which TODAY early-outs publish-less (`previous == fullCellId` at + `:1973` — because the merge already stamped it) and after D2 publishes. + **Net observable: exactly one `Rebucketed` delta per cell-changing + accepted Position — same kind, same value, same publisher, later point in + the same call.** A test pins this at the observable (one delta, not zero, + not two); it does not pin the source site. +2. `PropagateFullCellToChildren` for a parent whose accepted Position + changes its cell moves from the merge's directory wrapper + (`RuntimeEntityDirectory.cs:240-246`) to `CommitRebucket`'s public + `SetFullCell` (`:355`). Same packet, same value, same single propagation. + This is the #319 child-equality channel — a directory-level test pins + exactly one propagation per parent cell change on the steady-state path, + for BOTH parent classes (player `0x5…` and creature `0x8…`; process rule + from #319 — a test population that only ever sees sequence-0 parents is + blind to the player class). + +### D3 — the other two wire-cell writers STAY, and the register says so + +The scoping's premise — withholding S2 "moves residency changes onto +placement/simulation commits only" — is **false at HEAD** (§12 item 1). +Two more steady-state wire-cell writers exist downstream of the merge in +`OnPosition`, and C5b keeps both: + +- **W2 — the prologue rebucket** (`LiveEntityNetworkUpdateController.cs:2329` + `RebucketLiveEntity(update.Guid, p.LandblockId)` → + `LiveEntityRuntime.cs:935-945` → `CommitRebucket` → `SetFullCell(wire cell)`). + Runs for EVERY classification reaching the generic tail — near, far, + teleport, cell-less, unrouted, and every ordinary local-player Apply echo. + Its other jobs (draw-bucket move, bucket-promotion recovery, + `prepare_to_enter_world` clock edges) are not cell writes and are not in + scope. **Do NOT gate W2 by classification (trap T5):** for the local + player W2's echo-driven commit is the mechanism that advances the + canonical cell across landblocks during ordinary movement (AP-146/#320 + machinery — the per-tick projection deliberately passes a landblock id and + preserves the cell, `LiveEntityRuntime.cs:935-938`); gating it silently + freezes the player's cell between teleports and #319's child equality + inherits the freeze. +- **W3 — the post-routing wire-cell adopt** + (`TryAdoptWireCellAfterRouting`, `LiveEntityNetworkUpdateController.cs:1613-1624`): + `RemoteMotion.CellId = wire` for every non-placing arm, writing through to + `FullCellId` via `CommitCanonicalCell`. Already filed as retained + bookkeeping under **AP-135**; the placing arms (far/teleport) are + suppressed there because the placement receipt is the cell authority. + +W2/W3's population note: packets that RETURN before `:2329` never see W2 — +the force-local path (Committed/Deferred `:2055`, Rejected/Contention +`:2066`) and the missile arm (`:2256`). For those, post-D2 residency is +placement-receipt-authoritative (or, on a refused/contended force, +unchanged at the last commit — retail's shape, since retail's +`BlipPlayer`-era body keeps its last placed cell; AD-62 already files the +non-commit force outcomes). This is D2's genuinely new steady-state surface +and it is **retail-correcting**, asserted by §8 test 6. + +### D4 — register bookkeeping, in the implementation commit + +- **AP-131 RETIRES** (§7 evidence). Its "legacy caller is deleted at the + production cutover" framing is overtaken: the caller is not deleted, it is + **corrected** — the unconditional literals are replaced by the classified + computation. The retirement commit rewrites the row's text to past tense + with the evidence, per register rule 1 (a deviation found without a row is + a bug twice over; a retired divergence needs its retirement recorded, + which the C5a/AP-1 and 4b-3/AP-137 rewrites established as the house + style). +- **AD-60 — legacy half RETIRES, row REWRITTEN, not deleted** (4b-3's D8 + precedent: silent whole-row deletion would hide surviving wire-cell + channels). The rewrite keeps the executor half as the standing rule, + records the legacy half's retirement with §7's evidence, and **names the + surviving wire-cell channels**: W2 (prologue rebucket commit — cross-cited + into AP-146 for the local player) and W3 (already AP-135). The row's + surviving claim is therefore precise: "a wire Position never makes a + record resident *inside the merge or ahead of classification*; the + post-routing controller still adopts the wire cell for non-placement + outcomes as the bounded-residency bridge, filed at AP-135/W2." +- **AP-130 amendment (bookkeeping, not retirement):** its Site column names + only the executor's `hasAnimations` local; the merge now consumes the same + static proxy. One sentence added. The proxy itself MUST NOT be "improved" + to a live animation-queue read in this slice (trap T7). +- **AP-146 / #320 amendment (bookkeeping):** #320's edge list cites + "an accepted inbound Position/ForcePosition + (`RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`)" + as a local-player cell writer. After D2 that writer is the generic-tail + rebucket's `CommitRebucket` (and, for ForcePosition, the placement + receipt). Amend both in the same commit or the next reader files a + phantom regression. +- **AP-135 stays, its writes stay** (must-remain-true item 3). +- **AP-1, AD-1, AP-141–AP-145, AD-61, AD-62: pinned untouchable.** + +## 4. What must REMAIN true (process rule — the contract causes the defect) + +For every path this slice touches, including every refusal and rejection: + +1. **Classification reads pre-merge inputs only.** 4b-3's D1 binds in full: + `PreMergeCommittedCellId` is threaded exactly as today + (`RuntimeEntityObjectLifetime.cs:1904-1907`), measured by the SAME + `TryApplyPosition` call, never re-read after the merge. D2 adds the + structural property (the record equals it in the window) but the threaded + value stays the classifier's input of record. +2. **The D4 constraint-arm partition (4b-3) does not move.** One + post-operation `ConstrainTo` site, its partition table unchanged. C5b + touches no arming code. +3. **AP-135's bookkeeping writes stay** — the server-cell adopt + (`RemoteMotion.CellId`) and `LastServerPos`/`LastServerPosTime` samples + in `LiveEntityNetworkUpdateController`, on both airborne no-op + neighbourhoods. +4. **Dual-parent-class test discipline.** Every new test touching parent/ + child cell propagation runs a player (`0x5…`) parent AND a creature + (`0x8…`) parent (#319's lesson; the handoff's corrected gate-2 + criterion). +5. **The executor path is untouched** — its classified flags, its + `refreshPosition: false`, its replay semantics + (`ApplyAcceptedPositionExecutionRejectedSnapshot`). Do not unify the two + merge callsites into one helper "while here" (trap T8). +6. **The timestamp gate is untouched**; Gate A's teleport-concurrent shape + 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). +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. +8. **The local player's cell-freshness path** (echo → generic tail → W2 → + `CommitRebucket`) is unchanged. AP-146 stays accurate after its §D4 + amendment. +9. **The initial-residence branch of `TryApplyPosition`** + (`:1800-1860`) is untouched — it is the executor's enqueue path, already + classified. +10. **`Physics.ObserveLocalWorldFrame`** keeps reading the wire landblock + directly (`:1887-1889`); it is not a `FullCellId` consumer. +11. **No new skip** (process rule (d)) and no test weakened to make the + withhold pass (trap T2 — the withhold assertion lives at the merge + boundary, never at OnPosition level, where W2 legitimately re-stamps). + +## 5. THE BLAST RADIUS — itemised by consumer class + +Premise: S2's withhold is the only residency write C5b removes; W2/W3 stay +(D3). So the consumers that ever read a value that is intentionally NOT the +wire cell are (a) everything in the classification window (merge → +`:2329`), and (b) steady state after packets that return before W2 (force +path, missile arm). Everyone else reads the same value as today. The +#319 lesson is applied, not assumed: a stale cell is worse than a zero cell +**for derived/presentation copies** — but `FullCellId`'s whole semantic +post-D2 is "the last committed cell", which is retail's `this->cell`; that +is not staleness, it is the definition. Zero would be the lie here. + +### Class A — placement owners (read at/after placement). Verdict: CORRECT, no gate. + +`RuntimeSetPositionState` (pre-flight reads `:1172-1255`, commit compares +`:2305/:2760/:5020`, parks `:3004/:3031/:3677-3697`, `:3795/:3954`, +`:5225/:5242`), `RuntimeRemotePlacementDriveController` (`:1053-1068` — +verified: reads `record.FullCellId` AFTER the placement commit for the +shadow publish; the commit just wrote it), `RuntimeAcceptedPositionDriveController` +(receipt `resolvedCell:` reads `:914-1535`). Retail's placement reads +`this->cell` — the committed cell, never the wire cell. These get a MORE +honest input post-D2. + +### Class B — per-tick simulation. Verdict: CORRECT, no gate. + +`RuntimeOrdinaryPhysicsUpdater:127` (transits FROM `record.FullCellId` — +retail's per-frame cell transit reads `this->cell`), `RuntimePhysicsState` +(`:958`, commit `:2145-2159`), `RuntimeProjectilePhysicsUpdater` +(`:53-293`), the free-fall sweep's `rm.CellId != 0` gate (AP-135). The +simulation is the retail-sanctioned residency writer between packets. + +### Class C — classification inputs. Verdict: CORRECT — strengthened. + +`RuntimeAcceptedPositionRouteRequests` (`:57` route-1 overload — executor +scope, unchanged; `:641`-family drive-controller builds for the local +player — reads the local player's committed cell, whose freshness path is +W2, unchanged). The remote PositionEvent build is the threaded +`PreMergeCommittedCellId` — post-D2 the record in the window equals it +structurally (§3 D2). 4b-3's D1 moves from convention-defended to +structure-defended. + +### Class D — presentation/projection, event-time readers. Verdict: HARMLESS, with three named window sites. + +Event-time readers (landblock-loaded, visibility-changed, per-tick sync) run +outside the per-packet window, and W2 keeps steady-state values identical to +today for every generic-tail packet: +`RuntimePlacementPresentationSink:286` (post-commit receipts), +`LiveRenderProjectionJournal:270-271` (falls back when 0), +`EntityEffectController:481`, `LiveStaticAnimationResidency:25`, +`StaticLiveRootCommitter:76`, `ArchRenderScene` (`Residency.FullCellId` — +statics), `HeadlessLocalPlayerFrameHost:87`, +`HeadlessRuntimePlacementProjectionSink:103` (token compare), +`RemotePhysicsUpdater:239/:294` / `LiveEntityOrdinaryPhysicsUpdater:107` +(simulation-snapshot → `ParentCellId` sync). + +Named window sites (inside merge→W2, or pre-W2 returns) — verdict per site: + +| site | reads | verdict | +|---|---|---| +| `LiveEntityHydrationController:592` (`?? candidate.FullCellId` fallback) | landblock-loaded event | HARMLESS — event-time, post-W2 population; the fallback is third in a `??` chain behind projection/snapshot position | +| `LiveEntityHydrationController:1077` (`FullCellId != 0` gate) | projection recovery inside the window | VERIFY at implementation: after D2 it reads the committed cell (correct — recovery should re-place from the last commit, not from an unplaced wire claim). Pre-D2 it could read the just-stamped wire cell; that was the AP-1-shaped read this slice exists to remove | +| `LiveEntityRuntime:1219` / `:1564` (`token.ExactCellId` compares) | hydration/migration tokens | VERIFY: token cells are create/placement-derived, not wire-derived; the comparison against the committed cell is the intended predicate | +| `LiveEntityPresentationController:220` (`RestoreShadow`'s `FullCellId == 0` bail) | visibility-edge restore | HARMLESS/CORRECT — a cell-less body correctly restores no shadow row; the force-path population keeps its committed cell | + +### Class E — residency/liveness predicates (the "45+ sites"). Verdict: HARMLESS, two CORRECT-AND-LOAD-BEARING. + +- `GetRootObjectClockDisposition` (`LiveEntityRuntime:2602-2617`), + `HasSpatialRuntimeProjection` (`:3340-3345`), the ordinary-root gates + (`:953-956`, `:3452-3455`): tick/edge-time, read the committed cell — + retail's `this->cell != 0` predicate shape. CORRECT; unchanged in value + for generic-tail packets (W2), changed only for refused-force (retail's + shape). +- `IsAffectedCollisionResident` (`RuntimeSetPositionState:3949`) and its + `!HasCommittedParent` companion gates, the collision-retirement sweeps + (`:3771/:3796/:3990/:4052`): event-time (landblock retirement), read the + committed cell. CORRECT — a retirement sweep must act on where the body + IS, not where an unplaced wire packet claimed. +- The initial-create residence `FullCellId != 0` refusal + (`RuntimeInitialCreateResidenceState:583`) and executor baselines + (`:877-1182`, executor `:1836/:2484`): pending-residence records never + take the steady-state merge branch. UNAFFECTED. +- 4b-3's cell-less classification input: threaded pre-merge value (Class C). +- `RuntimeEntityDirectory:492` child propagation and + `RuntimeEntityObjectLifetime:1546-1555` parent-attach propagation: + write-side (D2's propagation-source move, pinned in §3 D2 item 2). +- `ProjectileController:279/:568/:930` and the missile arm: the ONLY + population whose residency becomes exclusively placement-receipt-driven + post-D2 (the arm returns before W2, `:2256`). Test-only today (AP-141: + ACE never sends a missile UpdatePosition). CORRECT — retail's projectile + cell comes from `SetPosition`, full stop. + +### The headline behavioural delta, stated once + +Post-C5b, a **refused or contended local ForcePosition** (AD-62's shapes +(iv)-(vi)) leaves `FullCellId` at the last committed cell where today the +merge stamps the refused packet's wire cell. Retail cannot refuse (AD-62) +and its body keeps the last placed cell — the new shape is the +retail-reachable one. Every other steady-state observable is either +unchanged (W2/W3) or retail-correcting (the classification window, the +placement-authoritative force/portal/missile arms). + +## 6. Proof obligations (must prove, not assume) + +1. **The truth table is the classifier's.** §8 test 4 drives both the merge + and `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` + (via `RuntimeAcceptedPositionRouteRequests.Build`) over the packet-shape + matrix and asserts the merge-installed + `AnimationFrame`/`PlacementId`/`ParentGuid`/`ParentLocation`/`Physics.Parent` + equal what the route's flags would install. Not a restated table in the + test body — the production classifier is the oracle (process rule (e): + no test that re-encodes the constant under test). +2. **The withhold is at the merge boundary.** §8 test 5 asserts at + `RuntimeEntityObjectLifetime.TryApplyPosition` level: an accepted + Position whose wire cell differs from the committed cell leaves + `canonical.FullCellId == beforeCell` immediately after the call. It MUST + NOT be asserted at `OnPosition` level — W2 legitimately commits the wire + cell there, and an OnPosition-level assertion would fail and tempt a + weakening (trap T2). +3. **Publish-delta conservation.** One `Rebucketed` delta per cell-changing + accepted Position on the steady-state path (§3 D2 item 1), and one child + propagation per parent cell change (item 2), both asserted at the + observable, both parent classes (must-remain-true item 4). +4. **The refused-force shape.** A ForcePosition whose drive execution + refuses/contends writes NO residency change anywhere (§8 test 6) — + merge, W2 (unreached), receipt (none). This is the assertion that makes + AD-60's retirement mean something. +5. Sabotage-verify every new discriminating test in BOTH directions + (process rule (e)) — the canonical half and the presentation/snapshot + half separately, per route 7's finding. + +## 7. Retirement evidence — AP-131 and AD-60's legacy half (same commit as the change) + +**AP-131** retires because: + +1. The literals at `InboundPhysicsStateController.cs:662-663` no longer + exist; the merged flags are computed by the §2-S1 truth table. A grep in + the commit message shows no production caller passes unconditional + `true/true` (the executor's `:1991-1992` threading is the classified + path, already retail-exact). +2. §8 tests 1-4 pin the new behaviour against the classifier-as-oracle, + sabotage-verified: (a) `Apply` + animated entity → wire frame NOT + installed (sabotage `installPlacementFrame: true` fails it); (b) + `Apply` + non-animated → wire frame installed (sabotage `false` fails + it); (c) `ForcePosition` + parented entity → parent fields retained + (sabotage `clearParent: true` fails it); (d) the matrix consistency test. +3. The row's risk column ("animated entity installs a placement frame retail + would skip; ForcePosition unparents where Gate A never reaches + `unset_parent`") describes code that no longer exists — the two + production behaviours are now the classifier's own rows + (`RuntimeAuthoritativePositionRouteClassifier.cs:311-333` and + `:336-465`). + +**AD-60's legacy half** retires because: + +1. `RuntimeEntityObjectLifetime.cs:1926` reads `refreshPosition: false`; + `RefreshDerivedState`'s `SetFullCell(position.LandblockId, …)` is + unreachable from the steady-state merge. +2. §8 tests 5-6 pin the withhold at the merge boundary and the refused-force + shape. +3. The row rewrite (§3 D4) names W2/W3 as the surviving, separately-filed + wire-cell channels, so the retirement cannot be misread as "wire + acceptance never changes residency anywhere" — it means "never inside + the merge, never ahead of classification, never for a packet whose + placement was declined", with the post-routing adoption for + non-placement outcomes filed where it belongs (AP-135 / the W2 citation). + +## 8. Test plan + +Focused Runtime tests (`tests/AcDream.Runtime.Tests`), each asserting the +layer that broke historically (merged snapshot fields, canonical cell, +publish deltas — never source-text pins): + +1. `Apply`, animated remote (`MotionTableId != 0`): merged snapshot keeps + `old.Physics.AnimationFrame` / `old.PlacementId`; pose and timestamps + still merge. Sabotage: force `installPlacementFrame: true`. +2. `Apply`, non-animated remote: merged snapshot installs + `update.PlacementId ?? 0u`. Sabotage: force `installPlacementFrame: false`. +3. `ForcePosition`, parented local player: merged snapshot retains + `ParentGuid` / `ParentLocation` / `Physics.Parent`; heading preservation + (`:797-807`) unchanged. Sabotage: force `clearParent: true`. +4. Matrix consistency (proof obligation 1): {Apply, ForcePosition} × + {animated, not} × {parented, not} × {player `0x5…`, creature `0x8…`} + — merge outcome == classifier route flags' outcome. +5. Withhold at the merge boundary (proof obligation 2): wire cell ≠ + committed cell → `canonical.FullCellId` unchanged after + `TryApplyPosition`; `Snapshot.Position` DID refresh (the pose half of + the merge is not the withheld half); a subsequent canonical placement + commit still changes `FullCellId`. +6. Refused-force shape (proof obligation 4): drive a ForcePosition whose + execution refuses (destination outside the service window) through the + drive controller; assert no residency write from merge, receipt, or + rebound — `FullCellId` is the pre-packet value throughout. +7. Publish-delta conservation (proof obligation 3): one `Rebucketed` per + cell-changing accepted Position; one child propagation per parent cell + change; player parent AND creature parent. +8. The existing `InboundPhysicsStateController` unit tests that assert the + UNCONDITIONAL flags are **rewritten, never delete-only** (the scoping's + §3 row, carried): each becomes its classified-flag counterpart. + +## 9. Gates + +- Focused tests above. +- Complete Release suite: `$env:ACDREAM_PAK_PATH` set, + `dotnet test AcDream.slnx -c Release -m:1`. **Re-measure the baseline; do + not inherit.** Recorded figures: 11,112 passed / 4 skipped / 0 failed at + `392c1e22` (C5a contract) and 11,106 at `6921a027` post-C5a (the tasking + figure). C5b's net count moves only by test rewrites/additions; the + commit message reconciles the net explicitly. Three known flakes, filed + separately, never conflated, never chased: **#302** + (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, App.Tests), + **#308** (`NakEmissionTests.LossSoak_…`, Core.Net.Tests, full-suite load), + **#321** (`DatSoundCacheTests` concurrent-decode-dedup, Core.Tests, + full-suite load — filed 2026-08-05, deliberately separate from the other + two; do NOT fold it in). If any appears, re-run and say which. +- **Connected gate: YES — argued, not assumed.** The change is on the + hottest wire path (every steady-state Position) and the campaign's + standing discipline is a connected gate per live-path behaviour change. + But the gate's pass evidence must be positive, because C5b's *improvement* + is absence-of-signal (a placement frame NOT installed; an animation + NOT popping — the #319/rule-(g) unfalsifiable-criterion trap). + Recipe (two-client, Release, `ACDREAM_RETAIL_UI=1`, + `ACDREAM_PROBE_REMOTE_TELEPORT=1`, pre-C5c so the probe still exists): + 1. **Pickup-then-drop** a ground item, several times — the positive probe + assertion: `[remote-teleport]` lines appear with `hookRan=True + placement=Committed`, proving the pre-merge-fed cell-less/teleport + classification still fires and commits after the merge stopped being a + residency writer. (Gate 4's closure at `af828a8a` established this + recipe reaches the arm; the probe label may read `teleport-ts` for the + short-circuit reason the handoff records — that is expected, not a + gap.) + 2. **A walking/running creature across ≥1 landblock boundary**, observed: + visible, smooth, correct cell, no freeze, no origin-snap — the W2 + regression watch (the one writer C5b leans on hardest). + 3. **`@teleto`/`@teleloc` a creature** (never a player character — rule + (g)): teleport arm unchanged, leash re-armed, no rubber-band, no + run-in-place. + 4. **Local `@teleto` + one portal recall**: the force/portal paths are + now placement-authoritative for residency — arrival pose correct, + leash armed, movement immediate. + 5. **An animated creature idling/fighting at UpdatePosition cadence**: + watch for NEW animation popping (regression) — recorded as an + observation only, explicitly NOT a pass criterion (the absence of the + OLD pop is the improvement and is test-gated, §8 test 1). + Graceful close per the standing rule. **No new probe flag for C5b**: the + discriminating signals are either absence-of-signal (ungateable) or + already carried by `[remote-teleport]`; adding a seventh temporary probe + for one session and stripping it in C5c is churn without evidence value. + +## 10. Size and split call + +Calibrated against the campaign (route 3 ~418, #319 76, 4b-3 ~250-700): + +| piece | production lines | test lines | +|---|---|---| +| D1 truth table + comment rewrites | ~25-40 | ~200-350 (matrix + rewrites) | +| D2 withhold + comment + publish-edge pins | ~15-25 | ~100-250 | +| Register rewrites + #320/AP-130 amendments | 0 (docs) | 0 | + +**Total ~40-65 production, ~300-600 test** — well under the ~500-line split +threshold, and dramatically under the scoping's ~150-400 estimate, because +the scoping assumed route plumbing (it did not see that the flags are +disposition-determined; §12 item 2). **One landing.** If the test rewrite +balloons past ~600 lines, the split line falls between D1's flag tests and +D2's withhold tests — but the PRODUCTION change lands atomically: D1+D2 in +one commit is one coherent behaviour (classify-then-merge); a half-flipped +intermediate (classified flags with the wire stamp, or vice versa) is +exactly the mixed-residency state this campaign keeps paying for. + +Single implementer, dual reviews (retail-conformance + architecture) per +the campaign's discipline, both re-run on the final diff. + +## 11. Traps (routes 6/7 style) + +- **T1 — reading `hasAnimations` post-merge.** Compute from `old` (the + pre-merge snapshot) inside `TryApplyPosition`. For Position packets the + merge cannot change `MotionTableId`, so the failure mode is latent, not + live — pin the discipline anyway, because the NEXT field added to this + computation may not share the property. +- **T2 — the withhold assertion at the wrong boundary.** W2 re-stamps the + wire cell at `:2329` for every generic-tail packet, BY DESIGN. A test + asserting "FullCellId unchanged after OnPosition" fails and invites a + weakening or a W2 gate. The assertion lives at + `RuntimeEntityObjectLifetime.TryApplyPosition` (§8 test 5). This is the + slice's "weaken a test to make the withhold pass" trap. +- **T3 — the moved publish edges.** `Rebucketed` moves from the merge's + ternary to `CommitRebucket`; child propagation moves with it. Pin the + observable (counts), never the source site; do not delete the coverage + when the old assertion site goes quiet. +- **T4 — the ForcePosition placement-frame half is inert** + (`appliedPlacement` keeps `old.PlacementId` under either flag). The + force-half discriminating assertion is PARENT RETENTION. Sabotaging the + placement frame on a ForcePosition proves nothing. +- **T5 — do not gate W2 "for symmetry".** The local player's canonical cell + freshness runs THROUGH W2 (echo → generic tail → `CommitRebucket`; + AP-146/#320). Gating it freezes the player's cell between teleports and + #319's child equality inherits the freeze. W2 is also the far arm's + destination-streaming nudge. +- **T6 — the missile arm returns before W2** (`:2256`). Post-D2 its + residency is placement-receipt-only; fixtures driving refused/deferred + missile placements must not assert wire-cell residency. +- **T7 — do not "improve" `hasAnimations`** to a live animation-queue read. + AP-130 files the static proxy deliberately; the merge consumes the same + proxy and the row is amended, not escalated. +- **T8 — no while-here unification** of the two merge callsites, the + executor's replay merge, or the timestamp gate. +- **T9 — flake discipline.** #302/#308/#321 are three separate filed + flakes; a full-suite red in any of them is re-run and named, never folded + into "the flake class" and never masked (#321's filing note: no retry, no + Skip, no delay). + +## 12. Scoping claims found wrong or stale at HEAD `02578441` + +1. **The scoping's core premise for the withhold is false.** §2: withholding + S2 "moves residency changes onto placement/simulation commits only — the + retail rule". At HEAD the steady-state path has TWO MORE wire-cell + writers downstream of the merge (W2 `LiveEntityNetworkUpdateController.cs:2329` + → `CommitRebucket`; W3 `TryAdoptWireCellAfterRouting:1613`), and C5b + keeps both (§3 D3). The retirement must be scoped to the merge boundary + and the survivors named in the register rewrite, or AD-60's retirement is + a shell game. This is the contract's load-bearing correction. +2. **The size estimate over-counts.** Scoping §7: "~150-400 changed on the + hottest inbound path". The flags are disposition+hasAnimations-determined + (§2-S1 truth table; retail runs both gates BEFORE the `MoveOrTeleport` + branch decision) — no route construction, no `playerDistance` threading, + no signature changes. Re-estimate: ~40-65 production lines (§10). +3. **Stale line citations (all re-verified):** `TryApplyPosition` at + `RuntimeEntityObjectLifetime.cs:1789` (scoping `:1781`); the threaded + overload's signature at `InboundPhysicsStateController.cs:673` (scoping + `:681` — that is its flags parameter line); S2's call at + `RuntimeEntityObjectLifetime.cs:1923-1926` (C5a's `:1926` correct; the + register's `:1338` still stale); `LiveEntityInboundAuthorityGate` lives + under `src/AcDream.App/Physics/` (scoping gave no directory; `:161` + exact). Everything else in scoping §1b/§2 holds. +4. **#320's edge list goes stale the day D2 lands** — its "accepted inbound + Position (`RefreshSnapshot` → `RuntimeEntityRecord.cs:234`)" writer + becomes the generic-tail rebucket's `CommitRebucket`. Amendment mandated + in the implementation commit (§3 D4), or the next reader files a phantom + regression. +5. **The scoping's §5 cell-less falsification session is overtaken:** gate 4 + CLOSED at `af828a8a` (the `cause=cellless` label was a probe-artifact — + the pickup-then-drop recipe exercised the arm all along). C5b does not + inherit that investigation; its connected gate reuses the same recipe as + positive evidence (§9). Route-7 thickening and #316's measurement are + C5c-ledger items, not C5b design inputs — the implementation session + checks the ledger for their status before starting, and does not block on + them. + +## 13. What C5b does NOT do + +- Does NOT wire the continuation executor into the steady-state path + (#275's alternative branch — the issue's "or delete it with the route"; + the steady-state merge stays a live, now-classified, production caller). +- Does NOT gate, move, or delete W2/W3 (§3 D3). +- Does NOT touch the OnPosition routing, arms, constraint partition, + generic render-pose gate, or interpolation machinery. +- Does NOT touch the timestamp gate, the executor, the initial-residence + branch, or the projectile arm's routing. +- Does NOT strip any probe (C5c), fix #316/#317/#320, or re-open AP-145's + seam. +- Does NOT add a live animation-queue read (AP-130 stands). + +--- + +## 14. Implementation outcome (appended at landing, 2026-08-05) + +Both design decisions landed exactly as pinned: `installPlacementFrame: +!force && !hasAnimations, clearParent: !force` at the S1 call site, and +`refreshPosition: false` at S2. No signature changed, no route was plumbed, +`playerDistance` was never needed, W2/W3 were not touched. The production +diff is 129 insertions / 38 deletions across two files, of which the +BEHAVIOUR is **seven lines** — four computing `force`/`hasAnimations`, two +flag arguments, one `refreshPosition` argument. Everything else in that +delta is the §3-D1 mandated comment rewrites. Well inside §10's ~40-65 +estimate; the estimate itself was counting comments. + +**§5's blast-radius survey missed three consumer sites.** All three are +D2-caused, all three were found by the test suite rather than by reading, and +all three turned out to be the intended semantics rather than regressions — +but the survey did not enumerate them, so they are recorded here: + +1. **`DatLiveEntityProjectionMaterializer`'s self-projection branch** + (`src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs`, the + `residence is AwaitRuntimePlacement && expectedCanonical.FullCellId != 0u + && !HasActiveInitialCreateResidence` gate). This is a Class-D window site: + it runs inside `OnPosition`'s prologue recovery (`:1973` / + `:2089`/`:2102`), ahead of W2. Pre-D2 it read the just-merged wire cell and + installed the spatial bucket there; post-D2 it reads 0 for a + withdrawn/inventory-only record and correctly declines to project from an + unplaced wire claim. Production still installs the bucket in the SAME + `OnPosition` call, at W2 (`:2329`) — verified by reading every `return` + between the recovery call and W2: none is conditioned on + `IsSpatiallyProjected` or `FullCellId`. Two + `LiveEntityHydrationControllerTests` cases asserted the bucket at the + recovery boundary and were extended to drive the production W2 step; this + is the same shape as trap T2, one layer up. +2. **`ProjectileController.SyncPresentationFromResolvedBody`** + (`entity.ParentCellId = record.FullCellId`). Trap T6 named the missile + arm's residency but not this presentation writer. On a REFUSED missile + placement the entity's pose still moves to the destination (acdream's + `StoreAcceptedDestinationPose` fallback) while `ParentCellId` now stays at + the committed source cell. The MAJOR-1 invariant the covering test exists + to protect (`ParentCellId == record.FullCellId != body.CellPosition + .ObjCellId`) is unchanged and is now asserted as that identity rather than + as a wire-cell constant. +3. **The `Rebucketed` publish edge does NOT always move to `CommitRebucket`.** + §3 D2 item 1 says the merge's ternary "becomes always-`Updated`". It does + not, and the ternary is deliberately kept: the `Physics.SetPosition.Forget + (canonical, restoreCancelledPark: true)` call a few statements earlier can + roll a wakeable lost-cell park back, and `RestoreParkWithdrawal` restores + canonical residency at the body's committed cell. That is a real cell edge + produced inside `TryApplyPosition` by a placement owner and must still + publish as `Rebucketed`. Collapsing the ternary would have silently + downgraded it. + +**One §5 verdict was checked and stands:** `LiveEntityHydrationController +:1077`'s `FullCellId != 0` gate is unreachable from this change — it sits +inside the `canonicalSpawn.Position is null` branch, and a merged +steady-state Position always carries one. + +**Not done, deliberately:** no automated OnPosition-level test drives the +full pickup→drop→reproject sequence (no such fixture exists; the collapse +matrix fixture does not cover pickup). The claim that production reprojects a +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. diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index f81be848..9b389132 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -593,17 +593,23 @@ public sealed class InboundPhysicsStateController /// position payload is rejected. This lets callers publish a freshly /// consumed FORCE_POSITION_TS without applying a stale pose. /// - /// Round 4 R4-15: this legacy immediate-apply path has no HasContact or - /// route-classification concept at all - it merges unconditionally on - /// the retained alone. The - /// continuation executor's ApplyPositionAction instead runs - /// RuntimeAuthoritativePositionRouteClassifier and derives - /// contact solely from the retained wire packet's own - /// IsGrounded bit. This is internal refactor debt tracked for - /// the eventual cutover unification (this file's TryApplyPosition - /// is today's only PRODUCTION Position wire caller; the classifier-based - /// path is test-only until a host wires the executor) - it is NOT a - /// retail divergence and does not belong in + /// C5b (#275): this steady-state merge no longer installs retail's two + /// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE + /// MoveOrTeleport is consulted, so their gates are a pure function + /// of the timestamp disposition and the static HasAnims proxy - which is + /// why classifying them here needs no route, no player distance and no + /// signature change (see the truth table at the call below). The + /// near/far/teleport routing decision proper is still downstream of this + /// merge and still belongs to + /// RuntimeAuthoritativePositionRouteClassifier, which the + /// continuation executor's ApplyPositionAction runs directly and + /// which the App-layer OnPosition tail runs post-merge for this caller; + /// contact still comes solely from the retained wire packet's own + /// IsGrounded bit on both. That remaining structural difference + /// (two callers computing the same two flags from the same two inputs + /// rather than sharing one code path) is internal refactor debt tracked + /// for the eventual cutover unification - it is NOT a retail divergence + /// and does not belong in /// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md /// for the tracked follow-up. /// @@ -646,6 +652,46 @@ public sealed class InboundPhysicsStateController teleportAdvanced: disposition is PositionTimestampDisposition.Apply && advancesTeleport, previousTeleport: previousTeleport); + // C5b (#275) — CLASSIFY BEFORE MERGE. Retires AP-131. + // + // SmartBox::HandleReceivedPosition (0x00453FD0) decides both + // pre-placement writes before it ever consults MoveOrTeleport: + // - GATE A @0x0045400C (the local-player FORCE_POSITION self-echo + // shortcut) returns @0x0045409D BEFORE CPhysicsObj::unset_parent + // @0x00454129 and before the SetPlacementFrame gate @0x00454137. + // A ForcePosition therefore neither unparents nor installs a + // placement frame. + // - unset_parent @0x00454129 is UNCONDITIONAL on every other + // accepted path: retail unparents before it learns the routing + // outcome, so `clearParent` needs no route. + // - SetPlacementFrame @0x00454142 is gated on + // CPhysicsObj::HasAnims(arg2) == 0 @0x00454137 and on nothing + // else. + // Because neither gate reads the near/far/teleport classification, + // the two flags are a pure function of (disposition, hasAnimations) + // and are computable here, pre-merge, with no route plumbing: + // + // disposition | installPlacementFrame | clearParent + // --------------+-----------------------+------------ + // Rejected | unread (timestamp-only branch) + // ForcePosition | false | false + // Apply | !hasAnimations | true + // + // That is exactly RuntimeAuthoritativePositionRouteClassifier's own + // ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows + // (ClassifyAcceptedPosition: false/false on the force row, and + // `!request.HasAnimations`/true on EVERY accepted non-force route). + // The classifier stays the oracle; the equality of the two small pure + // computations is pinned by test, not by a shared code path, so each + // remains separately sabotage-verifiable. + bool force = disposition is PositionTimestampDisposition.ForcePosition; + // AP-130's static proxy, computed from the PRE-merge snapshot `old` + // with the identical expression RuntimeAcceptedPositionRouteRequests + // uses. Deliberately NOT a live animation-queue read. + bool hasAnimations = + (old.MotionTableId ?? old.Physics?.MotionTableId) + is { } motionTableId + && motionTableId != 0u; accepted = ApplyAcceptedPosition( old, update, @@ -654,13 +700,8 @@ public sealed class InboundPhysicsStateController isLocalPlayer, forcePositionRotation, currentLocalVelocity, - // Legacy immediate-apply reproduces EXACT prior behavior: the - // placement frame and parent clear were always unconditional - // here (see the Round 3 A1/B6 admission handoff). Only the - // continuation executor threads the classified route's own - // ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting flags. - installPlacementFrame: true, - clearParent: true); + installPlacementFrame: !force && !hasAnimations, + clearParent: !force); _snapshots[update.Guid] = accepted; return true; } @@ -669,7 +710,10 @@ public sealed class InboundPhysicsStateController /// the remarks on . The /// executor passes its classified route's own /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting - /// flags rather than the legacy path's unconditional true/true. + /// flags; since C5b (#275) the steady-state + /// caller passes the same two values, + /// derived pre-merge from (disposition, hasAnimations) rather than read + /// off a route. internal bool ApplyAcceptedPositionSnapshot( uint guid, WorldSession.EntityPositionUpdate update, @@ -770,14 +814,16 @@ public sealed class InboundPhysicsStateController /// captured at admission time. /// /// / - /// (Round 3 B6) let the two callers reproduce two different retail - /// gates: the legacy immediate-apply path always passes true/true - /// (retail's HandleReceivedPosition unconditionally runs - /// unset_parent/SetPlacementFrame there), while the continuation - /// executor passes its classified route's own - /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting - - /// both false only for the FORCE_POSITION branch, which retail's - /// MoveOrTeleport returns from immediately, BEFORE either call. + /// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275) + /// BOTH callers supply the same classified values, from the same two + /// inputs: the continuation executor reads its classified route's + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting, + /// and the steady-state merge derives them + /// pre-merge from (disposition, hasAnimations) - see the truth table + /// there. Both are false only for the FORCE_POSITION branch, which + /// retail's HandleReceivedPosition Gate A returns from immediately, + /// BEFORE either call; is + /// additionally false whenever HasAnims is true. /// internal static WorldSession.EntitySpawn ApplyAcceptedPosition( WorldSession.EntitySpawn old, @@ -810,8 +856,11 @@ public sealed class InboundPhysicsStateController // id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact // value to SetPlacementFrame on a normal accepted update - but only // when the caller's route actually runs that step - // (installPlacementFrame; retail skips it entirely while HasAnimations - // is true). + // (installPlacementFrame; retail's @0x00454137 gate skips + // SetPlacementFrame entirely while HasAnims is true, and Gate A + // returns before reaching it at all). Since C5b (#275) that is live + // for BOTH callers: an animated entity's accepted Position no longer + // stamps the wire frame onto the steady-state merge either. uint? appliedPlacement = installPlacementFrame ? (disposition is PositionTimestampDisposition.Apply ? update.PlacementId ?? 0u diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 94471e8f..703c4b89 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1893,14 +1893,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable disposition is not PositionTimestampDisposition.Rejected; // C4 route 4b-3 (D1): the classifier's remote cell-less predicate // needs the PRE-merge committed cell — the value this method just - // measured as `beforeCell`, before `RefreshSnapshot` - // below stamps the accepted wire cell onto the canonical record via - // `RefreshDerivedState` -> `SetFullCell`. Reading `canonical.FullCellId` - // AFTER that merge (as the classifier's default builder overload - // does for every other caller) always sees the wire cell, which is - // why the predicate as fed to a remote PositionEvent was dead before - // this fix. `hadCanonical` is what makes this an honest value rather - // than a fabricated 0 — see the field's own doc. + // measured as `beforeCell`. Before C5b `RefreshSnapshot` below + // stamped the accepted wire cell onto the canonical record via + // `RefreshDerivedState` -> `SetFullCell`, so reading + // `canonical.FullCellId` AFTER the merge (as the classifier's default + // builder overload does for every other caller) always saw the wire + // cell, which is why the predicate as fed to a remote PositionEvent + // was dead before this fix. `hadCanonical` is what makes this an + // honest value rather than a fabricated 0 — see the field's own doc. + // + // C5b (#275) does NOT retire this threading. The merge no longer + // writes the cell (see the CANONICAL CELL SEMANTICS note below), so + // the post-merge record now CARRIES this same value through the whole + // classification window — the threaded value became a + // belt-and-suspenders duplicate of a structural property. It stays, + // and it stays the classifier's input of record: one observable, read + // once, at the point it is measured. timestamps = timestamps with { PreMergeCommittedCellId = hadCanonical ? beforeCell : null, @@ -1920,10 +1928,34 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable canonical, restoreCancelledPark: true); } + // CANONICAL CELL SEMANTICS (C5b, #275 — retires AD-60's legacy half; + // this caller now matches the continuation executor's long-standing + // rule verbatim): refreshPosition stays false. A wire position never + // directly makes the record resident; only a Runtime SetPosition + // commit or a simulation full-cell commit may change FullCellId. + // This is retail: SmartBox::HandleReceivedPosition (0x00453FD0) reads + // the wire objcell_id into a LOCAL @0x00453FE3 and hands it to + // BlipPlayer/TeleportPlayer/MoveOrTeleport/ConstrainTo; it never + // assigns the object's own cell. The object's cell moves only inside + // the placement family (SetPositionInternal @0x00515BD0 -> set_cell, + // enter_world) or per-frame movement transit. + // + // The snapshot's Position field itself IS still refreshed; only the + // derived FullCellId write is withheld. + // + // Two wire-cell writers deliberately SURVIVE this change, downstream + // of the merge and outside the classification window: the OnPosition + // prologue rebucket (LiveEntityNetworkUpdateController -> + // LiveEntityRuntime.RebucketLiveEntity -> CommitRebucket, which is + // also the local player's own cell-freshness path, AP-146/#320) and + // the post-routing wire-cell adopt for non-placing arms + // (TryAdoptWireCellAfterRouting, filed at AP-135). Neither is gated + // here: gating the prologue rebucket would freeze the local player's + // canonical cell between teleports. Entities.RefreshSnapshot( canonical, snapshot, - refreshPosition: acceptedPosition); + refreshPosition: false); if (acceptedPosition && ReferenceEquals(canonical, beforeCanonical)) { @@ -1942,6 +1974,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && canonical.SpatialAuthorityVersion == spatialVersion); } + // C5b (#275): the MERGE can no longer move the cell, so a wire cell + // change publishes its Rebucketed delta at the OnPosition prologue + // rebucket's CommitRebucket instead — same kind, same value, same + // publisher, a later point in the same call. This comparison is NOT + // dead, and is deliberately kept rather than collapsed to Updated: + // the cancellation above (Forget with restoreCancelledPark) can roll + // a wakeable lost-cell park back, and RestoreParkWithdrawal restores + // canonical residency at the body's committed cell. That is a real + // cell edge produced inside this method by a placement owner, and it + // must still publish as Rebucketed. return AcknowledgeProjectionAndPublish( canonical, () => acknowledgeProjection?.Invoke(canonical), diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index 6465ddd1..8c233421 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -615,14 +615,25 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests /// StoreAcceptedDestinationPose resolves the destination through /// Runtime's own world frame instead of a commit. Refused is /// still a storing (A1-admitted) outcome, so the App-level presentation - /// sync runs — the entity's position AND ParentCellId must both - /// move to the DESTINATION (the wire cell), not the stale INDOOR source - /// cell a body.CellPosition.ObjCellId read would have produced - /// (see 's doc comment for why an outdoor - /// source cell would not have discriminated here). + /// sync runs — the entity's position moves to the DESTINATION and its + /// ParentCellId tracks record.FullCellId, never the stale + /// INDOOR source cell a body.CellPosition.ObjCellId read would + /// have produced (see 's doc comment for + /// why an outdoor source cell would not have discriminated here). + /// + /// + /// C5b (#275), trap T6: record.FullCellId is now the COMMITTED + /// cell, so a REFUSED placement leaves it at the source. The missile arm + /// returns before the OnPosition generic tail, so its residency is + /// placement-receipt-driven and nothing else — which is retail + /// (a projectile's cell comes from SetPosition, full stop). The + /// MAJOR 1 regression check is unchanged in substance and is asserted + /// directly below as the identity it always meant: + /// ParentCellId == record.FullCellId != body.CellPosition.ObjCellId. + /// /// [Fact] - public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell() + public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithCommittedCell() { using var fixture = new Fixture(MissileGuid, isMissile: true); Assert.True(fixture.Lifetime.Entities.TryGetActive( @@ -649,12 +660,18 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests // The store fallback never re-derives an INDOOR cell id — confirms // the divergence this test is built to exercise actually occurred. Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId); - // The exact B1 regression check: ParentCellId is the DESTINATION - // (wire) cell, matching record.FullCellId — never - // body.CellPosition.ObjCellId, which just asserted it is STILL the - // stale indoor source cell. + // The exact B1 regression check: ParentCellId IS record.FullCellId — + // never body.CellPosition.ObjCellId, which just asserted it is STILL + // the stale indoor source cell. Assert.Equal(body.Position, fixture.Entity.Position); - Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + Assert.Equal(canonical.FullCellId, fixture.Entity.ParentCellId); + Assert.NotEqual( + body.CellPosition.ObjCellId, + fixture.Entity.ParentCellId); + // C5b: the refused placement committed nothing, so the committed cell + // is still the source. The wire cell is NOT residency here. + Assert.Equal(SourceCell, canonical.FullCellId); + Assert.NotEqual(DestinationCell, fixture.Entity.ParentCellId); fixture.DrainPlacementFifo(); } diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 24aa2c1c..0a123d66 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -488,9 +488,25 @@ public sealed class LiveEntityHydrationControllerTests positionAuthority, accepted)); + // C5b (#275): the accepted merge no longer stamps the wire cell onto + // the canonical record, so the recovery above rebuilds the logical / + // render-resource owner but installs no spatial bucket — the + // residence-managed materializer's self-projection branch + // (DatLiveEntityProjectionMaterializer, `FullCellId != 0`) correctly + // declines to project from an UNPLACED wire claim. Production + // installs the bucket a few statements later in the SAME + // OnPosition call, at the prologue rebucket + // (LiveEntityNetworkUpdateController -> RebucketLiveEntity, W2); + // nothing between the recovery call and that rebucket returns early + // on an unbucketed record. Drive that exact production step here + // rather than asserting a projection at a boundary that no longer + // owns it. + Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, Cell)); + Assert.Same(entity, record.WorldEntity); Assert.Same(body, record.PhysicsBody); Assert.True(record.IsSpatiallyProjected); + Assert.Equal(Cell, record.Canonical.FullCellId); Assert.False(record.AppearanceProjectionSynchronizationPending); Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId); Assert.Equal(1, applied); @@ -524,8 +540,15 @@ public sealed class LiveEntityHydrationControllerTests authorityVersion, out LiveEntityRecord record)); + // C5b (#275): as above — the wire cell is no longer merged onto the + // canonical record, so the first spatial projection's BUCKET arrives + // at the OnPosition prologue rebucket (W2), not at recovery. The + // logical construction, resource registration and ready publication + // this test is about all still happen at recovery. Assert.NotNull(record.WorldEntity); + Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, Cell)); Assert.True(record.IsSpatiallyProjected); + Assert.Equal(Cell, canonical.FullCellId); Assert.True(record.InitialHydrationCompleted); Assert.Equal(1, fixture.Resources.RegisterCount); Assert.Equal(1, fixture.Ready.PublishCount); diff --git a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs index 9665917b..45ff40bb 100644 --- a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs @@ -66,12 +66,28 @@ public sealed class InboundPhysicsStateControllerTests Assert.Equal(30f, returned.Position!.Value.PositionX); } + /// + /// C5b (#275) rewrite of the pre-classification test: the retail-zero + /// rule (PositionPack::UnPack @0x00516740 initializes an absent + /// placement id to zero and HandleReceivedPosition forwards that + /// exact value to SetPlacementFrame) is only reachable when the + /// gate ABOVE it passes — CPhysicsObj::HasAnims(arg2) == 0 + /// @0x00454137. The seed is therefore explicitly NON-animated; the + /// animated counterpart is + /// . + /// [Fact] public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero() { var controller = new InboundPhysicsStateController(); WorldSession.EntitySpawn seed = Spawn( - 0x70000009u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u); + 0x70000009u, + 7, + 20, + 1, + Position(0x0101FFFFu, 10f), + 0x408u, + motionTableId: null); seed = seed with { PlacementId = 7, @@ -122,6 +138,306 @@ public sealed class InboundPhysicsStateControllerTests Assert.Equal((uint)0, second.Physics!.Value.AnimationFrame); } + /// + /// C5b (#275) / AP-131 retirement, half 1. Retail gates + /// CPhysicsObj::SetPlacementFrame @0x00454142 on + /// CPhysicsObj::HasAnims(arg2) == 0 @0x00454137: an ANIMATED body + /// never takes the wire placement frame, because its animation sequencer + /// owns the pose. Before C5b the steady-state merge stamped it anyway, + /// which is the animation snap/reset half of AP-131. + /// + [Fact] + public void ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn seed = Spawn( + 0x8000C001u, + 7, + 20, + 1, + Position(0x0101FFFFu, 10f), + 0x408u, + motionTableId: 0x09000001u); + seed = seed with + { + PlacementId = 7, + Physics = seed.Physics!.Value with { AnimationFrame = 7 }, + }; + controller.AcceptCreate(seed); + + Assert.True(controller.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + seed.Guid, + Position(0x0101FFFFu, 20f), + Velocity: null, + PlacementId: 5, + IsGrounded: true, + InstanceSequence: 7, + PositionSequence: 21, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + // The gate above SetPlacementFrame never opened. + Assert.Equal((uint)7, accepted.PlacementId); + Assert.Equal((uint)7, accepted.Physics!.Value.AnimationFrame); + // The rest of the merge is unaffected: pose and POSITION_TS still land. + Assert.Equal(20f, accepted.Position!.Value.PositionX); + Assert.Equal((ushort)21, accepted.PositionSequence); + // unset_parent @0x00454129 is unconditional on this path. + Assert.Null(accepted.ParentGuid); + } + + /// + /// C5b (#275) / AP-131 retirement, half 1 (the other direction). A + /// NON-animated body reaches SetPlacementFrame @0x00454142 and + /// takes the wire frame verbatim. + /// + [Fact] + public void ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn seed = Spawn( + 0x8000C002u, + 7, + 20, + 1, + Position(0x0101FFFFu, 10f), + 0x408u, + motionTableId: null); + seed = seed with + { + PlacementId = 7, + Physics = seed.Physics!.Value with { AnimationFrame = 7 }, + }; + controller.AcceptCreate(seed); + + Assert.True(controller.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + seed.Guid, + Position(0x0101FFFFu, 20f), + Velocity: null, + PlacementId: 5, + IsGrounded: true, + InstanceSequence: 7, + PositionSequence: 21, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Equal((uint)5, accepted.PlacementId); + Assert.Equal((uint)5, accepted.Physics!.Value.AnimationFrame); + } + + /// + /// C5b (#275) / AP-131 retirement, half 2. Retail's Gate A + /// (@0x0045400C, the local-player FORCE_POSITION self-echo shortcut) + /// returns @0x0045409D BEFORE CPhysicsObj::unset_parent + /// @0x00454129, so a ForcePosition never unparents. Parent RETENTION is + /// the discriminating observable for the force row: the placement-frame + /// half is inert there (appliedPlacement keeps + /// old.PlacementId under either flag value), so sabotaging it on a + /// ForcePosition proves nothing. + /// + [Fact] + public void ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn seed = WithTimestamps( + Spawn(0x50000021u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u), + teleport: 10, + forcePosition: 0); + seed = seed with + { + ParentGuid = 0x70004444u, + ParentLocation = 9u, + Physics = seed.Physics!.Value with + { + Parent = new PhysicsAttachment(0x70004444u, 9u), + }, + }; + controller.AcceptCreate(seed); + var preserved = new Quaternion(0.1f, 0.2f, 0.3f, 0.9f); + + Assert.True(controller.TryApplyPosition( + PositionUpdate( + seed.Guid, + instance: 3, + position: 9, + teleport: 10, + forcePosition: 1), + isLocalPlayer: true, + forcePositionRotation: preserved, + currentLocalVelocity: Vector3.Zero, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + Assert.Equal(0x70004444u, accepted.ParentGuid); + Assert.Equal(9u, accepted.ParentLocation); + Assert.Equal( + new PhysicsAttachment(0x70004444u, 9u), + accepted.Physics!.Value.Parent); + // Heading preservation (BlipPlayer's get_heading/set_heading pair + // @0x00454056-68) is unchanged by C5b. + Assert.Equal(preserved.W, accepted.Position!.Value.RotationW); + Assert.Equal(preserved.X, accepted.Position!.Value.RotationX); + } + + /// + /// C5b (#275) proof obligation 1: the merge's two pre-placement flags ARE + /// 's + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting + /// rows. The production classifier is the oracle — this test does not + /// re-encode the truth table, it drives both computations over the packet + /// matrix and asserts the merged snapshot equals what the route's own + /// flags would have produced. Dual parent classes (player 0x5… and + /// creature 0x8…) per the #319 discipline. + /// + [Theory] + // guid, isLocalPlayer, animated, parented, force + [InlineData(0x50000031u, true, true, true, false)] + [InlineData(0x50000032u, true, true, false, false)] + [InlineData(0x50000033u, true, false, true, false)] + [InlineData(0x50000034u, true, false, false, false)] + [InlineData(0x50000035u, true, true, true, true)] + [InlineData(0x50000036u, true, true, false, true)] + [InlineData(0x50000037u, true, false, true, true)] + [InlineData(0x50000038u, true, false, false, true)] + [InlineData(0x80000031u, false, true, true, false)] + [InlineData(0x80000032u, false, true, false, false)] + [InlineData(0x80000033u, false, false, true, false)] + [InlineData(0x80000034u, false, false, false, false)] + public void MergedPrePlacementFieldsMatchTheClassifiedRouteFlags( + uint guid, + bool isLocalPlayer, + bool animated, + bool parented, + bool force) + { + WorldSession.EntitySpawn seed = WithTimestamps( + Spawn( + guid, + 3, + 10, + 1, + Position(0x0101FFFFu, 10f), + 0x408u, + motionTableId: animated ? 0x09000001u : null), + teleport: 10, + forcePosition: 0); + seed = seed with + { + PlacementId = 7, + Physics = seed.Physics!.Value with { AnimationFrame = 7 }, + }; + if (parented) + { + seed = seed with + { + ParentGuid = 0x70004444u, + ParentLocation = 9u, + Physics = seed.Physics!.Value with + { + Parent = new PhysicsAttachment(0x70004444u, 9u), + }, + }; + } + + WorldSession.EntityPositionUpdate update = new( + guid, + Position(0x0101FFFFu, 20f), + Velocity: null, + PlacementId: 5, + IsGrounded: true, + InstanceSequence: 3, + PositionSequence: force ? (ushort)9 : (ushort)11, + TeleportSequence: 10, + ForcePositionSequence: force ? (ushort)1 : (ushort)0); + + var merging = new InboundPhysicsStateController(); + merging.AcceptCreate(seed); + Assert.True(merging.TryApplyPosition( + update, + isLocalPlayer, + forcePositionRotation: isLocalPlayer ? Quaternion.Identity : null, + currentLocalVelocity: isLocalPlayer ? Vector3.Zero : null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn merged, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal( + force + ? PositionTimestampDisposition.ForcePosition + : PositionTimestampDisposition.Apply, + disposition); + + // The oracle: the production classifier, fed the same wire packet and + // the same static HasAnimations proxy. + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + new RuntimeAcceptedPositionRouteRequest( + new RuntimeAuthoritativePositionAuthority( + new RuntimeGenerationToken(7), + new RuntimeEntityKey(guid, 1), + PositionAuthorityVersion: 4UL, + AcceptedPositionSequence: update.PositionSequence, + timestamps.PreviousTeleport, + timestamps.Teleport, + disposition), + isLocalPlayer + ? RuntimePositionEntityKind.LocalPlayer + : RuntimePositionEntityKind.Remote, + RuntimeAcceptedPositionSource.PositionEvent, + update.Position, + PlacementFrame: update.PlacementId, + PositionPackVelocity: null, + CommittedCellId: 0x0101FFFFu, + HasContact: true, + PlayerDistance: 0f, + UsePositionFromServer: true, + HasAnimations: animated, + default)); + + // Rebuild what the classified route would have installed, through the + // SAME production merge, and require field equality. + var classified = new InboundPhysicsStateController(); + classified.AcceptCreate(seed); + Assert.True(classified.ApplyAcceptedPositionSnapshot( + guid, + update, + disposition, + timestamps, + isLocalPlayer, + isLocalPlayer ? Quaternion.Identity : null, + isLocalPlayer ? Vector3.Zero : null, + installPlacementFrame: route.ApplyPlacementFrameBeforeRouting, + clearParent: route.UnparentBeforeRouting, + out WorldSession.EntitySpawn expected)); + + Assert.Equal(expected.PlacementId, merged.PlacementId); + Assert.Equal( + expected.Physics!.Value.AnimationFrame, + merged.Physics!.Value.AnimationFrame); + Assert.Equal(expected.ParentGuid, merged.ParentGuid); + Assert.Equal(expected.ParentLocation, merged.ParentLocation); + Assert.Equal( + expected.Physics!.Value.Parent, + merged.Physics!.Value.Parent); + } + [Fact] public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector() { @@ -645,13 +961,22 @@ public sealed class InboundPhysicsStateControllerTests }; } + /// + /// C5b (#275): drives AP-130's static + /// HasAnimations proxy, which now gates the steady-state merge's + /// placement-frame install exactly as retail's + /// CPhysicsObj::HasAnims check @0x00454137 gates + /// SetPlacementFrame. The default keeps every pre-existing fixture + /// animated, which is what the retail population overwhelmingly is. + /// private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, ushort positionSequence, ushort stateSequence, CreateObject.ServerPosition? position, - uint state) + uint state, + uint? motionTableId = 0x09000001u) { var timestamps = new PhysicsTimestamps( positionSequence, @@ -669,7 +994,7 @@ public sealed class InboundPhysicsStateControllerTests Movement: null, AnimationFrame: null, SetupTableId: 0x02000001u, - MotionTableId: 0x09000001u, + MotionTableId: motionTableId, SoundTableId: null, PhysicsScriptTableId: null, Parent: null, @@ -696,7 +1021,7 @@ public sealed class InboundPhysicsStateControllerTests "fixture", null, null, - 0x09000001u, + motionTableId, PhysicsState: state, InstanceSequence: instance, MovementSequence: 1, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs new file mode 100644 index 00000000..06a86490 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs @@ -0,0 +1,297 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Tests.Entities; + +/// +/// C5b (#275) — the steady-state accepted-Position merge is no longer a +/// residency writer. Retires AD-60's legacy half. +/// +/// +/// Retail's SmartBox::HandleReceivedPosition @0x00453FD0 reads the +/// wire objcell_id into a LOCAL @0x00453FE3 and hands it only to +/// BlipPlayer/TeleportPlayer/MoveOrTeleport/ +/// ConstrainTo; it never assigns the object's own cell. The object's +/// cell moves inside the placement family +/// (SetPositionInternal @0x00515BD0 -> set_cell, +/// enter_world) or per-frame movement transit, and nowhere else. +/// +/// +/// +/// TRAP T2: the withhold assertion belongs at THIS boundary +/// (), never at +/// LiveEntityNetworkUpdateController.OnPosition level — the OnPosition +/// prologue rebucket legitimately commits the wire cell a few statements +/// later (W2, the local player's own cell-freshness path, AP-146/#320), and +/// an OnPosition-level assertion would fail and tempt a weakening. +/// +/// +public sealed class RuntimeSteadyStatePositionMergeTests +{ + private const uint Landblock = 0xA9C60000u; + private const uint LandblockSentinel = Landblock | 0xFFFFu; + private const uint Cell = Landblock | 0x0001u; + private const uint OtherCell = Landblock | 0x0002u; + + /// + /// C5b proof obligation 2. The pose half of the merge still lands; only + /// the derived FullCellId write is withheld, and a later canonical + /// commit still moves it. + /// + [Fact] + public void AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime); + const uint guid = 0x80005001u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, 1)).Canonical!; + Assert.Equal(Cell, canonical.FullCellId); + + Assert.True(lifetime.TryApplyPosition( + PositionUpdate(guid, OtherCell, positionSequence: 2), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + // THE withhold: an accepted wire cell that differs from the committed + // cell leaves the record exactly where the last commit put it. + Assert.Equal(Cell, canonical.FullCellId); + Assert.NotEqual(OtherCell, canonical.FullCellId); + // 4b-3's D1 threading now equals a structural property of the record + // through the whole classification window. + Assert.Equal(Cell, timestamps.PreMergeCommittedCellId); + + // The POSE half of the merge is emphatically not withheld. + Assert.Equal( + OtherCell, + canonical.Snapshot.Position!.Value.LandblockId); + Assert.Equal(30f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal((ushort)2, canonical.Snapshot.PositionSequence); + + // A canonical commit — the only kind of writer retail allows — still + // changes residency. + Assert.True(lifetime.CommitRebucket( + canonical, + OtherCell, + LandblockSentinel)); + Assert.Equal(OtherCell, canonical.FullCellId); + } + + /// + /// C5b proof obligation 3 — publish-delta conservation across the moved + /// edge (trap T3). Pre-C5b the merge itself published the + /// Rebucketed delta and propagated to children, and the prologue + /// rebucket then early-returned publish-less because the cell already + /// matched. Post-C5b the merge publishes Updated and the prologue + /// rebucket's CommitRebucket publishes the Rebucketed: + /// same kind, same value, same publisher, a later point in the same call. + /// Exactly ONE of each, never zero and never two — pinned at the + /// observable, not at the source site. + /// + /// Both parent classes per the #319 discipline: a test population + /// that only ever sees creature parents is blind to the player class. + /// + /// + [Theory] + [InlineData(0x50005101u, 0x50005102u)] + [InlineData(0x80005201u, 0x80005202u)] + public void CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation( + uint parentGuid, + uint childGuid) + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime); + RuntimeEntityRecord parent = + lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!; + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.Equal(Cell, child.FullCellId); + + ulong childSpatialBefore = child.SpatialAuthorityVersion; + var deltas = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new RecordingEntityObserver(deltas)); + + // The production steady-state sequence, in production order: the + // merge, then the OnPosition prologue rebucket (W2) at the wire cell. + Assert.True(lifetime.TryApplyPosition( + PositionUpdate(parentGuid, OtherCell, positionSequence: 3), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.True(lifetime.CommitRebucket(parent, OtherCell, LandblockSentinel)); + + Assert.Equal(OtherCell, parent.FullCellId); + RuntimeEntityDelta rebucketed = Assert.Single( + deltas, + d => d.Change is RuntimeEntityChange.Rebucketed + && d.Entity.Identity.ServerGuid == parentGuid); + Assert.Equal(OtherCell, rebucketed.Entity.CellId); + + // #319's child-equality channel: exactly one propagation, and the + // child lands on the parent's new cell. + Assert.Equal(OtherCell, child.FullCellId); + Assert.Equal(childSpatialBefore + 1UL, child.SpatialAuthorityVersion); + } + + private sealed class RecordingEntityObserver(List destination) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => destination.Add(delta); + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } + + private static RuntimeEntityObjectLifetime EngineLifetime() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static void Bind(RuntimeEntityObjectLifetime lifetime) + { + var token = new RuntimeGenerationToken(1UL); + lifetime.BindEventContext(() => token, static () => 1UL); + } + + private static bool CommitAttachment( + RuntimeEntityObjectLifetime lifetime, + uint parentGuid, + ushort parentInstance, + uint childGuid, + ushort childPositionSequence) + { + var relation = new ParentAttachmentRelation( + parentGuid, + childGuid, + ParentLocation: 0u, + PlacementId: 0u, + parentInstance, + childPositionSequence); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + var update = new ParentEvent.Parsed( + parentGuid, + childGuid, + 0u, + 0u, + parentInstance, + childPositionSequence); + if (!lifetime.TryApplyParent(update, acknowledgeProjection: null, out _)) + return false; + if (!lifetime.TryCommitParent(relation, acknowledgeProjection: null, out _)) + return false; + if (!lifetime.Entities.ParentAttachments.CommitProjection(relation)) + return false; + if (!lifetime.Entities.TryGetActive( + childGuid, + out RuntimeEntityRecord canonical)) + { + return false; + } + + return lifetime.CommitAcceptedParentCellless( + canonical, + canonical.PositionAuthorityVersion, + acknowledgeProjection: null); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + uint cell, + ushort positionSequence) => + new( + guid, + new CreateObject.ServerPosition(cell, 30f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: 0, + ForcePositionSequence: 0); + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, 10f, 20f, 7f, 1f, 0f, 0f, 0f) + : null; + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + SetupTableId: null, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + null, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index f475acaa..16bdd4b4 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1787,7 +1787,16 @@ public sealed class RuntimeSetPositionStateTests Assert.True(reentered); RuntimeEntityDelta only = Assert.Single(entities.Deltas); - Assert.Equal(RuntimeEntityChange.Rebucketed, only.Change); + // C5b (#275): the reentrant accepted Position publishes Updated, not + // Rebucketed — the steady-state merge is no longer a residency writer, + // so a wire cell arriving on a withdrawn (picked-up) record does not + // make it resident. Production commits the cell one step later, at the + // OnPosition prologue rebucket, which this Runtime-level fixture does + // not drive. The SUBJECT of this test is unchanged: exactly one delta, + // published by the reentrant Position, superseding the stale pickup + // discard. + Assert.Equal(RuntimeEntityChange.Updated, only.Change); + Assert.Equal(0u, record.FullCellId); Assert.True(only.Stamp.Sequence > discardStamp.Sequence); Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( pending.Projection)); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index a8a73b8f..eeb6dfbf 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -255,6 +255,81 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests } } + /// + /// C5b (#275) proof obligation 4 — the assertion that makes AD-60's + /// legacy-half retirement mean something. A local ForcePosition whose + /// drive execution CONTENDS performs no placement, so nothing may write + /// residency for that packet: the merge no longer stamps the wire cell + /// (C5b), there is no placement receipt, and the OnPosition generic tail + /// (W2) is never reached because the force path returns before it. + /// FullCellId is the pre-packet committed cell throughout. + /// + /// + /// Retail cannot refuse at all (AD-62), and its body simply keeps the + /// last cell set_cell gave it — so the surviving shape here is the + /// retail-reachable one. Before C5b the merge stamped the refused + /// packet's wire cell, which is the AD-60 legacy-half divergence. + /// + /// + [Fact] + public void ContendedForcePosition_WritesNoResidencyAnywhere() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + uint committedCellBefore = record.FullCellId; + Assert.NotEqual(0u, committedCellBefore); + + // A wire cell that is deliberately NOT the committed one, so the + // pre-C5b stamp would have been observable. + const uint wireCell = SpawnLandblock | 0x0002u; + Assert.NotEqual(wireCell, committedCellBefore); + WorldSession.EntityPositionUpdate wire = ForceUpdate( + new Vector3(30f, 32f, SpawnHeight), + landblockId: wireCell); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, wire); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + // (1) The merge itself wrote no residency. + Assert.Equal(committedCellBefore, record.FullCellId); + + RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(displaced.IsValid); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + Assert.Equal( + RuntimeAcceptedPositionExecutionStatus.Contention, + drive.TryExecuteAcceptedLocalPosition( + record, + wire, + disposition, + timestamps, + timestamps.PreviousTeleport)); + + // (2) No receipt, so no residency change from the execution either. + Assert.Equal(committedCellBefore, record.FullCellId); + Assert.Empty(gameActions); + + // (3) And nothing rebounds it on the settle. + drive.Advance(); + Assert.Equal(committedCellBefore, record.FullCellId); + + RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects + .Physics.SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + { + runtime.EntityObjects.Physics.SetPosition + .PublishCancellation(cancellation); + } + } + [Fact] public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes() {