diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f8560d4e..c2df6bd1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,7 +160,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 98 active rows (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-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; 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-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-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; 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 @@ -169,6 +169,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-141 | **Filed 2026-08-04, C4 route 5 (projectile authoritative placement); NARROWED 2026-08-04 at the round-2 delta review (B1/B2) — the far-branch clause was factually wrong for the adopted-body case and is corrected below.** Three related projectile-only shapes, all pinned by design (D-P4) rather than ported: (a) the near-`Interpolate` disposition is a NO-OP for a live missile, where retail would lazily build interpolation machinery (`InterpolateTo` @0x005163AF) for it; (b) the post-operation `ConstrainTo` @0x00454272 (`MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`) is never ARMED for a projectile — retail's single arming site has no kind test, so retail WOULD build a `PositionManager` on demand and arm a missile's leash on any nonzero `MoveOrTeleport` return; acdream never arms it on any disposition, including the adopted-body case (whose PRE-EXISTING leash the teleport/far branches now un-arm or clear queue state for, but never RE-anchor, per retail's post-operation `ConstrainTo`); (c) a null-classified or `Rejected*` accepted Position for a missile is swallowed (write nothing) rather than caught up through any remote-shaped policy. | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`ApplyAcceptedProjectilePosition`) | acdream deliberately does not construct an `EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a ballistic body — the route-5b split the C4 route 5 contract rejected. The context that makes this safe rather than merely convenient: ACE never sends `UpdatePosition` for a missile (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334`, `SendUpdatePosition()` commented out inside the `PhysicsState.Missile` branch at `:265`) — every half of this row is deterministic-test-gated only, never exercised against a real server. **The far branch's `StopInterpolating` skip is retail-faithful ONLY for a BARE missile** (no `RemoteMotion` — retail's own `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated object, so acdream's skip is faithful by consequence there). For the ADOPTED-BODY case (`TryBind`'s shared-body branch: an ordinary remote whose Missile bit was set by a later State packet, still carrying its `RemoteMotion`), retail's guard IS satisfied and retail WOULD clear the queue — acdream now ports this (`route.StopInterpolating && record.RemoteMotion is RemoteMotion adopted → adopted.Interp.Clear()`), matching the teleport branch's equivalent `StopInterpolating` action inside `teleport_hook`. What remains divergent for the adopted case is the post-operation `ConstrainTo` re-anchor @0x00454272 — retail re-anchors an existing leash at the just-updated position on every nonzero return; acdream never arms/re-anchors it on any projectile disposition (clause (b)). | A future change that DOES give projectiles a `PositionManager` (or a headless/no-window remote-motion consumer that expects one) must re-decide this row rather than silently building the machinery ad hoc; until then, a live missile never shows an ARMED constraint leash and never catches up via the near/UnroutedCatchUp policy — both unreachable in play. An adopted-body missile's INHERITED leash (armed before it became a missile) is un-armed by the teleport hook, has its queue cleared by both teleport and far, but is never re-anchored at the new position by either — its brake accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted Position the way retail's @0x00454272 re-anchor does. **Correction, round 3 (2026-08-04): the round-2 wording here — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted.** `ConstraintManager.ConstraintPos` is write-only in both retail and the port (never read by `AdjustOffset`), and `ConstraintManager::adjust_offset` @0x00556180 only tapers or zeroes an already-composed per-tick offset while `InContact` — a leash brakes motion the interp/sticky chain already produced; it has no mechanism to move anything toward the anchor. The real residual is confined to one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). | `CPhysicsObj::MoveOrTeleport` 0x00516330 (`InterpolateTo` @0x005163AF, `IsMovingTo` @0x0050EB10 returning 0 without a `MovementManager`; far branch `StopInterpolating` @0x005163C9-@0x005163CB); `SmartBox::HandleReceivedPosition` 0x00453FD0 (`ConstrainTo` arming site @0x00454272); `CPhysicsObj::ConstrainTo` 0x00510520 (`MakePositionManager` @0x00510523); `ConstraintManager::adjust_offset` 0x00556180 (brake-only taper, write-only anchor); `WorldObject_Tick.cs:333-334`/`:265` (ACE never-sends evidence) | +| 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.** 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. | `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`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`) | 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. | 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. | `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) | +| 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-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `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 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | @@ -284,9 +286,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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` | | AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) | -| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and a non-Position rebucket writer (the projection materializer `DatLiveEntityProjectionMaterializer`, or the equipped-child renderer `EquippedChildRenderController.TickChild` — C4 route 4b-3 deleted the third shipped writer, `RemoteTeleportController`'s rollback) can rebucket that field to a third landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | +| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and a non-Position rebucket writer (the projection materializer `DatLiveEntityProjectionMaterializer` — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer `EquippedChildRenderController.TickChild`, to a presentation-only bucket move that no longer touches `record.FullCellId`) can rebucket that field to a third landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | | AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten 2026-08-04 at the dual Opus review; REWRITTEN AGAIN 2026-08-04 (C4 route 4b-3) — the cell-less enqueue-vs-place delta this row existed to record is RETIRED, not merely re-scoped: the teleport arm now ports retail's `teleport_hook` verbatim and places unconditionally through the canonical Runtime placement owner, exactly like retail's `this_1->cell == 0` @0x00516386 branch. What survives is the two acdream-only divergences retail has no state for at all.** acdream can classify a remote's accepted Position into two states retail cannot reach, sharing ONE stated handler (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`, AP-87's shared `ApplyInterpolate` catch-up) instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; **route 4b-3 adds two more null-producing reasons** — the merge observed no PRIOR canonical record for this entity, so the classifier has no honest pre-merge cell to feed the teleport predicate and declines rather than fabricate one (D1); and the dormant initial-residence enqueue path (`RuntimeEntityObjectLifetime.TryApplyPosition`'s `EnqueueDormant` return, reached BEFORE the method's own `PreMergeCommittedCellId` write), whose timestamps therefore always carry `PreMergeCommittedCellId: null` too — fix round 2026-08-04 (R8), unverified from static reading whether `OnPosition` reaches `ClassifyRemoteAcceptedPosition` for an enqueued packet at all, stated honestly rather than guessed; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither. **D1 — the visibility arm is deleted, not merely narrowed.** Before 4b-3, `LiveEntityRuntime.TryApplyPosition` computed `projectionRequiresTeleportHook` as `pre-merge FullCellId == 0 OR !IsSpatiallyProjected OR !IsSpatiallyVisible` — a presentation predicate with NO retail analogue, since retail's `MoveOrTeleport` never reads visibility. That whole computation, the lifetime parameter, and the headless `false` argument are deleted; a not-visible remote's Position now classifies purely by distance/contact like any other, and visibility is presentation-only. **D2 — the wire-airborne leftover shape.** After the teleport/cell-less classification moves onto its own arm, a packet whose classification is null/`RejectedAuthority`/`RejectedData` AND whose wire contact bit is clear takes retail's return-0 shape: AP-135's two bookkeeping writes only (server-cell adopt, `LastServerPos`/`LastServerPosTime`), no body/queue/render write, no leash arm. This deletes the legacy player-arm fallback's entity-revert quirk (`entity.SetPosition(rmState.Body.Position)`) and unifies player and NPC remotes on one behaviour. **R3 (retained from the prior rewrite) — `RejectedData` is APPLIED anyway** when grounded. It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so both the far snap and the teleport arm are graphical-host-only paths | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); `src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs` (`OwnsTeleportPlacement`, the retired predicate's replacement); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s teleport check and default arm, the D2 wire-airborne shape); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body for the two survivors, so a remote keeps tracking the server through the login window and through a rejected packet | The two survivors are unaffected by 4b-3: a leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range. If AP-87's 4 m backstop were ever weakened, this arm would become a silent-freeze path | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport — now ported, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | -| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writers (the projection materializer, the equipped-child renderer — C4 route 4b-3 deleted the third shipped writer, `RemoteTeleportController`'s rollback) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | +| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writer (the projection materializer — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer, to a presentation-only move that no longer touches `record.FullCellId`) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 515a4686..6f42e361 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -106,13 +106,25 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. `acclient_2013_pseudo_c.txt`). Route-7's `TryCommitParent`/ `CommitWithdrawal` cancellation-symmetry fixes and host-visible cancellation receipts were BOTH closed at C0 (see the C0 slice below). - What actually remains for route 7: the child's canonical cell has two - writers (Runtime commits it cell-less unconditionally in + What actually remained for route 7: the child's canonical cell had two + writers (Runtime committed it cell-less unconditionally in `CommitAcceptedParentCellless`, while `EquippedChildRenderController - .TickChild` re-cells it from a per-frame render tick), and headless has no + .TickChild` re-celled it from a per-frame render tick), and headless had no `EquippedChildRenderController` at all, so every headless parented child - stays cell-less forever — the same defect seen from two sides, not two - separate gaps. + stayed cell-less forever — the same defect seen from two sides, not two + separate gaps. **Closed 2026-08-04 + (`docs/research/2026-08-04-c4-route-7-contract.md`).** Runtime is now the + sole canonical writer: `CommitAcceptedParentCellless` completes retail + `set_parent`'s attach-time re-cell (D1), and every canonical cell write + funnels through one directory chokepoint that recursively propagates to + committed children on every parent cell crossing (D2 — + `docs/research/2026-08-04-retail-parent-cell-propagation.md`), not only at + attach. `TickChild` is demoted to a presentation-only draw-bucket move + (D4); the headless host gained its own parent-realize drive running the + same commit pair the graphical host does (D5, + `RuntimeLiveEntitySessionController.OnParentUpdated`). The direct headless + regression test (a bot with an equipped item shows the child's canonical + `FullCellId` equal to the parent's) now passes. ## Slices diff --git a/docs/research/2026-08-04-c4-route-4b-3-contract.md b/docs/research/2026-08-04-c4-route-4b-3-contract.md index 1c8db7ec..25d356e2 100644 --- a/docs/research/2026-08-04-c4-route-4b-3-contract.md +++ b/docs/research/2026-08-04-c4-route-4b-3-contract.md @@ -705,6 +705,30 @@ blanket "gate passed", because it is exactly 4b-2's #309 shape: that session's 11 park probes were all one cause, the other cause went unexercised, and without the probe the session would have been recorded as full coverage. -To close it later: provoke a Position on an entity whose canonical cell is 0 — -the unwield-to-3D path is the cheapest reachable trigger — and confirm one -`cause=cellless` line. Do not treat it as closed by a teleport-ts session. +**Superseded 2026-08-04 by C4 route 7 (see +`docs/research/2026-08-04-c4-route-7-contract.md` §11).** The recipe below — +"the unwield-to-3D path is the cheapest reachable trigger" — no longer fires. +Route 7 ported retail's parent-cell propagation (`docs/research/2026-08-04-retail-parent-cell-propagation.md`): +retail's `unset_parent` does no cell work, so a wielded child's unwield +Position reaches `MoveOrTeleport` with `this->cell` = the parent's cell — +NON-zero — and retail's cell-less branch never fires for unwield either. A +committed child's canonical `FullCellId` is now deterministically the +parent's (nonzero whenever the parent is celled), so an unwield Position +classifies by TELEPORT_TS/distance like any other packet, exactly matching +retail's predicate population. This is a correctness fix, not a regression: +the OLD recipe only worked because a parented child's canonical cell was +whatever the render-tick writer last produced — zero headless and zero in +any pre-first-tick window — which was itself the #184-class defect route 7 +closed. + +**To close the honest gap now:** provoke a Position on a body that is +genuinely withdrawn/never-celled at merge time — a body between +`CommitWithdrawal`/`CommitAcceptedParentCellless`'s cell-less edge and its +next accepted Position, or a body whose initial Create never resolved a +cell. Whether ACE ever emits an UpdatePosition in that exact window is +UNESTABLISHED; this needs its own investigation before a live recipe can be +written down. Test 4 in this contract's plan (`4b-3`, "unwield-to-3D shape +classifies `SetPosition`") remains a valid Runtime-level fixture — it +directly constructs a record with `PreMergeCommittedCellId == 0` — but it is +a SYNTHETIC pre-merge-cell-0 fixture, not a live unwield behavior, and must +not be re-labeled as one. diff --git a/docs/research/2026-08-04-c4-route-7-architecture-review-round2.md b/docs/research/2026-08-04-c4-route-7-architecture-review-round2.md new file mode 100644 index 00000000..a6b93dbc --- /dev/null +++ b/docs/research/2026-08-04-c4-route-7-architecture-review-round2.md @@ -0,0 +1,418 @@ +# C4 route 7 — ARCHITECTURE / ADVERSARIAL review, round 2 (DELTA) — 2026-08-04 + +**Verdict: PASS.** + +All three round-1 blocking findings (A1, A2, A3) are properly remediated, and +each remediation is sabotage-sensitive in the direction that matters. Seven new +findings (B1–B7) are recorded below; none is a correctness defect in shipped +behaviour with a demonstrated failure, and each has a one-to-three-line fix. +**B1 and B3's logging clause are recommended before the connected gate**, but +neither blocks. + +Scope: delta only. Round 1's accepted conclusions — the `SetFullCell` blast-radius +enumeration, the D2 chokepoint-completeness proof, and the D7 inertness +verification — are not re-litigated. Round-1 report: +[`2026-08-04-c4-route-7-architecture-review.md`](2026-08-04-c4-route-7-architecture-review.md). + +Gates re-run: + +- `dotnet build` Runtime.Tests + App.Tests — 0 warnings, 0 errors. +- `dotnet test AcDream.Runtime.Tests` — **1,157 passed / 0 failed** (was 1,156; + +1 = the depth-cap test). +- `dotnet test AcDream.App.Tests --filter EquippedChild` — **37 passed / 0 + failed** (was 36; the round-1 D4 test was replaced by two). + +--- + +## 1. Round-1 blockers — re-verified + +### A1 (MAJOR, "the D4 test cannot fail") — **FIXED, and the fix is sound.** + +`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:321-393` +(`TickChild_D4_PresentationBucketMovesToTheDestinationLandblock`). + +**Is it measuring the draw bucket or a proxy?** The draw bucket. +`fixture.Spatial` is a real `GpuWorldState` (`:1443`, `= new()`), and +`CopyLiveEntitiesNearLandblock` (`src/AcDream.App/Streaming/GpuWorldState.cs:493-523`) +reads `_loadedLiveByLandblock` — the per-landblock `HashSet` the +renderer's live-entity publication actually maintains. It is not a mirror field +and not a derived view. It also `destination.Clear()`s at `:500`, so the +four sequential queries in the test do not contaminate each other (without that +clear, the `DoesNotContain` assertions would be false-negatives; with it, they +are real). + +**Can it pass with the demotion removed?** No, and I checked both sabotage +directions: + +- *Delete the `RebucketEquippedChildPresentation` call at + `EquippedChildRenderController.cs:411-425`* → the child never leaves + `oldLandblock`, so the post-tick `Assert.Contains(newLandblock…)` fails. This + is the failure the round-1 test could not produce. +- *Revert to the legacy `RebucketLiveEntity`* → the bucket moves (so the + `Contains` passes) but `CommitRebucket` → `RuntimeEntityRecord.SetFullCell` + bumps `SpatialAuthorityVersion` unconditionally (`RuntimeEntityRecord.cs:246-251`), + so the final `Assert.Equal(childSpatialVersionBeforeTick, …)` fails. + +Both halves of the transfer are therefore pinned in opposite directions. The +test also cannot pass **vacuously** on an empty query result: the pre-tick +`Assert.Contains(oldLandblock…)` and the post-tick `Assert.Contains(newLandblock…)` +are positive assertions on two different landblocks, and a +`!_availability.IsWorldAvailable` early-return (`GpuWorldState.cs:501`) would +fail the first one. The implementer's reported sabotage ("stubbing the demoted +call to claim success failed it — empty collection") is consistent with that +structure. + +### A2 (MAJOR, "silent write-nothing outcome") — **FIXED. The fork is the right call; `NotAttached`-as-benign is safe.** + +I judged the fork against the alternative I originally suggested, and the +implementer's choice is better. Reasoning, done by enumeration rather than by +accepting the stated rationale: + +`NotAttached` is returned only when `!HasCommittedParent(serverGuid)` or when an +initial-create residence is active. The complete set of things that can remove +`_lastAcceptedByChild[child]` is `ParentAttachmentState.RemoveCommittedChild`, +reached from exactly six call sites: + +| remover | is it an unwind edge? | +|---|---| +| `CommitProjection` (`:581`) | re-adds on the next line — transient, unobservable | +| `EndChildProjection` (`:753`) | yes — pickup / Position-unparent | +| `EndGeneration(child)` (`:709`) | yes — child replaced | +| `DeleteGeneration(child)` (`:734`) | yes — child deleted | +| `RemoveCommittedParentReferences(parent)` (`:865-873`, from `EndGeneration`/`DeleteGeneration` on the PARENT) | yes — parent deleted/replaced | +| `RemoveObject` / `RemoveChild` (`:660`, `:756`) | zero production callers | + +`CommitProjection` is also the **only** writer, and +`PrepareAndTryRealize` always calls it before `TryRealize` installs the +`AttachedChild` (`EquippedChildRenderController.cs:861-891`), with the recovery +branch gated on `Relations.IsCommitted`. So `_attachedByChild` non-empty ⇒ +committed, **unless one of the five genuine unwind edges has fired**. There is +no state in which a child is legitimately attached, rendering, and +`HasCommittedParent` is false. `NotAttached` therefore cannot swallow a real +pose-loss — it can only fire in a window where a teardown is already in flight +and owns the child's fate. + +The parent-deleted case is worth naming because it is the longest window +(`_pendingOrphanRemovalByChild` defers the withdrawal across frames, +`EquippedChildRenderController.cs:342`/`:584`): during it, D3 has already zeroed +the child's canonical cell and the bucket is frozen at the parent's last +landblock. That is unchanged from pre-route-7 behaviour (the old +`RebucketLiveEntity` would have rebucketed to the same stale +`parent.ParentCellId`), so it is not a regression. + +Routing `NoProjection` into `WithdrawForPoseLoss` is also correct for the +`_projections.TryGetCurrent` failure it is named for — see **B6** for the second +condition that shares the label. + +### A3 (MEDIUM, "P8 enumerated the wrong interface") — **FIXED.** + +`RuntimeEntityDirectory.cs`'s `PropagateFullCellToChildren` doc now carries a +dedicated `` naming `GameRuntimeEventHub` as itself an +`IRuntimeEntityObjectObserver` that fans out to `IRuntimeEventObserver`, and +`RuntimeTraceRecorder.OnEntity` as the real non-stub consumer, and explicitly +retracts "no consumer at all". The decision (publish nothing) is unchanged and +its stated basis is now true. P4's write-up in the same comment also correctly +states the `ProjectionKind is World` reason for the no-spatial-root claim rather +than the round-1 relation-based reason. + +### A4, A5, A7, A9, A10 — spot-checked, all correct. + +- **A4** — `RuntimeEntityObjectLifetime.CommitAcceptedParentCellless` carries the + payload-contract doc, and `Attach_ParentCelled_…` now subscribes a + `RecordingEntityObserver` and asserts the `Withdrawn` delta's + `Entity.CellId == parent.FullCellId` and `!= 0` + (`RuntimeEntityChildCellPropagationTests.cs:34-57`). Sabotage-sensitive: + moving D1's re-cell after the publish makes `withdrawn.Entity.CellId` zero and + fails two assertions. This is a real pin, not a source-text pin. +- **A5** — `RuntimeInitialCreateContinuationExecutor.cs:2184-2190`: the dormant + pickup replay now runs `EndChildProjection` before `LeaveWorld`, matching + `TryApplyPickup`. Both pickup paths are one shape. +- **A7** — the skip is now keyed on the `(FullCellId, CanonicalLandblockId)` + pair (`RuntimeEntityDirectory.cs:389-395`), with the reason stated. Cycle + termination is unaffected: A→B→A still writes A, writes B, then finds A's + **pair** already equal and stops (`PropagationChokepoint_TerminatesAHostileTwoCycle…` + still green). +- **A9** — `RebucketEquippedChildPresentation` now carries the same + `MaterializationResidence is AwaitRuntimePlacement && HasActiveInitialCreateResidence` + gate `RebucketLiveEntity` honours (`LiveEntityRuntime.cs:1140-1145`). +- **A10** — the C3c-R1 F6 summary is back on + `FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` + (`RuntimeLiveEntitySessionControllerTests.cs:422-430`); no duplicate + `` remains. + +--- + +## 2. New findings + +### B1 — MEDIUM. The `NotAttached` test's "the draw bucket did NOT move" assertion is non-discriminating. + +**Where:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:395-450` +(`TickChild_D4_NotAttachedDisposition_SkipsTheBucketMoveWithoutFailingTheTick`), +final assertion at `:445-448`. + +Unlike its sibling, this test never moves the parent: both entities are spawned +at the fixture's default `Cell` and no `CommitRebucket` runs. So +`parent.WorldEntity.ParentCellId` still names the **same landblock the child's +bucket is already in** (`0x0101FFFF`). If the `HasCommittedParent` guard were +deleted outright, `RebucketLiveEntityPresentationOnly` would run and rebucket +the child to the landblock it is already in — and +`Assert.Contains(buffer, kv => kv.Value == child.WorldEntity)` on `oldLandblock` +would still pass. + +**Concrete failure scenario:** delete the `HasCommittedParent` check at +`LiveEntityRuntime.cs:1137-1138`. The guard A2 exists to establish is gone; the +enum collapses to `Moved`/`NoProjection`; this test stays green. + +The test's **load-bearing half is sound** — `LastFullPoseCompositionVisits` + +`AttachedEntityIds` genuinely pin "the tick was not torn down as a pose loss", +which is the fork judgement A2 asked for, and *that* half does fail if +`NotAttached` were routed to `return false`. Only the bucket clause is +decorative. + +**Fix direction:** mirror the sibling — register the second landblock, drive +`CommitRebucket(parent, newCell, newLandblock)` before the tick, and assert the +child is **still in `oldLandblock`** afterwards. Then the assertion discriminates. + +### B2 — MEDIUM. A6's justification comment contradicts the R6 gap comment two methods away, and the narrowing removed a (weak) recovery path. + +**Where:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:414-433` +(`RetryChildrenWaitingForParent`'s A6 ``) vs `:346-370` +(`ResolveAndCommitChildAttachment`'s R6 KNOWN-GAP ``). + +The A6 comment justifies scanning only `_unresolvedByChild` on the grounds that +"this drive never populates `_stagedByChild`/`_recoveryByChild` through any path +those two sweeps exist to catch". That is false: `ParentAttachmentState.Resolve` +sets `_stagedByChild[childGuid]` on accept (`:472-475`), and the R6 gap +documented two methods above is *precisely* a relation left staged-but-uncommitted +because `TryCommitParent`'s POSITION_TS gate was not satisfied during the child's +pending initial-create residence. + +**Concrete failure scenario:** a headless `ParentEvent` arrives while the child's +initial residence is pending. `Resolve` stages it; `TryCommitParent` refuses; the +relation sits in `_stagedByChild` forever. Under round 1's +`ChildrenWaitingForParent` (which unioned all three tables) a later spawn naming +the same parent guid would have re-driven it; under `ChildrenUnresolvedForParent` +nothing will. Weak in practice — it needs a *new parent generation* to fire — but +the narrowing strictly reduced recovery, and the comment claims it did not. + +The R6 gap itself is disclosed honestly and I accept it as an out-of-scope +residual (headless committed nothing on this path before D5 existed). The +finding is the contradiction, plus the consequence for the **contract §7 headless +gate**: its scenario is "the local player equips via the bot command surface and +crosses a boundary". If the equip lands during the item's own residence, the gate +produces no `cause=headless-attach` line and must be read as a **not-run**, not a +pass — the same rule the contract already states for `cause=propagate`. + +**Fix direction:** correct the A6 `` to say what is actually true (this +drive promotes unresolved relations; a staged-but-uncommitted relation is the R6 +gap and is deliberately not retried here), and cross-reference the R6 paragraph. +No code change required. + +### B3 — MEDIUM. The depth cap's failure mode contradicts AP-142 clause (a), is unrecoverable, and is invisible without the probe flag. + +**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:349-357` +(the constant) and `:433-441` (the early return). + +Three separate points, in descending importance: + +1. **The failure mode is the shape the model exists to avoid.** Past depth 64 + the tail keeps a **stale non-zero** `FullCellId`. Under acdream's single-field + model that reads as "still resident at the old cell" at all 45+ predicate + sites — exactly the `#184` invisible-but-solid shape that AP-142 **clause (a)** + cites as the reason the removal path propagates zero rather than reproducing + retail's stale-`objcell_id` residue. The cap therefore reintroduces, as its + *chosen* failure mode, the residue the row rejects. Zeroing the truncated + subtree instead ("we cannot maintain this, so it is not resident") is both + consistent with the model and strictly safer. +2. **It is unrecoverable.** Every subsequent crossing truncates at the same + depth, so a subtree that falls past the cap once never re-syncs. And because + the skip is value-idempotent, a later same-value write prunes even earlier + (the asymmetry AP-142 clause (b)'s R10 correction already records). A + permanently-stale resident subtree is worse than a transiently-stale one. +3. **It is silent in production.** `[child-cell-depth-exceeded]` is emitted only + under `PhysicsDiagnostics.ProbeChildCellEnabled`, i.e. only when someone has + already set `ACDREAM_PROBE_CHILD_CELL=1`. A defensive guard whose premise is + "this must never happen" should be unconditionally observable — a one-shot + log or a counter on the ownership ledger — or nobody will ever learn it fired. + +**Is 64 defensible?** Yes, as a number. Real chains are 2–3 deep +(player → weapon; quiver → arrow). 64 is ~20× any plausible depth and is well +inside a 1 MB stack for a two-frame-per-level recursion. **Is the cap the right +mechanism?** An iterative worklist over a pre-sized scratch would remove the +hazard entirely, keep 0 B after warmup, and need no cap, no divergence row, and +no failure mode — that is the answer I would take if this is revisited. But the +cap is a strict improvement over round 1's uncatchable process kill, so it is not +a blocker. + +**Fix direction (cheapest first):** (i) make the past-cap log unconditional; +(ii) zero the truncated subtree rather than leaving it stale, and amend AP-142 +clause (e) to say so; (iii) if revisited, replace the recursion with an +iterative worklist and retire clause (e). + +### B4 — LOW. The reused scratch buffer is not re-entrancy-safe; round 1's fresh array was. + +**Where:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:73` +(`_unresolvedChildrenScratch`), `:434-440` (filled, then iterated while calling +`ResolveAndCommitChildAttachment`). + +`ChildrenUnresolvedForParent` **clears and refills the caller's buffer** +(`ParentAttachmentState.cs:657-660`). `RetryChildrenWaitingForParent` then +iterates that same instance while calling into +`ResolveAndCommitChildAttachment` → `TryCommitParent` / +`CommitAcceptedParentCellless` → `AcknowledgeProjectionAndPublish` → synchronous +observer fan-out through `GameRuntimeEventHub`. + +**Concrete failure scenario:** an `IRuntimeEventObserver` synchronously feeds a +spawn back into the sink → nested `OnSpawned` → nested +`RetryChildrenWaitingForParent` → the shared buffer is cleared and refilled → +the outer loop's `waiting.Count` and indices now address the inner call's +contents, so children are skipped or retried twice. Requires an observer that +re-enters the sink; the shipped headless policies are empty stubs, so this is +latent, not live. The graphical sibling it mirrors +(`ChildrenWaitingForParent` → `.ToArray()`) is re-entrancy-safe by allocation, +and round 1's version inherited that safety; the A6 optimization traded it away +without a guard. + +**Fix direction:** a `_retryInProgress` flag that falls back to a fresh `List` +when re-entered, or state the single-entry precondition in the doc and assert it. + +### B5 — LOW. The recursion no longer routes children through the public `SetFullCell`, creating a silent divergence trap. + +**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:409-414` — +propagation now calls `child.SetFullCell(...)` (the **record** method) plus a +direct recursive call, where round 1 re-entered `RuntimeEntityDirectory.SetFullCell`. + +The two are equivalent **today** — the public method is exactly +`EnsureKnown` + `record.SetFullCell` + propagate, and `EnsureKnown` is a +validation-only throw (`:812-820`) already subsumed by the preceding +`TryGetActive`. The change was needed to thread `depth`. + +**Concrete failure scenario:** anyone later adding a side effect to +`RuntimeEntityDirectory.SetFullCell` — an index update, a delta publish, a +telemetry counter — gets it for the root and **silently not** for propagated +children, reintroducing exactly the "mapping written against one caller's +reachable set" class D2 was designed to eliminate. Nothing in the code says the +equivalence is load-bearing. + +**Fix direction:** give the public method a private `depth`-carrying overload and +have the recursion call *that*, so there is one body; or add one comment line at +`:409` naming the equivalence as an invariant to maintain. + +### B6 — LOW. `NoProjection` conflates two different outcomes, and one of them now triggers a teardown that round 1 did not. + +**Where:** `src/AcDream.App/World/LiveEntityRuntime.cs:1131-1152`. The enum doc +(`:45-56`) defines `NoProjection` as "the graphical projection itself is gone", +but `:1147-1152` also returns it when `RebucketLiveEntityPresentationOnly` +returns `false` — which means the **projection operation was displaced by a +re-entrant callback** (`IsCurrentProjectionOperation` at `:1029`/`:1054`), i.e. a +*newer* rebucket took over. `TickChild` then returns `false` → +`Tick()`'s `failed` list → `WithdrawForPoseLoss`. + +**Concrete failure scenario:** a re-entrant observer starts a newer rebucket for +the same child mid-`_spatial.RebucketLiveEntity`; the older tick concludes +"projection gone" and withdraws the child's projection that the newer operation +just legitimately re-established. Low reachability — the +`_presentationOnlySpatialMutationDepth` guard (`:3354-3360`) already suppresses +the most likely re-entrant rebucket source — but round 1 discarded the bool, so +this teardown is new behaviour introduced by the A2 fix. + +**Fix direction:** either split the displaced case into its own disposition +(`Superseded`, treated as benign — the newer operation owns the bucket), or +document at `:1147` that displacement is deliberately treated as pose loss and +why. + +### B7 — INFO. One other test derives a built-collection size from a live production constant. + +Asked directly, so answered directly: I swept `tests/` for the shape that caused +the implementer's 64,000-node stack overflow (a test that both *reads* a +production constant and *sizes work* by it). Exactly one other match with real +blast radius: + +`tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs:210` — +`for (uint i = 0; i <= LandblockStaticEntityIdAllocator.MaxCounter + 1u; i++)` +builds one `Stab` per iteration. `MaxCounter = 0xFFF` +(`src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs:16`), so 4,097 cheap +allocations today. A sabotage raising it by orders of magnitude would OOM or hang +rather than stack-overflow, and the test's `Assert.Contains("4096-entry", …)` +literal pins the constant — but only *after* the build loop, so the pin does not +protect the loop. Milder than the route-7 case and pre-existing; noted, not +filed against this slice. + +The other three matches are benign: `LightManagerTests` (`MaxGlobalLights = 128`, ++50 iterations of trivial work) and the two `CapacityTrimIdleFrames` frame loops. + +The route-7 depth test's own remaining constant read +(`RuntimeEntityChildCellPropagationTests.cs:473`, +`for (i = 0; i <= MaxPropagationDepth; i++)`) is **correctly protected**: the +literal pin `Assert.Equal(64, RuntimeEntityDirectory.MaxPropagationDepth)` at +`:441` fires first, and the chain length at `:442` is a literal. The boundary is +pinned in both directions — a cap of 70 would give index 69 the new cell and fail +the tail assertion; a cap of 60 would leave indices 61–64 stale and fail the loop. + +--- + +## 3. Direct answers to the coordinator's remaining questions + +### The `WarmedSteadyContactRefreshDoesNotAllocate` flake — **#302 class. Not this slice.** + +Stated definitely rather than hedged, because it is provable by reachability: + +- The measured window + (`RuntimeCollisionReportingStateTests.cs:2375-2379`) contains **only** + `lifetime.Physics.HandleSetPositionCollisions(...)`. Entity registration and + shadow setup happen before `GC.GetAllocatedBytesForCurrentThread()` is sampled. +- `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs` contains **zero** + occurrences of `SetFullCell` and **zero** of `ParentAttachments` (grepped). Its + only `_entities` interactions are read-only queries (`TryGetByLocalId`, + `IsCurrent`, `SessionLifetimeVersion`, `CurrentLifetimeMutation`) plus + `StopMissileAfterCollision`, which mutates `FinalPhysicsState`, not the cell. + `StopMissileAfterCollision` is not in the complete `SetFullCell` caller set + enumerated in round 1 §0 and re-verified against this diff. + +So `PropagateFullCellToChildren` is not reachable from the measured call at all, +and the fixture has no committed parent relations in any case. A +`GC.GetAllocatedBytesForCurrentThread() == 0` assertion that passes in isolation +and on re-run but fails once under full-suite load is the documented **#302** +shape (tiered-JIT / first-touch allocation on the measuring thread), and should +be treated as such: re-run and name it, never chase it. The instinct to look +rather than auto-dismiss was right — the answer is that the new code is not on +that path. + +### Point 6 — deliberately-not-done items: **my "not blocking alone" assessment holds.** + +- **Writer-family coverage for `CommitCanonicalCell` / `RuntimeSetPositionState`.** + Still covered *by construction*, and round 2 did not weaken that: both funnel + through the identical `RuntimeEntityDirectory.SetFullCell:359-366` line that + `CommitRebucket` (tested) and `RefreshSnapshot` (tested) use, and round 1's + chokepoint-completeness proof (`RefreshDerivedState` has exactly two callers; + `RuntimeEntityRecord.SetFullCell` exactly two) is unchanged by this diff. Two + of the four writer families are tested end-to-end; the other two are the same + three lines of code. +- **Fuller P2 across N crossings** — `NeverArmPartition_D8_…` already drives five + crossings; the clock/suspension assertions live in the attach and pickup tests. + Thin, not absent. +- **Dedicated P5/P6 tests** — still argued by construction, and the construction + argument is still valid after round 2: `PropagateFullCellToChildren` remains + field-writes + one dictionary probe + `TryGetActive`, with no callback, no + LINQ, no closure, and `ChildrenAttachedToParent` still returning the stored + `List` or the cached `Array.Empty()`. The round-2 restructuring + (direct `child.SetFullCell` instead of re-entering the directory method) makes + the path *cheaper*, not richer. **B4** is the one place round 2 introduced new + re-entrancy surface, and it is in D5's retry, not the propagation. + +--- + +## 4. Recommended before the connected gate (non-blocking) + +1. **B1** — make the `NotAttached` test's bucket assertion discriminate (move the + parent first). Two lines. +2. **B3 clause 3** — make the past-cap log unconditional. One line. +3. **B2** — correct the A6 justification comment so it does not contradict the R6 + gap paragraph, and carry the "no `cause=headless-attach` line ⇒ not-run" + reading into the headless gate step. Comment only. + +The **connected two-client gate remains unrun** and is still the acceptance test +for the D4 transfer, with the contract's rule intact: a session showing zero +`cause=propagate` lines during the landblock-crossing step is a not-run, not a +pass. The headless gate now carries the same caveat for `cause=headless-attach` +(B2). diff --git a/docs/research/2026-08-04-c4-route-7-architecture-review.md b/docs/research/2026-08-04-c4-route-7-architecture-review.md new file mode 100644 index 00000000..17bcce24 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-7-architecture-review.md @@ -0,0 +1,493 @@ +# C4 route 7 — independent ARCHITECTURE / ADVERSARIAL review (2026-08-04) + +**Verdict: FAIL** — remediation is narrow and mechanical (two test gaps plus one +incomplete proof enumeration). No defect was found in the shipped propagation +mechanism itself; the failure is that the route's single highest-risk, +user-visible half (the D4 presentation transfer) has **no test that can fail**, +which is the exact recurring defect class route 5's review rounds named and +which §3 item 7 / §6 test 10 of the contract explicitly pinned. + +Scope reviewed: uncommitted working-tree diff at HEAD `ca96ea5e`, branch +`claude/acdream-physics-divergence-5aa784`. Route-7 contract and route-3 +scoping docs treated as inputs, not as change under review. + +Gates re-run by the reviewer: + +- `dotnet build tests/AcDream.Runtime.Tests` — 0 warnings, 0 errors. +- `dotnet test AcDream.Runtime.Tests` — **1,156 passed / 0 failed**. +- `dotnet test AcDream.App.Tests --filter EquippedChild` — **36 passed / 0 failed**. +- New `RuntimeEntityChildCellPropagationTests` + the two `DirectSink_D5` tests — + **14 passed**. + +--- + +## 0. Judgement on the four implementer claims + the `SetFullCell` blast radius + +### Claim 1 — "D7's reorder is unobservable" → **VERIFIED. Genuinely unobservable.** + +`EndChildProjection(guid)` (`ParentAttachmentState.cs:749-754`) touches exactly +three tables keyed by the **child** guid: `_stagedByChild`, `_recoveryByChild`, +and (via `RemoveCommittedChild`) `_lastAcceptedByChild` plus the removal of that +guid from **its parent's** `_committedChildrenByParent` list. It never touches +`_committedChildrenByParent[(update.Guid, incarnation)]` — the picked-up +entity's own children list. + +The three calls now bracketed by the move are +`Physics.CollisionReports.LeaveWorld`, `Physics.SetPosition.Forget`, and +`Entities.SuspendObjectClock`. I enumerated every `ParentAttachments` reader +reachable from them: + +- `RuntimeSetPositionState.cs:6027` / `:6049` (`ArmLostFamilyDeadlines` / + `CancelLostFamilyDeadlines`) read `ChildrenAttachedToParent(operation.Record + .ServerGuid, …)` — the record **as a parent**, unaffected. +- `RuntimeSetPositionState.cs:3944` (`IsAffectedCollisionResident` → + `HasCommittedParent`) reads the record **as a child** and IS order-sensitive — + but it is reached only from `ParkCollisionResidents`, a landblock-quiescence + entry point, never synchronously from `Forget`. +- `CancelExactLostKey` iterates operations by `RuntimeEntityKey`, not by relation. + +No other reader exists. The honest "reverted the order, all 12 tests still +passed" report is the correct outcome and should be recorded as such, not +papered over with a manufactured test. **One consistency gap follows from it — +see A5.** + +### Claim 2 — "`RuntimeEntityChange.Rebucketed` has NO consumer" → **FALSE as stated. Enumeration incomplete. See A3.** + +The implementer enumerated `IRuntimeEntityObjectObserver` implementations and +found empty stubs. But `GameRuntimeEventHub` (`GameRuntimeEventHub.cs:45`) +*implements* `IRuntimeEntityObjectObserver` and its `OnEntity` +(`:239-254`) **fans the delta out to every `IRuntimeEventObserver`**, and +`RuntimeTraceRecorder.OnEntity` (`GameRuntimeEvents.cs:246-254`) is a +non-stub shipped consumer that records `delta.Change` and +`delta.Entity.CellId`. The `IHeadlessBotPolicy` implementations +(`HeadlessBotPolicy.cs:48/151/281/516`) are the empty stubs; they are not the +whole set. No functional break is demonstrated (the trace recorder is +diagnostic and equipped-child `Rebucketed` deltas were graphical-only), so the +publication default itself stands — but P8 was answered against the wrong +interface and must be restated. + +### Claim 3 — "a committed child never becomes a spatial root" → **VERIFIED, for the right reason (which the claim did not state).** + +The reason is not the parent relation: it is the **projection kind**. +`LiveEntityRuntime.HasSpatialRuntimeProjection` (`:3251-3256`) requires +`record.ProjectionKind is LiveEntityProjectionKind.World`, and +`EquippedChildRenderController.TryRealize` materializes children with +`LiveEntityProjectionKind.Attached` (`:569`). So +`RefreshSpatialRuntimeIndexes` → `AcknowledgeSpatialProjection(canonical, +spatial: false)` → `RemoveSpatialProjection` on every tick, and +`_spatialRoots` (`RuntimePhysicsState.cs:697-711`) never gains the child. +Headless never calls `AcknowledgeSpatialProjection` on any route-7 path at all +(its only callers are `LiveEntityRuntime.cs:3272` and +`RuntimeSetPositionState.cs:2756/3664/5138`, none of which route 7 reaches). +So no child broadphase gap, and the §0 trap-5a "no per-crossing shadow rebuild" +pin holds. **Note for the record:** had the child been kind `World`, the +`FullCellId != 0` clause of `HasSpatialRuntimeProjection` would have made D1/D5 +newly promote children into the physics workset — the claim is right, but it is +one line of `ProjectionKind` away from being wrong, and P4's write-up should say +so. + +### Claim 4 — "P5/P6 are safe by construction, no dedicated tests" → **ACCEPTABLE for the propagation path; NOT acceptable as a blanket statement for the slice. See A6.** + +- **P6 (zero alloc) on the propagation path: verified.** + `ChildrenAttachedToParent` (`ParentAttachmentState.cs:648-658`) returns the + stored `List` or the cached `Array.Empty()`; both convert to + `IReadOnlyList` by reference conversion (no boxing). + `PropagateFullCellToChildren` (`RuntimeEntityDirectory.cs:381-405`) is an + index loop with no LINQ, no closures, no temporaries. 0 B confirmed by + inspection. `ParentIncarnation` is a `readonly record struct` + (`ParentAttachmentState.cs:923`) so the dictionary probe does not box. +- **P5 (re-entrancy) on the propagation path: verified.** The only work is + `EnsureKnown`, `record.SetFullCell` field writes, `TryGetActive`, and an + optional `Console.WriteLine`. Nothing calls back into `RuntimeSetPositionState` + / `RuntimePhysicsState` / any observer, so the recursion cannot re-enter a + mid-commit owner. I also confirmed the live `List` returned by + `ChildrenAttachedToParent` cannot be mutated during the loop (nothing on the + propagation path touches the relation tables), so the swap-remove in + `RemoveCommittedChild` (`:853-859`) is not an aliasing hazard here. +- **But the slice's OTHER new path is not zero-alloc and was not measured** — + D5's per-spawn `ChildrenWaitingForParent` scan. See A6. + +### The `SetFullCell` blast radius — **enumerated; correct at every site, with one structural caveat (A7).** + +`RuntimeEntityDirectory.SetFullCell` (`:348-355`) now has a side effect for +every caller. Full production caller set, each checked: + +| site | value written | children now follow — correct? | +|---|---|---| +| `RuntimeEntityObjectLifetime.cs:1314` (`TryApplyPickup`) | 0,0 | ✔ retail `leave_world`'s recursive `leave_cell(this,0)` @0x005155E6 | +| `:1484` (`WithdrawCommittedChildrenToCellless`) | 0,0 | ✔ explicit D3 edge | +| `:1509` then `:1530` (`CommitAcceptedParentCellless`) | 0,0 then parent's | ✔ grandchildren are zeroed then restored inside the same synchronous call | +| `:1945` (`CommitRebucket`) | new cell | ✔ D2's headline case | +| `:1991` (`CommitWithdrawal`) | 0,0 | ✔ | +| `:2737` (`InitializeAcceptedCreateResidence`) | 0,0 | ✔ vacuous — a fresh incarnation has no committed children (`_committedChildrenByParent` is keyed by `(guid, instance)`) | +| `RuntimeInitialCreateContinuationExecutor.cs:2188` (dormant pickup replay) | 0,0 | ✔ same shape as `TryApplyPickup` | +| `RuntimePhysicsState.cs:2148` (`CommitCanonicalCell`) | new cell | ✔ guarded by an equality early-out at `:2145`, so no probe on a same-cell commit | +| `RuntimeSetPositionState.cs:2743` / `:3660` (placement commits) | new cell | ✔ both guarded by `record.FullCellId != result.CellId` | +| `:5003` (residency restore) | resident cell | ✔ re-propagates children the park zeroed | +| `:5224` (`WithdrawCanonical`) | 0,0 | ✔ park/lost-cell; self-heals through `:5003` | +| `LiveEntityRuntime.cs:192` / `:200` (record property setters) | mixed | test-only (no production assignment exists) — but see A7 | + +No caller means "clear only this record": every zeroing site is a +retail `leave_world`/`change_cell(null)` analogue, and the two "restore" sites +re-propagate. The `RefreshSnapshot` half (`:231-246`) is correctly gated on an +actual change and `RefreshDerivedState` has exactly the two callers the contract +claimed (record ctor `RuntimeEntityRecord.cs:29` and `RefreshSnapshot`) — I +re-verified this repo-wide, so **contract open question 1 is answered: no +canonical cell write bypasses the hook.** + +**Termination**: verified by construction and by test. The skip is evaluated in +the *parent's* loop before descending, so an A→B→A wire cycle writes A, writes +B, then finds A already equal and stops. +`PropagationChokepoint_TerminatesAHostileTwoCycle…` pins it. A self-parent +(reachable headless, where D5 skips `ValidateParentProjection`'s +self-parent rejection at `EquippedChildRenderController.cs:897-898`) also +terminates on the first skip. + +**Ordering**: children are written *before* every parent-side dependent step — +before `CellCommitted` (`RuntimePhysicsState.cs:2152`), before +`AdvancePlacementCommit`/`AcknowledgeSpatialProjection` +(`RuntimeSetPositionState.cs:2746-2756`), and before +`AcknowledgeProjectionAndPublish` in `CommitRebucket`. There is no synchronous +callback anywhere inside the recursion, so no observer can see a +partially-propagated tree. This matches retail (`enter_cell`'s child recursion +runs inside `change_cell`, before the caller's tail). + +--- + +## 1. Findings + +### A1 — MAJOR. The only test for the D4 demotion's presentation half cannot fail if that half is deleted outright. + +**Where:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:322-362` +(`TickChild_D4_CanonicalCellIsRuntimesWriteNotTheRenderTicks`), against +`src/AcDream.App/Rendering/EquippedChildRenderController.cs:403` and `:409-415`. + +The test's two assertions are: + +1. `Assert.Equal(newCell, child.WorldEntity!.ParentCellId!.Value)` — "the render + tick moved only the DRAW bucket". +2. `Assert.Equal(childSpatialVersionBeforeTick, child.Canonical.SpatialAuthorityVersion)` + — "and did not re-write the canonical cell". + +Assertion 1 does **not** test the bucket. `TickChild` writes +`child.Entity.ParentCellId = parent.ParentCellId` **unconditionally at +`:403`**, before the rebucket call, and `AttachedChild.Entity` is the same +`WorldEntity` instance the test reads (the fixture passes +`child.WorldEntity!` into the `AttachedChild` ctor — +`EquippedChildProjectionWithdrawalTests.cs:1529`). Assertion 2 is a negative +that is trivially satisfied by doing nothing. + +**Concrete failure scenario:** delete lines `:409-415` entirely (or let +`RebucketEquippedChildPresentation` return `false` on its first guard — see A2). +The child's canonical cell is still right, `ParentCellId` still mirrors, both +assertions still pass, the whole App suite stays green — and the equipped weapon +is **left behind at the previous landblock's draw bucket** when the player +crosses a boundary: exactly the "left behind at a landblock boundary (the +demotion's specific risk)" regression the contract's §7 gate names, and the +`#184` invisible-but-solid family. + +`RebucketEquippedChildPresentation` has **zero** test references repo-wide +(grep across `tests/` returns nothing), so this is the slice's only coverage of +the presentation transfer. + +Contract §6 test 10 asked for precisely what is missing: *"assert the child's +render entity moved buckets (spatial index / visibility state)"*. The +implementer's sabotage note covers the canonical half only (reverting to +`RebucketLiveEntity` fails assertion 2 — I confirmed that is true, because +`RuntimeEntityRecord.SetFullCell` at `:246-251` bumps `SpatialAuthorityVersion` +unconditionally); the presentation half's sabotage was not run. + +**Fix direction:** assert against the spatial layer, not the mirror field. The +fixture already reaches `LiveEntityRuntime`, so either (a) assert +`record.IsSpatiallyVisible` / the spatial index's resident cell for the child +key after the tick with the child pre-seeded into a *different* bucket, or +(b) assert `RebucketEquippedChildPresentation`'s observable effect through the +fake `_spatial` (a recorded `RebucketLiveEntity(key, entity, cell)` call with +the new cell). Then run the prescribed sabotage: stub the call out and confirm +the test goes red. + +### A2 — MAJOR. The new App entry point can silently write nothing, and its status is discarded. (Route 5's A1 defect class, recurring.) + +**Where:** `src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101` +(`RebucketEquippedChildPresentation`) and +`src/AcDream.App/Rendering/EquippedChildRenderController.cs:409-415`. + +The method returns `false` — writing nothing — on three conditions +(`!HasCommittedParent`, no current projection, no `WorldEntity`). `TickChild` +**discards the return value**, then unconditionally returns `true` and raises +`ProjectionPoseReady?.Invoke(child.ChildGuid)` at `:416`, i.e. it advances +presentation on a write-nothing outcome. That is verbatim the defect the route-7 +contract lists among the route-5 classes it "addresses by name" (§preamble: +*"an App glue site discarding the Runtime seam's status and advancing +presentation on write-nothing outcomes"*). + +The `HasCommittedParent` guard is **new**; it did not gate the old +`RebucketLiveEntity` call. It is reachable-false while `_attachedByChild` still +holds the child, because Runtime clears `_lastAcceptedByChild` (via +`EndChildProjection`, `RuntimeEntityObjectLifetime.cs:1307` on pickup and +`:1908` on the Position-unparent) **before** the App's +`EquippedChildRenderController.OnChildBecameUnparented` (`:264-284`) runs, and +that method can defer the teardown across frames +(`AdvanceUnparentTransition` + `_pendingOrphanRemovalByChild`, retried at +`:342`). During that window every `Tick()` composes the pose, mirrors +`ParentCellId`, publishes the pose, raises `ProjectionPoseReady`, and moves +**no bucket at all**. + +Today that window is benign (the child is on its way out). The finding is that +the benign-ness is accidental and untested: nothing distinguishes "guard +correctly declined" from "guard wrongly declined", and A1 means no test would +notice either way. + +**Fix direction:** consume the bool. Either treat `false` as a `TickChild` +failure (which already has a defined path — `Tick()`'s `failed` list → +`WithdrawForPoseLoss` at `:296-297`), or, if declining is legitimate for the +unparent window, make that explicit: return a small disposition +(`Moved` / `NotAttached`) and assert the `NotAttached` case in a test rather +than inferring it. Do not leave a silent bool on the floor. + +### A3 — MEDIUM. P8's consumer enumeration is against the wrong interface; a non-stub `OnEntity` consumer ships. + +**Where:** `src/AcDream.Runtime/GameRuntimeEventHub.cs:45` and `:239-254`; +`src/AcDream.Runtime/GameRuntimeEvents.cs:104` and `:246-254`. + +See claim 2 above. `GameRuntimeEventHub` is itself an +`IRuntimeEntityObjectObserver` that forwards to every `IRuntimeEventObserver`, +and `RuntimeTraceRecorder.OnEntity` records `(delta.Change, +delta.Entity.CellId)` for every entity delta including `Rebucketed`. + +**Failure scenario (bounded):** any current or future headless gate / trace +assertion that counts entity deltas for an equipped child now sees fewer +`Rebucketed` entries than before the demotion, with no note anywhere saying so. +I found no such assertion today, so this is a proof defect rather than a live +break — but the stated basis for D2's "publish nothing" default ("safe by +inspection, every implementation is an empty stub") is not true and must not be +carried forward as if it were. + +**Fix direction:** restate P8 in the commit against **both** observer +interfaces, name `RuntimeTraceRecorder` explicitly as the one real consumer, and +say why a diagnostic trace losing graphical-only child `Rebucketed` entries is +acceptable. No code change needed. + +### A4 — MEDIUM. `CommitAcceptedParentCellless` now publishes `Withdrawn` carrying a NON-zero cell. + +**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1530-1543`. + +D1's re-cell is placed **before** `AcknowledgeProjectionAndPublish(…, +RuntimeEntityChange.Withdrawn, …)`. Prior to this slice, a `Withdrawn` delta +from this method always carried `FullCellId == 0`; it now carries the parent's +cell whenever the parent is celled — i.e. in the ordinary equip case. + +**Failure scenario:** any observer that treats `Withdrawn` as "no longer +resident" and reads `delta.Entity.CellId` to decide where to unregister (the +plugin world-state bridge, a future headless bot policy, a future radar/journal +consumer) now gets a delta whose kind and payload disagree. The shipped +consumers are stubs or diagnostic, so nothing breaks today; the risk is that the +invariant "Withdrawn ⇒ cell 0" was previously true and is now silently false, +with no test pinning either reading. + +**Fix direction:** cheapest correct answer is to state the new payload contract +in the method's doc comment and add one assertion to the D1 attach test +(`Attach_ParentCelled_…`) pinning the published change kind **and** the +delta's cell, so the pair is deliberate rather than incidental. If any consumer +is later found to need the old shape, the re-cell moves after publication — +which is safe, because the same-transaction argument is about *callers*, not +about the delta. + +### A5 — MEDIUM. D7's reorder was applied to one of the two pickup leave-world sites; the sibling still has retail's inverted order. + +**Where:** fixed at `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1304-1314`; +**not** fixed at `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:2184-2189` +(`ApplyPickupAction`, the dormant-residence pickup replay), where +`EndChildProjection` still runs **after** `LeaveWorld` / `SetFullCell(0,0)`. + +**Failure scenario:** none observable — the same inertness argument that +validates claim 1 applies verbatim to this site. The defect is bookkeeping: D9 +and the commit will say the recorded T7 inversion is "retired", while a second +instance of it remains in-tree at the path a pickup takes when it arrives during +a pending initial-create residence. A future reader grepping for the inversion +finds it and re-opens a settled question. + +**Fix direction:** either apply the same three-line move at `:2189` (preferred — +it is provably inert and makes both pickup paths one shape), or add one +sentence to the D7 commit text naming `ApplyPickupAction` as a deliberate, +inert survivor. + +### A6 — MEDIUM. D5's per-spawn retry is an O(live-relations) LINQ scan with allocations, on the headless spawn path. + +**Where:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:143` +(`RetryChildrenWaitingForParent` called on **every** accepted spawn) → +`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:618-640` +(`ChildrenWaitingForParent`), plus `:339-388` +(`ResolveAndCommitChildAttachment`). + +`ChildrenWaitingForParent` allocates a `HashSet`, fully enumerates +`_stagedByChild`, `_recoveryByChild` **and** `_unresolvedByChild`, runs +`queue.Any(lambda)` per unresolved child, and returns `.ToArray()`. +`_recoveryByChild` retains an entry for every child ever committed +(`CommitProjection`, `:595`), so it grows with the number of equipped +NPCs/players in view. `ResolveAndCommitChildAttachment` additionally allocates +three `this`-capturing closures per call (`:341-350`). + +**Failure scenario:** a 30-session headless host (Slice K's stated target) +loading a dense landblock does one full scan of three dictionaries per accepted +`CreateObject`. With N resident entities of which K are attached children, that +is O(N·(N+K)) work and O(N) allocations across the load, on the path Slice K +measured to a resource ceiling. The graphical host does the same thing +(`EquippedChildRenderController.OnSpawn` → `RetryWaitingDescendants`), so the +shape is not novel — but the headless host is the one with a per-process +allocation budget and a 30-root gate, and this is new cost there. + +**Fix direction:** the retry only needs children whose *unresolved* queue names +this parent. Index that: keep a `Dictionary childGuids>` +maintained by `Enqueue`/`Resolve`, or at minimum skip the `_recoveryByChild` +and `_stagedByChild` sweeps (both are already-resolved states that +`ResolveAndCommitChildAttachment` no-ops on). Cache the three callbacks as +fields. Then re-measure the K4 30-session resource envelope, or state +explicitly that this slice does not. + +### A7 — LOW. The propagation's idempotence key is `FullCellId` alone, but `SetFullCell` takes an independent `canonicalLandblockId`. + +**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:389-395` +(`child.FullCellId == fullCellId` → `continue`). + +Every production writer derives the landblock from the cell — +`(cellId & 0xFFFF0000u) | 0xFFFFu` at `RuntimeEntityRecord.cs:235`, +`RuntimePhysicsState.cs:2150`, `RuntimeSetPositionState.cs:2744/3663/5005` — and +D1 copies the parent's pair verbatim, so the two fields are coupled today and +the skip is safe. + +**Failure scenario:** the coupling is not enforced anywhere. +`LiveEntityRecord.CanonicalLandblockId`'s setter +(`src/AcDream.App/World/LiveEntityRuntime.cs:200-203`) calls +`SetFullCell(Canonical, Canonical.FullCellId, value)` — a same-cell, +different-landblock write. It has no production caller today (tests only), but +if one ever appears, children keep the **stale** `CanonicalLandblockId` while +the parent updates, because the skip fires. `LiveRenderProjectionJournal.cs:273` +reads `record.CanonicalLandblockId` to pick the owning landblock, so the +symptom would be a child journaled against the wrong landblock. + +**Fix direction:** make the skip test the pair +(`child.FullCellId == fullCellId && child.CanonicalLandblockId == canonicalLandblockId`), +or assert the derivation invariant in `RuntimeEntityRecord.SetFullCell`. One +line either way. + +### A8 — LOW. Wire-driven recursion depth is unbounded; a deep attachment chain is a `StackOverflowException` (process kill). + +**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:381-405` +(mutual recursion `PropagateFullCellToChildren` ↔ `SetFullCell`). + +Termination against cycles is correct (verified above), but depth is bounded +only by the length of the committed parent chain, which is server-supplied. +Nothing in `ParentAttachmentState`, `TryApplyParent`, or `TryCommitParent` +caps chain depth; only self-parenting is rejected, and only in the graphical +`ValidateParentProjection` (which D5 deliberately skips). + +**Failure scenario:** a buggy or hostile server commits a 10k-long +A→B→C→… chain; the first cell write on the root overflows the 1 MB stack. +`StackOverflowException` is uncatchable in .NET — the process dies, taking all +30 headless sessions with it. Not reachable against a well-behaved ACE. + +**Fix direction:** a depth counter with a hard cap (retail's own recursion is +equally unbounded, so a cap is an acdream divergence — it belongs in AP-142's +clause list) or an explicit iterative worklist over a pre-sized scratch buffer, +which also removes the stack cost entirely and stays 0-alloc after warmup. + +### A9 — LOW. The C3c active-residence gate is not carried to the new presentation entry point. + +**Where:** `src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101` vs the gate at +`:806-834`. + +The public `RebucketLiveEntity` refuses to install a bucket while an +initial-create residence is ACTIVE, with a stated reason ("a re-entrant caller +could install a bucket for a suppressed record before its placement ever +committed"). `RebucketEquippedChildPresentation` guards on +`HasCommittedParent` only. + +**Failure scenario:** bounded — `CommitAcceptedParentCellless` calls +`ForgetInitialCreateResidence` (`:1502`) before any `AttachedChild` is +installed, so by the time `TickChild` can run, the child's residence is +cancelled. The gap is that the guard's *reason* is now implicit in call +ordering three files away rather than enforced at the entry point. + +**Fix direction:** add `&& !HasActiveInitialCreateResidence(record.Canonical)` +to the new method's guard (cheap, and it makes the "can never become a general +bypass" claim in its doc comment actually true), or cite the +`ForgetInitialCreateResidence` ordering in that doc comment. + +### A10 — LOW. Duplicate `` block; one existing test lost its documentation. + +**Where:** `tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs:316-330`. + +The new D5 test's doc comment was inserted **after** the closing `` +of the C3c-R1 F6 block that documented +`FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner`. Both summaries +now attach to `DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell`; +the F6 test is undocumented. Mechanical fix: move the new block below the old +one's member, or move the old block down to `:231`. + +--- + +## 2. Contract obligations checked and found MET + +Recorded so the next reviewer does not re-derive them. + +- **D2 chokepoint completeness (contract open question 1).** `RefreshDerivedState` + has exactly two callers repo-wide (`RuntimeEntityRecord.cs:29` ctor, + `RuntimeEntityDirectory.cs:239`); `RuntimeEntityRecord.SetFullCell` has + exactly two (`RefreshDerivedState`, `RuntimeEntityDirectory.SetFullCell`). + No canonical cell write bypasses the hook. +- **D3 delete ordering (P7).** `WithdrawCommittedChildrenToCellless` runs at + `:2070` before `DeleteGeneration` at `:2073`, and at `:1005` before + `EndGeneration` at `:1009`. In the `EndGeneration` case `RemoveActive` has + already run, but the relation table is untouched by it and children are + looked up independently, so the read is valid. Recursion to grandchildren + rides D2. Pinned by `Delete_ZeroesChildrenBeforeRelationsAreTornDown_P7` and + `EndGeneration_ReplacementParentGeneration_…`. No double-withdraw: the second + pass would find every child already at 0 and skip. +- **No orphaned teardown sites.** `ParentAttachmentState.RemoveObject` / + `RemoveChild` have zero production callers, so `DeleteGeneration` / + `EndGeneration` are the complete set of relation-teardown edges needing a cell + edge, and both got one. +- **D8 never-arm partition.** No `ConstrainTo`, park, service-window, or + `CanAttemptDestination` code was added; `NeverArmPartition_D8_…` pins the + placement-operation count across attach + 5 crossings + pickup + delete. + (Contract §6 test 6 also asked for park counts and + `RemotePlacementDrivePendingCount`; only `ActiveOperationCount` is asserted — + a thinner pin than specified, noted but not a finding on its own.) +- **§3 item 10 tripwire.** The classifier diff is a pure deletion of + `ClassifyLeaveWorld` + its two types + its one test. `ValidCreateAuthority` + survives; no surviving classifier test changed an expectation. 1,156 Runtime + tests green. +- **D9 bookkeeping.** AP-142 and AP-143 both filed; AP-136's writer list + correctly shrinks to the projection materializer alone, and the two mirrored + doc comments (`RuntimeSetPositionState.cs:4542`, + `RuntimeRemotePlacementDriveController.cs:1619`) were updated in the same + diff. Repo grep for stale "TickChild is a canonical writer" claims returns + only the corrected sites. +- **Headless gate is a real regression test.** Both `DirectSink_D5_…` tests seed + parent and child at **distinct** landblocks and assert `NotEqual(0u, …)` + alongside the equality, so they cannot pass by coincidence or at zero. Both + fail without D1/D2/D5. +- **P1.** `P1_SnapshotMutationOfACommittedChild_…` drives an ObjDesc merge and + asserts `child.Snapshot.Position` stays null and the cell still tracks the + parent — a genuine behavioural pin, not a source-text pin. + +--- + +## 3. Remediation required to convert this to PASS + +1. **A1** — rewrite the App test so it asserts the spatial bucket / visibility + move, and run the contract's prescribed sabotage (stub + `RebucketEquippedChildPresentation` out; the test must go red). +2. **A2** — consume `RebucketEquippedChildPresentation`'s status in `TickChild`, + and cover the declining branch with a test. +3. **A3** — restate P8 against `IRuntimeEventObserver` as well, naming + `RuntimeTraceRecorder`. + +A4–A10 are recommended in the same slice (all are one-to-three-line changes or +commit-text corrections) but none of them alone blocks the route. + +The **connected two-client gate has not been run** and remains the acceptance +test for the D4 transfer regardless of the above — with the contract's own +rule that a session showing zero `cause=propagate` lines during the +landblock-crossing step is a not-run, not a pass. diff --git a/docs/research/2026-08-04-c4-route-7-contract.md b/docs/research/2026-08-04-c4-route-7-contract.md new file mode 100644 index 00000000..3e7891c6 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-7-contract.md @@ -0,0 +1,985 @@ +# C4 route 7 — pickup / parent / delete: pinned contract (2026-08-04) + +**Scope:** make Runtime the sole writer of a parented child's canonical cell — +port retail `set_parent`'s attach-time `change_cell` half into the Runtime +parent commit, add retail's parent-cell-crossing **propagation step** at the +one canonical cell-write funnel, demote App's render-tick child rebucket +(`EquippedChildRenderController.TickChild`) to presentation-only, give the +headless host the parent-realize commit it has never had, adopt retail's +pickup ordering, and delete the dead `ClassifyLeaveWorld` classifier entry. +Route 7 performs **no placement**: there is no SetPosition, no park, no +service window, no leash on this route. + +Pinned at HEAD **`cff52c44`**, clean tree, branch +`claude/acdream-physics-divergence-5aa784`. **Line numbers in this contract +are as-of `cff52c44` and WILL go stale; every citation also names the symbol — +trust the symbol** (process rule 6). + +Predecessor documents, binding where they still apply: + +- [`2026-08-04-retail-parent-cell-propagation.md`](2026-08-04-retail-parent-cell-propagation.md) + — **the settling research. Its §10 contract requirements are BINDING and + restated in §0 below.** Do not re-derive the retail mechanism; it is read, + cited, and offset-verified there. +- [`2026-08-04-retail-child-cell-ownership.md`](2026-08-04-retail-child-cell-ownership.md) + — the earlier child-cell research: `set_parent` contains no cell write of + its own; `unset_parent` performs zero cell work; `leave_world` is where a + detaching child is scrubbed. +- [`2026-08-04-c4-routes-6-7-scoping.md`](2026-08-04-c4-routes-6-7-scoping.md) + §7 — the route scoping. **Its file:line references predate routes 4b-3/5, + the OnPosition collapse, and route 6's closure, and are stale throughout; + §10 of this contract lists every claim found false or superseded.** Its + trap list (T1–T8) survives and is resolved item-by-item below. +- [`2026-08-04-c4-route-5-contract.md`](2026-08-04-c4-route-5-contract.md) + plus its three dual review rounds — the contract standard, and the + recurring defect classes each addressed by name here: an App glue site + discarding the Runtime seam's status and advancing presentation on + write-nothing outcomes; unrecorded divergences (register rule 1); a pinned + obligation left unwired; zero coverage of the presentation layer; + negative-only tests. +- [`2026-08-04-c4-route-4b-3-contract.md`](2026-08-04-c4-route-4b-3-contract.md) + — its 13 "must REMAIN true" invariants; the ones route 7 can even reach are + re-asserted in §3. +- [`2026-08-04-session-handoff-c4-remaining.md`](2026-08-04-session-handoff-c4-remaining.md) + — the six process rules apply verbatim. Rule 1 (the contract causes the + defect), rule 4 (assert the layer that broke), and rule 5 (a clean session + is not a passed gate) are the load-bearing ones for this route. +- [`docs/plans/2026-08-02-placement-cutover.md`](../plans/2026-08-02-placement-cutover.md) + — the campaign plan. Its corrected route-7 gap statement ("the child's + canonical cell has two writers … the same defect seen from two sides") is + exactly this contract's scope; the plan also pins T8 (the `TryCommitParent` + `LeaveWorld` omission is retail-REQUIRED). + +**Sequencing:** routes 4a, 4b-1/2/3, the OnPosition collapse, route 5 +(`36255af0`), and route 6's zero-production closure (`1b484937`) are all in. +Route 7 is next; route 3 (portal) remains after it. + +--- + +## 0. Facts settled before this contract — BINDING, do not re-derive + +From `2026-08-04-retail-parent-cell-propagation.md` (all addresses verified +against `acclient_2013_pseudo_c.txt`, struct offsets closed by the +`acclient.h` walk — no PE byte-decode needed): + +1. **Retail re-cells children when the parent crosses a cell, recursively, to + unbounded depth.** `CPhysicsObj::SetPositionInternal` @0x00515330 branches + on `this->cell == curr_cell` @0x0051536d; the cell-CHANGED branch + @0x00515372 calls `change_cell` @0x00513390, which delegates to + `leave_cell` @0x0051339f / `enter_cell` @0x005133af. **`change_cell` + itself has NO child loop — the recursion lives in the delegates.** + `enter_cell` @0x00510ed0 self-recurses over children @0x00510f03 and + writes the FULL identity per level: `CObjCell::add_object` @0x00510ee2, + `objcell_id` @0x00510f1e, part-array cell id @0x00510f2b, `cell` pointer + @0x00510f35, lights @0x00510f3e. `leave_cell` @0x00510f50 mirrors it + @0x00510f84. **CORRECTION (post-implementation retail-conformance + review, R1 MAJOR): this enumeration silently dropped the guard AROUND + all five writes and the recursion itself — + `enter_cell`'s entire body is gated on `this->part_array != 0` + @0x00510ed8 (the propagation research's own §3 called this "Guard, + load-bearing"). A child with a null part array receives NONE of the + five writes and its whole subtree is skipped. This is the contract + defect the review traced R1 to — process rule 1, "the contract causes + the defect" — and it is why an implementer following this list alone + ships an unconditional write. See D2's AP-142 clause (d) for why the + guard has no reproducible analogue at acdream's canonical layer (its + `HasPartArray` field is populated only by the graphical mesh pipeline, + never headless) and is therefore recorded, not ported.** +2. **The depth-1 loop @0x0051539c–0x005153d8 is the SAME-CELL fast path, NOT + the propagation.** It refreshes only each direct child's `objcell_id` + (child `+0x4c` @0x005153bd) and part-array id @0x005153cc, deliberately + not the `cell` pointer, and only when the parent did NOT change cell. An + implementer who finds this loop first will wrongly conclude "depth-1, + id-only" and ship a stranded-child bug. **This contract says so + explicitly: the propagation is the `else` @0x00515372, not this loop.** +3. **The clincher:** `update_object` @0x00515d10 early-returns on + `parent != 0` @0x00515d40 — a child never runs its own physics tick, so + parent propagation is the ONLY mechanism that maintains a child's cell. +4. **The four binding requirements** (research §10): (i) write at attach AND + on every parent cell crossing; (ii) the authoritative write belongs on + the physics-commit path — Runtime's write must be a PROPAGATION STEP, not + a one-shot at `set_parent`; (iii) propagation is recursive — a depth-1 + implementation needs an explicit stated assumption plus a register row; + (iv) write the full identity — id-only leaves the #184 class half-closed. +5. **Two adjacent traps:** (a) child cross-cell/shadow lists are NOT + refreshed per parent tick — `SetPositionInternal` calls the non-recursive + `calc_cross_cells` @0x0051551b; the recursive `recalc_cross_cells` + @0x00515a30 runs only at attach (`set_parent` @0x00515b15). Do not + rebuild child shadow registrations per crossing. (b) On the removal path + (`change_cell` with a null target) retail leaves children with + `cell == nullptr` but a STALE non-zero `objcell_id` @0x005133c1 — + `leave_cell` never touches child ids. §4 D3 resolves how acdream's + single-field model maps this. + +From this contract's own HEAD verification: + +6. **acdream DOES use `FullCellId != 0` / `== 0` as a residency/liveness + predicate, pervasively** — 45+ sites, including + `RuntimeInitialCreateResidenceState:583` (residence admission), + `LiveEntityRuntime` `isOrdinaryRoot` (`:915-918`) and its two sibling + predicates (`:3213`, `:3323`), `LiveEntityPresentationController:220`, + `RuntimeSetPositionState:2985` (the lost predicate), + `HeadlessLocalPlayerFrameHost:87`, and route 4b-3's cell-less + classification input (`PreMergeCommittedCellId == 0` → the `SetPosition` + cell-less arm). The retail stale-id asymmetry (item 5b) therefore MUST + NOT be reproduced literally — see D3. +7. **Route 5 and route 6 landed after the scoping**, so the scoping's "route + 6 first" ordering and its campaign-plan correction are already satisfied + (`1b484937` corrected `docs/plans/2026-08-02-placement-cutover.md:97-116`). +8. **The canonical cell has exactly ONE funnel.** + `RuntimeEntityRecord.SetFullCell` (`RuntimeEntityRecord.cs:244-251`) has + exactly two callers: `RuntimeEntityDirectory.SetFullCell` + (`RuntimeEntityDirectory.cs:340-346`) and + `RuntimeEntityRecord.RefreshDerivedState` (`:230-242`), and + `RefreshDerivedState` is itself reached only from the record constructor + (`:29`, no children can exist yet) and + `RuntimeEntityDirectory.RefreshSnapshot` (`:231-238`). Every producer — + `CommitRebucket` (`RuntimeEntityObjectLifetime.cs:1863-1894`), + `RuntimePhysicsState.CommitCanonicalCell` (`:2138-2160`, fed by the + ordinary/remote/projectile simulation commits and the remote `writeCell` + binding `:958-960`), `RuntimeSetPositionState`'s four direct writes + (`:2743`, `:3660`, `:5001`, `:5222`), the withdrawal family + (`SetFullCell(canonical, 0u, 0u)` at `:1301`, `:1464`, `:1921`, `:2660`), + and the wire merge (`RefreshSnapshot` → `RefreshDerivedState`) — funnels + through the directory. This is what makes D2's single-chokepoint design + sound rather than a per-caller mapping (the 4b-3 review's "mapping + written against one caller's reachable set" defect class). +9. **The per-parent committed-children list already exists in Runtime.** + `ParentAttachmentState.ChildrenAttachedToParent(parentGuid, + parentInstanceSequence)` (`ParentAttachmentState.cs:623-633`) returns the + exact live CHILDLIST analog (doc comment already cites retail's live + CHILDLIST), maintained by `CommitProjection` (`:546-572`) / + `RemoveCommittedChild` (`:809-838`). Its two existing consumers are the + lost-family deadline arm/cancel (`RuntimeSetPositionState:6024-6036`, + `:6046-6060`). It allocates nothing on the read path (returns the stored + `List` or `Array.Empty`). +10. **Parented children's snapshots carry no Position.** + `InboundPhysicsStateController.ApplyParent` (`:1347-1373`) sets + `Position = null` (top-level AND PhysicsSpawnData); `ApplyAcceptedParent` + / `ApplyAcceptedCreateParent` are timestamp-only. So the wire merge's + `RefreshDerivedState` cell stamp (`Snapshot.Position is { } position`, + `RuntimeEntityRecord.cs:232`) cannot fire for a committed child and + cannot fight the propagation. P1 pins this with a test. + +--- + +## 1. Site inventory — re-located at `cff52c44` + +Every site verified by reading at HEAD, not inherited from the scoping. + +### 1.1 The Runtime commit family (`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`) + +| symbol | at HEAD (was, in scoping) | route-7 relevance | +|---|---|---| +| `TryApplyPickup` | `:1249-1312` (was `:1226-1245`) | pickup: gate → `RefreshSnapshot` → `ForgetInitialCreateResidence` `:1293` → `AdvancePositionAuthority` `:1294` → `CollisionReports.LeaveWorld` `:1295` → `SetPosition.Forget` `:1297` → `SuspendObjectClock` `:1300` → `SetFullCell(0,0)` `:1301` → `ParentAttachments.EndChildProjection` `:1302` → publish `Withdrawn`. **T7's inversion lives at `:1295-1302`** (leave-world work before the unparent). Dormant-residence deferral `:1255-1281`. | +| `TryApplyParent` | `:1336-1386` | accepts/stages the standalone ParentEvent; dormant-residence deferral `:1342-1379`; live path `Entities.TryApplyParent` → `CommitPositionChannelUpdate`. Untouched by this slice. | +| `TryApplyCreateParent` | `:1314-1334` | envelope flavor; untouched. | +| `TryCommitParent` | `:1388-1441` (was `:1360-1374`) | the parent-relation commit (retail `add_child`-success analog). Carries the C0-4(a) cancellation chokepoint `:1426-1431` and the **F4 deliberate `LeaveWorld` omission comment `:1418-1425`** (T8 — do not "fix"). `AdvanceParentCommit` `:1432`. **D1 does NOT add the re-cell here** — see D1 for why it lives on the cell-less commit's successor instead. | +| `CommitAcceptedParentCellless` | `:1443-1474` (was `:1377-1408`) | retail `set_parent`'s `leave_world` edge: cancellations → `CollisionReports.LeaveWorld` `:1462` → `SuspendObjectClock` `:1463` → `SetFullCell(0,0)` `:1464` → publish `Withdrawn`. **D1's extension point: the missing `parent->cell != 0` → `change_cell` half goes immediately after this edge.** | +| `TryApplyPosition`'s unparent edge | `EndChildProjection` at `:1838`, after `RefreshSnapshot` `:1830` | the Position-unparent (retail `HandleReceivedPosition`'s `unset_parent` @0x00454129). **Same inversion shape as T7 but on route 4's surface — recorded in §9 as a non-goal, NOT touched here.** Also carries 4b-3's `PreMergeCommittedCellId` measurement `:1801-1814` — see §11 for the cross-contract interaction. | +| `CommitRebucket` | `:1863-1894` | the App rebucket's canonical write; publishes `Rebucketed` on an actual cell change. After D4, no equipped-child caller remains. | +| `CommitWithdrawal` | `:1896-1929` (was `:1845-1860`) | withdrawal-to-cellless with the C0-4(b) symmetric cancellation; `SetFullCell(0,0)` `:1921`. D2's propagation covers its children automatically. | +| `TryAcceptDelete` | `:1973-2034` (was `:1939-1952`) | **`ParentAttachments.DeleteGeneration` runs at `:1996-1998`, BEFORE the active record retires (`RemoveActive` `:2005`), and the delete path performs NO `SetFullCell`** — so D2's chokepoint never fires for a deleted parent's children and D3's explicit delete edge must run before `:1996`. | +| `ForgetInitialCreateResidence` / `PreferCancellation` | `:2620-2637` / `:2639-2642` (was `:2552-2569` / `:2571-2574`) | unchanged by this slice. | +| `AcknowledgeProjectionAndPublish` | `:2255` on (was `:2187-2213`) | publication discipline: cancellation receipt first, then currency re-check, host ack, publish. Unchanged. | +| `CommitChildNoDraw` | `:1931-1950` | retail `set_parent`'s NoDraw inheritance — already ported; untouched. | + +### 1.2 The classifier (`src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs`) + +| symbol | at HEAD | relevance | +|---|---|---| +| `RuntimeLeaveWorldCause` | `:31-36` | deleted by D6. | +| `RuntimeLeaveWorldRouteRequest` | `:141-145` | deleted by D6. | +| `ClassifyLeaveWorld` | `:480-510` (was `:475-505`) | **ZERO production callers at HEAD** (re-verified: repo-wide grep returns the definition, one test at `RuntimeAuthoritativePositionRouteClassifierTests.cs:335-352`, and a comment at `RuntimeInitialCreateContinuationExecutorTests.cs:1672`). Deleted by D6, with the test. | +| `ValidCreateAuthority` | `:512-516` (was `:507-512`) | requires `PreviousTeleportSequence == AcceptedTeleportSequence` — the #307 predicate shape. **T3 verified first-hand:** no pickup/parent gate measures a teleport pair (`InboundPhysicsStateController.TryApplyPickup` `:180-186` gates on `TryAcceptPositionChannelEvent` — retail's POSITION stamp @0x0045224B analog; `TryApplyParent` `:263-271` adds only parent-instance currency; `TryCommitParent` `:308-314` re-checks POSITION_TS currency). Wiring the classifier would force a fabricated, vacuously-equal teleport pair. This is D6's second leg. `ValidCreateAuthority` itself SURVIVES (the create route uses it); only the leave-world consumer dies. | + +### 1.3 App (`src/AcDream.App`) + +| symbol | at HEAD | relevance | +|---|---|---| +| `EquippedChildRenderController.TickChild` | `Rendering/EquippedChildRenderController.cs:373-413`; the rebucket at `:406-408` (was `:405-408`) | the render-tick canonical writer: after pose composition succeeds, `_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId)`. **D4's demotion target — the ONLY `RebucketLiveEntity` call in the file (re-verified).** | +| `EquippedChildRenderController.PrepareAndTryRealize` | `:841-885` | the graphical realize protocol: `CommitStagedParent` (→ `TryCommitParent`) `:856` → `Relations.CommitProjection` `:857` → `CommitAcceptedParentCellless` `:869-871` → `WithdrawPriorProjection` `:875-881` → `TryRealize`. D1's attach re-cell rides inside the Runtime commit this already calls — the App protocol does not grow a fourth call. | +| `EquippedChildRenderController.ValidateParentProjection` | `:887-912` | retail `add_child` validation (Setup `HoldingLocations` via `_dats.Get`) — graphical-only today. D5's headless validation question. | +| `EquippedChildRenderController.ResolveRelations` | `:786-795` | drives `Relations.Resolve` with snapshot-lookup callbacks — the resolution shape D5's headless drive reproduces Runtime-side. | +| `LiveEntityRuntime.RebucketLiveEntity` | `World/LiveEntityRuntime.cs:801-977` (scoping's range still accurate) | the full legacy branch: spatial bucket + `CommitRebucket` `:904-907` + object-clock edges `:919-946` (whose own comment already states "parented/attached objects take retail update_object's parent early-out and remain suspended") + visibility publication. | +| `LiveEntityRuntime.RebucketLiveEntityPresentationOnly` | `:993-1065` (was `:993-1050`) | the C3c presentation-only shape: spatial bucket + visibility, **deliberately no `CommitRebucket` / clock work**, guarded by `BeginPresentationOnlySpatialMutation`. Private; sole caller `TryApplyInitialCreateCompletionPresentation` `:1107`. D4 adds the equipped-child entry point beside it. | +| `LiveEntityRuntime` wrappers | `TryApplyPickup` `:2312-2316`; `CommitStagedParent` `:2330-2336`; `CommitAcceptedParentCellless` `:2338-2363` (was `:2280-2312`) | the cell-less wrapper's doc (`:2338-2343`) still says "Commits retail `set_parent`'s cell-less edge" — accurate only for `parent->cell == 0`; D9 corrects it with D1. | +| `LiveEntityHydrationController.OnPickup` | `World/LiveEntityHydrationController.cs:460-474` (was `:455-474`) | `TryApplyPickup` then `_relationships.OnChildBecameUnparented` — App-level order unchanged by D7 (D7 reorders INSIDE the Runtime method). | +| `LiveEntityDeletionController` | `World/LiveEntityDeletionController.cs` | purely logical (re-verified: no placement/cell API). Untouched. | + +### 1.4 Headless (`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`) + +| symbol | at HEAD | relevance | +|---|---|---| +| `OnParentUpdated` | `:312-316` (was `:313-317`) | calls ONLY `Entities.TryApplyParent` — stages the relation forever. **Neither `TryCommitParent` nor `CommitAcceptedParentCellless` has any headless caller (re-verified: the only production callers are `EquippedChildRenderController.cs:856/:869` and the `LiveEntityRuntime` wrappers).** D5's insertion point. | +| `OnPickedUp` / `OnDeleted` | `:176-180` / `:155-174` | thin pass-throughs; correct as-is. | + +### 1.5 Confirmed-clean (scoping §7.3, re-verified at HEAD) + +All six cancellation choke points live and symmetric (`:1141`, `:1293-1299`, +`:1426-1431`, `:1456-1461`, `:1913-1919`, `:2007-2017`); ordering correct +(`AcknowledgeProjectionAndPublish` publishes the cancellation first); +receipts host-visible (`RuntimeSetPositionState.PublishCancellation` → +`PublishPlacement`, consumed by both graphical sinks and +`HeadlessRuntimePlacementProjectionSink`); pickup/parent during a pending +residence defer as dormant continuations; grep for +`SnapToCell|CommitRebucket|SuspendObjectClock|SetFullCell` across +`src/AcDream.App` + `src/AcDream.Headless` still returns no +pickup/parent/delete site outside the inventoried ones. + +--- + +## 2. Retail ground truth — verified for this contract; verify again yourself + +| claim | anchor | status | +|---|---|---| +| Pickup = `unset_parent` + `leave_world`, gated ONLY on the POSITION stamp; no placement, no rejection path that skips them once the stamp accepts | `SmartBox::DoPickupEvent` @0x00452240: gate @0x0045224B-0x00452274, stamp write @0x00452278, `unset_parent` @0x0045227F, `leave_world` @0x00452286 | **CORRECTED (retail-conformance review):** the gate span itself is UNVERIFIABLE from this source — Binary Ninja lowered both `DoPickupEvent`'s and `DoParentEvent`'s gate comparisons to a literal always-false expression (`if (-((eax_4 - eax_4)) != 0)`), losing the x87/flag-based wrapped-sequence compare. Only the gate's SHAPE (a wrap-aware sequence compare against `update_times[0]`) and the ORDER of the writes after it are legible — the write order is what D7 relies on and remains ✓. Was previously marked "✓ (scoping §7.2, re-read)", which overstated what the source supports. | +| Parent = `set_parent` + `SetPlacementFrame`; `SetParentedState(1)` for a non-player parent gaining its first child | `SmartBox::DoParentEvent` @0x00452290: gate @0x00452296-0x004522C5, @0x004522F4, `set_parent` @0x00452305, `SetPlacementFrame` @0x00452313 | **CORRECTED, same basis as the row above:** the gate span is unverifiable from this source (same BN lowering artifact); the post-gate write order is legible and unaffected. D6's argument does not depend on this citation either — it rests on acdream already enforcing the POSITION_TS gate in `InboundPhysicsStateController` (verified directly), not on retail's gate expression being readable. | +| `set_parent` order: `add_child` success → `unset_parent` @0x00515ABA → **one** `leave_world` @0x00515AC1 → `parent =` @0x00515AC6 → `if (parent->cell != 0)` @0x00515AD1 → `change_cell` @0x00515AD6 → `UpdateChild` @0x00515B0E → `recalc_cross_cells` @0x00515B15 → NoDraw inheritance @0x00515B26-38 | `CPhysicsObj::set_parent` @0x00515A90 (4-arg overload @0x00515B50 same shape) | ✓ | +| `unset_parent` performs ZERO cell work: `remove_child` → NoDraw restore → `parent = null` → `update_time` → `clear_transient_states` | @0x00513470 (@0x00513484/@0x005134AC/@0x005134BF/@0x005134CE) | ✓ (child-cell-ownership doc §4) | +| `leave_world` scrubs the detaching object: `remove_shadows_from_cells` @0x005155DD, recursive `leave_cell(this, 0)` @0x005155E6, zeroes only ITS OWN `objcell_id` @0x005155F4 | `CPhysicsObj::leave_world` @0x005155A0 | ✓ | +| The propagation mechanism and its cadence — §0 items 1–3 | @0x00515330 / @0x00513390 / @0x00510ed0 / @0x00510f50 / @0x00515d40 | ✓ (settling research; binding) | +| Delete order: `exit_world` @0x0050846B + `leave_world` @0x00508472 run BEFORE `unparent_children` @0x005084B9 — children's cells are nulled by the recursion while still attached, then unparented with no cell restore | `CObjectMaint::DeleteObject` @0x00508460 (same pattern in `DestroyObjects` @0x00508C30) | ✓ | +| Cross-cell/shadow: `recalc_cross_cells` @0x00515A30 recurses children @0x00515A79 but runs only at attach; the per-move tail calls the non-recursive forms only @0x0051551B/@0x0051553E-4C | settling research §8 | ✓ (binding trap 5a) | +| Route 7 never reaches `HandleReceivedPosition` @0x00453FD0 — `DoPickupEvent`/`DoParentEvent` are separate wire handlers; the single remote `ConstrainTo` arm @0x00454272 is unreachable from this route | scoping T1, re-affirmed | ✓ — the basis of D8 | + +--- + +## 3. What must REMAIN true (process rule 1 — for every path, including every refusal) + +1. **A committed child's canonical `FullCellId` equals its parent's at every + stable observation point** — after attach (parent celled), after every + parent cell crossing (any writer: simulation commit, rebucket, canonical + placement, wire merge), after parent teleport, in BOTH hosts. This is the + route's headline invariant and the headless gate's assertion (it fails + today). +2. **The child never becomes a self-simulating object.** Its `ObjectClock` + stays suspended, it is never a spatial root, it joins no physics workset, + and the propagation path never changes any of that (retail + `update_object`'s `parent != 0` early-out @0x00515D40; the existing + comment at `LiveEntityRuntime.cs:919-922` already states this rule for + the App side). +3. **No placement machinery engages on this route.** No + `RuntimeSetPositionState` operation, no park, no `DeferredCell`, no + service-window pre-flight, no ledger entry — the child cell write is + retail `change_cell`: a direct identity write, not a placement (T6). The + `ParkCollisionResidents` overlap throw stays unreachable and + `RemotePlacementDrivePendingCount` is unaffected by any number of + attach/crossing/withdraw events (4b-3 invariants 9/10 extended). +4. **`ConstrainTo` is NEVER armed by route 7** — not at attach, not at + pickup, not at delete, not on any child, regardless of what routes + 2/4a/4b/5 established for their arms (T1; D8's partition). +5. **The six cancellation choke points and their ordering are unchanged**: + exactly-once `ForgetInitialCreateResidence` → `SetPosition.Forget` → + `PreferCancellation`, receipt published before the entity delta. +6. **`TryCommitParent` keeps exactly zero `CollisionReports.LeaveWorld` + calls** (T8; the F4 comment at `:1418-1425` and the campaign plan both + pin it — retail `set_parent` has ONE `leave_world` @0x00515AC1, and it is + the cell-less commit's edge in acdream's staged protocol). +7. **Presentation still advances, and is asserted** (process rule 4 / #312's + layer): the equipped child renders in the hand, follows the parent across + cell boundaries with no frame where it is bucket-stranded, disappears + cleanly on unwield/pickup, and its collision leaves the world with it. A + child must never be invisible-but-solid (#184) or solid-but-invisible. +8. **No per-crossing child shadow/cross-cell rebuild** (§0 trap 5a). The + child's broadphase state is established at attach/unparent edges only. +9. **The dormant-residence deferrals are untouched**: pickup/parent arriving + during a pending initial residence still enqueue + `RuntimeInitialCreateContinuationKind.Pickup`/`Parent` continuations and + replay through the executor (`RuntimeInitialCreateContinuationExecutor`'s + parent replay `:2110-2126`, which already routes through + `RuntimeEntityObjectLifetime.TryCommitParent`). +10. **Route 1/2/4/5 classification inputs and dispositions are byte-identical.** + Route 7 deletes `ClassifyLeaveWorld` (zero production callers) and + changes NOTHING else in the classifier — `ClassifyCreate`'s + `Parented`/`PickedUp` residence handling, `ValidCreateAuthority`'s + create-route use, and every accepted-position branch stay untouched. + Zero expectation changes in surviving classifier tests is the tripwire. +11. **AP-135's writes, AP-131, #276, and #316 are untouched** (§9). +12. **The lost-family deadline enumeration keeps working**: + `ArmLostFamilyDeadlines`/`CancelLostFamilyDeadlines` read + `ChildrenAttachedToParent` — D2/D3 change nothing about relation + lifetime, only cell values. +13. **Ledger convergence**: teardown, session reset, and generation change + with committed children present (attached, mid-crossing, mid-unparent) + converge the combined ownership ledger to zero — the J-series suites' + shape, driven through the new edges. + +--- + +## 4. Design decisions — pinned, not open for redesign + +### D1 — the attach half: Runtime completes retail `set_parent`, on the cell-less commit + +Retail's attach sequence (§2 row 3) ends with `if (parent->cell != 0) +change_cell(this, parent->cell)`. acdream's realize protocol today ends at +the `leave_world` edge (`CommitAcceptedParentCellless`) and lets a render +tick supply the re-cell. Pinned: + +- **`CommitAcceptedParentCellless` (or a successor commit it becomes part + of) gains retail's second half**: after the cell-less edge's existing + writes, if the PARENT's canonical record is active and + `parent.FullCellId != 0`, write the child's full canonical cell identity + to the parent's exact values (`FullCellId`, `CanonicalLandblockId`) — + through the same D2 write path, so attach and crossing are one mechanism, + not two. If the parent is cell-less (retail `parent->cell == 0` + @0x00515AD1), the child stays cell-less — exactly today's behavior, now + by the retail-cited gate instead of by omission. +- **Both halves are ONE synchronous Runtime transaction.** No caller may + observe the child cell-less between the edge and the re-cell within the + same call; no deferred continuation may interleave. The method needs the + parent's identity to do this — the natural source is the committed + relation (`ParentAttachmentState.TryGetProjection`/`_lastAcceptedByChild` + via a lookup, or a parent parameter threaded from the caller, both of + which the realize protocol and the executor's parent replay already + hold); implementer's choice, pinned constraint: the parent must be + resolved by (guid, incarnation) currency, never by guid alone. +- **Why not inside `TryCommitParent`?** Retail's cell write follows the + `leave_world` (@0x00515AC1 precedes @0x00515AD6). acdream's protocol + splits `set_parent` across `TryCommitParent` (relation commit) then + `CommitAcceptedParentCellless` (leave-world edge); the re-cell belongs + after the second, preserving retail's order. Putting it in + `TryCommitParent` would re-cell BEFORE the leave-world edge zeroes it — + a self-defeating order. The executor's deferred parent replay and the + graphical realize both already call the pair in this order; D5's headless + drive calls the same pair. +- `UpdateChild` (frame composition) and NoDraw inheritance remain where + they are (App pose composition; `CommitChildNoDraw`) — unchanged. +- `recalc_cross_cells` @0x00515B15: acdream's analog at attach is the + EXISTING behavior (the child's collision reports were force-ended by the + cell-less edge; no child broadphase registration exists to rebuild). + Pinned: **no new cross-cell/shadow machinery is built at attach**, and P4 + requires the implementer to state the child's actual broadphase state at + each edge with the retail anchor. + +### D2 — the sustaining half: propagation at the one canonical-cell funnel + +**The load-bearing decision.** Retail's trigger is "every mechanism that +changes the parent's cell" — in retail that is one function (`change_cell`); +in acdream the analog is the one funnel every canonical cell write already +passes through (§0 item 8). Pinned: + +- **The propagation hook lives at the directory funnel** — inside + `RuntimeEntityDirectory.SetFullCell` and the `RefreshSnapshot` → + `RefreshDerivedState` derived write (either by routing the latter through + the former or by hooking both; implementer's structural choice, pinned + outcome: **no canonical cell write can bypass the hook**). Per-committer + hooks (≥8 sites) are REJECTED — that is the "mapping written against one + caller's reachable set" defect class, and one missed site is a stranded + child. +- **The step:** when a record's `FullCellId` changes and + `ParentAttachments.ChildrenAttachedToParent(record.ServerGuid, + record.Incarnation)` is non-empty, write each active committed child's + canonical cell to the parent's new exact values, **recursively** (a + child's own committed children follow — retail `enter_cell`/`leave_cell` + self-recursion, §0 item 1). Depth-1-only is NOT acceptable without an + explicit stated assumption plus a register row (§0 item 4.iii) — and + since recursion here is a dictionary probe per level, ship the recursion. +- **Termination and idempotence:** skip a child whose `FullCellId` already + equals the target value. This terminates any wire-induced relation cycle + (self-parenting is already rejected at + `EquippedChildRenderController.ValidateParentProjection:890-891`, but + A→B→A via wire must still terminate), avoids spurious + `SpatialAuthorityVersion` churn, and subsumes retail's same-cell depth-1 + id refresh (§0 item 2): with one field playing both retail roles, a + same-value restamp is unobservable, so the same-cell fast path needs no + separate mechanism. **This equivalence is a stated assumption of the + single-field model and rides in D9's register row.** +- **What the step writes:** the child's canonical `FullCellId` + + `CanonicalLandblockId` — acdream's full canonical identity (§0 item 6: + the field IS the residency predicate). What it must NOT do: no clock + changes, no workset/spatial-root changes, no shadow work, no placement + operations, no `CollisionReports` calls, no App callbacks. Field writes + plus version bumps only — safe to run re-entrantly inside a + `RuntimeSetPositionState`/`RuntimePhysicsState` transaction that is + mid-commit on the parent (P5). +- **Publication:** per-child lifetime deltas are NOT published from the + propagation step, matching the physics-commit precedent + (`RuntimePhysicsState.CommitCanonicalCell` publishes no lifetime delta; + it fires `CellCommitted`, which is parent-scoped and unchanged). The + attach re-cell (D1) rides inside a commit that already publishes; the + crossing propagation is silent. **Uniformity note:** today's TickChild + path DID publish `Rebucketed` deltas for children via `CommitRebucket` + (`:1889-1893`); D4 removes those. P8 requires enumerating `Rebucketed` + consumers and confirming none needs a per-child delta — if one does, flip + this default and publish uniformly from both D1 and D2, and say so in the + commit. +- **Allocation:** 0 B on the propagation path (the children list is the + stored list; recursion uses the call stack or a pre-sized scratch — + Slice I discipline). +- **Do not** propagate to `_stagedByChild`/`_recoveryByChild`/unresolved + relations — retail's CHILDLIST holds committed children only, and + `ChildrenAttachedToParent`'s own doc already pins this ("must not capture + staged, unresolved, or future-generation relations"). + +### D3 — the withdrawal and delete edges (resolves §0 trap 5b under the single-field model) + +Retail's removal behavior: `leave_cell` recursion nulls each child's `cell` +pointer but leaves a stale non-zero `objcell_id`; the functional state is +"not resident anywhere." acdream has ONE field, and that field is the +residency/liveness predicate at 45+ sites (§0 item 6). Pinned: + +- **Withdrawal propagates zero.** A parent's `SetFullCell(0,0)` (pickup + `:1301`, `CommitWithdrawal` `:1921`, cell-less parent commit `:1464`, + residence re-begin `:2660`) flows through D2's chokepoint like any other + value: committed children (and their subtrees) go cell-less. This is the + functional mapping of retail's recursive `leave_cell` — the retail + stale-id residue is NOT reproduced, because reproducing it would leave a + child "resident" per every acdream predicate while retail's own gating + field (`cell == nullptr`) says it is not. **The id/pointer collapse and + this deliberate non-reproduction are recorded in D9's register row.** +- **Delete gets an explicit edge.** `TryAcceptDelete` performs no + `SetFullCell`, and `ParentAttachments.DeleteGeneration` (`:1996`) removes + the relations before the record retires — so the chokepoint alone leaves + a deleted parent's children stranded at a stale non-zero cell, which + under acdream's predicates means "still resident" (the #184 shape, until + each child's own DeleteObject arrives). Pinned: **before + `DeleteGeneration` runs, the delete path applies the children's + leave-world edge** — for each active committed child of the exact deleted + incarnation (recursively), cell-less via the same D2 write path. Retail + order anchor: `DeleteObject`'s `leave_world` @0x00508472 runs before + `unparent_children` @0x005084B9, i.e. children are still attached when + the recursion nulls their cells. The children's RELATIONS are then torn + down by the existing `DeleteGeneration` exactly as today; the children's + own records stay alive awaiting their own wire terminal (retail: + `unparent_children` does not destroy children either). +- `EndGeneration` (`ParentAttachmentState:665-694`, the replacement- + generation path) — same stranding shape, same fix, same edge, applied at + its Runtime call site (`RuntimeEntityObjectLifetime:1000`). + +### D4 — the App demotion: TickChild becomes presentation-only (resolves T5) + +- `EquippedChildRenderController.TickChild:406-408` stops calling the + public `RebucketLiveEntity` and calls a new **internal equipped-child + presentation rebucket** on `LiveEntityRuntime` — the + `RebucketLiveEntityPresentationOnly` shape (`:993-1065`: spatial bucket + move, visibility resolution, presentation refresh, visibility-change + publication, `BeginPresentationOnlySpatialMutation` guard), with + **deliberately no `CommitRebucket`, no clock edges** — because after + D1/D2, Runtime already owns the canonical commit, which is exactly the + C3c precondition that method's doc demands for presentation-only use. + (The C3c-R1 R2 warning at `:824-832` — "post-residence moves take the + full legacy branch" — does not apply: it protects entities whose ONLY + cell authority would otherwise be the graphical rebucket; an equipped + child's authority is now the D1/D2 Runtime write.) +- The entry point is child-scoped (assert the record has a committed parent + relation, or is called only from the equipped-child controller) so it can + never become a general bypass of the legacy branch. +- **T5's regression risk is the acceptance test, not a reason to keep the + old writer:** route 4a's R1 showed that dropping the bucket move leaves an + entity body-correct but draw-bucket-stale (invisible-but-solid). The + demoted call MUST still move the graphical bucket every time the parent's + `ParentCellId` changes — TickChild's existing cadence (per recomposition, + with `ParentPresentationMatches`/`CaptureParentPresentation` change + detection on `LastParentCellId`, `:426-446`) already provides the + trigger; only the canonical half is removed. The connected gate's + carry-across-landblock step plus the dual-layer tests (§6) enforce it. +- All other TickChild effects (pose composition, `ParentCellId` mirror, + draw-visibility inheritance, `PublishChildPose`, `ProjectionPoseReady`) + are untouched. `WorldEntity.ParentCellId` remains presentation (AP-133's + split is not re-litigated). + +### D5 — the headless parent-realize drive + +`RuntimeLiveEntitySessionController.OnParentUpdated` (`:312-316`) grows the +realize that headless never had: after `TryApplyParent` accepts/stages, +resolve the staged relation (the `ParentAttachmentState.Resolve` + +`TryGetStagedProjection` protocol `ResolveRelations` demonstrates — +snapshot-known + instance-currency callbacks, all Runtime-readable) and run +the SAME commit pair the graphical protocol runs: `TryCommitParent` → +`CommitAcceptedParentCellless`-with-D1. Also drive the deferred/recovery +retry the graphical controller performs on parent arrival (children waiting +for a parent that appears later — `OnSpawned`'s projection path), to the +extent the direct host receives those events; state what is deliberately +not driven (pose composition, which is presentation and does not exist +headless). + +**The validation gap, pinned rather than discovered later:** retail's +`add_child` validates the holding location against the parent's Setup +(`CSetup::GetHoldingLocation` @0x0050F896); the graphical host ports this +via `ValidateParentProjection`'s DAT read. The headless host reads prepared +collision content, which does not expose `Setup.HoldingLocations`. Pinned: +**the headless drive commits on gate acceptance + relation resolution +alone, skipping the holding-location validation, recorded as a register row +in the same commit** (a server-sent invalid location would attach headless +where retail/graphical reject — unreachable against a well-behaved ACE, but +a divergence and it gets its row; precedent: the content-less host's +documented reduced-fidelity registration at +`RuntimeLiveEntitySessionController:108-117`). If the reviewer finds +`HoldingLocations` cheaply exposable through existing prepared content, that +retires the row — but do NOT extend the bake format for it in this slice +(stop-and-report if that seems required). + +### D6 — `ClassifyLeaveWorld` is DELETED (resolves T2, informed by T3) + +Delete `ClassifyLeaveWorld` (`:480-510`), `RuntimeLeaveWorldRouteRequest` +(`:141-145`), `RuntimeLeaveWorldCause` (`:31-36`), and the one pinning test +(`RuntimeAuthoritativePositionRouteClassifierTests:335-352`). Rationale, +recorded in the commit: + +- **Retail has no classification here.** `DoPickupEvent` @0x00452240 and + `DoParentEvent` @0x00452290 are separate wire handlers dispatching + directly; they never reach `HandleReceivedPosition`. Method-per-cause in + `RuntimeEntityObjectLifetime` IS the retail shape — the scoping's worry + ("the cause discriminator is implicit in which method the caller picked") + describes retail's own dispatch, not a defect. +- **The only gate retail has is the POSITION stamp**, and acdream already + enforces exactly that, in Runtime, at + `InboundPhysicsStateController.TryApplyPickup/TryApplyParent/TryCommitParent` + (§1.2). Wiring the classifier would ADD a second gate + (`ValidCreateAuthority`'s teleport-pair equality) that no pickup/parent + path can honestly populate (T3, verified) — a vacuous-or-wrong predicate + with the #307 defect shape, plus T2's rejected-classification + silent-pickup-drop hazard, for zero behavioral gain. +- This closes the scoping's "wire it or delete it" demand in the direction + the evidence points; the scoping's lean ("wire it") predates the T3 + verification and is overridden with cause (§10). + +### D7 — pickup ordering adopts retail's (resolves T7) + +`TryApplyPickup` reorders to retail's `unset_parent`-then-`leave_world`: +`ParentAttachments.EndChildProjection` moves ahead of +`CollisionReports.LeaveWorld` → `SetPosition.Forget` → `SuspendObjectClock` +→ `SetFullCell(0,0)` (anchors @0x0045227F before @0x00452286). The +cancellation sequence, `AdvancePositionAuthority`, and the publication +discipline are unchanged. Verified inert against the new machinery: the +picked-up entity's own D2 propagation consults ITS children, not its +relation to its parent, so the reorder cannot change propagation; no +in-between callback exists (`AcknowledgeProjectionAndPublish` runs after +both). This retires the recorded inversion instead of carrying the "not +proven inert" caveat forward. **The sibling inversion on the +Position-unparent edge (`TryApplyPosition:1830/:1838`) is route 4's surface +and is NOT touched — recorded in §9.** + +### D8 — the inverse-leash partition, and the guards that do NOT come along + +The route-7 column of the campaign's constraint-arm partition — stated so an +implementer arriving from 4b-2/4b-3/5 ("arm on nonzero return, on every +placement outcome") cannot carry the rule across: + +| event | retail path | `ConstrainTo`? | placement? | distance/snap guards? | +|---|---|---|---|---| +| pickup | `DoPickupEvent` — never reaches `HandleReceivedPosition` | **never** | none | none | +| parent (attach) | `DoParentEvent` — same | **never** | none — `change_cell` is an identity write | none | +| parent cell crossing (propagation) | `SetPositionInternal` child handling — the PARENT's own route arms whatever ITS route arms; the child arms nothing | **never (for the child)** | none | none | +| delete | `DeleteObject` | **never** | none | none | + +Explicitly NOT imported (T4/T6): AP-87's 4 m `BodySnapThreshold`, the 96 m +`MaxPhysicsDistance`, `MoveOrTeleport`'s near/far split, 4b-1's +service-window machinery, and any `CanAttemptDestination` pre-flight — +pickup/parent/delete have no distance concept and no deferrable Core +condition (retail's `change_cell` runs no sweep and no `AdjustPosition`). +If an implementer finds a reason a child cell write CAN defer, that is a +new finding: stop and report. + +### D9 — register and comment bookkeeping, in the implementation commit + +- **ONE new AP row — the parented-child cell model** (three clauses, all + intentional-architecture): (a) acdream collapses retail's + `cell`-pointer/`objcell_id` pair into one canonical `FullCellId` that is + also the residency predicate; consequently (b) the removal path + propagates ZERO to children where retail leaves a stale non-zero + `objcell_id` under a null pointer (@0x005133C1 / `leave_cell`'s absent id + write — deliberate non-reproduction, D3), and (c) retail's same-cell + depth-1 per-tick id refresh (@0x005153BD) is subsumed by the + value-idempotent chokepoint (D2) rather than ported as a tick loop. + Anchors: @0x00513390, @0x00510ed0, @0x00510f50, @0x0051539c-@0x005153d8, + @0x00515d40. +- **ONE new AP row — headless holding-location validation skip** (D5), if + the reviewer confirms no cheap prepared-content read exists. +- **AP-136's writer list shrinks**: "the equipped-child renderer + `EquippedChildRenderController.TickChild`" dies as a canonical rebucket + writer (register line ~287); the surviving non-Position rebucket writer + is the projection materializer alone. Update the row and the two doc + comments that carry the same claim: + `RuntimeSetPositionState.cs:4543` and + `RuntimeRemotePlacementDriveController.cs:1617`. +- **Comment corrections** (process rule 6, each verified against the code + beside it): `LiveEntityRuntime.CommitAcceptedParentCellless`'s doc + (`:2338-2343`) — "commits retail set_parent's cell-less edge" gains the + D1 second half; the same class's `RebucketLiveEntityPresentationOnly` doc + ("called ONLY from `TryApplyInitialCreateCompletionPresentation`") + updates for the D4 entry point; `TickChild`'s surroundings; the + `ParentAttachments.EndChildProjection` doc ("after Pickup or a world + Position") if D7's reorder makes its phrasing stale; grep + `TickChild|CommitAcceptedParentCellless|RebucketLiveEntity` across + `src/` + `docs/architecture/` and re-point every survivor. +- **No row deletion.** AP-124, AP-131, AP-132 (queued-parent incarnation + gating), AP-133, AP-135 all survive untouched. +- **ISSUES.md**: none closed by this slice unless the implementer finds the + headless child-cell defect has a filed number (none found at HEAD — it is + recorded only in the campaign plan's gap list; update that list's wording + when this lands). + +--- + +## 5. Proof obligations (must prove, not assume; stated in the implementation commit) + +- **P1 — no merge fights the propagation.** For a committed child, every + snapshot-mutation path (ObjDesc, motion, state, PVP bitfield, parent + re-commit) leaves `Snapshot.Position` null (§0 item 10), so + `RefreshDerivedState` never stamps a child cell from its own snapshot. + One test drives each mutation family against an attached child and + asserts the canonical cell still tracks the parent. +- **P2 — the child stays parent-suspended.** After attach, after ten + crossings, and after a parent teleport: child `ObjectClock` suspended, + not a spatial root, in no workset, no `RemoteMotion`, body (if any) + inactive. (Invariant 2; retail @0x00515D40.) +- **P3 — no placement-ledger engagement.** Attach/crossing/withdraw/delete + sequences leave `RemotePlacementDrivePendingCount`, SetPosition operation + counts, and park counts at their prior values. +- **P4 — the child broadphase story, stated.** What is a child's + shadow/broadphase registration at attach, across crossings, at + unparent-by-Position, at pickup, at parent delete? The implementer writes + the answer down with the retail anchors (`leave_world`'s + `remove_shadows_from_cells` @0x005155DD; `recalc_cross_cells` at attach + only; §0 trap 5a) and confirms the propagation path performs zero shadow + work. If a gap is found (e.g. a child shadow that should exist and does + not), it is FILED, not silently fixed in this slice. +- **P5 — re-entrancy safety of the chokepoint.** The propagation runs + inside whatever transaction wrote the parent's cell + (`RuntimeSetPositionState` placement commit, `RuntimePhysicsState` + simulation commit, `CommitRebucket`, the wire merge). Because it is + field-writes-only (D2), it cannot re-enter those owners. Prove with a + focused test per writer family plus the existing reset/reentrancy suites + green. +- **P6 — zero allocation** on the propagation path (the Slice I + discipline): a warmed crossing with N children allocates 0 B. +- **P7 — delete-edge ordering.** The children's leave-world edge reads + `ChildrenAttachedToParent` BEFORE `DeleteGeneration` removes the + relations; a test deletes a parent with an attached (and a + grand-attached) child and asserts both went cell-less. +- **P8 — publication consumers.** Enumerate `RuntimeEntityChange.Rebucketed` + consumers; confirm none requires the per-child deltas TickChild's + `CommitRebucket` used to produce, or flip D2's publication default and + say so. (This is route 5's A1 lesson applied prospectively: the App/host + layer must be shown to tolerate the Runtime seam's chosen silence.) + +--- + +## 6. Test plan + +Rules (route 5 §7's, verbatim where they apply): assert the layer that +historically broke — presentation and canonical cell, not only +`InWorld`/clock; assert positive facts, not only negatives; every new test +must fail against a broken implementation (no source-text pins). The +**dual-HOST discipline** is this route's analog of route 5's dual-kind +theories: every Runtime-level scenario runs against the Runtime owners +directly (headless-shaped) AND through the graphical wrappers, asserting +the same canonical outcome — that is what makes the headless gap a failing +test rather than a host-specific accident. + +Focused Runtime tests (`tests/AcDream.Runtime.Tests`): + +1. **Attach, parent celled** (D1): commit pair on a child whose parent has + `FullCellId = A` → child ends at A (positive), `Withdrawn`-then-re-celled + within one call (no observable cell-less escape), collision reports + force-ended, clock suspended, POSITION_TS consumed. Companion: parent + cell-less → child stays cell-less (the @0x00515AD1 gate), and a LATER + parent cell commit re-cells the child through D2 (the deferred-attach + catch-up retail gets for free from propagation). +2. **Crossing propagation per writer family** (D2): parent cell changed via + (a) `CommitRebucket`, (b) `RuntimePhysicsState.CommitCanonicalCell` + (simulation commit), (c) a canonical placement commit + (`RuntimeSetPositionState`), (d) the wire merge (`RefreshSnapshot` with + a Position) → child follows in every case; grandchild follows + (recursion); a cycle (A→B committed both ways by hostile wire) terminates. +3. **Same-cell idempotence**: a parent commit to its CURRENT cell leaves + child `SpatialAuthorityVersion` unchanged (the D2 short-circuit, + positive form: the child was already correct). +4. **Withdrawal edges** (D3): pickup of the parent, `CommitWithdrawal` of + the parent, and residence re-begin each zero the child (and grandchild); + delete of the parent zeroes children BEFORE relations vanish (P7); + `EndGeneration` same. +5. **Pickup of the child itself** (D7): relation removed before the + leave-world writes (order pinned via the relation table's state at the + cell write — e.g. a propagation-visible probe or the committed-children + list emptiness at `SetFullCell(0,0)` time), cell zeroed, clock + suspended, `Withdrawn` published with the cancellation receipt first — + and the entity's own children (if any) went cell-less too. +6. **Never-arm partition** (D8): after attach + five crossings + pickup + + delete, no constraint/`PositionManager` state exists for parent or child + beyond what the parent's OWN route had already armed; arm counts + unchanged by every route-7 event. +7. **No-placement invariant** (P3) and **ledger convergence** (invariant + 13): teardown/reset/generation-change with children attached, + mid-crossing, and mid-unparent. +8. **Headless parent-realize** (D5): through + `RuntimeLiveEntitySessionController.OnParentUpdated` with a live + directory: staged → committed → celled at the parent's cell — **the + test named by the route-6 scoping as failing today** (child canonical + `FullCellId == parent's` at a stable checkpoint), now in-tree and green; + plus the deferred flavor (parent arrives after the relation). +9. **Classifier deletion** (D6): surviving classifier tests byte-identical + (zero expectation changes — the §3 item 10 tripwire). + +App-layer tests (`tests/AcDream.App.Tests`): + +10. **The demotion keeps presentation whole** (T5/#184's layer): drive the + realize + a parent cell change through the graphical stack; assert the + child's render entity moved buckets (spatial index / visibility state), + `ParentCellId` mirrors the parent, AND the canonical cell was written + by Runtime (not by the presentation path — assert + `CommitRebucket` was not the writer, e.g. via the presentation-only + guard). **Sabotage check (manual, TWO runs — corrected at the + architecture review, A1): break D2's propagation and confirm THIS test + fails on the CANONICAL half while presentation still moves; separately, + stub out the presentation rebucket call (`RebucketEquippedChildPresentation`) + and confirm THIS SAME test fails on the PRESENTATION half while the + canonical cell is still correct.** The first implementation round ran + only the first of these two and shipped an assertion + (`child.WorldEntity.ParentCellId`) that TickChild writes unconditionally + before the demoted call runs — satisfied whether or not the demoted + call executes at all — so the presentation half had zero effective + coverage despite the contract asking for it. If EITHER sabotage run + leaves the test green, the test is asserting the wrong layer; fix the + test. Assert against the actual spatial bucket (e.g. a landblock + membership query), not a mirror field TickChild writes elsewhere. +11. **Unwield/pickup teardown**: after pickup, the child's projection is + gone, no bucket residue, no shadow residue (the invisible-but-solid + regression assert, stated positively: the cell is 0, the projection + withdrew, the relation is gone). +12. **P8's publication check** as a test where feasible (a consumer-facing + assertion that the graphical host converges without per-child + `Rebucketed` deltas). + +--- + +## 7. Gates + +- **Focused**: the §6 suites, green. +- **Complete Release suite**: + `$env:ACDREAM_PAK_PATH = "$env:USERPROFILE\Documents\Asheron's Call\acdream.pak"`, + `dotnet test AcDream.slnx -c Release -m:1`. **Baseline 11,063 passed / 4 + skipped / 0 failed at `cff52c44`.** The count will move (one classifier + test deleted, new suites added) — measure and record the new figure; do + not inherit the baseline. Two known flakes, never chase and never + conflate: **#302** (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, + GC-allocation assertion, App.Tests) and **#308** + (`NakEmissionTests.LossSoak_…`, wall-clock deadline, Core.Net.Tests, + full-suite load only). If either appears, re-run and say which. +- **Connected two-client gate (user-run) — this route IS user-visible.** + Probe: `ACDREAM_PROBE_CHILD_CELL=1`, `PhysicsDiagnostics`-owned, marked + TEMPORARY with the existing probe family; one `[child-cell]` line per + Runtime child-cell write with parent guid, child guid, old→new cell, and + cause (`attach` / `propagate` / `withdraw` / `delete` / + `headless-attach`). **A session counts as a pass ONLY if the probe shows + the propagation executed** (process rule 5; 4b-3's gate precedent — a + clean-looking session with zero `cause=propagate` lines during step 2 is + a not-run). Recipe (scoping §7.7, carried): + 1. Equip/unequip cycle — weapon then shield, five times, observer + watching: in the hand, at the hand, oriented with the hand, clean + disappearance on unwield. + 2. **Carry across ≥2 landblock boundaries and back**, both directions of + observation; expect `cause=propagate` lines at each crossing, child + cell always equal to the player's. Include one indoor/dungeon + traversal (EnvCell-to-EnvCell crossings are the high-frequency case). + 3. Pickup: drop the weapon, pick it back up — leaves the ground, no + ghost, no invisible collider at the drop site. + 4. Loot an equipped item from a kill (the delete edge under load). + 5. Reconnect with equipment — re-attaches. + 6. Portal recall while equipped — equipment present and following after + arrival. + Regressions to watch: weapon drawn at the world origin or its last + ground position; invisible while equipped; **left behind at a landblock + boundary** (the demotion's specific risk); invisible-but-solid at a + former position (#184); child culled while the parent is visible or vice + versa. Graceful close per the standing ACE session rule. +- **Headless gate**: the §6 test 8 assertion (child canonical `FullCellId` + equals the parent's at a stable checkpoint) — the direct regression test + for the defect, which **fails today** — plus one headless session where + the local player equips (via the bot command surface) and crosses a + boundary, asserting the same, with `cause=headless-attach`/`propagate` + probe lines in the log. + +--- + +## 8. Budget and stop conditions + +**Size estimate at HEAD** (supersedes the scoping's §7.6 table, whose shape +changed twice — the propagation research added D2/D3, and D6 became a +deletion): + +| piece | non-comment production lines | +|---|---| +| D1 attach re-cell in the cell-less commit | 40-80 | +| D2 directory-funnel propagation + recursion/idempotence guards | 50-90 | +| D3 delete/EndGeneration explicit edges | 15-35 | +| D4 demotion + internal presentation-only child entry point | 40-80 | +| D5 headless parent-realize drive | 60-110 | +| D6 `ClassifyLeaveWorld` family deletion | net −60 to −70 | +| D7 reorder | ~3 | +| probe | 15-25 | +| **net added** | **~165-355** | + +Within the scoping's 300-490 envelope (below it, thanks to D6 being a +deletion). Tests are the larger share, ~450-750 lines. + +**Route 7 remains ONE slice and MUST NOT be split** — re-validated at HEAD: +the Runtime canonical write (D1/D2) and the App demotion (D4) are two +halves of one transfer. Landing D4 without D1/D2 leaves every equipped +child cell-less/stranded (the #184 shape); landing D1/D2 without D4 creates +a per-frame two-writer race on the canonical cell — the exact defect class +this campaign exists to remove. D5 rides along because it is the same +Runtime commit with a thin driver, and the headless gate is the route's +direct regression test. + +**Stop and report rather than pushing through when:** + +1. Added production lines exceed **550** — the likely cause would be the + propagation needing its own publication/receipt machinery (P8 flipping + the default into something structural) or the headless resolve needing + more of the graphical protocol than the thin drive assumed; either is a + decomposition conversation, not an ad-hoc build. +2. Any placement, park, service-window, or `ConstrainTo` machinery starts + looking necessary on this route (D8's last paragraph). +3. P8 finds a `Rebucketed` consumer that genuinely needs per-child deltas + AND publishing them breaks an ordering invariant. +4. P4 finds a live child broadphase registration that per-crossing + propagation would leave stale (that would mean acdream has child shadow + state retail does not, and the design changes). +5. D5's validation gap turns out to require extending the prepared-content + bake format. +6. Any surviving classifier test changes expectation (§3 item 10). +7. The complete Release suite deviates from baseline beyond the two named + flakes. + +--- + +## 9. What this slice does NOT do + +- **AP-131** (shared merge call / `clearParent` gating) — C5. The + Position-unparent edge's ordering inversion (`TryApplyPosition:1830/:1838` + vs retail @0x00454129) is the same family: **recorded here, not touched** + — it belongs with AP-131's route-4-side correction. +- **AP-135, #276, #316** — untouched. +- **R6-a** (retail `DeclareValid`'s `SetSelectedObject` split-recovery + selection transfer) — out of C4, per route 6's closure; file separately. +- **`UnparentBeforeRouting` / `ApplyPlacementFrameBeforeRouting`** stay + recorded-not-consumed (4b-3's non-goal, carried). +- **No child `PositionManager`/interpolation/`RemoteMotion` machinery**; no + child self-simulation of any kind. +- **No changes to routes 2/4/5/6**, the local-player paths, the remote + tail, or the continuation executor beyond the parent-replay path already + calling the extended commit. +- **No re-litigation of AP-133** (`ParentCellId`/`EffectCellId` split) — + the child's render-parent field remains presentation. +- **Route 3 (portal)** — after this slice. + +--- + +## 10. Stale and false scoping claims — reported, not smoothed (§7 of `2026-08-04-c4-routes-6-7-scoping.md`) + +**Substantively false or superseded (5):** + +1. **T5's open question — "whether retail re-cells a child when its parent + crosses a cell is NOT established" — is SETTLED (yes, recursively, from + the physics commit)** by the propagation research, which the scoping + demanded before demotion. Superseded, exactly as the scoping asked. +2. **§7.4's fix shape — "move the parent-cell commit into `TryCommitParent` + / `CommitAcceptedParentCellless`" — is INSUFFICIENT as written.** An + attach-only commit is correct at attach and stale from the parent's + first crossing (research §10 item 2). D1+D2 replace it: attach half PLUS + the sustaining propagation. The scoping's own §7.6 budget row inherited + the insufficiency. +3. **§7.6's "retail's `change_cell` + `recalc_cross_cells` half" — porting + `recalc_cross_cells` per the commit is WRONG**: retail runs the + recursive form at attach only; per-move it calls only the non-recursive + forms (research §8, binding trap). No cross-cell/shadow rebuild ships. +4. **§7.5's lean ("wire `ClassifyLeaveWorld`") is overridden with cause**: + T3's verification (no pickup/parent gate measures a teleport pair; the + only retail gate is POSITION_TS, already enforced in + `InboundPhysicsStateController`) plus retail's separate-wire-handler + dispatch make deletion the evidence-backed choice (D6). +5. **§8's "recommended order: route 6 first, then route 7" and the + campaign-plan correction it demanded are MOOT** — both landed + (`1b484937`; plan lines `:97-116` corrected). + +**Stale line references (every RuntimeEntityObjectLifetime citation, plus +several others):** `TryApplyPickup :1226-1245` → `:1249-1312`; +`TryCommitParent :1360-1374` → `:1388-1441`; `CommitAcceptedParentCellless +:1377-1408` → `:1443-1474`; `CommitWithdrawal :1845-1860` → `:1896-1929`; +`TryAcceptDelete :1939-1952` → `:1973-2034`; `ForgetInitialCreateResidence +:2552-2569` → `:2620-2637`; `AcknowledgeProjectionAndPublish :2187-2213` → +`:2255` on; `ClassifyLeaveWorld :475-505` → `:480-510`; +`ValidCreateAuthority :507-512` → `:512-516`; TickChild rebucket `:405-408` +→ `:406-408`; `RebucketLiveEntityPresentationOnly :993-1050` → `:993-1065`; +App wrappers `:2280-2312` → `:2312-2363` (doc comment `:2288-2292` → +`:2338-2343`); headless `OnParentUpdated :313-317` → `:312-316`; +`OnPickup :455-474` → `:460-474`. The scoping's §7.3 verification table +(cancellation choke points `:1074-1094` etc.) is wholly re-verified at the +new locations in §1.5. Its structural claims all still hold; only the +coordinates moved. + +**Confirmed still true at HEAD:** `ClassifyLeaveWorld` has zero production +callers; `RebucketLiveEntity` is not presentation-only (canonical +`CommitRebucket` at `:904-907`); headless has no realize; the six +cancellation choke points and their C0 fixes; T8's pinned `LeaveWorld` +omission; `LiveEntityDeletionController` purely logical; the register's +AP-124 status. + +--- + +## 11. Cross-contract finding — route 7 changes route 4b-3's cell-less trigger population (reported honestly) + +Route 4b-3's connected gate recorded an honest gap: `cause=cellless` was +never observed live, and its closure note says "the unwield-to-3D path is +the cheapest reachable trigger" (`2026-08-04-c4-route-4b-3-contract.md`, +final section). **After route 7 that provocation stops working, and that is +the retail-faithful direction:** + +- Retail: `unset_parent` performs no cell work, so a wielded child's + unwield Position reaches `MoveOrTeleport` with `this->cell` = the + parent's cell — NON-zero. Retail's cell-less branch does NOT fire for + unwield; it fires only for genuinely never-celled/withdrawn bodies. +- acdream today: a parented child's canonical cell is whatever the + render-tick writer last produced — nonzero in the graphical host while + TickChild runs, **zero headless and zero in any pre-first-tick window** — + so `PreMergeCommittedCellId == 0` (the 4b-3 D1 input, measured at + `TryApplyPosition:1801-1814`) could classify an unwield as cell-less. +- After D1/D2: a committed child's pre-merge cell is deterministically the + parent's (nonzero whenever the parent is celled), so the unwield Position + classifies by TELEPORT_TS/distance — matching retail's predicate + population exactly. + +Consequences to carry: (a) 4b-3's test 4 ("unwield-to-3D shape classifies +`SetPosition`") remains valid ONLY as a synthetic pre-merge-cell-0 fixture — +it must not be re-labeled as the live unwield behavior; (b) the recorded +live-closure recipe for `cause=cellless` needs a different provocation +(a genuinely withdrawn body receiving a Position without an intervening +Create — whether ACE ever emits that shape is unestablished); update the +4b-3 contract's closure note in this slice's docs commit rather than +leaving a recipe that can no longer fire. No code in the 4b-3 arm changes. + +--- + +## 12. Open questions routed to the reviewers + +1. **D2's chokepoint placement** (retail-conformance + architecture): the + directory funnel is argued from §0 item 8's caller closure — verify + independently that no canonical cell write bypasses + `RuntimeEntityDirectory.SetFullCell`/`RefreshSnapshot` at HEAD (the + load-bearing claim; if a bypass exists, D2 has a hole exactly where the + defect class predicts). +2. **D3's delete edge**: confirm by reading the App teardown/orphan flow + (`EquippedChildRenderController`'s `_pendingOrphanRemovalByChild`, + `LiveEntityRuntimeTeardownController`) that zeroing children's cells at + parent delete cannot race a child projection teardown already in flight, + and that the child's later own-DeleteObject converges. +3. **D4's entry point**: confirm the presentation-only child rebucket + cannot be reached for a non-child record (the general-bypass hazard) and + that `BeginPresentationOnlySpatialMutation`'s guard semantics hold for + the per-frame cadence. +4. **D5's validation gap**: confirm no existing prepared-content surface + exposes `Setup.HoldingLocations` before accepting the register row; and + review what the headless drive deliberately does not drive. +5. **P8's publication decision**: adversarially hunt a `Rebucketed` + consumer that needs the per-child deltas the demotion removes (route + 5's A1 class — the App tolerating the seam's silence must be shown, not + assumed). +6. **D7's inertness argument** — verify no observer distinguishes the + reordered pickup sequence (the claim is argued, with the T7 history, not + merely asserted; but it is an ordering change on a live path). +7. **§11's 4b-3 interaction** — confirm the synthetic fixture reading and + that no OTHER consumer of `PreMergeCommittedCellId` changes population + when children stop being cell-less. diff --git a/docs/research/2026-08-04-c4-route-7-retail-review-round2.md b/docs/research/2026-08-04-c4-route-7-retail-review-round2.md new file mode 100644 index 00000000..4722a03e --- /dev/null +++ b/docs/research/2026-08-04-c4-route-7-retail-review-round2.md @@ -0,0 +1,366 @@ +# C4 route 7 — retail-conformance review, ROUND 2 (delta) + +**Date:** 2026-08-04 +**Reviewer role:** independent retail-conformance reviewer, review-only. +**Subject:** the uncommitted working tree at HEAD `19ebf043`, after the round-1 +fix pass. Round 1 is `docs/research/2026-08-04-c4-route-7-retail-review.md` +(verdict FAIL, R1–R11); the parallel architecture review is +`docs/research/2026-08-04-c4-route-7-architecture-review.md` (A1–A10). +**Scope:** delta only. Round 1's §A retail verification (every address in +`enter_cell` / `leave_cell` / `change_cell` / `SetPositionInternal` / +`update_object` / `set_parent` / `DoPickupEvent` / `DoParentEvent`) stands +unchanged and is not re-litigated here. + +--- + +## VERDICT: **PASS** + +All three round-1 MAJORs are closed, and closed properly rather than +argued away: + +- **R1** — the implementer's pushback is **CORRECT and my premise was wrong in + the letter**: `RuntimeEntityRecord.HasPartArray` does exist on the canonical + record. I re-verified the writer enumeration independently and it is complete + and correct (§1). R1's *conclusion* — that this is a real, unrecorded retail + divergence needing a register row — was right, and AP-142 clause (d) is an + honest row that does not mischaracterise retail's intent. +- **R2 / A1** — the D4 test now queries real `GpuWorldState` landblock + membership before and after the tick. I verified by construction that it + cannot pass with the demotion removed (§2). +- **R3 / A2** — the typed-disposition fork is **sound**, and the implementer's + choice of the sanctioned alternative over the primary suggestion is the + better call for a reason the review did not state (§3). + +R5, R7, R9, R10, R11 and all three contract corrections landed. Build green +(0 errors); focused suites 22/22 Runtime + 28/28 App. Per process rule 5 that +is not the basis of this verdict. + +Seven new MINORs (N1–N7) below, none blocking. Two of them (N1, N5) are the +comment-precision class this campaign keeps hitting, and one (N6) is a new +untested control-flow branch created by the R3 fix itself. + +--- + +## 1. R1 — the pushback, verified on all three questions + +### (a) Is the `HasPartArray` writer enumeration complete, and are both writers graphical-only? — **YES, verified.** + +Repo-wide grep over `src/` + `tests/` (excluding bin/obj) for `HasPartArray`: + +| site | role | +|---|---| +| `src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs:203` | `SetHasPartArray(expectedCanonical, true)` — **App / graphical** | +| `src/AcDream.App/Rendering/EquippedChildRenderController.cs:609` | `childRecord.HasPartArray = true` — **App / graphical** (the row cites `:591`; see N3) | +| `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1167` | `SetHasPartArray(canonical, false)` — Runtime, but a *clear*, not a set | +| `RuntimeEntityDirectory.cs:495-498`, `LiveEntityRuntime.cs:250-253/:534-537` | plumbing (setter/forwarder), no value origin | +| 6 sites under `tests/` | test-only | +| `ProjectileController.cs:681/:720`, `RemotePhysicsUpdater.cs:101`, `LiveEntityAnimationScheduler.cs:240`, `EquippedChildRenderController.cs:920` | **readers only** | + +**No Runtime, Headless, or otherwise presentation-independent site ever sets +`HasPartArray = true`, for a child or for an ordinary root.** The implementer's +claim is exactly right, and the consequence it draws is right too: gating +D1/D2 on `child.HasPartArray` would permanently strand every headless committed +child — the functional inverse of this slice's purpose. My round-1 R1 asserted +"acdream's propagation has no analogue"; the *field* has an analogue, the +*canonical layer* does not. The correction is accepted. + +### (b) Is AP-142 clause (d) an honest description? — **YES, with two precision defects (N1, N2).** + +The row states the guard's address (@0x00510ed8), its full effect (the write +**and** the recursion **and** the subtree skip), that acdream writes +unconditionally, why gating is not available, and puts the consequence in the +risk column. It does not claim fidelity it lacks. That is what a divergence row +is supposed to look like. + +### (c) Is retail's guard moot under acdream's structure, or a real behavioural difference? — **A REAL behavioural difference, correctly accepted.** + +Retail's guard is not decorative. `enter_cell`'s `CObjCell::add_object` +@0x00510ee2 maintains the cell's object list, which retail uses for *both* +drawing and collision/visibility. So a part-array-less object in retail is +neither drawn nor cell-resident, and acdream — where `FullCellId` is the +residency predicate at 45+ sites — will mark such a child resident. The row says +this plainly and puts "acdream celling a child retail would leave nowhere" in +the risk column. **No mischaracterisation of retail's intent.** This is the +question I was watching for and the row passes it. + +### (b')/R10 — clause (b)'s over-claimed equivalence: **CORRECTED, accurately.** + +The row now reads: *"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 … but it is an asymmetry, not a proven +equivalence."* I re-read both addresses: `enter_cell`'s recursion @0x00510f03 is +inside the `part_array` guard but has no per-child cell test, and +`leave_cell`'s @0x00510f5b prune is exactly as described. **The correction is +verbatim-accurate.** R10 closed. + +--- + +## 2. R2 / A1 — the D4 presentation test: verified it cannot pass without the demotion + +`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:321-402`. + +Verified by construction, not by taking the sabotage note on trust: + +1. `fixture.Spatial` is a **real `GpuWorldState`** (`:1443`, + `internal GpuWorldState Spatial { get; } = new()`), and + `CopyLiveEntitiesNearLandblock` (`src/AcDream.App/Streaming/GpuWorldState.cs:493-520`) + is a genuine per-landblock query over `_loadedLiveByLandblock`, radius 0, that + early-returns empty if the world is unavailable. +2. The **pre-assertions** (`Assert.Contains(old, child)` + + `Assert.DoesNotContain(new, child)`) do double duty: they establish a + distinct starting bucket *and* they prove the query is live — a dead or + always-empty query would fail the `Contains` immediately. This is what makes + the post-assertion load-bearing rather than vacuous, and it is the exact + property round 1's version lacked. +3. `TickChild`'s **only** spatial mutation for the child is the demoted call at + `EquippedChildRenderController.cs:409-431`. `CommitRebucket` (the Runtime + producer driven before the tick) writes canonical fields only and touches no + spatial index. So stubbing `RebucketEquippedChildPresentation` leaves the + child in `oldLandblock` and `Assert.Contains(newBuffer, child)` fails. + +**R2 closed.** The test now asserts the layer that historically broke. + +--- + +## 3. R3 / A2 — the typed-disposition fork: sound, and the deviation from the review's primary suggestion is the better call + +`LiveEntityRuntime.cs:45-61` (the enum), `:1088-1148` (the method), +`EquippedChildRenderController.cs:406-431` (the caller). + +The implementer took the *sanctioned alternative* (branch explicitly, do not +fail the tick on `NotAttached`) over the review's primary suggestion (assert / +propagate as a failure). **That is correct, for a reason worth pinning:** + +- `NotAttached` genuinely coincides with an in-flight subtree withdrawal. + `OnChildBecameUnparented` already owns that teardown and drives + `BeginProjectionSubtreeWithdrawal`. Returning `false` from `TickChild` would + route the same child into `WithdrawForPoseLoss` in the same frame — a + **second concurrent withdrawal trigger on a subtree that already has one in + flight**. The implementer's stated worry is real, not defensive hand-waving. +- `NoProjection` is the branch that *should* fail the tick, and it does. Its + first guard (`TryGetCurrent` / `WorldEntity is not { }`) is unreachable from + `TickChild` — `TryResolveExactAttachment` (`:379`, and again at `:409`) + already required `IsCurrentRecord(child.ChildRecord)` and a non-null + `WorldEntity` — so in practice `NoProjection` only arises from + `RebucketLiveEntityPresentationOnly` returning `false`, i.e. a projection + operation displaced mid-flight, where withdrawal is the right response. + +So the fork partitions cleanly: benign-decline vs displaced-projection, with the +new failure path landing only on the genuinely-broken case. **R3 closed** (see +N6 for the one gap it leaves). + +A9 was also folded in: the new method now carries the active-initial-create- +residence gate (`:1136-1141`), making its "can never become a general bypass" +doc claim true at the entry point rather than by call-ordering three files away. + +--- + +## 4. Round-1 findings — closure status + +| # | Status | +|---|---| +| R1 | **CLOSED.** Premise corrected by the implementer (verified §1a); AP-142 clause (d) filed and honest (§1b/c). N1/N2/N4 are precision follow-ups on the row's text, not on the decision. | +| R2 | **CLOSED.** §2. | +| R3 | **CLOSED.** §3. | +| R4 | **PARTIAL — confirmed still non-blocking, now more comfortably so.** Writer families (b) `RuntimePhysicsState.CommitCanonicalCell` and (c) `RuntimeSetPositionState` still have no dedicated propagation test, and P2 is still only asserted on one path. Three things reduce the residual risk below the blocking line: the hook is at the *funnel*, not per-caller (so a missed writer is structurally impossible, not merely unobserved); **both** reviewers have now independently traced the complete `SetFullCell` caller set (the architecture review's 13-row table, which I spot-checked against my own round-1 grep and found consistent); and the connected gate's pass criterion is `cause=propagate` probe lines at each landblock crossing, which *is* the physics/placement writer families in live play. P8 and P4 are now stated in-code (`RuntimeEntityDirectory.cs`'s doc `` blocks) with A3's correction folded in. | +| R5 | **CLOSED.** `RuntimeInitialCreateContinuationExecutor.cs:2184-2190` — `EndChildProjection` now precedes `LeaveWorld` on the dormant replay, matching @0x0045227F-before-@0x00452286. Both pickup paths are one shape, and the fix is real code, not a narrowed comment. | +| R6 | **CLOSED as documentation.** `RuntimeLiveEntitySessionController.cs:345-368` now states the gap in full — the `TryCommitParent` position-timestamp gate, why nothing re-drives it, why `RetryChildrenWaitingForParent` does not cover it (the relation is staged, not unresolved), that it is not a regression, and that invariant 9 is untouched. That is the right disposition for a pre-existing gap surfaced by a new drive. | +| R7 | **CLOSED.** AP-143 now names all three skipped checks with the correct retail anchors and an inertness argument for (1) and (2). Verified against the code: self-parent `:915-916`, `HasPartArray` `:920`, `HoldingLocations` `:924-937`. (Line citations are stale — N3.) | +| R8 | **CLOSED / withdrawn.** My nested-descendant concern was wrong: a grandchild's own `ParentEvent` reaches `ResolveAndCommitChildAttachment` directly, and if it arrives before its parent spawns, `RetryChildrenWaitingForParent(childGuid)` fires on that spawn. No transitive descent is needed for this host. The new `ChildrenUnresolvedForParent` (`ParentAttachmentState.cs:642-663`) scopes the sweep correctly and its doc states the narrower claim honestly ("This is a narrower claim than '0 B'"). | +| R9 | **CLOSED.** The C3c-R1 F6 summary is back on `FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` (`:422-430`); the D5 tests carry their own. | +| R10 | **CLOSED.** §1(b'). | +| R11 | **CLOSED in substance, incomplete in its stated proof.** See N7. | + +**Contract corrections — all three landed and are accurate:** +§0 item 1 now carries the `part_array` guard correction with the "the contract +causes the defect" attribution (`:75-87`); §2's two gate rows are marked +UNVERIFIABLE with the BN always-false-lowering explanation and the note that +D7/D6 do not depend on them (`:236-237`); test 10's sabotage instruction now +requires **two** runs with round 1's failure recorded as the cautionary example +(`:724-736`). + +--- + +## 5. New findings (round 2) + +### N1 — MINOR — AP-142 clause (d) overstates the semantic gap between retail's `part_array` and acdream's `HasPartArray` + +**File:** `docs/architecture/retail-divergence-register.md:172`. +**Retail address:** `CPhysicsObj::makeAnimObject` @0x0050e930. + +The row says acdream's flag *"means 'the renderer built a mesh,' not retail's +'this CPhysicsObj has ANY part array.'"* Retail's flag has exactly one +assignment site: + +``` +0050e930 int32_t CPhysicsObj::makeAnimObject(CPhysicsObj* this, IDClass<_tagDataID,32,0> arg2, int32_t arg3) +0050e93e class CPartArray* eax = CPartArray::CreateSetup(this, arg2, arg3); +0050e94d this->part_array = eax; +``` + +`part_array` **is** the product of building the object's parts from its Setup — +i.e. retail's flag also means "the client built this object's parts". The two +are near-synonyms, not a broad/narrow pair. The real reason acdream cannot gate +on it is a **layering** commitment, not a semantic mismatch: Slice J made the +canonical Runtime layer presentation-independent by design, so the only place +the flag can be set is App, and headless has no part-construction step at all. + +The row's operative claim and its conclusion survive intact. The framing should +be corrected so a future reader does not conclude retail's guard was looser than +it is — that misreading would make the divergence look smaller than it is. + +### N2 — MINOR — clause (d)'s risk column scopes the divergence as headless-only; the graphical host has the same window + +**File:** `docs/architecture/retail-divergence-register.md:172` (risk column); +`src/AcDream.App/Rendering/EquippedChildRenderController.cs:609` vs `:876-880`. + +The risk column argues (d) is *"a headless-only concern, since the graphical +`HasPartArray` gate is already implicitly satisfied by the time `TickChild` can +run — a child's own `WorldEntity`/mesh must exist for `TickChild` to reach the +rebucket call at all."* That is true of the **rebucket** and false of the +**canonical write**, which is what clause (d) is about. The graphical realize +order is: + +``` +PrepareAndTryRealize: CommitStagedParent → CommitProjection + → CommitAcceptedParentCellless ← D1 writes the child's cell HERE + → WithdrawPriorProjection → TryRealize + └─ :609 childRecord.HasPartArray = true +``` + +So the graphical host also cells a child before its part-array flag is true. +Retail has no equivalent window: a `CPhysicsObj`'s `part_array` is built at +object creation (`makeAnimObject`), long before any `set_parent` @0x00515A90 can +run. The window is bounded (nothing reads the child's cell between those two +calls in the same synchronous realize) and inert, but the scoping sentence is +wrong as written and should say "predominantly headless, plus a bounded +graphical realize-ordering window". + +### N3 — MINOR — same-commit stale line citations in both new register rows + +- AP-142 cites `EquippedChildRenderController.cs:591` for the `HasPartArray` + writer; it is at **`:609`**. +- AP-143 cites `:897-898` (self-parent) and `:902` (`HasPartArray`) in + `ValidateParentProjection`; they are at **`:915-916`** and **`:920`**. + +Both are off by the +18 lines this same commit's D4 edit inserted above them — +i.e. the citations were written against the pre-fix file and not re-checked +after. Contract process rule 6 ("trust the symbol") makes them recoverable, and +the symbols are named, so this is cosmetic. It is listed because "verify every +comment the fix touched" is a standing rule and these were touched by the fix. + +### N4 — MINOR — clause (e)'s depth-cap residue is the shape clause (a) declares unacceptable, and is strictly worse on the withdraw path + +**File:** `docs/architecture/retail-divergence-register.md:172` (clause e); +`src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:349-357/:389-397`; +`tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs:430-486`. + +Clause (a) says acdream deliberately refuses to reproduce retail's +stale-nonzero-cell-under-a-dead-object residue, *because* a stale nonzero +`FullCellId` reads as "resident" to every acdream predicate. Clause (e)'s cap +re-introduces precisely that residue past 64 levels — the shipped test asserts +it directly (`Assert.Equal(originalCell, tail.FullCellId)`). + +On a **crossing** that is a stale-but-plausible cell (cosmetic). On a +**withdraw** (`SetFullCell(x, 0, 0)`) the tail keeps a nonzero cell forever, +which is the #184 invisible-but-solid shape verbatim. Clause (e) discloses +"left at its PRIOR cell rather than partially propagated", so nothing is hidden +— but the (a)/(e) interaction is not noted, and the withdraw case is the one +worth a sentence. The architecture review's suggested iterative worklist would +retire both. + +### N5 — MINOR — the `NotAttached` test's third assertion cannot distinguish decline from a same-destination move + +**File:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:404-447`. + +The comment says *"the draw bucket did NOT move, because the guard correctly +declined (NotAttached) and `RebucketLiveEntityPresentationOnly` was never +called"*, and the assertion is +`Assert.Contains(CopyLiveEntitiesNearLandblock(oldLandblock), child)`. In that +fixture the parent's `ParentCellId` is never changed, so a rebucket that *did* +run would target the child's current bucket and the assertion would pass +identically. It cannot fail either way. + +The test's load-bearing assertions — the tick still counted a pose-composition +visit and the child is still in `AttachedEntityIds` (i.e. it was **not** treated +as pose loss) — are sound and do pin the fork's benign branch. Only the third +comment overclaims what its assertion demonstrates. Same class as round 1's R2, +one severity lower. Moving the parent to a new cell first would make it real. + +### N6 — MINOR — the `NoProjection` branch is a new withdrawal trigger with no test + +**File:** `src/AcDream.App/Rendering/EquippedChildRenderController.cs:418-424`. + +`TickChild` now returns `false` on `NoProjection`, which routes the child into +`WithdrawForPoseLoss` via `Tick()`'s `failed` list — a control-flow edge that +did not exist before this slice (the old `RebucketLiveEntity` call's result was +never consulted). Round 2 added a test for `Moved` and one for `NotAttached`; +`NoProjection` has none. It is the branch with real consequences, and it is +reachable only via `RebucketLiveEntityPresentationOnly` returning `false` +(a displaced projection operation) — exactly the kind of narrow path that +regresses silently. + +### N7 — MINOR — R11's "proven, not merely assumed" enumerates two of three `PhysicsBody` constructors + +**File:** `src/AcDream.App/World/LiveEntityRuntime.cs:1113-1124` (the R11 +``). + +The doc argues: a committed child's `Snapshot.Position` is always null, and *"the +ONLY production `PhysicsBody` constructors reachable from a live projection +(`DatLiveEntityProjectionMaterializer`'s static scheduler, `ProjectileController`) +both require a non-null `spawn.Position`"*. Repo-wide grep for +`GetOrCreatePhysicsBody` returns a **third** production constructor: +`src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs:425`, which builds +from `lease.InitialCreate.Physics` — and a committed child *can* hold an +initial-create lease (that is the whole dormant-residence machinery). + +This matters slightly more than a bookkeeping nit because +`RuntimeEntityRecord.SuspendObjectClock` (`RuntimeEntityRecord.cs:94-98`) does +**not** synchronise a body's `TransientStateFlags.Active` bit — it only +deactivates the clock — so if a committed child ever did hold a body, the +dropped `SynchronizePhysicsBodyActiveState` would be a real loss rather than a +no-op. The conclusion is still very likely correct (first-entry admission +should require a placement position, which a parented child never has), but +**I did not resolve that third path and am flagging it rather than guessing.** +One sentence covering `RuntimeRemoteFirstEntryState` would convert this back to +a genuine proof. This is the same defect shape as the architecture review's A3 +(an enumeration argued against an incomplete set). + +--- + +## 6. Verification performed + +- Re-read `CPhysicsObj::makeAnimObject` @0x0050e930 in + `docs/research/named-retail/acclient_2013_pseudo_c.txt` (N1's basis) and + re-confirmed `enter_cell` @0x00510f03 / `leave_cell` @0x00510f5b for R10's + correction. +- Independent repo-wide grep of `HasPartArray` (§1a) and + `GetOrCreatePhysicsBody` (N7). +- Read `GpuWorldState.CopyLiveEntitiesNearLandblock` and the + `ControllerFixture` to confirm the D4 test queries a real spatial index (§2). +- Traced `TickChild` → `RebucketEquippedChildPresentation` → + `RebucketLiveEntityPresentationOnly` for the disposition fork's reachability + (§3). +- `dotnet build AcDream.slnx -c Debug` → 0 errors. +- `AcDream.Runtime.Tests` filtered to `ChildCellPropagation` + + `RuntimeLiveEntitySessionControllerTests` → **22 passed / 0 failed**. +- `AcDream.App.Tests` filtered to `EquippedChildProjectionWithdrawalTests` → + **28 passed / 0 failed**. +- Contract §0 item 1, §2 rows, and §6 test 10 re-read against the code they + describe. + +## 7. Recommended before commit (none blocking) + +1. N1 + N2: two sentences in AP-142 clause (d) — correct the `part_array` + semantics with the @0x0050e930 anchor, and re-scope the risk column to + include the bounded graphical realize-ordering window. +2. N3: fix the four line citations (`:591`→`:609`, `:897-898`→`:915-916`, + `:902`→`:920`). +3. N7: one sentence covering `RuntimeRemoteFirstEntryState.cs:425`, or drop the + word "proven". +4. N4: one clause noting that (e)'s residue on the *withdraw* path is (a)'s + rejected shape. +5. N5: move the parent's cell in the `NotAttached` test, or soften its comment. +6. N6: a `NoProjection` test, whenever the withdrawal path is next touched. diff --git a/docs/research/2026-08-04-c4-route-7-retail-review.md b/docs/research/2026-08-04-c4-route-7-retail-review.md new file mode 100644 index 00000000..79127c84 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-7-retail-review.md @@ -0,0 +1,504 @@ +# C4 route 7 — retail-conformance review (independent) + +**Date:** 2026-08-04 +**Reviewer role:** independent retail-conformance reviewer, review-only (no edits, +no commits, no fixes). +**Subject:** the uncommitted working-tree diff at branch +`claude/acdream-physics-divergence-5aa784`, HEAD `ca96ea5e` +(`git diff HEAD` + the two untracked files +`docs/research/2026-08-04-c4-route-7-contract.md` and +`tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs`). +The route-7 contract and the route-3 scoping docs are inputs, not subject. + +--- + +## VERDICT: **FAIL** + +Narrow fail. The retail mechanism this slice ports is **correct** — I re-read +every cited address in `docs/research/named-retail/acclient_2013_pseudo_c.txt` +and the propagation research is accurate on all of them (see §A). The directory +chokepoint is genuinely the sole funnel (§B). `ConstrainTo` is never armed (§C). +`TryCommitParent` still holds exactly zero `LeaveWorld` calls (§D). D7 adopts +retail's real order and is genuinely inert (§E). + +The fail is on three counts, all small to fix: + +1. **R1 (MAJOR)** — retail's `enter_cell` gates the ENTIRE cell write *and the + recursion* on `this->part_array != 0` @0x00510ed8. The shipped propagation + has no analogue and **no register row**. The propagation research itself + flagged this guard as "Guard, load-bearing" (§3); the contract's §0 item 1 + enumerated `enter_cell`'s five writes and silently dropped the guard, and the + code inherited the omission. Register rule 1: a deviation found without a row + is a bug twice over. +2. **R2 (MAJOR)** — D4's presentation half is **untested**. The one App-layer + assertion is satisfied by a line that runs before, and independently of, the + demoted call. `RebucketEquippedChildPresentation` has zero references in + `tests/` repo-wide. This is exactly the layer contract §6 test 10 named + ("the child's render entity moved buckets (spatial index / visibility + state)") and exactly the #184 / route-4a-R1 regression D4's own text calls its + specific risk. +3. **R3 (MAJOR)** — `TickChild` discards the demoted call's `bool`, and the new + `HasCommittedParent` guard introduces a silent-false path the old + `RebucketLiveEntity` did not have. Contract D4 said "**assert** the record has + a committed parent relation"; the code returns `false` and the caller ignores + it. Route 5's A1 defect class, verbatim. + +Everything else is MINOR. Build is green (`dotnet build AcDream.slnx -c Debug`: +0 errors) and the focused Runtime suites pass (21/21, including all 12 new +propagation tests and the 2 new headless D5 tests). Per process rule 5 that is +not evidence, and it is not what this verdict rests on. + +--- + +## A. Retail ground truth — verified first-hand, not inherited + +Every claim below was re-read from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` at the stated address. + +| Claim under review | Verdict | +|---|---| +| `change_cell` @0x00513390 delegates: `leave_cell` @0x0051339f, `enter_cell` @0x005133af, early return @0x005133b5; removal tail @0x005133c1 zeroes only **this**'s `objcell_id`, `cell = nullptr` @0x005133d8. **No child loop of its own.** | ✓ CONFIRMED verbatim | +| `enter_cell` @0x00510ed0 self-recurses over `children->objects.data[i]` @0x00510f03; per level writes `CObjCell::add_object` @0x00510ee2, `objcell_id` @0x00510f1e, part-array cell id @0x00510f2b, `cell` pointer @0x00510f35, lights @0x00510f3e. Unbounded depth. | ✓ CONFIRMED verbatim | +| `leave_cell` @0x00510f50 mirrors it: self-recursion @0x00510f84, `remove_object` @0x00510f5e, `cell = nullptr` @0x00510fa7, and **never** writes `objcell_id` (source of the stale-id asymmetry). Per-level guard `this->cell != 0` @0x00510f5b prunes an already-cell-less subtree. | ✓ CONFIRMED verbatim | +| **The depth-1 loop @0x0051539c-@0x005153d8 is the SAME-CELL fast path.** It sits inside `if (this->cell == curr_cell)` @0x0051536d; the `else` @0x00515372 is `change_cell`. The loop writes child `+0x4c` (`m_position.objcell_id`) @0x005153bd and child `+0x10`'s part-array cell id @0x005153cc — **id only, never `+0x90` (`cell`)**, and **not** recursive. | ✓ CONFIRMED — this is the design's load-bearing claim and it holds | +| `update_object` @0x00515d10 early-returns on `parent != 0` @0x00515d40 (`this_3->parent != 0 \|\| this_3->cell == 0 \|\| state & 0x1000000`), clearing `transient_state & ~0x80` and returning. A child never runs its own tick. | ✓ CONFIRMED verbatim | +| `DoPickupEvent` @0x00452240 = timestamp gate, stamp write @0x00452278, `unset_parent` @0x0045227f, `leave_world` @0x00452286. **No placement, no `ConstrainTo`, no `HandleReceivedPosition`.** | ✓ CONFIRMED — the `unset_parent`-before-`leave_world` ORDER is unambiguous | +| `DoParentEvent` @0x00452290 = gate, `SetParentedState(1)` for a non-player parent gaining its first child @0x004522f4, `set_parent` @0x00452305, `SetPlacementFrame` @0x00452313. No placement, no `ConstrainTo`. | ✓ CONFIRMED verbatim | +| `set_parent` @0x00515a90 has **exactly ONE** `leave_world` @0x00515ac1, after `unset_parent` @0x00515aba; then `parent =` @0x00515ac6, `if (edi->cell != 0)` @0x00515ad1, `change_cell` @0x00515ad6, `UpdateChild` @0x00515b0e, `recalc_cross_cells` @0x00515b15, NoDraw inheritance @0x00515b26. | ✓ CONFIRMED — one, and only one, `leave_world` | +| Per-move path calls the **non-recursive** `calc_cross_cells` @0x0051551b / shadow rebuild @0x0051553e-4c; the recursive `recalc_cross_cells` @0x00515a30 runs at attach only. | ✓ CONFIRMED | + +**Research-document defects found (2, both minor, neither changes the verdict):** + +- **The "read verbatim" blocks are normalized, not verbatim.** e.g. research §2 + prints `if ((state & 0x1000) == 0)` where the file reads + `if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x10) == 0)`. Same bit, + but the doc's own §7 ledger calls these "read directly from the pseudo-C + (verbatim, cited)". Paraphrase presented as transcript. Harmless here; a + hazard if a future reader diffs against the file. +- **The `DoPickupEvent` / `DoParentEvent` gates are NOT readable from this + source.** Binary Ninja lowered both to `if (-((eax_4 - eax_4)) != 0)` — a + literal always-false expression — because the x87/flag-based wrapped-sequence + comparison was lost. The contract's §2 row cites "gate + @0x0045224B-0x00452274" as if it were legible; only its *shape* (a + wrap-aware sequence compare against `update_times[0]`) is. **Flagged as + unverifiable rather than guessed.** It does not affect D7 (the ORDER is + legible) or D6 (whose argument is that acdream already enforces the + POSITION_TS gate in `InboundPhysicsStateController`, which I verified at + `InboundPhysicsStateController.cs:180-186/:263-271/:308-314`). + +--- + +## B. D2's chokepoint claim — independently verified + +Contract §12 open question 1 asks whether any canonical cell write bypasses the +funnel. Repo-wide grep for `SetFullCell` / `RefreshDerivedState` / +`CanonicalLandblockId =` over `src/**` (excluding bin/obj) returns: + +- `RuntimeEntityRecord.SetFullCell` (`RuntimeEntityRecord.cs:244-251`) — two + callers only: `RuntimeEntityDirectory.SetFullCell` (`:348-356`, now hooked) + and `RuntimeEntityRecord.RefreshDerivedState` (`:230-242`). +- `RefreshDerivedState` — reached from the record constructor (`:29`, no + children possible) and `RuntimeEntityDirectory.RefreshSnapshot` (`:234-247`, + now hooked with an explicit `previousCell` compare). +- `LiveEntityRecord.FullCellId` / `.CanonicalLandblockId` setters + (`LiveEntityRuntime.cs:189-204`) route to `_directory.SetFullCell`; + `LiveEntityRecord.RefreshDerivedState` (`:353-354`) routes to + `_directory.RefreshSnapshot`. **No App-side bypass.** +- All eight producers (`CommitRebucket`, `RuntimePhysicsState.CommitCanonicalCell`, + `RuntimeSetPositionState`'s four writes, the withdrawal family, the executor's + `:2188`) call `Entities.SetFullCell`. + +**Conclusion: the chokepoint holds. No hole.** I also confirmed +`canonicalLandblockId` is uniformly `(cell & 0xFFFF0000) | 0xFFFF` at every +producer, so D2's skip-on-equal-`FullCellId` cannot leave a stale landblock id. + +Cycle termination and re-entrancy were checked by construction: the propagation +writes fields and bumps a version only, never mutates the relation tables it is +iterating, and `ChildrenAttachedToParent` returns the stored `List` or +`Array.Empty` (0 B). + +--- + +## C. Route 7 arms nothing — verified + +`ConstrainTo` / `ConstrainPhase` / `PositionManager` / park / service-window / +`CanAttemptDestination` appear **nowhere** in the diff's production hunks. The +D8 inversion is respected. `RuntimeEntityChildCellPropagationTests +.NeverArmPartition_D8_...` asserts the SetPosition operation ledger is unchanged +across attach + five crossings + pickup + delete, and the committed-relation +count converges to zero. ✓ + +## D. T8 — `TryCommitParent` `LeaveWorld` omission preserved + +`RuntimeEntityObjectLifetime.TryCommitParent` (`:1400-1453`) still has zero +`Physics.CollisionReports.LeaveWorld` calls; the F4 comment (`:1430-1437`) is +intact and its retail citation (@0x00515A90's single `leave_world`) is correct +per §A. No second `LeaveWorld` was added anywhere in the parent commit family. ✓ + +## E. D7's inertness — verified, and the implementer's honest report is CORRECT + +The implementer reported that reverting D7's ordering leaves all 12 propagation +tests green, and reported it rather than manufacturing a test. **That is true, +and "unobservable" is genuinely true against today's code**, for a reason worth +writing down: + +- `EndChildProjection(guid)` → `RemoveCommittedChild(guid)` removes the entity + as a **child** (`_lastAcceptedByChild` + its parent's committed list). It does + **not** touch `_committedChildrenByParent[(guid, incarnation)]` — the entity's + own children. `RemoveCommittedParentReferences` would, and is not called. +- The only work between the two positions is + `CollisionReports.LeaveWorld` → `SetPosition.Forget` → `SuspendObjectClock` → + `SetFullCell(0,0)`. I checked every `ChildrenAttachedToParent` consumer + (`RuntimeSetPositionState.cs:6015-6063`, `ArmLostFamilyDeadlines` / + `CancelLostFamilyDeadlines`): both enumerate `operation.Record`'s **own** + children, i.e. the picked-up entity's subtree, which `EndChildProjection` does + not alter. Nothing else reads the child-relation table in that window. +- Even in a hostile A→B/B→A cycle both orders converge to the same values. + +So: **no observable difference exists that anyone failed to construct.** The +correctness is contingent on "no consumer reads the child-relation table between +those two points" — a property of today's code, not a semantic equivalence — so +adopting retail's real order is right on principle and the change should stand. +See R5 for the half that was missed. + +--- + +## Findings + +### R1 — MAJOR — retail's `part_array != 0` guard is dropped, with no register row + +**File:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:381-403` +(`PropagateFullCellToChildren`) and +`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1517-1535` (D1). +**Retail address contradicted:** `CPhysicsObj::enter_cell` @0x00510ed8. + +Retail: + +``` +00510ed0 void __thiscall CPhysicsObj::enter_cell(CPhysicsObj* this, CObjCell* arg2) +00510ed8 if (this->part_array != 0) <-- the ENTIRE body, including the +00510ee2 CObjCell::add_object(...) child recursion, is inside this +00510f03 enter_cell(child, arg2) guard +00510f1e objcell_id = ... +00510f35 this->cell = arg2 +``` + +A child with a null `part_array` receives **nothing** — no `add_object`, no +`objcell_id`, no `cell` pointer — and **its whole subtree is skipped**, because +the recursion is inside the guard. The propagation research called this out +explicitly and named it load-bearing (§3, "Guard, load-bearing"). The route-7 +contract's §0 item 1 lists the five writes and omits the guard entirely, and the +shipped propagation writes the child's cell unconditionally. + +**Why it matters in acdream specifically:** `FullCellId` is the residency/liveness +predicate at 45+ sites (the contract's own §0 item 6). Writing a nonzero cell for +an object retail would leave nowhere makes it "resident" to every one of those +predicates — the #184 shape, arrived at from the other direction. The graphical +host's `ValidateParentProjection` checks `parent.HasPartArray` +(`EquippedChildRenderController.cs:902`) — the **parent**'s, not the child's — +so it is not the analogue. + +**Correct behaviour:** either port the per-level guard (acdream has a +`HasPartArray` notion on the graphical record, but not on the canonical one, so +this may be a deliberate impossibility headless), **or** file a register row +stating that acdream's canonical record has no part-array concept, that the +guard is therefore not reproducible at the canonical layer, and what the +consequence is. Register rule 1 requires the row in the same commit. Right now +neither exists, and AP-142 — which is otherwise a careful, honest row — reads as +if the port were complete. + +### R2 — MAJOR — D4's presentation half has zero effective coverage + +**File:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:334-374` +(assertions at `:368-373`), against +`src/AcDream.App/Rendering/EquippedChildRenderController.cs:403` vs `:409-415`. + +The test's only presentation assertion is: + +```csharp +Assert.Equal(newCell, child.WorldEntity!.ParentCellId!.Value); +``` + +`TickChild` sets that field at **line 403**: + +```csharp +child.Entity.ParentCellId = parent.ParentCellId; // :403 — unconditional +... +if (TryResolveExactAttachment(child, out parent) + && parent.ParentCellId is { } parentCellId) // :409 +{ + _liveEntities.RebucketEquippedChildPresentation(...); // :412 — the demoted call +} +``` + +The assertion is therefore satisfied whether or not +`RebucketEquippedChildPresentation` runs, returns `false`, or is deleted. Grep +confirms `RebucketEquippedChildPresentation` has **zero** references under +`tests/`. The spatial bucket (`_spatial.RebucketLiveEntity`), the visibility +resolution (`IsLiveEntityProjectionResident`), and +`PublishProjectionVisibilityChanged` — the things +`RebucketLiveEntityPresentationOnly` actually does — are unasserted. + +Contract §6 test 10 required exactly this and required a sabotage check. The +test's own doc comment claims sabotage verification, but the sabotage described +("reverting TickChild to call the public `RebucketLiveEntity` again makes the +final assertion fail, because that call always bumps `SpatialAuthorityVersion`") +only exercises the **canonical** half. The presentation half — "left behind at a +landblock boundary", D4's named specific risk, and #184's layer — is unguarded. + +**Correct behaviour:** assert the spatial index / visibility state moved (the +route-4a R1 lesson), and sabotage-verify by no-op'ing +`RebucketEquippedChildPresentation`. + +### R3 — MAJOR — the demoted call's status is discarded, and its new guard can fail silently + +**File:** `src/AcDream.App/Rendering/EquippedChildRenderController.cs:412-415`; +`src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101`. + +```csharp +internal bool RebucketEquippedChildPresentation(uint serverGuid, uint parentCellId) +{ + if (!_directory.ParentAttachments.HasCommittedParent(serverGuid)) + return false; // NEW failure mode + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + return false; // NEW failure mode + ... +} +``` + +`TickChild` ignores the return and still returns `true` (`:417`). The old +`RebucketLiveEntity` had no committed-relation precondition, so both of these are +paths that previously could not exist. `EndChildProjection` / +`RemoveCommittedChild` run at several points while a projection is still +installed (`EquippedChildRenderController.cs:270`, `:1175`, `:1274`, `:1316`; +`RuntimeInitialCreateContinuationExecutor.cs:1998`, `:2189`), so a tick landing in +that window now silently stops moving the draw bucket, and the pose loop reports +success. + +Contract D4 pinned "the entry point is child-scoped (**assert** the record has a +committed parent relation…)". A silent `false` swallowed by the caller is the +route-5 A1 class the contract's own predecessor list names: *"an App glue site +discarding the Runtime seam's status and advancing presentation on write-nothing +outcomes."* + +**Correct behaviour:** either throw/assert on the guard (it is an invariant, not a +condition), or propagate the `false` into `TickChild`'s result so the existing +`WithdrawForPoseLoss` path handles it. Note this finding is only *observable* +today because of R2 — with a real bucket assertion, a fix for R3 would be +test-covered. + +### R4 — MINOR — contract §6 test 2 shipped 2 of 4 writer families; P2/P4/P5/P6/P8 are unstated + +**File:** `tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs`. + +Shipped: (a) `CommitRebucket` and (d) the wire merge (`RefreshSnapshot`). +**Missing:** (b) `RuntimePhysicsState.CommitCanonicalCell` (the simulation commit +— the writer that actually fires per tick in live play) and (c) a +`RuntimeSetPositionState` canonical placement commit. I verified by reading that +both reach `Entities.SetFullCell` (`RuntimePhysicsState.cs:2148`, +`RuntimeSetPositionState.cs:2743/:3660/:5003/:5224`), so the hook does fire — the +gap is regression coverage, not present-tense correctness. P5 (per-writer-family +re-entrancy) has no test at all; P6 (0 B/crossing) has none; P2 asserts only +`ObjectClock.IsActive` on one path, not "no workset / not a spatial root / body +inactive after N crossings and a teleport"; P4 (the child broadphase story) and +P8 (the `Rebucketed` consumer enumeration) are stated nowhere in the tree. + +For P8 I did the enumeration myself: `RuntimeEntityChange.Rebucketed` has **no** +production consumer that branches on it (only `GameRuntimeEvents.cs:43`'s enum +member and two test assertions). D2's silent-publication default is safe. That +should be written into the commit, per §5, rather than left for a reviewer. + +### R5 — MINOR — D7 is half-applied; the executor's replayed pickup keeps the inverted order + +**File:** `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:2183-2189`. + +```csharp +_entities.AdvancePositionAuthority(canonical); +_physics.CollisionReports.LeaveWorld(canonical); // leave_world +... SetPosition.Forget / SuspendObjectClock / SetFullCell(0,0) +_entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); // unset_parent LAST +``` + +This is the dormant-residence replay of the **same** wire event +(`SmartBox::DoPickupEvent` @0x00452240), whose retail order is `unset_parent` +@0x0045227f **before** `leave_world` @0x00452286. D7 corrected +`RuntimeEntityObjectLifetime.TryApplyPickup` and left this one. Inert for the +same reason (§E), but the new comment at +`RuntimeEntityObjectLifetime.cs:1304-1306` — "This was inverted; +EndChildProjection now runs first" — now reads as a codebase-wide statement that +is only half true. (The `TryApplyPosition` unparent edge at `:1908` **is** +correctly scoped out as route 4's surface in contract §9; this one is not.) + +### R6 — MINOR — D5's deferred-residence flavor is neither driven nor declared a non-goal + +**File:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-379`. + +`ResolveAndCommitChildAttachment` runs `Resolve` → `TryCommitParent` → +`CommitProjection` → `CommitAcceptedParentCellless` synchronously. When the +child has a pending initial residence, `Resolve`'s accept callback lands in +`RuntimeEntityObjectLifetime.TryApplyParent`'s dormant branch +(`:1354-1391`), which advances the gate via `TryAcceptDeferredParent` and +`EnqueueDormant`s a `Parent` continuation, returning `true` — so the relation is +**staged**, and the immediately following `TryCommitParent` is gated on +`gate.PositionTimestamp == positionSequence && child.PositionSequence == +positionSequence` (`InboundPhysicsStateController.cs:308-314`), which the +deferred merge has not yet satisfied. The drive returns `false`. + +Nothing re-drives it: the executor's `CommitParentAttachment` deliberately does +**not** commit the attach ("it is the App-layer `EquippedChildRenderController`'s +job" — `RuntimeInitialCreateContinuationExecutor.cs:2110-2123`), and headless has +no such controller and no post-drain retry hook. So headless, a ParentEvent +arriving during a pending initial residence leaves the child staged and +cell-less indefinitely. + +Not a regression (headless committed nothing before), and the invariant-9 +deferrals are correctly untouched. But the plan doc's new closure text and D5's +doc comment both read as if the headless gap is closed. Contract D5 required +"state what is deliberately not driven"; this case is not stated. **Flagging the +`TryCommitParent` rejection as inferred-from-reading, not executed** — no test +covers it. + +### R7 — MINOR — AP-143 under-describes what the headless drive skips + +**File:** `docs/architecture/retail-divergence-register.md:173`; +`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-379`. + +AP-143 records only the `Setup.HoldingLocations` check. The graphical +`ValidateParentProjection` (`EquippedChildRenderController.cs:894-919`) also +rejects **self-parenting** (`:897-898`) and requires the parent to have a +**part array** (`:902` — retail's @0x00510ed8 guard, and the closest thing +acdream has to it). The headless drive performs neither. Self-parenting turns +out to be inert by construction (the D1 gate sees `parent.FullCellId == 0` +because the child was just zeroed, and D2's skip-on-equal terminates the +one-node cycle), but the row should say what it skips, not one of three things. +Contract §6 gave zero coverage of the headless rejection paths. + +### R8 — MINOR — the headless drive is not the "same protocol" for nested/recovery cases + +**File:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:381-393`. + +- The graphical retry is `_relationRecoveryOrder.RealizeDescendants(parentGuid, + Relations.ChildrenWaitingForParent, ResolveAndTryRealize)` + (`EquippedChildRenderController.cs:925-931`) — a **transitive, parent-first** + descent. `RetryChildrenWaitingForParent` iterates the direct waiters on one + guid only, so after committing a child, grandchildren waiting on **that** child + are not retried in the same pass. The doc comment claims it "mirrors + `RetryWaitingDescendants`'s role"; it mirrors a subset. +- The drive never calls `MarkProjected`, so `_recoveryByChild` retains every + committed relation headless, and it never handles the recovery branch the + graphical `ResolveAndTryRealize` handles (`:835-844`). Benign today (recovery + is a re-projection concept that does not exist headless, and the entries are + cleared by `EndChildProjection`/`EndGeneration`/`Clear`), but undeclared. + +Given AP-142 explicitly ships unbounded-depth recursion as a design commitment, +a depth-limited headless realize is worth a sentence somewhere. + +### R9 — MINOR — a doc comment now describes the wrong test method + +**File:** `tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs:316-331` +and `:426-431`. + +The pre-existing `` for +`FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` (the C3c-R1 F6 +latch) was left in place and the two new tests were inserted **after** it, so: + +- `DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell` now carries + **two** `` elements, the first of which describes drive-controller + route latching. +- `FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` now carries the + D5 *deferred-parent* summary. + +Exactly the endemic stale-comment class this campaign keeps hitting, in the +commit that was supposed to be checking for it. + +### R10 — MINOR — AP-142 clause (b) overstates the equivalence + +**File:** `docs/architecture/retail-divergence-register.md:172`; +`src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:391-395`. + +The skip guard (`child.FullCellId == fullCellId → continue`) does two things: +it subsumes retail's same-cell id refresh (accurate, as the row says) **and** it +prunes the child's entire subtree. Retail's `enter_cell` has no such pruning — +it recurses unconditionally (@0x00510f03) and only `leave_cell` prunes +(@0x00510f5b, on `cell != 0`). So acdream matches retail exactly on the removal +side and diverges on the entry side. Currently unreachable-by-construction (after +D4 nothing writes a grandchild's cell independently of its parent), but the row +claims a clean equivalence it does not have, and this is precisely the class of +"row asserting behaviour the code lacks" in the other direction. + +### R11 — MINOR — the demotion drops `SynchronizePhysicsBodyActiveState` on a child's first projection + +**File:** `src/AcDream.App/World/LiveEntityRuntime.cs:923-927` (legacy branch, +no longer reached for children) vs `:995-1067` (presentation-only branch). + +The legacy `RebucketLiveEntity` ran +`record.SuspendObjectClock(); SynchronizePhysicsBodyActiveState(record);` when +`!wasProjected && !isOrdinaryRoot` — true on a child's first tick after realize. +`RebucketLiveEntityPresentationOnly` does neither. The canonical clock is +suspended by Runtime (`CommitAcceptedParentCellless`'s +`Entities.SuspendObjectClock`), and `SynchronizePhysicsBodyActiveState` no-ops +when `record.PhysicsBody is null` (the equipped-item case), so this is very +likely inert — but P2's "body (if any) inactive" is the obligation that would +have proven it, and it is unproven. **Flagged as unverified rather than +asserted.** + +--- + +## Register discipline — assessed row by row + +| Row | Assessment | +|---|---| +| **AP-142** (parented-child single-field cell model) | Clauses (a) and (c) describe shipped behaviour accurately (verified: `PropagateFullCellToChildren` + `WithdrawCommittedChildrenToCellless` + the D1 half). Clause (b) overstates — see R10. **Missing the `part_array` guard clause — see R1.** Anchors all correct. | +| **AP-143** (headless holding-location skip) | Accurately describes the skip and the reason. Under-describes the scope — see R7. The claim "repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `HoldingLocations`" is consistent with what I saw; the row correctly refuses to extend the bake format. | +| **AP-136** writer-list shrink | ✓ Correct: line 289 now reads "…demoted the third, `EquippedChildRenderController.TickChild`, to a presentation-only bucket move that no longer touches `record.FullCellId`". Matches the code. | +| **AP-138** writer-list shrink | ✓ Correct, same shape. | +| `RuntimeSetPositionState.cs:4536-4548` doc comment | ✓ Corrected; singular "writer" now, projection materializer only. Matches the code. | +| `RuntimeRemotePlacementDriveController.cs:1613-1626` doc comment | ✓ Corrected. Matches the code. | +| 4b-3 contract closure note | ✓ Honest and correct. It states plainly that the old `cause=cellless` recipe no longer fires, explains why that is the retail-faithful direction, marks test 4 as a synthetic fixture that must not be relabelled, and says the replacement provocation is UNESTABLISHED rather than inventing one. This is the best-written doc change in the diff. | +| Placement-cutover plan gap statement | ✓ Correctly moved to past tense and closed, with the one caveat that "the direct headless regression test … now passes" is true for the non-deferred flavor only (R6). | +| No row deletions | ✓ AP-124/131/132/133/135 untouched. | + +--- + +## Contract defects found + +1. **§0 item 1 dropped `enter_cell`'s `part_array != 0` guard** (@0x00510ed8), + which the research it cites called load-bearing. The contract enumerated the + five writes and not the gate around them; the implementation inherited the + omission. This is the root of R1 — process rule 1 ("the contract causes the + defect") in action. +2. **§2's `DoPickupEvent`/`DoParentEvent` gate citations are not readable from + the named-retail source** (BN lowered both to a literal always-false + expression). The contract presents them as verified. Nothing downstream + depends on it, but "✓" is the wrong mark. +3. **§4 D4 said "assert"; §6 test 10 said "assert the child's render entity + moved buckets".** The implementation did neither, and the contract's own + sabotage instruction ("break D2's propagation and confirm THIS test fails on + the canonical half **while presentation still moves** — if it stays green, + the test is asserting the wrong layer") was applied to the canonical half + only. The contract was right; it was not followed. R2/R3. +4. **§5's proof obligations are not stated anywhere in the working tree.** P4 and + P8 in particular were explicitly "must prove, not assume". P8's answer is + benign (I checked), P4's is unwritten. + +## Propagation research defects found + +Two, both cosmetic, listed in §A: the "verbatim" blocks are normalized +paraphrase, and §7's read/inferred ledger is otherwise scrupulous and correct. +**The verdict, the addresses, the recursion claim, the same-cell-fast-path +disambiguation, the `update_object` clincher, and the cross-cells trap are all +correct.** The research is the strongest artifact in this slice; the contract +weakened one of its findings on the way through (R1). + +--- + +## What would flip this to PASS + +- R1: a `part_array` register clause (or the guard, if a canonical analogue + exists). +- R2: an App test that asserts the spatial bucket / visibility state moved, with + the demoted call sabotage-verified. +- R3: assert-or-propagate on `RebucketEquippedChildPresentation`'s two guards. + +R4–R11 are worth folding in but none of them alone blocks the slice. diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 1be511df..87f4b7c6 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -403,9 +403,52 @@ public sealed class EquippedChildRenderController : IDisposable child.Entity.ParentCellId = parent.ParentCellId; CaptureParentPresentation(child, parent); PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); + // C4 route 7 D4: the canonical cell is Runtime's D1/D2 + // propagation write, not this render tick's job any more — move + // only the graphical draw bucket to match. The disposition is + // consumed explicitly (review A2/R3, route 5's A1 class): a + // silently-discarded bool must not advance presentation on a + // write-nothing outcome. if (TryResolveExactAttachment(child, out parent) && parent.ParentCellId is { } parentCellId) - _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId); + { + EquippedChildPresentationRebucketDisposition disposition = + _liveEntities.RebucketEquippedChildPresentation( + child.ChildGuid, + parentCellId); + if (disposition is + EquippedChildPresentationRebucketDisposition.NoProjection) + { + // Defensive: as this call site is structured today, + // this branch cannot actually fire. TryResolveExactAttachment + // (just above, and again at the top of this method) requires + // _liveEntities.IsCurrentRecord(child.ChildRecord), which is + // the exact same _projections.TryGetCurrent(guid) lookup + // RebucketEquippedChildPresentation's own NoProjection guard + // performs for the same guid one call later — if the + // projection were gone, TryResolveExactAttachment would + // already have failed and this method would already have + // returned false above, never reaching here (round-3 review + // N6; verified by sabotage — see + // RebucketEquippedChildPresentation_D4_NoLiveProjection_ReturnsNoProjectionDisposition + // in EquippedChildProjectionWithdrawalTests.cs, which tests + // the disposition value directly rather than through this + // unreachable path). Kept as a fail-safe in case a future + // refactor reorders or removes the TryResolveExactAttachment + // gate above. + return false; + } + // Moved: normal case. NotAttached: the known-benign + // in-flight unparent/pending-residence window — + // OnChildBecameUnparented (or the residence conductor) owns + // the child's fate. Displaced (B6, round-3 review): a newer + // operation already superseded this exact rebucket attempt + // mid-flight, so the record is current and healthy, just not + // via THIS call — not evidence of a problem. Both leave the + // bucket wherever the more current operation put it, and + // this tick still reports success because the pose itself + // composed and published correctly. + } ProjectionPoseReady?.Invoke(child.ChildGuid); return true; } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 4501a642..08ffe9f8 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -42,6 +42,44 @@ internal enum LiveEntityMaterializationResidence AwaitRuntimePlacement, } +/// +/// C4 route 7 A2/R3 remediation: +/// 's typed +/// outcome. is the ordinary case. +/// is the KNOWN-benign decline — the child's Runtime relation already unwound +/// (pickup/Position-unparent, or an active initial-create residence) while +/// EquippedChildRenderController._attachedByChild still holds it, ahead +/// of OnChildBecameUnparented's own teardown — callers must not treat it +/// as success. +/// +/// +/// Round-3 review (B6): and +/// were originally ONE case and are now split, because they mean different +/// things and warrant different caller behavior. — +/// no LiveEntityRecord/WorldEntity exists for this guid AT ALL +/// despite _attachedByChild holding the key — is NOT expected and is +/// treated as a genuine pose-loss failure. — the +/// spatial rebucket STARTED but a re-entrant caller superseded this exact +/// projection operation before it finished (IsCurrentProjectionOperation +/// went false mid-call, inside RebucketLiveEntityPresentationOnly) — +/// means the record was current a moment ago and some OTHER, newer operation +/// already committed a different outcome for it. Unlike +/// , this is treated as BENIGN, the same as +/// (see TickChild): a superseded operation +/// is not evidence anything is wrong with the entity, only that a fresher +/// write already ran — tearing it down as pose loss would act on a stale +/// snapshot against a projection a newer, presumably-valid operation just +/// updated. +/// +/// +internal enum EquippedChildPresentationRebucketDisposition +{ + Moved, + NotAttached, + NoProjection, + Displaced, +} + /// /// Logical-resource seam coordinated by . /// Spatial bucketing is deliberately absent: registering or removing meshes, @@ -978,12 +1016,14 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource /// /// C3c: the graphical-bucket-only projection of a conductor-owned - /// initial placement — called ONLY from + /// initial placement — called from /// (the - /// completion receipt at the initial-create residence boundary), never - /// from the public (C3c-R1 review R2: - /// post-residence moves take the full legacy branch there). - /// Deliberately never calls CommitRebucket, + /// completion receipt at the initial-create residence boundary) and, + /// since C4 route 7 D4, from + /// (an attached child, whose canonical cell Runtime's D1/D2 propagation + /// already owns). Never from the public + /// (C3c-R1 review R2: post-residence moves take the full legacy branch + /// there). Deliberately never calls CommitRebucket, /// SuspendObjectClock, or ResetObjectClockForEnterWorld — /// Runtime's SetPosition commit already owns all of those for the /// residence-driven placement this receipt projects. May place into a @@ -1064,6 +1104,91 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + /// + /// C4 route 7 D4: the equipped-child render-tick presentation-only + /// rebucket. Runtime's D1/D2 propagation + /// () is now the + /// canonical writer for a committed child's cell — the render tick only + /// needs to move the DRAW bucket to match. Deliberately the same C3c + /// shape as : no + /// CommitRebucket, no clock edges, because a committed child's + /// ObjectClock stays suspended for its whole attached lifetime + /// (retail update_object's parent != 0 early-out, + /// @0x00515D40) and Runtime already owns the canonical spatial + /// authority. Guarded to a record with a currently COMMITTED parent + /// relation, AND (review A9) the same active-initial-create-residence + /// gate itself honours, so this can + /// never become a general bypass of the legacy branch for an ordinary + /// root entity. Returns a typed disposition rather than a bare bool + /// (review A2/R3) — + /// is the KNOWN-benign case (the Runtime relation already unwound while + /// _attachedByChild still holds the child, ahead of + /// OnChildBecameUnparented's own teardown); the caller must not + /// discard the result. + /// + /// + /// R11 (retail-conformance review, LOW; DOWNGRADED at the round-3 + /// review, N7 — an earlier draft of this note overstated "proven"): + /// unlike the legacy , this path never + /// calls SynchronizePhysicsBodyActiveState. VERY LIKELY inert, + /// not fully proven: a committed child's Snapshot.Position is + /// always null (contract §0 item 10 / P1), and two of the production + /// PhysicsBody constructors reachable from a live projection + /// (DatLiveEntityProjectionMaterializer's static scheduler, + /// ProjectileController) both require a non-null + /// spawn.Position before calling GetOrCreatePhysicsBody, + /// ruling them out for a committed child. A THIRD constructor — + /// RuntimeRemoteFirstEntryState.cs:425, the remote first-entry + /// conductor's own body construction for an entity's OWN + /// initial-create residence — is NOT ruled out with the same rigor: it + /// does not gate on spawn.Position, and a committed child CAN + /// hold an initial-create residence lease during route 7's dormant- + /// residence deferral window (invariant 9). The argument for inertness + /// there is that CommitAcceptedParentCellless calls + /// ForgetInitialCreateResidence before this method's caller + /// (TickChild) can ever run — by the time a child has an + /// AttachedChild entry at all, its residence should already be + /// cancelled — but this is NOT independently traced end-to-end against + /// 's own state machine, and + /// SuspendObjectClock does not itself synchronize a body's + /// Active transient bit, so IF a body existed in that window the + /// dropped call would not be a no-op. Flagged honestly as unverified + /// rather than asserted; the correct fix if this is ever found reachable + /// is to add the call back or route the synchronization through the + /// residence-cancellation edge that already runs first. + /// + /// + internal EquippedChildPresentationRebucketDisposition RebucketEquippedChildPresentation( + uint serverGuid, + uint parentCellId) + { + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + { + return EquippedChildPresentationRebucketDisposition.NoProjection; + } + if (!_directory.ParentAttachments.HasCommittedParent(serverGuid)) + return EquippedChildPresentationRebucketDisposition.NotAttached; + if (record.MaterializationResidence is + LiveEntityMaterializationResidence.AwaitRuntimePlacement + && HasActiveInitialCreateResidence(record.Canonical)) + { + return EquippedChildPresentationRebucketDisposition.NotAttached; + } + // B6 (round-3 review): a `false` here ALWAYS means the projection + // operation was superseded mid-flight (RebucketLiveEntityPresentationOnly's + // only `return false` sites are both guarded by + // `!IsCurrentProjectionOperation`) — the record was current when + // this method started, so this is Displaced, never NoProjection. + return RebucketLiveEntityPresentationOnly( + serverGuid, + record, + entity, + parentCellId) + ? EquippedChildPresentationRebucketDisposition.Moved + : EquippedChildPresentationRebucketDisposition.Displaced; + } + /// /// C3c: applies one initial-Create ExecutorCompleted receipt's /// presentation — the graphical binding point for a residence-driven @@ -2340,6 +2465,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource /// after the parent accepted the child (PartArray + holding-location /// validation). The POSITION_TS is consumed before this step, matching /// retail; invalid parent relationships retain the old world projection. + /// Since C4 route 7 D1, this also completes retail's second half — if + /// the committed parent is celled, the child is re-celled to the + /// parent's exact values in the SAME transaction (see + /// ). + /// A cell-less parent leaves the child cell-less, exactly as before, + /// now by the retail-cited gate instead of by omission; a later parent + /// cell commit re-cells the child through D2's propagation. /// internal bool CommitAcceptedParentCellless( LiveEntityRecord record, diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 5831481d..7e728ffa 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -61,6 +61,20 @@ public static class PhysicsDiagnostics public static bool ProbeCellEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1"; + /// + /// C4 route 7 (pickup/parent/delete) connected-gate confirmation + /// signal — TEMPORARY, part of the existing probe family. When true, + /// one [child-cell] line is emitted per Runtime committed-child + /// canonical cell write: parent guid, child guid, old and new cell, + /// and a cause tag (attach / headless-attach / + /// propagate / withdraw / delete). A clean-looking + /// session with zero cause=propagate lines during a landblock + /// crossing is a not-run, not a pass. Initial state from + /// ACDREAM_PROBE_CHILD_CELL=1. + /// + public static bool ProbeChildCellEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CHILD_CELL") == "1"; + /// /// Issue #309's connected-gate confirmation signal (2026-08-04, C4 route /// 4b-2). The gate's quiescence steps need a ForcePosition to land INSIDE diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 1ec4a209..06caa9cc 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -516,6 +516,31 @@ public sealed class ParentAttachmentState public bool HasCommittedParent(uint childGuid) => _lastAcceptedByChild.ContainsKey(childGuid); + /// + /// Route 7 D1/D5: the exact (guid, incarnation) a child's committed + /// relation currently names, read straight from the committed table + /// rather than the staged/recovery timing window - the source the + /// attach-time re-cell and the headless drive both resolve the parent + /// through, per the contract's "currency, never by guid alone" pin. + /// + public bool TryGetCommittedParent( + uint childGuid, + out uint parentGuid, + out ushort parentInstanceSequence) + { + if (_lastAcceptedByChild.TryGetValue( + childGuid, + out ParentAttachmentRelation relation)) + { + parentGuid = relation.ParentGuid; + parentInstanceSequence = relation.ParentInstanceSequence; + return true; + } + parentGuid = 0u; + parentInstanceSequence = 0; + return false; + } + public bool IsPending( ParentAttachmentRelation relation, ParentProjectionCandidateKind kind) => @@ -614,6 +639,39 @@ public sealed class ParentAttachmentState return result.ToArray(); } + /// + /// Route 7 D5/A6 (architecture review): a lighter-weight sibling of + /// for a caller that only needs + /// to discover a child whose relation is still UNRESOLVED (waiting on + /// this exact parent guid to become addressable) — the headless + /// RuntimeLiveEntitySessionController retry, whose own + /// Resolve/TryGetStagedProjection/CommitProjection + /// sequence handles a relation already sitting in + /// _stagedByChild/_recoveryByChild when it re-resolves a + /// specific child directly, so this query does not need to sweep those + /// two dictionaries or allocate the HashSet + /// uses to de-duplicate across + /// all three. Allocates a fresh only when it has + /// something to return (round-3 review, B4): a shared reused buffer was + /// tried and reverted — it broke reentrancy safety a fresh-array return + /// already had, since a nested call into the same query while an outer + /// caller was still iterating its result would clear/refill the SAME + /// list out from under it. + /// + public IReadOnlyList ChildrenUnresolvedForParent(uint parentGuid) + { + List? result = null; + foreach ((uint childGuid, Queue queue) in _unresolvedByChild) + { + if (queue.Any(relation => relation.ParentGuid == parentGuid)) + { + result ??= new List(); + result.Add(childGuid); + } + } + return (IReadOnlyList?)result ?? Array.Empty(); + } + /// /// Returns only the exact direct children currently committed to one /// parent incarnation. Lost-cell destruction follows retail's live diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 07582f30..fe733332 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -234,8 +234,16 @@ public sealed class RuntimeEntityDirectory bool refreshPosition = false) { EnsureKnown(record); + uint previousCell = record.FullCellId; record.Snapshot = accepted; record.RefreshDerivedState(refreshPosition); + if (record.FullCellId != previousCell) + { + PropagateFullCellToChildren( + record, + record.FullCellId, + record.CanonicalLandblockId); + } } public void AdvanceCreateAuthority(RuntimeEntityRecord record) @@ -344,6 +352,157 @@ public sealed class RuntimeEntityDirectory { EnsureKnown(record); record.SetFullCell(fullCellId, canonicalLandblockId); + PropagateFullCellToChildren(record, fullCellId, canonicalLandblockId); + } + + /// + /// Reused across every call. + /// Always empty on entry and on exit (the loop drains it unconditionally + /// before returning) — see that method's remarks for why sharing it is + /// safe rather than a repeat of the round-3 A6/B4 scratch-buffer + /// reentrancy lesson: this stack never escapes the method, and nothing + /// on the write path can trigger a nested call while it holds state. + /// + private readonly Stack _propagationWorklist = new(); + + /// + /// C4 route 7 D2 — the sole propagation hook. Retail's parent-cell- + /// crossing propagation (CPhysicsObj::SetPositionInternal + /// @0x00515330's changed-cell branch @0x00515372 -> change_cell + /// @0x00513390 -> the self-recursive enter_cell/leave_cell + /// @0x00510ed0/@0x00510f50) has exactly one acdream analogue: every + /// canonical cell write already funnels through this method or + /// 's derived-state write (see + /// docs/research/2026-08-04-retail-parent-cell-propagation.md and the + /// route-7 contract's D2). + /// + /// + /// ITERATIVE, not recursive (round-3 remediation — both the retail and + /// architecture reviews independently converged on the same finding, + /// N4/B3: a depth-capped RECURSIVE version left a subtree beyond the cap + /// at a STALE NONZERO cell permanently — on the withdraw path that is + /// the #184 shape verbatim, the exact defect AP-142 clause (a) exists to + /// reject, shipped fresh inside the slice whose headline is fixing it. + /// A reused stack removes the depth + /// concept entirely rather than mitigating it: there is no C# call-stack + /// growth to bound, so the only limit is the number of committed + /// relations actually in the system (which cannot exceed the live + /// entity count) — matching retail's own genuinely unbounded recursion + /// exactly, with no acdream-only cap and no register row for one. This + /// also retires B5 in the sense that there is no longer a RECURSIVE call + /// to reason about — but the bypass itself is NOT retired: the loop + /// below still calls a child's own SetFullCell directly rather + /// than the public , and that bypass is + /// DELIBERATE and LOAD-BEARING, not incidental. The public + /// calls this method, and this method opens + /// with _propagationWorklist.Clear() — routing a child through + /// the public method would re-enter this method mid-drain, clear the + /// shared worklist out from under the OUTER loop, and silently drop + /// every sibling still waiting on the stack, with no error and no + /// exception. Any side effect added to the public + /// in the future MUST be mirrored by hand at + /// this call site, because this call site cannot route through it. + /// + /// + /// + /// Skipping a child whose + /// (and , A7) + /// already equals the target is BOTH the cycle guard (an A→B→A wire- + /// induced relation cycle writes A, pushes B, writes B, pops B, finds A + /// already at the target value, and does not re-push it — no visited + /// set needed) and the retail-behavioral subsumption of retail's + /// separate same-cell depth-1 id refresh (@0x0051539c-@0x005153d8) + /// under this project's single-field cell model (AP-142). Zero + /// allocation after warmup: + /// returns + /// the stored list or , and the worklist + /// stack is a reused field, never a fresh collection per call. Field + /// writes only — no clock, workset, shadow, or placement work — so this + /// is safe to run re-entrantly inside whatever transaction is mid-commit + /// on the parent's own cell; the worklist field itself is safe to share + /// across calls because the loop below unconditionally drains it to + /// empty before this method returns, and nothing on the write path + /// (a field assignment and an optional diagnostic log line) can call + /// back into a nested . NOT gated on retail's + /// part_array != 0 guard (@0x00510ed8) — see AP-142 clause (d) + /// for why that guard has no reproducible analogue at acdream's + /// canonical layer. + /// + /// + /// P8 (contract proof obligation, corrected at the architecture review + /// round — A3): this step deliberately publishes NO per-child + /// RuntimeEntityChange.Rebucketed delta, matching + /// RuntimePhysicsState.CommitCanonicalCell's precedent. The + /// correct basis for that decision is BOTH observer interfaces, not + /// just IRuntimeEntityObjectObserver's direct implementations: + /// GameRuntimeEventHub itself implements + /// IRuntimeEntityObjectObserver and fans every entity delta out + /// to IRuntimeEventObserver, and RuntimeTraceRecorder.OnEntity + /// (GameRuntimeEvents.cs) is a non-stub shipped consumer that + /// records (delta.Change, delta.Entity.CellId) for every entity + /// delta — it is diagnostic tracing, not gameplay logic, and the + /// entries it would lose are graphical-only equipped-child + /// Rebucketed deltas that TickChild's demoted rebucket used to + /// produce, so silence here is acceptable, but "no consumer at all" is + /// false and must not be restated that way. + /// + /// + /// + /// P4 (contract proof obligation — the child broadphase story, stated): + /// this step writes canonical cell FIELDS only. At attach, the + /// preceding cell-less edge already force-ends collision reporting + /// (Physics.CollisionReports.LeaveWorld) and no child broadphase + /// registration exists to rebuild — retail's own + /// recalc_cross_cells @0x00515A30 runs at attach only, never + /// per-crossing (the §0 trap this hook deliberately does not port). At + /// every crossing thereafter, a committed child never becomes a spatial + /// root (LiveEntityRuntime.HasSpatialRuntimeProjection keys off + /// ProjectionKind is World, and an attached child is always + /// ProjectionKind.AttachedAcknowledgeSpatialProjection + /// is never called on this path, headless or graphical), so it never + /// joins any physics workset or shadow/cross-cell list this step would + /// need to maintain. Confirmed, not assumed: zero shadow work is + /// performed anywhere in this method. + /// + /// + private void PropagateFullCellToChildren( + RuntimeEntityRecord root, + uint fullCellId, + uint canonicalLandblockId) + { + _propagationWorklist.Clear(); + _propagationWorklist.Push(root); + while (_propagationWorklist.Count > 0) + { + RuntimeEntityRecord current = _propagationWorklist.Pop(); + IReadOnlyList children = ParentAttachments.ChildrenAttachedToParent( + current.ServerGuid, + current.Incarnation); + for (int i = 0; i < children.Count; i++) + { + // A7 (architecture review, LOW): key the idempotence/cycle + // skip on the PAIR, not FullCellId alone. Every production + // writer derives canonicalLandblockId from fullCellId, so + // the two are coupled today, but the coupling is not + // enforced anywhere (LiveEntityRuntime.CanonicalLandblockId's + // setter can write a same-cell, different-landblock pair) — + // testing the pair is the version that is correct + // independent of that coupling. + if (!TryGetActive(children[i], out RuntimeEntityRecord child) + || (child.FullCellId == fullCellId + && child.CanonicalLandblockId == canonicalLandblockId)) + { + continue; + } + if (PhysicsDiagnostics.ProbeChildCellEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[child-cell] parent=0x{current.ServerGuid:X8} child=0x{child.ServerGuid:X8} old=0x{child.FullCellId:X8} new=0x{fullCellId:X8} cause={(fullCellId == 0u ? "withdraw" : "propagate")}")); + } + child.SetFullCell(fullCellId, canonicalLandblockId); + _propagationWorklist.Push(child); + } + } } public void SetFinalPhysicsState( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a51a11d1..37377f05 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -997,6 +997,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration) { + // D3 (route 7): the replacement-generation path has the same + // stranding shape as delete - EndGeneration tears down the + // OLD generation's committed-children edge with no cell work. + if (prior is not null) + { + WithdrawCommittedChildrenToCellless( + prior.ServerGuid, + prior.Incarnation); + } Entities.ParentAttachments.EndGeneration( incoming.Guid, result.Snapshot.InstanceSequence); @@ -1292,6 +1301,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimePlacementCancellationReceipt initialCancellation = ForgetInitialCreateResidence(canonical); Entities.AdvancePositionAuthority(canonical); + // D7 (route 7): retail order is unset_parent @0x0045227F THEN + // leave_world @0x00452286 (SmartBox::DoPickupEvent). This was + // inverted; EndChildProjection now runs first. + Entities.ParentAttachments.EndChildProjection(update.Guid); Physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt ordinaryCancellation = Physics.SetPosition.Forget(canonical); @@ -1299,7 +1312,6 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable PreferCancellation(initialCancellation, ordinaryCancellation); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); - Entities.ParentAttachments.EndChildProjection(update.Guid); ulong positionVersion = canonical.PositionAuthorityVersion; ulong spatialVersion = canonical.SpatialAuthorityVersion; return AcknowledgeProjectionAndPublish( @@ -1440,6 +1452,54 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable cancellation); } + /// + /// D3 (route 7): retail DeleteObject/set_parent's replacement- + /// generation shape both null a subtree's children while they are + /// still attached - acdream's relation teardown + /// (/ + /// ) removes the + /// committed-children edge outright with no cell work of its own, so + /// callers run this first, through the SAME D2 write path + /// (, recursive by + /// construction) - acdream's single-field cell model propagates zero + /// rather than reproducing retail's stale-`objcell_id`-under-a-null- + /// pointer residue (AP-142). + /// + private void WithdrawCommittedChildrenToCellless( + uint parentGuid, + ushort parentInstanceSequence) + { + IReadOnlyList children = Entities.ParentAttachments.ChildrenAttachedToParent( + parentGuid, + parentInstanceSequence); + for (int i = 0; i < children.Count; i++) + { + if (!Entities.TryGetActive(children[i], out RuntimeEntityRecord child)) + continue; + if (PhysicsDiagnostics.ProbeChildCellEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[child-cell] parent=0x{parentGuid:X8} child=0x{child.ServerGuid:X8} old=0x{child.FullCellId:X8} new=0x00000000 cause=delete")); + } + Entities.SetFullCell(child, 0u, 0u); + } + } + + /// + /// Route 7 D1 (architecture review A4): the published + /// delta's payload contract + /// changed under this slice. Before D1 existed, a Withdrawn from + /// this method always carried FullCellId == 0. It now carries + /// the PARENT's exact cell whenever the parent is celled at attach — + /// i.e. in the ordinary equip case — because D1's re-cell runs before + /// this method's call, in + /// the SAME synchronous transaction (so no caller ever observes the + /// child cell-less in between). This is deliberate: the kind still + /// correctly says "this projection was withdrawn and must be + /// re-realized," and the payload's cell is the child's ACTUAL resulting + /// cell, not a stale zero. Pinned by + /// Attach_ParentCelled_ChildEndsAtParentCellAndStaysSuspended. + /// public bool CommitAcceptedParentCellless( RuntimeEntityRecord canonical, ulong positionAuthorityVersion, @@ -1462,6 +1522,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); + // D1 (route 7): retail set_parent's second half, run on the SAME + // synchronous transaction so no caller observes the child + // cell-less between the leave-world edge and the re-cell. + // @0x00515AC6 parent = ...; @0x00515AD1 if (parent->cell != 0) + // @0x00515AD6 change_cell(this, parent->cell). The committed + // relation (not the guid alone) resolves the parent so a stale or + // superseded incarnation can never re-cell the child. + if (Entities.ParentAttachments.TryGetCommittedParent( + canonical.ServerGuid, + out uint parentGuid, + out ushort parentInstanceSequence) + && Entities.TryGetActive(parentGuid, out RuntimeEntityRecord parent) + && parent.Incarnation == parentInstanceSequence + && parent.FullCellId != 0u) + { + if (PhysicsDiagnostics.ProbeChildCellEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[child-cell] parent=0x{parentGuid:X8} child=0x{canonical.ServerGuid:X8} old=0x00000000 new=0x{parent.FullCellId:X8} cause=attach")); + } + Entities.SetFullCell( + canonical, + parent.FullCellId, + parent.CanonicalLandblockId); + } ulong spatialVersion = canonical.SpatialAuthorityVersion; return AcknowledgeProjectionAndPublish( canonical, @@ -1993,6 +2078,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.AdvanceLifetimeMutation(delete.Guid); + // D3 (route 7): retail's DeleteObject nulls a subtree's children + // while they are still attached (leave_world @0x00508472 runs + // BEFORE unparent_children @0x005084B9) - DeleteGeneration below + // performs no cell work of its own, so run the children's + // leave-world edge first or a deleted parent's children are + // stranded at a stale nonzero cell (the #184 shape). + WithdrawCommittedChildrenToCellless(delete.Guid, delete.InstanceSequence); Entities.ParentAttachments.DeleteGeneration( delete.Guid, delete.InstanceSequence); diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs index 49dc2855..1d2c24af 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -2181,12 +2181,18 @@ internal sealed class RuntimeInitialCreateContinuationExecutor // must not tear down the residence mid-drain: only ordinary // SetPosition.Forget runs here, never ForgetInitialCreateResidence. _entities.AdvancePositionAuthority(canonical); + // D7 (route 7, architecture review A5): retail order is + // unset_parent @0x0045227F THEN leave_world @0x00452286 + // (SmartBox::DoPickupEvent) - the same reorder + // RuntimeEntityObjectLifetime.TryApplyPickup applies to the live + // pickup path, applied here to the DORMANT replay of the same wire + // event so both pickup paths are one shape. + _entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); _physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt cancellation = _physics.SetPosition.Forget(canonical); _entities.SuspendObjectClock(canonical); _entities.SetFullCell(canonical, 0u, 0u); - _entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); // Round 3 B2: mutate -> rebaseline -> publish. Round 4 R4-4: // AdvancePositionAuthority + SetFullCell(0,0) move // PositionAuthorityVersion and FullCellId, the only two of the diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index 7b214d41..6c1f9b3d 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -28,13 +28,6 @@ internal enum RuntimeAcceptedPositionSource : byte SameIncarnationCreate, } -internal enum RuntimeLeaveWorldCause : byte -{ - Unknown, - Pickup, - Parent, -} - internal enum RuntimeAuthoritativePositionDisposition : byte { RejectedAuthority, @@ -138,12 +131,6 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest( bool HasAnimations, RuntimePositionPlacementFacts PlacementFacts); -internal readonly record struct RuntimeLeaveWorldRouteRequest( - RuntimeAuthoritativePositionAuthority Authority, - RuntimePositionEntityKind EntityKind, - RuntimeLeaveWorldCause Cause, - RuntimePositionPlacementFacts PlacementFacts); - /// /// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport. /// It deliberately contains no renderer, world entity, UI, or host callback. @@ -477,38 +464,6 @@ internal static class RuntimeAuthoritativePositionRouteClassifier reporting); } - internal static RuntimeAuthoritativePositionRoute ClassifyLeaveWorld( - in RuntimeLeaveWorldRouteRequest request) - { - RuntimeSetPositionOperationKind operation = OperationKind( - request.EntityKind, - initialCreate: false); - bool reporting = request.PlacementFacts.CollisionBatchEligible; - if (!ValidCreateAuthority(request.Authority) - || !ValidEntityKind(request.EntityKind) - || request.Cause is RuntimeLeaveWorldCause.Unknown) - { - return RejectedAuthority(request.Authority, operation, reporting); - } - - return new RuntimeAuthoritativePositionRoute( - request.Authority, - RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, - operation, - PhysicsSetPositionFlags.None, - 0u, - UnparentBeforeRouting: false, - ApplyPlacementFrameBeforeRouting: false, - LeaveWorld: true, - TeleportHookPhase: RuntimeTeleportHookPhase.None, - StopInterpolating: false, - ConstrainPhase: RuntimePositionConstrainPhase.None, - PreserveHeading: false, - ZeroVelocity: false, - SendPositionImmediately: false, - reporting); - } - private static bool ValidCreateAuthority( in RuntimeAuthoritativePositionAuthority authority) => authority.IsStructurallyValid diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index e56cb112..ee5eaa9b 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -4536,11 +4536,13 @@ internal sealed class RuntimeSetPositionState : IDisposable /// read from that same record field. It is NOT invariant for a RETAINED /// operation: both drives re-submit from their own cadence pump with no /// fresh merge in between (SubmitAndResolve re-reads the record as - /// it then stands). The surviving non-Position rebucket writers (C4 + /// it then stands). The surviving non-Position rebucket writer (C4 /// route 4b-3 deleted the third, RemoteTeleportController's - /// rollback) are the projection materializer - /// (DatLiveEntityProjectionMaterializer) and the equipped-child - /// renderer (EquippedChildRenderController.TickChild). + /// rollback; C4 route 7 D4 demoted the equipped-child renderer's + /// EquippedChildRenderController.TickChild to a presentation-only + /// bucket move — Runtime's own D1/D2 propagation is the child's + /// canonical writer now, so it is no longer in this list) is the + /// projection materializer (DatLiveEntityProjectionMaterializer). /// RuntimeRemotePlacementDriveController's /// CanAttemptDestination doc states this correctly; treat the arm /// as live, not as dead code. diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index db0ed0a0..ce19cd37 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -65,6 +65,13 @@ public sealed class RuntimeLiveEntitySessionController /// private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private bool _initialLoginCompleteSent; + // A6 (architecture review): D5's ResolveAndCommitChildAttachment ran on + // every accepted spawn and every ParentEvent, allocating three + // this-capturing closures per call. These capture nothing per-call + // (only `this`), so cache them once instead of per invocation. + private readonly Func _isChildGuidKnown; + private readonly Func _resolveParentInstance; + private readonly Func _acceptParentEvent; public RuntimeLiveEntitySessionController( GameRuntime runtime, @@ -78,6 +85,15 @@ public sealed class RuntimeLiveEntitySessionController _log = log ?? (_ => { }); _worldProjection = worldProjection; _acceptedPositionDrive = acceptedPositionDrive; + _isChildGuidKnown = guid => Entities.Entities.TryGetSnapshot(guid, out _); + _resolveParentInstance = guid => + Entities.Entities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn) + ? spawn.InstanceSequence + : null; + _acceptParentEvent = candidate => Entities.TryApplyParent( + candidate, + acknowledgeProjection: null, + out _); } public LiveEntitySessionSink CreateSink() => new( @@ -138,6 +154,9 @@ public sealed class RuntimeLiveEntitySessionController canonical, canonical.ServerGuid == _runtime.PlayerIdentity.ServerGuid); + // D5: this spawn may be the parent a standalone ParentEvent + // already named before its own CreateObject arrived. + RetryChildrenWaitingForParent(canonical.ServerGuid); if (_worldProjection is null && canonical.ServerGuid == _runtime.PlayerIdentity.ServerGuid @@ -309,11 +328,146 @@ public sealed class RuntimeLiveEntitySessionController out _, out _); - private void OnParentUpdated(ParentEvent.Parsed update) => - _ = Entities.TryApplyParent( - update, - acknowledgeProjection: null, - out _); + private void OnParentUpdated(ParentEvent.Parsed update) + { + Entities.Entities.ParentAttachments.Enqueue(update); + ResolveAndCommitChildAttachment(update.ChildGuid); + } + + /// + /// C4 route 7 D5: the headless parent-realize drive. Resolves a queued + /// standalone through the SAME staged + /// -> committed protocol the graphical + /// EquippedChildRenderController.ResolveAndTryRealize / + /// PrepareAndTryRealize pair runs — + /// , then + /// -> + /// -> + /// + /// (which carries D1's attach re-cell) — so a direct/no-window host + /// gets the same canonical child-cell commit the graphical host has + /// always had. Deliberately does NOT drive pose composition, the + /// render bucket, or ValidateParentProjection's self-parenting + /// / part-array / Setup.HoldingLocations checks — see AP-143. + /// + /// + /// KNOWN GAP, stated rather than silently left implicit (retail- + /// conformance review R6): if has a + /// PENDING initial-create residence when the relation resolves to + /// staged, 's + /// gate (InboundPhysicsStateController.TryCommitParent's + /// gate.PositionTimestamp == positionSequence check) is not yet + /// satisfied and this method returns . Nothing + /// re-drives it: the residence executor's own parent-attach tail + /// (RuntimeInitialCreateContinuationExecutor.CommitParentAttachment) + /// deliberately does not commit the relation either — that has always + /// been the graphical host's job — and headless has no + /// EquippedChildRenderController-equivalent post-drain retry. + /// The relation stays staged and the child stays cell-less until SOME + /// other event re-invokes + /// for the same child (a later ParentEvent, or a spawn naming the same + /// parent guid via — which + /// does not cover this case either, since the relation is already + /// staged, not unresolved). Not a regression (headless committed + /// nothing on this path before D5 existed), and the invariant-9 + /// dormant-residence deferrals themselves are untouched — but a + /// headless ParentEvent arriving during a child's own pending initial + /// residence is NOT closed by this slice. + /// + /// + private bool ResolveAndCommitChildAttachment(uint childGuid) + { + ParentAttachmentState relations = Entities.Entities.ParentAttachments; + relations.Resolve( + childGuid, + _isChildGuidKnown, + _resolveParentInstance, + _acceptParentEvent); + if (!relations.TryGetStagedProjection( + childGuid, + out ParentAttachmentRelation staged)) + { + return false; + } + if (!Entities.Entities.TryGetActive( + childGuid, + out RuntimeEntityRecord canonical)) + { + return false; + } + + ulong positionAuthorityVersion = canonical.PositionAuthorityVersion; + if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _) + || !relations.CommitProjection(staged)) + { + return false; + } + bool committed = Entities.CommitAcceptedParentCellless( + canonical, + positionAuthorityVersion, + acknowledgeProjection: null); + if (committed && PhysicsDiagnostics.ProbeChildCellEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[child-cell] parent=0x{staged.ParentGuid:X8} child=0x{canonical.ServerGuid:X8} new=0x{canonical.FullCellId:X8} cause=headless-attach")); + } + return committed; + } + + /// + /// D5 companion: a ParentEvent can precede the parent's own CreateObject + /// (retail: the standalone parent handler queues by parent guid). Retry + /// every child waiting on the guid that just became addressable. + /// + /// + /// A6 (architecture review): unlike the graphical + /// EquippedChildRenderController.RetryWaitingDescendants → + /// ParentAttachmentState.ChildrenWaitingForParent, this drive's + /// OWN Resolve/TryGetStagedProjection/CommitProjection + /// sequence in consumes a + /// relation out of _stagedByChild within the SAME synchronous + /// call it was staged in, for the ordinary case. **Correction (B1/B2, + /// round-3 review): this is NOT an absolute "never populates + /// _stagedByChild" claim** — the R6 gap documented on + /// is exactly the + /// counter-example: a relation CAN be left sitting in + /// _stagedByChild across calls when the child has a pending + /// initial-create residence, because TryCommitParent's gate + /// isn't satisfied yet. What is true is narrower: THIS retry method + /// never needs ChildrenWaitingForParent's STAGED/RECOVERY sweeps + /// to find that dangling relation, because it re-resolves through + /// childGuid directly via ResolveAndCommitChildAttachment + /// on every retry rather than needing a separate discovery query for + /// already-staged children — only the UNRESOLVED sweep matters for + /// discovering a NEW parent guid becoming addressable. Scanning the + /// shared (heavier) ChildrenWaitingForParent on every accepted + /// headless spawn would pay for two sweeps and a HashSet + /// allocation this discovery step never needs; + /// ChildrenUnresolvedForParent scans only + /// _unresolvedByChild. **Correction (B4, round-3 review): the + /// first round shared a reused scratch buffer across calls for this + /// query, which broke reentrancy safety a fresh-array return had — + /// a reentrant call into this method (or into + /// 's loop below) could + /// clear/refill the SAME shared list the outer call was still + /// iterating. Reverted to a fresh return per call, matching + /// ChildrenWaitingForParent's own allocation shape**, since this + /// query already only allocates when it has something to return (most + /// parent guids have no unresolved children waiting on them). This is + /// a narrower claim than "0 B" either way — the per-child + /// queue.Any(lambda) predicate check still allocates a closure + /// per call, same as the pre-existing graphical sweep; a full + /// incremental parent-guid->children index would remove both + /// allocations and is not done in this slice. + /// + /// + private void RetryChildrenWaitingForParent(uint parentGuid) + { + IReadOnlyList waiting = Entities.Entities.ParentAttachments + .ChildrenUnresolvedForParent(parentGuid); + for (int i = 0; i < waiting.Count; i++) + ResolveAndCommitChildAttachment(waiting[i]); + } private void OnTeleportStarted(uint rawSequence) { diff --git a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs index 07b33f5d..15fc6caa 100644 --- a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs @@ -1613,12 +1613,14 @@ internal sealed class RuntimeRemotePlacementDriveController /// /// re-reads this predicate and is subject to the /// same two gaps, plus a third: a non-Position rebucket (the projection - /// materializer DatLiveEntityProjectionMaterializer, the - /// equipped-child renderer EquippedChildRenderController.TickChild - /// — C4 route 4b-3 deleted the third shipped writer, - /// RemoteTeleportController's rollback) can move - /// record.FullCellId to a THIRD landblock between the retained - /// submit and the retry. Both remaining writers are + /// materializer DatLiveEntityProjectionMaterializer — C4 route + /// 4b-3 deleted the second shipped writer, RemoteTeleportController's + /// rollback, and C4 route 7 D4 demoted the third, + /// EquippedChildRenderController.TickChild, to a presentation- + /// only bucket move that no longer touches record.FullCellId — + /// Runtime's own D1/D2 propagation is the child's canonical writer now) + /// can move record.FullCellId to a THIRD landblock between the + /// retained submit and the retry. The remaining writer is /// harmless for the same reason (delta review N3). That reason is the /// paragraph below — NOT, as the round-2 text claimed, that re-reading /// record.CurrentCellId here would "re-derive a private Core diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 779aa605..6726e5e7 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -318,6 +318,241 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits); } + /// + /// C4 route 7 D4 (review round 2, A1/R2 remediation): TickChild's + /// rebucket is now presentation-only — Runtime's D1/D2 propagation is + /// the canonical writer. Drives the parent's canonical cell change + /// through a Runtime producer (CommitRebucket, standing in for + /// any of D2's four writer families) BEFORE any render tick runs, + /// proving the canonical half needs no tick at all; then ticks and + /// proves the DRAW BUCKET itself moved — the actual + /// GpuWorldState spatial index membership, not the + /// ParentCellId mirror field (which TickChild writes + /// unconditionally before the rebucket call and therefore proves + /// nothing about it — the original review's A1 finding). Both + /// landblocks are registered so membership is checked by an actual + /// per-landblock query (CopyLiveEntitiesNearLandblock), not by a + /// field read. + /// + [Fact] + public void TickChild_D4_PresentationBucketMovesToTheDestinationLandblock() + { + using var fixture = new ControllerFixture((_, _, _) => + new(ExactProjectionWithdrawalDisposition.Completed, Failure: null)); + const uint oldLandblock = 0x0101FFFFu; + const uint newCell = 0x01020001u; + const uint newLandblock = 0x0102FFFFu; + fixture.Spatial.AddLandblock(new LoadedLandblock( + newLandblock, new LandBlock(), Array.Empty())); + + LiveEntityRecord parent = fixture.Spawn(0x70000260u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000261u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + + var relation = new ParentAttachmentRelation( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + fixture.CommitRenderedRelation(relation); + + // Sanity: the child's draw bucket starts in the OLD landblock (set + // by materialization), not the new one — equality after the tick + // cannot be coincidental. + var buffer = new List>(); + fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer); + Assert.Contains(buffer, kv => kv.Value == child.WorldEntity); + fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer); + Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity); + + // D2: the parent's canonical cell write propagates to the + // committed child's canonical cell with NO render tick involved. + Assert.True(fixture.EntityObjects.CommitRebucket( + parent.Canonical, newCell, newLandblock)); + Assert.Equal(newCell, child.Canonical.FullCellId); + ulong childSpatialVersionBeforeTick = + child.Canonical.SpatialAuthorityVersion; + + // The parent's OWN presentation already followed its canonical + // cell (a route-7-unrelated concern) so TickChild's pose loop reads + // the same destination; give the child a stale draw bucket first. + parent.WorldEntity!.ParentCellId = newCell; + child.WorldEntity!.ParentCellId = 0x01010001u; + + fixture.Controller.Tick(); + + // D4: the render tick moved the actual spatial bucket — the child + // left the OLD landblock's live-entity set and joined the NEW one. + fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer); + Assert.Contains(buffer, kv => kv.Value == child.WorldEntity); + fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer); + Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity); + // ...and did not re-write the canonical cell. + Assert.Equal( + childSpatialVersionBeforeTick, + child.Canonical.SpatialAuthorityVersion); + } + + /// + /// C4 route 7 D4 (review round 2, A2/R3 remediation; round-3 review + /// B1/B2/N5 fix): the KNOWN-benign decline. The committed relation can + /// unwind (pickup / Position-unparent) while _attachedByChild + /// still holds the child, ahead of OnChildBecameUnparented's own + /// teardown. TickChild must not silently discard that disposition and + /// must not move the draw bucket on a write-nothing outcome, but the + /// tick itself still "succeeds" (the pose composed and published) + /// rather than being torn down as a pose-loss failure. + /// + /// + /// CORRECTED (B1/B2/N5, round-3 review): the first round's "bucket + /// didn't move" assertion was non-discriminating — the parent's cell + /// never changed in that version, so the child's bucket would have + /// stayed in the same landblock whether or not the NotAttached guard + /// fired at all. This version moves the PARENT's canonical cell to a + /// real second landblock BEFORE removing the relation, so if the guard + /// were bypassed the child WOULD move there — the assertion can now + /// actually fail. + /// + /// + [Fact] + public void TickChild_D4_NotAttachedDisposition_SkipsTheBucketMoveWithoutFailingTheTick() + { + using var fixture = new ControllerFixture((_, _, _) => + new(ExactProjectionWithdrawalDisposition.Completed, Failure: null)); + const uint oldLandblock = 0x0101FFFFu; + const uint newCell = 0x01020001u; + const uint newLandblock = 0x0102FFFFu; + fixture.Spatial.AddLandblock(new LoadedLandblock( + newLandblock, new LandBlock(), Array.Empty())); + + LiveEntityRecord parent = fixture.Spawn(0x70000262u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000263u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + + var relation = new ParentAttachmentRelation( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + fixture.CommitRenderedRelation(relation); + + // A real destination now exists: move the parent's canonical cell + // (D2 propagates it to the still-committed child too) and its + // presentation field, so TickChild's pose loop WOULD send the + // child to newLandblock if the NotAttached guard did not fire. + Assert.True(fixture.EntityObjects.CommitRebucket( + parent.Canonical, newCell, newLandblock)); + parent.WorldEntity!.ParentCellId = newCell; + + // Runtime already unwound the relation (pickup/Position-unparent), + // but the App's _attachedByChild still holds the child (the + // pre-OnChildBecameUnparented window A2 names). + Assert.True(fixture.Live.ParentAttachments.HasCommittedParent( + child.ServerGuid)); + fixture.Live.ParentAttachments.EndChildProjection(child.ServerGuid); + Assert.False(fixture.Live.ParentAttachments.HasCommittedParent( + child.ServerGuid)); + + int visitsBefore = fixture.Controller.LastFullPoseCompositionVisits; + fixture.Controller.Tick(); + + // The tick still ran and composed the pose (not treated as pose + // loss)... + Assert.Equal(visitsBefore + 1, fixture.Controller.LastFullPoseCompositionVisits); + Assert.Contains(child.WorldEntity!.Id, fixture.Controller.AttachedEntityIds); + // ...but the draw bucket did NOT move to the new landblock — a real + // destination existed and the guard correctly declined + // (NotAttached), so RebucketLiveEntityPresentationOnly never ran. + var buffer = new List>(); + fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer); + Assert.Contains(buffer, kv => kv.Value == child.WorldEntity); + fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer); + Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity); + } + + /// + /// C4 route 7 D4 (round-3 review, N6): the OTHER new disposition value + /// — . + /// + /// + /// CORRECTED (round-3 sabotage pass): the first version of this test + /// called Controller.Tick() and asserted ProjectionPoseReady + /// did not fire, expecting to exercise TickChild's + /// if (disposition is NoProjection) return false; guard at + /// EquippedChildRenderController.cs:419-427. Sabotaging that exact + /// guard (forcing the branch to be skipped) did NOT break the test — + /// investigation showed why: TickChild's own top-of-method gate, + /// TryResolveExactAttachment (called at both + /// EquippedChildRenderController.cs:379 and again at :412 + /// immediately before the rebucket call), requires + /// _liveEntities.IsCurrentRecord(child.ChildRecord) to hold — and + /// IsCurrentRecord (LiveEntityRuntime.cs:3336-3338) is + /// itself defined as _projections.TryGetCurrent(guid, out current) && + /// ReferenceEquals(current, record) — the EXACT SAME store lookup + /// RebucketEquippedChildPresentation's own NoProjection + /// guard (LiveEntityRuntime.cs:1165) performs for the same guid, + /// one call later with nothing in between that could invalidate it. + /// Whenever RebucketEquippedChildPresentation would see no current + /// projection, TryResolveExactAttachment already failed first and + /// TickChild already returned at + /// :442, never reaching the rebucket call at all. The + /// NoProjection check inside RebucketEquippedChildPresentation + /// is therefore defensive/unreachable from this call site as currently + /// structured, not a live withdrawal trigger — left in place because it + /// is the correct contract for the method taken on its own (any other + /// caller, or a future refactor that removes/reorders the + /// TryResolveExactAttachment gate, could reach it), but it is not + /// exercisable end-to-end through TickChild today. + /// + /// + /// + /// This test therefore calls RebucketEquippedChildPresentation + /// directly — the same technique already used by + /// ControllerFixture.RemoveProjectionOnly to reach the abnormal + /// state — proving the disposition VALUE is correct in isolation, rather + /// than asserting a TickChild-level trigger that does not exist. The + /// earlier "pose-loss recovery also no-ops here" finding (reported, not + /// fixed, in the prior revision of this comment) is superseded by this + /// finding: since TickChild never reaches NoProjection from this + /// state, WithdrawForPoseLoss's interaction with it is moot. + /// + /// + [Fact] + public void RebucketEquippedChildPresentation_D4_NoLiveProjection_ReturnsNoProjectionDisposition() + { + using var fixture = new ControllerFixture((_, _, _) => + new(ExactProjectionWithdrawalDisposition.Completed, Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000264u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000265u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + + var relation = new ParentAttachmentRelation( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + fixture.CommitRenderedRelation(relation); + + // The relation stays committed (unlike the NotAttached test) — only + // the projection itself vanishes, which is the ABNORMAL case + // NoProjection exists to catch. + Assert.True(fixture.Live.ParentAttachments.HasCommittedParent( + child.ServerGuid)); + fixture.RemoveProjectionOnly(child); + + EquippedChildPresentationRebucketDisposition disposition = + fixture.Live.RebucketEquippedChildPresentation( + child.ServerGuid, + 0x01010001u); + + Assert.Equal( + EquippedChildPresentationRebucketDisposition.NoProjection, + disposition); + } + [Fact] public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist() { @@ -1481,6 +1716,31 @@ public sealed class EquippedChildProjectionWithdrawalTests map.Add(child.ProjectionKey!.Value, attached); } + /// + /// C4 route 7 D4 test support (N6, round-3 review): removes ONLY + /// 's LiveEntityRuntime projection + /// entry (via the same private _projections store + /// MaterializeLiveEntity's own rollback paths use), leaving + /// any separately-installed _attachedByChild entry (from + /// ) untouched. Constructs the + /// deliberately abnormal "stale AttachedChild, no live projection" + /// state + /// exists to catch — no production path is expected to reach it, so + /// this reflection is the test's own construction, not a stand-in + /// for a real caller. + /// + internal void RemoveProjectionOnly(LiveEntityRecord record) + { + FieldInfo projectionsField = typeof(LiveEntityRuntime).GetField( + "_projections", + BindingFlags.Instance | BindingFlags.NonPublic)!; + object projections = projectionsField.GetValue(Live)!; + MethodInfo removeActive = projections.GetType().GetMethod( + "RemoveActive", + BindingFlags.Instance | BindingFlags.Public)!; + Assert.True((bool)removeActive.Invoke(projections, [record])!); + } + internal void CommitRenderedRelation(ParentAttachmentRelation relation) { Live.ParentAttachments.AcceptCreateObjectRelation(relation); diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs new file mode 100644 index 00000000..678450c8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs @@ -0,0 +1,616 @@ +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; + +/// +/// C4 route 7 (pickup / parent / delete). Focused Runtime tests for D1 +/// (attach re-cell), D2 (crossing propagation at the directory funnel), D3 +/// (withdrawal/delete/EndGeneration edges), and D7 (pickup ordering). See +/// docs/research/2026-08-04-c4-route-7-contract.md. +/// +public sealed class RuntimeEntityChildCellPropagationTests +{ + private const uint Landblock = 0xA9C60000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void Attach_ParentCelled_ChildEndsAtParentCellAndStaysSuspended() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040001u; + const uint childGuid = 0x70040002u; + RuntimeEntityRecord parent = + lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!; + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + + // A4 (architecture review): D1's re-cell runs BEFORE this commit's + // publish, so the Withdrawn delta it emits carries the child's + // ACTUAL resulting (non-zero, parent) cell rather than the stale + // zero every Withdrawn from this method carried before D1 existed. + var deltas = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new RecordingEntityObserver(deltas)); + + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + // D1: the child ends at the parent's exact cell in the SAME + // synchronous transaction — never observably cell-less afterward. + Assert.Equal(parent.FullCellId, child.FullCellId); + Assert.Equal(parent.CanonicalLandblockId, child.CanonicalLandblockId); + Assert.NotEqual(0u, child.FullCellId); + // Invariant 2 / P2: the child is parent-suspended, never a + // self-simulating object (retail update_object @0x00515D40). + Assert.False(child.ObjectClock.IsActive); + Assert.Null(child.Snapshot.Position); + + RuntimeEntityDelta withdrawn = Assert.Single( + deltas, + d => d.Change is RuntimeEntityChange.Withdrawn + && d.Entity.Identity.ServerGuid == childGuid); + Assert.Equal(parent.FullCellId, withdrawn.Entity.CellId); + Assert.NotEqual(0u, withdrawn.Entity.CellId); + } + + private sealed class RecordingEntityObserver(List destination) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => destination.Add(delta); + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } + + [Fact] + public void Attach_ParentCellless_ChildStaysCellless_ThenLaterParentCellCommitRecellsViaPropagation() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040101u; + const uint childGuid = 0x70040102u; + lifetime.RegisterEntity(Spawn(parentGuid, 1, includePosition: false)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + + Assert.True(lifetime.Entities.TryGetActive( + parentGuid, out RuntimeEntityRecord parent)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.Equal(0u, parent.FullCellId); + // @0x00515AD1: parent->cell == 0 -> the child stays cell-less too. + Assert.Equal(0u, child.FullCellId); + + // The deferred-attach catch-up retail gets for free from + // propagation: a LATER parent cell commit re-cells the child + // through D2, with no separate mechanism. + Assert.True(lifetime.CommitRebucket(parent, Cell, Landblock | 0xFFFFu)); + Assert.Equal(Cell, child.FullCellId); + } + + [Fact] + public void PropagationChokepoint_RecursesThroughGrandchildAndIsIdempotentOnASameCellCommit() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040201u; + const uint childGuid = 0x70040202u; + const uint grandchildGuid = 0x70040203u; + RuntimeEntityRecord parent = + lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!; + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2)); + + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.Entities.TryGetActive( + grandchildGuid, out RuntimeEntityRecord grandchild)); + Assert.Equal(parent.FullCellId, child.FullCellId); + Assert.Equal(parent.FullCellId, grandchild.FullCellId); + + // The production writer (CommitRebucket) crosses the parent's cell. + uint newCell = parent.FullCellId + 0x0002u; + uint newLandblock = (newCell & 0xFFFF0000u) | 0xFFFFu; + Assert.True(lifetime.CommitRebucket(parent, newCell, newLandblock)); + + Assert.Equal(newCell, child.FullCellId); + // Unbounded-depth recursion: the grandchild follows too. + Assert.Equal(newCell, grandchild.FullCellId); + + // Idempotence: a same-cell re-commit does not churn the child's + // SpatialAuthorityVersion (D2's explicit short-circuit). + ulong childVersionBefore = child.SpatialAuthorityVersion; + ulong grandchildVersionBefore = grandchild.SpatialAuthorityVersion; + Assert.True(lifetime.CommitRebucket(parent, newCell, newLandblock)); + Assert.Equal(childVersionBefore, child.SpatialAuthorityVersion); + Assert.Equal(grandchildVersionBefore, grandchild.SpatialAuthorityVersion); + } + + [Fact] + public void PropagationChokepoint_TerminatesAHostileTwoCycleInsteadOfLoopingForever() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint aGuid = 0x70040301u; + const uint bGuid = 0x70040302u; + RuntimeEntityRecord a = + lifetime.RegisterEntity(Spawn(aGuid, 1)).Canonical!; + lifetime.RegisterEntity(Spawn(bGuid, 1, includePosition: false)); + // A hostile wire commits BOTH directions: B is A's child, and A is + // ALSO B's child (a relation cycle no single-relation check like + // self-parenting rejection catches). + Assert.True(CommitAttachment(lifetime, aGuid, 1, bGuid, 2)); + Assert.True(CommitAttachment(lifetime, bGuid, 1, aGuid, 2)); + + Assert.True(lifetime.Entities.TryGetActive(bGuid, out RuntimeEntityRecord b)); + uint newCell = a.FullCellId + 0x0002u; + + // Must terminate (not stack-overflow / infinite-loop) and still + // propagate once around the cycle. + Assert.True(lifetime.CommitRebucket(a, newCell, (newCell & 0xFFFF0000u) | 0xFFFFu)); + Assert.Equal(newCell, a.FullCellId); + Assert.Equal(newCell, b.FullCellId); + } + + [Fact] + public void RefreshSnapshot_WireMergeCellChange_PropagatesToCommittedChild() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040401u; + const uint childGuid = 0x70040402u; + 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(parent.FullCellId, child.FullCellId); + + WorldSession.EntitySpawn moved = parent.Snapshot with + { + Position = parent.Snapshot.Position!.Value with + { + LandblockId = parent.Snapshot.Position!.Value.LandblockId + 0x0002u, + }, + }; + lifetime.Entities.RefreshSnapshot(parent, moved, refreshPosition: true); + + Assert.NotEqual(0u, parent.FullCellId); + Assert.Equal(parent.FullCellId, child.FullCellId); + } + + [Fact] + public void P1_SnapshotMutationOfACommittedChild_LeavesItsCanonicalCellTrackingTheParent() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040501u; + const uint childGuid = 0x70040502u; + 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)); + uint expectedCell = parent.FullCellId; + + // A representative snapshot-mutation family: ObjDesc. The child's + // Snapshot.Position stays null (contract §0 item 10), so + // RefreshDerivedState's cell stamp can never fire from the child's + // own merge and cannot fight the propagation. + var objDesc = new ObjDescEvent.Parsed( + childGuid, + new CreateObject.ModelData( + BasePaletteId: null, + SubPalettes: [], + TextureChanges: [], + AnimPartChanges: []), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc( + objDesc, acknowledgeProjection: null, out _)); + + Assert.Null(child.Snapshot.Position); + Assert.Equal(expectedCell, child.FullCellId); + } + + [Fact] + public void Withdrawal_PickupOfParent_ZeroesCommittedChildrenRecursively() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040601u; + const uint childGuid = 0x70040602u; + const uint grandchildGuid = 0x70040603u; + lifetime.RegisterEntity(Spawn(parentGuid, 1)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.Entities.TryGetActive( + grandchildGuid, out RuntimeEntityRecord grandchild)); + Assert.NotEqual(0u, child.FullCellId); + Assert.NotEqual(0u, grandchild.FullCellId); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(parentGuid, InstanceSequence: 1, PositionSequence: 2), + acknowledgeProjection: null, + out _)); + + Assert.Equal(0u, child.FullCellId); + Assert.Equal(0u, grandchild.FullCellId); + } + + [Fact] + public void Withdrawal_CommitWithdrawalOfParent_ZeroesCommittedChildren() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040701u; + const uint childGuid = 0x70040702u; + 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.NotEqual(0u, child.FullCellId); + + Assert.True(lifetime.CommitWithdrawal(parent)); + + Assert.Equal(0u, parent.FullCellId); + Assert.Equal(0u, child.FullCellId); + } + + [Fact] + public void Delete_ZeroesChildrenBeforeRelationsAreTornDown_P7() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040801u; + const uint childGuid = 0x70040802u; + const uint grandchildGuid = 0x70040803u; + lifetime.RegisterEntity(Spawn(parentGuid, 1)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.Entities.TryGetActive( + grandchildGuid, out RuntimeEntityRecord grandchild)); + Assert.NotEqual(0u, child.FullCellId); + Assert.NotEqual(0u, grandchild.FullCellId); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + // The children's own leave-world edge ran before DeleteGeneration + // removed the relation ledger (P7) — both the direct and the + // grand-attached child went cell-less. DeleteObject unparents only + // the deleted object's DIRECT children (retail unparent_children): + // the child's own relation to the deleted parent is gone, but the + // grandchild's relation to the (still-alive, merely cell-less) + // child is untouched — exactly retail's shape. + Assert.Equal(0u, child.FullCellId); + Assert.Equal(0u, grandchild.FullCellId); + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid)); + Assert.True(lifetime.Entities.ParentAttachments.HasCommittedParent(grandchildGuid)); + } + + [Fact] + public void EndGeneration_ReplacementParentGeneration_ZeroesFormerlyCommittedChildren() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040901u; + const uint childGuid = 0x70040902u; + lifetime.RegisterEntity(Spawn(parentGuid, 1)); + 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.NotEqual(0u, child.FullCellId); + + // A new CreateObject generation for the SAME parent guid — the + // replacement-generation path has the same stranding shape as + // delete (D3). + lifetime.RegisterEntity(Spawn(parentGuid, 2)); + + Assert.Equal(0u, child.FullCellId); + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid)); + } + + [Fact] + public void PickupOfTheChildItself_D7_RelationGoneCellZeroClockSuspendedAndOwnChildrenAlsoCellless() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040A01u; + const uint childGuid = 0x70040A02u; + const uint grandchildGuid = 0x70040A03u; + lifetime.RegisterEntity(Spawn(parentGuid, 1)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.Entities.TryGetActive( + grandchildGuid, out RuntimeEntityRecord grandchild)); + + // Pick up the CHILD itself (not the parent) — retail's + // unset_parent-then-leave_world order (D7). PositionSequence 3: + // the attach commit already consumed 2. + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(childGuid, InstanceSequence: 1, PositionSequence: 3), + acknowledgeProjection: null, + out _)); + + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid)); + Assert.Equal(0u, child.FullCellId); + Assert.False(child.ObjectClock.IsActive); + // The picked-up child's OWN children went cell-less too — the + // pickup's SetFullCell(0,0) is a value like any other at D2's + // chokepoint. + Assert.Equal(0u, grandchild.FullCellId); + } + + [Fact] + public void NeverArmPartition_D8_RouteSevenEventsNeverEngagePlacementOrParkMachinery() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint parentGuid = 0x70040B01u; + const uint childGuid = 0x70040B02u; + lifetime.RegisterEntity(Spawn(parentGuid, 1)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + + int placementOpsBefore = + lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount; + + Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + parentGuid, out RuntimeEntityRecord parent)); + for (int i = 0; i < 5; i++) + { + uint nextCell = parent.FullCellId + 0x0002u; + Assert.True(lifetime.CommitRebucket( + parent, nextCell, (nextCell & 0xFFFF0000u) | 0xFFFFu)); + } + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(childGuid, InstanceSequence: 1, PositionSequence: 3), + acknowledgeProjection: null, + out _)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + // No placement, park, or ConstrainTo machinery engaged at any point + // (P3 / D8): attach, five crossings, pickup, and delete leave the + // placement operation ledger exactly where it started. + Assert.Equal( + placementOpsBefore, + lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Ledger convergence (invariant 13): both relations are gone. + Assert.Equal( + 0, + lifetime.CaptureOwnership().CommittedParentRelationCount); + } + + /// + /// Round-3 remediation: the recursion + depth cap were replaced outright + /// by an iterative worklist (both the retail and architecture reviews + /// independently found the same defect — a capped recursive version + /// left a truncated tail at a STALE NONZERO cell forever, the #184 + /// shape verbatim, on the withdraw path). Builds a chain well beyond + /// the OLD 64-level cap (200 nodes) and proves the ENTIRE chain — not + /// just a prefix — follows the parent on BOTH a write (crossing) and a + /// withdraw (zero) with no truncation and no + /// . + /// + [Fact] + public void PropagationChokepoint_DeepChain_FullyPropagatesOnBothWriteAndWithdraw() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const int chainLength = 200; // well beyond the retired 64-level cap + var guids = new uint[chainLength + 1]; + for (int i = 0; i <= chainLength; i++) + guids[i] = 0x70050000u + (uint)i; + + lifetime.RegisterEntity(Spawn(guids[0], 1)); + for (int i = 1; i <= chainLength; i++) + lifetime.RegisterEntity(Spawn(guids[i], 1, includePosition: false)); + for (int i = 0; i < chainLength; i++) + { + Assert.True(CommitAttachment( + lifetime, guids[i], 1, guids[i + 1], childPositionSequence: 2)); + } + + Assert.True(lifetime.Entities.TryGetActive( + guids[0], out RuntimeEntityRecord root)); + // D1's attach re-cell already propagated the ORIGINAL cell down the + // whole chain one attach at a time — confirm the tail matches the + // root before testing the crossing, so the assertions below cannot + // pass by coincidence. + uint originalCell = root.FullCellId; + Assert.True(lifetime.Entities.TryGetActive( + guids[chainLength], out RuntimeEntityRecord tailBeforeCrossing)); + Assert.Equal(originalCell, tailBeforeCrossing.FullCellId); + Assert.NotEqual(0u, originalCell); + + // WRITE path: the whole 200-deep chain follows a crossing, tail + // included — no truncation, no stack overflow. + uint newCell = originalCell + 0x0002u; + Assert.True(lifetime.CommitRebucket( + root, newCell, (newCell & 0xFFFF0000u) | 0xFFFFu)); + for (int i = 0; i <= chainLength; i++) + { + Assert.True(lifetime.Entities.TryGetActive( + guids[i], out RuntimeEntityRecord record)); + Assert.Equal(newCell, record.FullCellId); + } + + // WITHDRAW path: the whole chain follows a withdrawal to zero too — + // this is the #184-shaped half both reviews flagged (a truncated + // tail left at a STALE NONZERO cell is exactly the "resident but + // isn't" defect AP-142 clause (a) exists to reject). + Assert.True(lifetime.CommitWithdrawal(root)); + for (int i = 0; i <= chainLength; i++) + { + Assert.True(lifetime.Entities.TryGetActive( + guids[i], out RuntimeEntityRecord record)); + Assert.Equal(0u, record.FullCellId); + } + } + + // --------------------------------------------------------------- + // Harness + // --------------------------------------------------------------- + + 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, ulong generation) + { + var token = new RuntimeGenerationToken(generation); + lifetime.BindEventContext(() => token, static () => 1UL); + } + + /// + /// Drives the SAME staged -> committed protocol the graphical + /// EquippedChildRenderController.PrepareAndTryRealize and the + /// headless D5 drive both run: seed the staged relation, consume the + /// child's POSITION_TS gate, commit the relation + /// (), mark it + /// committed (), + /// then run the cell-less edge that D1 extends + /// (). + /// + private static bool CommitAttachment( + RuntimeEntityObjectLifetime lifetime, + uint parentGuid, + ushort parentInstance, + uint childGuid, + ushort childPositionSequence, + uint parentLocation = 0u, + uint placementId = 0u) + { + var relation = new ParentAttachmentRelation( + parentGuid, + childGuid, + parentLocation, + placementId, + parentInstance, + childPositionSequence); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + var update = new ParentEvent.Parsed( + parentGuid, + childGuid, + parentLocation, + placementId, + 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.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(), + BasePaletteId: null, + ObjScale: null, + Name: "route-7 fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: physics.RawState, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs index 296d6741..49169a28 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -1668,8 +1668,10 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests // AwaitFreshPosition - RuntimeAuthoritativePositionRouteClassifier. // ClassifyAcceptedPosition (the only classifier ApplyPositionAction ever // calls) has no branch that returns AwaitFreshPosition; that disposition - // is produced exclusively by ClassifyCreate (Parented/PickedUp) and - // ClassifyLeaveWorld (neither of which ApplyPositionAction calls). A + // is produced exclusively by ClassifyCreate (Parented/PickedUp) - the + // route-7 contract deleted the classifier's other AwaitFreshPosition + // producer, ClassifyLeaveWorld, which had zero production callers (see + // docs/research/2026-08-04-c4-route-7-contract.md D6). A // parented/picked entity's raw Position wire events are retained as // Position continuations exactly like any other entity's and are // classified with the SAME Remote/LocalPlayer logic once drained - the diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs index e43f97fd..ef9867ee 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs @@ -331,28 +331,6 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests Assert.False(route.ConstrainAfterRouting); } - [Theory] - [InlineData(RuntimeLeaveWorldCause.Pickup)] - [InlineData(RuntimeLeaveWorldCause.Parent)] - internal void PickupAndParent_LeaveWorldAndAwaitLaterFreshPosition( - RuntimeLeaveWorldCause cause) - { - RuntimeAuthoritativePositionRoute route = - RuntimeAuthoritativePositionRouteClassifier.ClassifyLeaveWorld( - new RuntimeLeaveWorldRouteRequest( - Authority(), - RuntimePositionEntityKind.Projectile, - cause, - default)); - - Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, - route.Disposition); - Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative, - route.OperationKind); - Assert.True(route.LeaveWorld); - Assert.False(route.PerformsSetPosition); - } - [Fact] public void HiddenAndNoDrawDoNotGatePlacement_ButReportingRemainsSeparate() { diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 8bbe3632..24916a45 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -313,6 +313,112 @@ public sealed class RuntimeLiveEntitySessionControllerTests projection.LastPositionDisposition); } + /// + /// C4 route 7 headless gate (D5) — the direct regression test for the + /// route-6 scoping's stranded-equipped-child defect: a committed + /// child's canonical FullCellId must equal its parent's, driven purely + /// through + /// with no App/graphical layer involved. Fails without D1/D2/D5. + /// + [Fact] + public void DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + CommitLandblockCollision(runtime, 0x01020000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + + const uint parentGuid = 0x70000020u; + const uint childGuid = 0x70000021u; + sink.Spawned(SpawnAt(parentGuid, incarnation: 1, 0x01010001u)); + drive.DriveAll(); + DrainPlacementFifo(runtime); + sink.Spawned(SpawnAt(childGuid, incarnation: 1, 0x01020001u)); + drive.DriveAll(); + DrainPlacementFifo(runtime); + + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + parentGuid, out RuntimeEntityRecord parent)); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.NotEqual(0u, parent.FullCellId); + // Distinct starting cells — equality below can only come from D5's + // commit, never from coincidence. + Assert.NotEqual(parent.FullCellId, child.FullCellId); + + sink.ParentUpdated(new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 0u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + + Assert.Equal(parent.FullCellId, child.FullCellId); + Assert.NotEqual(0u, child.FullCellId); + } + + /// + /// D5 companion: the deferred flavor — a standalone ParentEvent naming a + /// parent whose own CreateObject has not arrived yet must commit once + /// that guid becomes addressable (OnSpawned's + /// RetryChildrenWaitingForParent). + /// + [Fact] + public void DirectSink_D5_DeferredParentEventCommitsOnceTheParentBecomesAddressable() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + CommitLandblockCollision(runtime, 0x01020000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + + const uint parentGuid = 0x70000022u; + const uint childGuid = 0x70000023u; + sink.Spawned(SpawnAt(childGuid, incarnation: 1, 0x01020001u)); + drive.DriveAll(); + DrainPlacementFifo(runtime); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + uint childOriginalCell = child.FullCellId; + + // The ParentEvent arrives BEFORE the parent's own CreateObject. + sink.ParentUpdated(new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 0u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + Assert.Equal(childOriginalCell, child.FullCellId); + + sink.Spawned(SpawnAt(parentGuid, incarnation: 1, 0x01010001u)); + drive.DriveAll(); + DrainPlacementFifo(runtime); + + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + parentGuid, out RuntimeEntityRecord parent)); + Assert.Equal(parent.FullCellId, child.FullCellId); + Assert.NotEqual(0u, child.FullCellId); + } + /// /// C3c-R1 review F6: the drive controller outlives its session routes, /// so "session reset precedes a new route" is an asserted latch, not a @@ -639,10 +745,22 @@ public sealed class RuntimeLiveEntitySessionControllerTests private static WorldSession.EntitySpawn Spawn( uint guid, - ushort incarnation) + ushort incarnation) => + SpawnAt(guid, incarnation, 0x01010001u); + + /// + /// C4 route 7 D5 gate: parametrized over the wire + /// landblock, so a parent and a child can be seeded at DISTINCT cells — + /// equality after the D5 commit can then only come from the + /// propagation write, never from a coincidental shared default. + /// + private static WorldSession.EntitySpawn SpawnAt( + uint guid, + ushort incarnation, + uint landblockId) { var position = new CreateObject.ServerPosition( - 0x01010001u, + landblockId, 10f, 10f, 5f,