From 392c1e22c1d758b699ffc5949b48a5b48eec2ffa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 11:56:31 +0200 Subject: [PATCH] fix(physics): bind a parented child to the parent's live incarnation (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A player-parented child never received a canonical cell. Its FullCellId stayed 0 for its whole attached lifetime, so it could not follow the player across a boundary. Scope was wider than the local player: every REMOTE player's equipment too. ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0 for a parented CreateObject. Correct for creatures and statics, which really are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins (ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while the record carried TotalLogins, so both route-7 write sites — D1's attach re-cell and D2's propagation lookup — keyed on an incarnation that never matched. TryCommitParent did not validate the sequence, so the attach succeeded and printed normally. Silent. A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call route 7 deleted was keyed on the child guid alone and was structurally immune to a wrong parent key. THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them. Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id @0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e, with SetChildren @0x00509370 hash-walking by guid — and neither set_parent overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any player test or instance-sequence read. Our player/non-player split was purely an artifact of keying relations by (guid, incarnation) against a wire message that carries no parent incarnation. Late-binding to whoever currently holds the guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and OnCreateParentAccepted, the second carrying the byte-identical defect and not named in the contract's scope line. THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I offered: every one of the 45 FullCellId liveness predicates excludes a committed child on a NON-cell clause first, so the child inherits only the parent record's existing staleness, which is already present today with no symptom. The key fix alone restores child-equals-parent for every parent class. TWO SITES GATED, inert only because the cell was zero and would have woken wrongly: the hydration candidate loop (a nonzero-cell child would take the legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route 7's exact defect class) and RestoreShadow (would install a broadphase row for the weapon, the #184 shape, contradicting route 7's P4). Retail anchor: update_object's parent != 0 early-out @0x00515D40 — children are never independently re-placed. THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for an unaddressable parent, carrying a missing child-freshness gate (A2), a sentinel-0 collision with the generation filters (A3), and unbounded accumulation (A5). Both reviewers then proved the deferred branch unreachable for BOTH producers — RegisterEntityCore defers the entire CreateObject one layer above, reading the same ?? chain, and CreateParentUpdate is produced only inside AcceptCreateCore, after that gate passes. The machinery was deleted rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24 while gaining the A1 fix. Retail confirmed the deletion does not diverge: acdream's real port of retail's per-guid replay (QueueBlobForObject) is a different, untouched layer, and the deleted queue was a third redundant one downstream of it. THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw InvalidOperationException AFTER the canonical half had committed, so the one time it fired it left the child parented with no committed relation and a staged one blocking Resolve — a torn transaction, the exact outcome the contract pinned against. Now a pure CanCommitIncarnation precondition checked BEFORE the commit at both sites, with a logged refusal instead of a throw. Route 3's N3 principle (do not make a transient fatal on a host that must survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands alone. TEST QUALITY, the recurring lesson in its most refined form. The A1 test initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence meant TryCommitParent's own gate refused in either ordering, so the three assertions carrying A1's meaning passed both ways and only an incidental staging assertion failed. It failed on stranding, not tearing. Corrected, the sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the parent's guid in it — proving the canonical mutation happened before the catch. "Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is the real question. The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8… incarnation 0, identical outcomes, sabotage-verified in both directions) is the structural fix for how this survived a full dual review and two connected sessions: every prior test and both captured gate logs used sequence-0 parents. Register: AP-142 clause (f); AP-132 amended to distinguish the two producers; new row AP-146 for the local player's coarse canonical cell (retail writes it per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8, so retail's per-tick child propagation lives in the same function). That divergence had no row at all, a standing rule-1 violation now corrected. Follow-up #320 filed for making the player's cell track ordinary movement — deliberately excluded here: it touches the landblock-preserve contract, the Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race. Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed. Diagnostic refusals are latched per child guid and the latch clears on Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather than being silently suppressed. Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed (baseline 11,090 at 52175aa1, +22). Neither known flake fired. STILL OWED: the connected gate, with the CORRECTED positive criterion — assert the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is a failure, not a silence), run with BOTH a player and a creature parent, plus the new step carrying an armed creature across a landblock unload/reload. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 124 +++- .../retail-divergence-register.md | 7 +- .../2026-08-04-c4-route-7-contract.md | 13 + ...05-issue-319-architecture-review-round2.md | 287 ++++++++ ...026-08-05-issue-319-architecture-review.md | 421 +++++++++++ .../research/2026-08-05-issue-319-contract.md | 674 ++++++++++++++++++ ...26-08-05-issue-319-retail-review-round2.md | 281 ++++++++ .../2026-08-05-issue-319-retail-review.md | 380 ++++++++++ .../EquippedChildRenderController.cs | 135 +++- .../World/LiveEntityHydrationController.cs | 39 + .../World/LiveEntityPresentationController.cs | 20 +- .../Entities/ParentAttachmentState.cs | 75 +- .../Entities/RuntimeEntityObjectLifetime.cs | 10 +- .../RuntimeLiveEntitySessionController.cs | 13 +- .../EquippedChildProjectionWithdrawalTests.cs | 229 +++++- .../LiveEntityHydrationControllerTests.cs | 71 ++ .../LiveEntityPresentationControllerTests.cs | 66 ++ .../Entities/ParentAttachmentStateTests.cs | 217 ++++++ .../RuntimeEntityChildCellPropagationTests.cs | 34 + 19 files changed, 3076 insertions(+), 20 deletions(-) create mode 100644 docs/research/2026-08-05-issue-319-architecture-review-round2.md create mode 100644 docs/research/2026-08-05-issue-319-architecture-review.md create mode 100644 docs/research/2026-08-05-issue-319-contract.md create mode 100644 docs/research/2026-08-05-issue-319-retail-review-round2.md create mode 100644 docs/research/2026-08-05-issue-319-retail-review.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8db48f3d..d7522918 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,79 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #320 — The local player's canonical cell does not track ordinary movement (follow-up from #319) + +**Status:** OPEN +**Severity:** LOW today (no observed symptom — see below); the correctness +question is real and unresolved +**Filed:** 2026-08-05, filed in the #319 fix commit per that contract's §2.2/§4/§9 +**Component:** physics / entity lifetime / local player canonical cell + +**Description.** Retail writes the local player's cell on EVERY physics tick +(`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's +canonical `FullCellId` for the LOCAL player is written only at three edges: +login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound +Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → +`RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit +(`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). +Ordinary WASD movement passes a LANDBLOCK id, not an exact cell +(`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in +both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the +prior canonical cell for that shape. So the player's canonical cell is coarse +and mostly-frozen between teleports — see the register row this issue's fix +commit files (AP-146) for the full citation and the argument that this is +currently safe for every EXISTING consumer. + +**Why this is not #319's blast radius.** #319's fix makes a player-parented +equipped child inherit the parent's (the player's) canonical cell EXACTLY — +an equality invariant, not a freshness one. The child is stale-but-equal +wherever the player's own record already is; #319's fix does not touch the +player's own cell-writing paths at all. See +[`docs/research/2026-08-05-issue-319-contract.md`](research/2026-08-05-issue-319-contract.md) +§2.2 for the full argument that folding this into #319 would have put that +slice at route-3 scale (~418 lines) and was the wrong bundling regardless. + +**What this issue must resolve before implementation, not after (§2.2's +enumeration + §9 item 2):** + +1. Making the player's canonical cell exact-track movement touches the + deliberate landblock-preserve contract at `LiveEntityRuntime.cs:935-938` — + a generic rebucket rule, not player-specific, so changing its input + population changes it for the one caller that relies on it. +2. `Rebucketed` entity-delta publication cadence: today the player NEVER + publishes a `Rebucketed` delta during WASD (the preserve path early-outs + at `CommitRebucket`'s `previous == fullCellId`, + `RuntimeEntityObjectLifetime.cs:1965-1972`); an exact-cell commit would + publish per EnvCell crossing — audit every consumer before shipping. +3. The accepted-Position classification inputs for the LOCAL player (route 2 + and the 4b-3 `PreMergeCommittedCellId` measurement at + `TryApplyPosition:1801-1814`): a fresh committed cell changes the + pre-merge population on live correction paths AP-136/AP-138 spent four + review rounds pinning. +4. The portal-space freeze interaction + (`LocalPlayerProjectionController.Project:100-103` — the teleport owner + alone projects the destination while the local controller deliberately + retains its frozen source cell): a canonical exact-cell writer must not + race the teleport owner. +5. The `isOrdinaryRoot` family (`LiveEntityRuntime.cs:915-918`, `:3213`, + `:3323`) and the animation-scheduler local-player exclusion + (`LiveEntityAnimationScheduler.cs:183-227`). +6. **First verification step, unresolved by the #319 investigation or its + fix:** whether the local player is a `RuntimePhysicsState` spatial root, + and if so whether a stale-cell landblock retirement (a player WASD-ing + beyond the streaming radius from its last teleport, with no intervening + teleport or inbound Position) can sweep the player into + `ParkCollisionResidents`. The connected routes exercised so far all + teleport between stops, which refreshes the cell and may be masking this. + Establish this BEFORE deciding whether exact-cell tracking is even + optional — if the player can already be swept today, that is a separate, + more urgent bug independent of this issue's scope. + +**Do not implement without a fresh retail-conformance argument** — this is a +design call (which of the two cells is the source of truth for a +client-authoritative parent), not a bug fix, per the #319 contract's §2 +verdict. + ## #318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering **Status:** OPEN @@ -13263,11 +13336,60 @@ by `LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved` / ## #319 — A player-parented child never receives a canonical cell (ParentInstanceSequence hardcoded 0) -**Status:** OPEN +**Status:** FIX IMPLEMENTED, awaiting the connected acceptance gate (§7 of the +contract) and commit — NOT YET COMMITTED in this worktree. Do not mark DONE +until the gate runs and the change lands. **Severity:** LOW for the user (no observable symptom — verified, not assumed), HIGH for process (it defeats route 7's own connected gate; see below) **Filed:** 2026-08-05 **Component:** physics / entity lifetime / equipped children + +**Fix summary (implementation, revised after the dual review — retail PASS, +architecture FAIL/6 MAJORs, both 2026-08-05).** Both CreateObject-carried +producers (`EquippedChildRenderController.OnSpawn` for a raw CreateObject and +`OnCreateParentAccepted` for the same-generation `CreateParentUpdate` +envelope — the contract named only `OnSpawn`; `OnCreateParentAccepted` has the +identical structural defect and was fixed alongside it) now route through +`AcceptLateBoundCreateObjectRelation`: if the parent's live snapshot is known +at accept time (always true in production — see A6 below), stage the relation +with the parent's live `InstanceSequence`; otherwise log a loud refusal with +no state mutation (a deferred-relation queue was tried here and REMOVED after +the review; see A6). `ParentAttachmentState.CanCommitIncarnation` is a pure, +side-effect-free precondition checked BEFORE either half of a parent-attach +commit mutates anything (moved there by architecture review finding A1: the +original shape checked from inside `CommitProjection`, reached only AFTER the +canonical commit had already landed, so a mismatch tore the transaction — +canonically parented, no committed relation, a staged relation blocking +`Resolve` forever). It logs and returns `false` rather than throwing (Route +3's N3 principle: a possibly-transient condition must not be fatal on a host +that must survive long endurance sessions) — wired from both the App producer +and the headless `RuntimeLiveEntitySessionController`, both now checking it +BEFORE their canonical commit. Two structural gates +(`LiveEntityHydrationController.OnLandblockLoaded`, +`LiveEntityPresentationController.RestoreShadow`) now refuse a record with a +committed parent, closing the two call sites the contract's §3.1/§3.2 flagged +as inert-only-because-the-cell-is-zero (§3.2's premise was corrected by +architecture finding A4: the gate is a live behavior change for +CREATURE-parented children, route 7's D1 already re-cells them nonzero — see +the connected gate's Half B watch item). **A6 — the deferred-relation +question, decided:** an initial revision queued a CreateObject-carried +relation whose parent was not yet addressable and adopted the parent's live +incarnation once it arrived. Both independent reviews proved this queue was +structurally unreachable in production for BOTH producers — +`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate` +gate defers the ENTIRE CreateObject (both wire shapes) before either producer +ever runs — while it carried three latent defects of its own (a missing child +POSITION_TS gate, a placeholder-incarnation collision with the generation +filters, unbounded mid-session accumulation), exercised only by a test that +bypassed production routing. Deleted rather than fixed in place: dead code +carrying three defects is a worse trade than a loud refusal for a case the +layer above already guarantees cannot happen. New ledger-convergence tests +(dual-parent-class: child removal, parent removal, full teardown) close the +gap this decision would otherwise have left untested. Full contract: +[`docs/research/2026-08-05-issue-319-contract.md`](research/2026-08-05-issue-319-contract.md). +Follow-up filed as #320 (the local player's canonical cell does not track +ordinary movement — deliberately NOT bundled into this fix). Register: +AP-142 clause (f), AP-132 clarifying sentence, new row AP-146. **Regressed by:** `cd3129e9` (C4 route 7), which un-masked a pre-existing latent bug rather than creating it. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9f41ffa7..88ea318d 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -159,7 +159,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 102 active rows (AP-145 filed 2026-08-05, C4 route 3 round 3 (B5, issue #318) — the local-player collision-shadow presentation write goes through a direct cache `.Set()` that bypasses the publisher's own `ShadowObjects` write, self-healing only once dedup diverges; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-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) — 103 active rows (AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-145 filed 2026-08-05, C4 route 3 round 3 (B5, issue #318) — the local-player collision-shadow presentation write goes through a direct cache `.Set()` that bypasses the publisher's own `ShadowObjects` write, self-healing only once dedup diverges; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-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 @@ -168,9 +168,10 @@ 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-142 | **Filed 2026-08-04 (C4 route 7, pickup/parent/delete). AMENDED 2026-08-04 at the dual-Opus retail-conformance/architecture review round (R1/A8 MAJOR+LOW; R10 MINOR) — clause (d) added, clause (b) corrected. AMENDED AGAIN 2026-08-04 at the round-3 dual review (N1/N2/N4, B3) — clause (d)'s reasoning corrected and its risk-column scope widened; clause (e) RETIRED — the depth cap it described is deleted outright, replaced by an iterative worklist with no depth concept at all. AMENDED AGAIN 2026-08-05 at the #319 fix — clause (f) added. AMENDED AGAIN 2026-08-05 at the #319 dual-review round (retail PASS, architecture FAIL/6 MAJORs) — clause (f) rewritten: the tripwire moved above the canonical commit and no longer throws (A1), and the deferred late-bind queue A1's fix text originally described was deleted per A6 (both reviews proved it production-unreachable for both producers).** acdream collapses retail's `CPhysicsObj` pair — a `cell` pointer plus a separately-written `objcell_id` — into ONE canonical `RuntimeEntityRecord.FullCellId`, which is also the residency/liveness predicate acdream reads at 45+ sites. Four consequences, all intentional: (a) the removal path propagates ZERO to a subtree's children (withdrawal, delete, `EndGeneration`), where retail's `leave_cell` recursion nulls only each child's `cell` pointer and leaves a STALE non-zero `objcell_id` (`change_cell`'s removal tail @0x005133C1 never touches a child's id) — reproducing that stale-id residue would leave a child "resident" per every acdream predicate while retail's own gating field (`cell == nullptr`) says it is not; (b) retail's same-cell depth-1 per-tick `objcell_id` refresh (`SetPositionInternal` @0x0051539c-@0x005153d8, gated on the parent NOT crossing a cell) is subsumed by the value-idempotent propagation chokepoint (`RuntimeEntityDirectory.SetFullCell`'s "skip a child whose `FullCellId` already equals the target" guard) rather than ported as a separate tick loop — a same-value restamp is unobservable with one field playing both retail roles. **Correction (R10): this is a clean equivalence only on the REMOVAL side.** The skip ALSO prunes the child's whole subtree on a same-value WRITE, which retail's `enter_cell` does not do — it recurses over children unconditionally (@0x00510f03); only `leave_cell` prunes (@0x00510f5b, on `cell != 0`). Currently unreachable-by-construction (after D4 nothing writes a grandchild's cell independently of its own committed parent), but it is an asymmetry, not a proven equivalence; (c) the sustaining propagation itself: retail re-cells children when the parent crosses a cell, recursively, on EVERY `SetPositionInternal`/`change_cell` (@0x00515372/@0x00513390), not only at attach — acdream ports this as a single hook every canonical cell-write funnels through, so an attach-only write (the pre-existing shape) is deliberately NOT what shipped. **(d) retail's `enter_cell` gates its ENTIRE body — the write AND the recursion into children — on `this->part_array != 0` (@0x00510ed8); a child with a null part array receives nothing and its whole subtree is skipped. acdream's propagation has NO analogue and writes unconditionally. CORRECTED reasoning (round-3 review, N1/N2): the original draft of this clause argued acdream's `HasPartArray` means something semantically different from retail's `part_array` (a "renderer built a mesh" flag vs. "this CPhysicsObj has any part array"). That framing is WRONG — retail's `part_array` has exactly ONE assignment site, `CPhysicsObj::makeAnimObject` @0x0050e930 → `CPartArray::CreateSetup`, assigned @0x0050e94d, so retail's flag is ALSO a mesh-construction product; the two are near-synonyms, not different concepts. The REAL reason acdream cannot gate the canonical D1/D2 write on `HasPartArray` is LAYERING, not semantics: Slice J made the Runtime canonical layer presentation-independent by design (`docs/research/2026-07-25-slice-j1-runtime-contract-closeout.md` and the Slice J campaign generally), and `HasPartArray` is populated exclusively by App/graphical code (`EquippedChildRenderController.cs:609`, `DatLiveEntityProjectionMaterializer.cs:203`) — the canonical layer structurally cannot depend on a flag only the presentation layer ever writes, headless or not. CORRECTED scope (round-3 review): this is NOT headless-only. `PrepareAndTryRealize` calls `CommitAcceptedParentCellless` (hence D1's re-cell) BEFORE `TryRealize` sets `HasPartArray = true` at `:609` — so at the exact moment D1 runs, `child.HasPartArray` is FALSE in the GRAPHICAL host too, and gating on it would break attach there as well, not just headless. Retail has no equivalent window at all: `part_array` is assigned once at construction and `enter_cell`'s guard reads that same, already-settled field.** The guard is deliberately NOT reproduced at the canonical layer. **(e) RETIRED 2026-08-04 (round-3 review, N4/B3 — both reviews independently found the same defect).** Previously: recursion depth capped at 64 levels as hostile/buggy-server hardening. The cap's actual failure mode was worse than what it guarded against: a subtree beyond the cap was left at its PRIOR — on the withdraw path, STALE NONZERO — cell PERMANENTLY, logged only under a probe flag nobody runs by default. On the withdraw path that is the #184 shape verbatim: an entity every acdream residency predicate calls resident that retail (and clause (a) above) says is not. Shipping that inside the slice whose headline is fixing exactly this class was unacceptable. Retired by deleting the cap outright and replacing the recursion with an iterative worklist (`RuntimeEntityDirectory._propagationWorklist`), which has no stack-frame-bounded depth at all — the only limit is the number of committed relations actually in the system, matching retail's own genuinely unbounded recursion with no acdream-only cap and therefore no register row for one. **(f) Filed 2026-08-05 (#319 fix).** A CreateObject-carried parent relation (the raw spawn's `Physics.Parent` field, and the same-generation `CreateParentUpdate` envelope) names the parent's GUID and location only — neither wire shape carries a parent instance sequence, matching retail's own GUID-only attach (`PhysicsDesc::get_parent_id` @0x00558a18 → `CObjectMaint::GetObjectA` @0x00558a2d → `CPhysicsObj::set_parent` @0x00558a3e; the reverse `CObjectMaint::SetChildren` @0x00509370 hash-walks by guid with a `GetNullObject` placeholder @0x005093e6 — no instance-sequence field or comparison exists anywhere in either direction). acdream's committed-relation table is nonetheless keyed by (guid, incarnation) (clause (c)'s D1/D2 requirement), so a CreateObject-carried relation must adopt SOME incarnation to file under; it now LATE-BINDS to the parent's LIVE incarnation at accept time (`EquippedChildRenderController.AcceptLateBoundCreateObjectRelation`, both the raw-CreateObject and same-generation `CreateParentUpdate` producers) rather than the previously-hardcoded 0, which silently mis-keyed every player-parented CreateObject relation (a player's `ObjectInstance` is `Character.TotalLogins`, never 0) and defeated D1/D2 for the local player's own login equipment and every remote player's observed equipment (#319). A commit-time tripwire (`ParentAttachmentState.CanCommitIncarnation`, checked BEFORE either half of the commit mutates state — architecture review A1, 2026-08-05, moved it there after the original throw-after-canonical-commit shape was shown to tear the transaction it was built to protect) refuses (logs, returns false, never throws) rather than silently filing a relation under a mismatched incarnation whenever the parent is currently addressable. **A1 also settled A6's design question**: an initial revision queued a relation whose parent was not yet addressable through a deferred/late-bind retry mechanism; both reviews independently proved that queue was structurally unreachable in production for BOTH producers (`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate` gate defers the ENTIRE CreateObject, for both wire shapes, before either producer ever runs) while carrying three latent defects of its own (a missing child POSITION_TS gate, a placeholder-incarnation collision with the generation filters, unbounded accumulation) — it was deleted rather than fixed in place; the unaddressable-parent case now logs and refuses outright, matching the invariant the layer above already enforces. | `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` (`SetFullCell`, `PropagateFullCellToChildren`, `RefreshSnapshot`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitAcceptedParentCellless`'s D1 half, `WithdrawCommittedChildrenToCellless`); `src/AcDream.Runtime/Entities/ParentAttachmentState.cs` (`TryGetCommittedParent`, `CanCommitIncarnation`, `CommitProjection`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`); `src/AcDream.App/Rendering/EquippedChildRenderController.cs` (`AcceptLateBoundCreateObjectRelation`, `OnSpawn`, `OnCreateParentAccepted`, `PrepareAndTryRealize`); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Reproducing retail's pointer/id split would require a second field acdream's 45+ liveness call sites would then have to be individually audited for which half they mean — the single-field model is a stated, load-bearing simplification, not an oversight; see `docs/research/2026-08-04-retail-parent-cell-propagation.md` and `docs/research/2026-08-04-c4-route-7-contract.md` D2/D3/D9. Clause (f) is retail-faithful for the identical reason clauses (a)-(d) are: retail's attach has no incarnation gate on this path at all, so adopting the current holder of the guid IS the retail behavior, not an approximation of it. | A future consumer that expects retail's exact stale-`objcell_id`-under-a-null-`cell` shape (none identified) would see a fully cell-less child instead. (d)'s risk: acdream celling a child retail would leave nowhere — none identified in play against a well-behaved ACE, since a server-authored equip always names a real, DAT-resolvable Setup, and the graphical host's own brief pre-`TryRealize` window is bridged by D1 running inside the same synchronous transaction as the rest of the attach commit, not by `HasPartArray` being true. (f)'s risk: none identified against a well-behaved ACE — a CreateObject's parent guid always names the entity that currently holds it by construction. | `CPhysicsObj::change_cell` 0x00513390 (@0x005133C1 removal tail); `CPhysicsObj::enter_cell` 0x00510ed0 (@0x00510ed8 the `part_array` guard); `CPhysicsObj::leave_cell` 0x00510f50; `CPhysicsObj::SetPositionInternal` 0x00515330 (@0x0051536d branch, @0x0051539c-@0x005153d8 same-cell loop, @0x00515372 cell-change branch); `CPhysicsObj::makeAnimObject` 0x0050e930 (`CPartArray::CreateSetup` assignment @0x0050e94d); `PhysicsDesc::get_parent_id` 0x00558a18; `CObjectMaint::GetObjectA` 0x00558a2d; `CPhysicsObj::set_parent` 0x00558a3e; `CObjectMaint::SetChildren` 0x00509370 (`GetNullObject` placeholder @0x005093e6) | | AP-143 | **Filed 2026-08-04 (C4 route 7 D5, headless parent-realize drive). AMENDED 2026-08-04 at the retail-conformance review round (R7 MINOR) — this row originally described only ONE of the three checks the drive skips. Line citations corrected at the round-3 review (N3).** The graphical `EquippedChildRenderController.ValidateParentProjection` performs three retail-anchored checks before accepting a parent-attach request: (1) self-parenting rejection (`relation.ParentGuid == relation.ChildGuid`, `:915-916`); (2) the parent must have a constructed part array (`parent.HasPartArray`, `:920` — the closest acdream analogue to retail's `part_array != 0` guard, AP-142 clause d); (3) `Setup.HoldingLocations` validates the specific holding location (`CSetup::GetHoldingLocation` @0x0050F896, via `PartArray::add_child`). `AcDream.Headless`/`AcDream.Runtime`'s direct-host parent-realize drive (`RuntimeLiveEntitySessionController.ResolveAndCommitChildAttachment`) performs NONE of the three — it commits on the POSITION_TS gate acceptance and relation resolution alone. (1) is inert by construction: D1's re-cell gate reads `parent.FullCellId == 0` (the child was just zeroed by the cell-less edge before D1 runs), and D2's skip-on-equal terminates the resulting one-node cycle — a self-parent headless commits the relation but never observably re-cells through it. (2) has no headless analogue at all (see AP-142 clause d — `HasPartArray` is populated only by the graphical mesh pipeline, never headless, for ANY entity). (3) has no prepared-content surface (repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `Setup.HoldingLocations`). | `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Precedent: the content-less host already accepts reduced fidelity elsewhere (`RuntimeLiveEntitySessionController:108-117`'s documented content-less registration). A server-sent self-parent, part-array-less parent, or invalid holding location is unreachable against a well-behaved ACE (ACE only emits `ParentEvent` for a location its own `Player_Inventory`/wield validation already accepted), so this is a defense-in-depth gap, not a live-play one. | A malicious or buggy server could attach a child headless where retail and the graphical host would both reject it — inert against ACE today for all three. Retiring (3) means extending the prepared-content bake format with `Setup.HoldingLocations`, deliberately NOT done in this slice (route 7 contract §4 D5); (2) has no retiring action available until acdream's canonical layer gains its own construction-time part-array concept (a larger architectural question, out of scope here). | `PartArray::add_child` (`CSetup::GetHoldingLocation` 0x0050F896); `CPhysicsObj::enter_cell` 0x00510ed8 (the `part_array` guard); `EquippedChildRenderController.ValidateParentProjection` (graphical port, all three checks) | | AP-144 | **Filed 2026-08-05 (C4 route 3, round-3 review R7). Register discipline finding, not an implementer's disposition** — CLAUDE.md's register rule binds regardless of whether the gap has a live symptom yet. `RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal`'s teleport-arrival movement-event send gates on `!RuntimeCharacterState.UsePositionFromServer` — retail's `CommandInterpreter::UsePositionFromServer` @0x006B3B40, which is `autonomy_level != 2`. But the retail function that ACTUALLY gates this send is a different one: `CommandInterpreter::SendMovementEvent` @0x006B4680 (the `PlayerTeleported` tail-jump), which gates on `autonomy_level != 0` — the LOOSER test, excluding only level 0, satisfied by BOTH level 1 and level 2. acdream's gate reuses the STRICTER `UsePositionFromServer` test (excluding two of the three levels, 0 AND 1), built from the wrong retail function, so it sends only at level 2 and wrongly suppresses at level 1. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`ReconcileAndAcknowledgePortal`, the `!_usePositionFromServer()` guard around `TrySendMovement`); `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`UsePositionFromServer`, `AutonomyLevel`) | The two gates agree at level 0 (both suppress) and level 2 (both send); they diverge only at level 1. `RuntimeCharacterState.TrySetAutonomyLevel` has zero production callers today, so no live code path can ever reach `AutonomyLevel == 1` — the divergence is filed for completeness, not because it is currently reachable. | The instant a future feature calls `TrySetAutonomyLevel(1)` (a partial-autonomy mode, if one is ever built), a portal-arrival movement-event ACE expects to receive at level 1 is silently dropped, until this row's fix threads the raw `AutonomyLevel` through the constructor (touching both host compositions) and gates on `!= 0` directly instead of reusing `UsePositionFromServer`. | `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (`autonomy_level != 2`); `CommandInterpreter::SendMovementEvent` @0x006B4680 (`autonomy_level != 0`, the `PlayerTeleported` tail-jump call site) | +| AP-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.App/World/LiveEntityRuntime.cs:935-938` (the landblock-preserve branch); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | | AP-145 | **Filed 2026-08-05 (C4 route 3, round-3 review B5; carried as issue #318).** `RuntimePlacementPresentationSink.TryPublishPlace` updates the local player's collision-shadow pose by calling `LocalPlayerShadowState.Set` DIRECTLY — a plain cache write with no side effect beyond recording `Current`. This bypasses `LocalPlayerShadowSynchronizer.SyncPose`, the ONLY call site that actually publishes to `PhysicsEngine.ShadowObjects` (`ShadowPositionSynchronizer.Sync(_physics.ShadowObjects, ...)`) — `SyncPose` calls `_state.Set(...)` itself, AFTER publishing, as its own last step. Because `SyncPose`'s own early-return dedup check compares the NEW pose against `_state.Current` (skip if the same cell and within 1e-4 m² / 0.99999 dot-product of orientation), a portal placement's direct `Set` call pre-seeds that cache with the DESTINATION pose — so the very next `SyncPose` call (the player's first post-placement movement tick) can find "nothing changed" and skip its OWN `ShadowObjects` publish too, unless the position has already drifted (settle, gravity) past the dedup threshold by then. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, the `_localPlayerShadow.Set(...)` call); `src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs` (`SyncPose`, the dedup check and its OWN `_state.Set` call); `src/AcDream.App/Physics/LocalPlayerShadowState.cs` (`Set` — a plain cache write, no publish) | A portal placement's presentation suffix runs once per commit and its direct `Set` call is a narrow, low-frequency path; in practice the destination placement (ring search/floor snap) rarely lands EXACTLY on the pre-placement dedup-cache pose, so the next ordinary per-tick `SyncPose` call from ordinary local-player movement almost always sees the position it had already recorded and diverges from the ACTUAL committed pose by more than the threshold, triggering a real publish. This is why the risk has not been observed live. | If the destination placement happens to land within the dedup tolerance of whatever `_state.Current` held before (e.g., two placements to nearly the same spot, or the shadow was never set to begin with, or nothing calls `SyncPose` again before the player's next teleport), `PhysicsEngine.ShadowObjects` never reflects the destination — anything reading the collision shadow directly (NPC pathing around the player's own body, hit-testing) sees the PRE-teleport pose until an unrelated movement tick forces a real publish. Issue #318's composition test asserts `PhysicsEngine.ShadowObjects` directly (not the cache) to close this. | No retail analogue — retail has no separate shadow-cache/publish split; this is an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()` call can desynchronize from | | 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 | @@ -283,7 +284,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | | AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | | AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) | -| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | +| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). AMENDED 2026-08-05 at the #319 fix — clarifying sentence added distinguishing this row's producer from the CreateObject producer AP-142 clause (f) covers.** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. **This row's incarnation gate applies ONLY to the `ParentEvent` wire producer, which NAMES a specific parent incarnation on the wire (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. The CreateObject producer (AP-142 clause (f)) is different in kind: neither a raw CreateObject's `Physics.Parent` nor the same-generation `CreateParentUpdate` envelope carries a parent instance sequence AT ALL, so there is no wire-named value to gate against; that producer LATE-BINDS to the parent's live incarnation instead of gating on a wire value, which is the same "honor what the server actually sent" principle applied to a message that sent no incarnation.** | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | | AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | | AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` | | 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) | diff --git a/docs/research/2026-08-04-c4-route-7-contract.md b/docs/research/2026-08-04-c4-route-7-contract.md index 3e7891c6..dd875a11 100644 --- a/docs/research/2026-08-04-c4-route-7-contract.md +++ b/docs/research/2026-08-04-c4-route-7-contract.md @@ -747,6 +747,19 @@ App-layer tests (`tests/AcDream.App.Tests`): ## 7. Gates +> **SUPERSEDED 2026-08-05 (#319).** This section's gate criterion ("A session +> counts as a pass ONLY if the probe shows the propagation executed") is +> UNFALSIFIABLE in the presence of #319's defect — a zero-cell player child +> emits NO `[child-cell]` line at all, which this criterion reads as "clean" +> rather than "broken." Two captured gate sessions passed this exact +> criterion while carrying the defect. The corrected criterion (a positive +> equality assertion — the equipped child's `FullCellId` equals the parent's +> after a crossing — instantiated for BOTH parent classes) lives in +> [`2026-08-05-c4-closeout-handoff.md`](2026-08-05-c4-closeout-handoff.md) +> and is run at +> [`2026-08-05-issue-319-contract.md`](2026-08-05-issue-319-contract.md) §7. +> Do not re-run this section's recipe as written; use the corrected one. + - **Focused**: the §6 suites, green. - **Complete Release suite**: `$env:ACDREAM_PAK_PATH = "$env:USERPROFILE\Documents\Asheron's Call\acdream.pak"`, diff --git a/docs/research/2026-08-05-issue-319-architecture-review-round2.md b/docs/research/2026-08-05-issue-319-architecture-review-round2.md new file mode 100644 index 00000000..7e42db50 --- /dev/null +++ b/docs/research/2026-08-05-issue-319-architecture-review-round2.md @@ -0,0 +1,287 @@ +# Issue #319 — architecture / adversarial review, ROUND 2 (delta) — 2026-08-05 + +**Verdict: FAIL — one blocking finding, a one-line test change.** + +Round 1: [`2026-08-05-issue-319-architecture-review.md`](2026-08-05-issue-319-architecture-review.md). + +**Every production concern from round 1 is closed, and I verified each by +reading the code rather than accepting the summary.** The A1 ordering fix is +correct on both hosts, the A6 deletion argument is provable and all three of +its links check out, and A2/A3/A5 are gone by deletion rather than relocated. +The single blocker is evidentiary: the new test that exists specifically to +guard the A1 ordering property has three headline assertions that are +**vacuous in its own fixture**, so the guard against the exact regression it +was written for is one incidental assertion. That is fixable by changing one +constant. + +Verification performed for this round: + +- `dotnet build -c Release` — succeeded, 0 warnings, 0 errors. +- `AcDream.Runtime.Tests` (`ParentAttachmentStateTests`, + `RuntimeEntityChildCellPropagationTests`) — **38/38** (was 32). +- `AcDream.App.Tests` (`EquippedChildProjectionWithdrawalTests`, + `LiveEntityHydrationControllerTests`, `LiveEntityPresentationControllerTests`, + `LiveEntityRuntimeTests`) — **208/208** (was 206). +- Production delta measured independently: **76 non-comment lines added, 13 + removed** — matches the stated 76/13, down from 91/24. + +--- + +## B1 — MAJOR, BLOCKING — `PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit`'s three canonical assertions cannot fail, in either ordering + +**`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs`**, +the new A1 test. + +The test injects `wrongRelation` with `ChildPositionSequence: 1` against a +child created by `fixture.RegisterOnly(childGuid, generation: 1, hasPosition: true)`. +That helper builds the spawn from `ControllerFixture.SpawnData`, which sets +`Timestamps.Position: 0` and never sets the top-level `PositionSequence` +(`WorldSession.EntitySpawn`'s `ushort PositionSequence = 0` default, +`WorldSession.cs:163`). Unlike its sibling +`NoPositionCreateParent_CommitsAfterParentPartArrayValidation`, this test does +**not** call `TryApplyCreateParent`/`TryApplyParent` first, so nothing ever +advances the child's POSITION_TS. + +The canonical half is +`CommitStagedParent` → `RuntimeEntityObjectLifetime.TryCommitParent:1406-1412` +→ `InboundPhysicsStateController.TryCommitParent:299-312`, whose gate is: + +```csharp +if (!TryGet(childGuid, out gate, out child) + || gate.PositionTimestamp != positionSequence // 0 != 1 + || child.PositionSequence != positionSequence) // 0 != 1 +{ accepted = default; return false; } +``` + +`positionSequence` is `relation.ChildPositionSequence` = 1; both gate values +are 0. **The canonical commit refuses on POSITION_TS before the incarnation is +ever consulted — in either ordering.** + +**Consequence.** Revert the fix (put `CanCommitIncarnation` back after +`CommitStagedParent`, or delete it entirely) and trace the test: + +| assertion | reverted-order outcome | +|---|---| +| `Assert.Null(snapshot.ParentGuid)` | **still passes** — `TryCommitParent` refused, snapshot untouched | +| `Assert.NotNull(snapshot.Position)` | **still passes** — same reason | +| `Assert.False(TryGetCommittedParent(...))` | **still passes** — `CommitProjection` never reached | +| `Assert.False(TryGetStagedProjection(...))` | fails — `return default` leaves the relation staged | + +So the test does fail under sabotage — the letter of "every new test must fail +against broken behaviour" is met — but it fails on the *stranding* assertion, +not the *tearing* one. The claim relayed to me ("asserts the child's snapshot +stays un-parented; sabotage-verified by reverting the order") is literally true +and materially misleading: the snapshot assertions never execute against a live +canonical commit, so they assert nothing. + +**Why this blocks rather than being cosmetic.** The regression this guard +exists to catch is "someone lets the canonical half run before the incarnation +check." A plausible future variant — moving the check back inside +`CommitProjection` while *also* calling `RejectProjection` on refusal — would +re-tear the transaction and **pass this test on all four assertions**. The +guard does not cover its own finding. + +**Fix (one line).** Change `wrongRelation`'s `ChildPositionSequence: 1` to `0` +so it matches the fixture's gate. Then under a reverted ordering +`TryCommitParent` succeeds, nulls `snapshot.Position`, writes `ParentGuid`, and +all three canonical assertions bite — while the fixed code still refuses at +`CanCommitIncarnation` (which reads only the parent incarnation and is +independent of POSITION_TS) and `ValidateParentProjection` still returns +`Ready`. Re-run the sabotage and confirm the failure message names one of the +three snapshot assertions, not the staged-projection one. + +--- + +## What I verified as CLOSED + +### A1 — ordering fix: torn-transaction window genuinely closed on both hosts ✓ + +- `ParentAttachmentState.CanCommitIncarnation:589-605` is genuinely pure: it + reads `resolveParentInstance` and writes only stderr. No table is touched. +- **Graphical** (`EquippedChildRenderController.cs:974-989`): the pre-check runs + before `CommitStagedParent`, refuses via `RejectProjection`, and returns + `CanAdvanceWireQueue: true`. Correct on three counts I checked separately: + (i) no canonical mutation precedes it; (ii) `RejectProjection` clears + `_stagedByChild`, so `Resolve`'s early return (`:457-458`) is unblocked — + the round-1 "stranded forever" outcome is gone; (iii) `CanAdvanceWireQueue: true` + lets `ResolveAndTryRealize`'s `while (true)` loop continue, and it still + terminates, because each iteration either breaks or consumes one relation from + the finite `_unresolvedByChild` queue, and `Resolve` can only re-stage on + exact incarnation equality — which is precisely the condition + `CanCommitIncarnation` accepts. +- **Headless** (`RuntimeLiveEntitySessionController.cs:408-417`): same + pre-check, before `TryCommitParent`, with `RejectProjection` + `return false`. +- `CommitProjection` now returns `false` instead of throwing, and its internal + check (`:628-629`) sits after the `_stagedByChild` lookup but before + `RemoveCommittedChild`/`_lastAcceptedByChild`, so it mutates nothing before + refusing. +- The residual window I looked for does not exist: between the pre-check and + `CommitProjection`, the only mutation is `TryCommitParent` writing the + **child's** snapshot; the parent's `_snapshots` entry — the tripwire's input — + is untouched, so the two reads cannot disagree. + +Route 3's N3 principle is now honoured: the condition is refused loudly and +recoverably, never fatally. + +### A6 — the deletion argument: all three links independently verified ✓ + +This was the justification for deleting rather than fixing, so I checked it +rather than accepting it. + +1. **`RegisterEntityCore` defers the whole CreateObject.** + `RuntimeEntityObjectLifetime.cs:795-812`: + `uint parentGuid = incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u;` + then `if (beginInitialResidence && parentGuid != 0u && !Entities.TryGetActive(parentGuid, out _))` + → `EnqueueDeferredCreate` + `DeferredForParent: true`. Confirmed this is the + **earliest** branch in the method that can admit a create — it precedes + `PreviewCreateDisposition` (`:818`), the pending-residence early return, and + `AcceptCreate` (`:866-868`). +2. **`beginInitialResidence` is true for every graphical-host CreateObject.** + `LiveEntityRuntime.cs:583` is the sole App registration entry and uses + `RegisterEntityWithInitialResidence`. The three internal replay/drain sites + (`RuntimeEntityObjectLifetime.cs:234`, `:317`, `:400`) also use it, so the + gate re-applies on deferred-create replay rather than being bypassed by it. +3. **`CreateParentUpdate` cannot precede the gate.** It exists only inside + `InboundCreateResult.SameGenerationEvents`, produced by + `BuildSameGenerationEvents` in `InboundPhysicsStateController.AcceptCreate` + (`:87`) / `AcceptCreateDeferredSameGeneration`, both called at + `RuntimeEntityObjectLifetime.cs:866-868` — after the gate. I traced every + consumer and all three originate there: + `LiveEntityHydrationController.cs:340` (`result.SameGenerationEvents`), + `LiveEntityNetworkUpdateController.cs:400-401` (via + `ApplySameGeneration`/`LiveEntitySameGenerationUpdateRouter`), and + `RuntimeEntityObjectLifetime.cs:2544` (`AdmitSameGenerationCreate`, also + post-gate). + +**The chain holds.** `AcceptLateBoundCreateObjectRelation`'s `else` branch is +structurally unreachable for both producers, and the deletion removes no real +path. + +### A2 / A3 / A5 — gone by deletion, not relocated ✓ + +Repo-wide grep for `DeferCreateObjectRelation`, `LateBindParentInstance`, and +`lateBind` returns **zero** hits in `src/` and `tests/` (only unrelated +`InteractionUiLateBindings` matches). `ParentAttachmentState.Resolve` is +byte-identical to HEAD — the diff touches only `CanCommitIncarnation` and +`CommitProjection`, and `ParentAttachmentRelation` gains no field. So: + +- **A2** (missing child POSITION_TS gate on a relation that skipped `accept`) — + no relation skips `accept` any more; `Resolve`'s single path is the original + ParentEvent one. +- **A3** (placeholder `ParentInstanceSequence = 0` misread by + `FilterParentCandidates`) — no relation is enqueued with a placeholder; the + only unresolved-queue writer is the pre-existing `Enqueue`. +- **A5** (unbounded accumulation of deferred CreateObject relations) — that + population no longer exists. The surviving `_unresolvedByChild` accumulation + question is pre-existing ParentEvent behaviour, untouched by this fix. + +The replacement — a loud stderr line with **no state mutation** — is the right +trade: if the upstream invariant ever breaks, the failure mode is a +non-attached (cell-less, position-less) child plus a log line, not a resurrected +cross-generation relation. + +### Test 9 — covers the shapes I named ✓ + +Three dual-class tests in `ParentAttachmentStateTests`. Checked against the A2/A5 +shapes specifically: + +- `LedgerConvergence_ChildRemoval_ZeroesEveryTable` asserts all four counters + (`CommittedRelationCount`, `RecoveryRelationCount`, `StagedRelationCount`, + `UnresolvedRelationCount`) **plus** the `_committedChildrenByParent` reverse + index via `ChildrenAttachedToParent` — that reverse index is the table + round 1's stranding scenarios would have leaked into, and asserting it + separately from `HasCommittedParent` is the right call. +- `LedgerConvergence_ParentRemoval_ZeroesEveryTable` covers `RemoveObject`'s + parent-reference sweep — the A2 "relation outlives its parent" half. +- `LedgerConvergence_Teardown_ZeroesEveryTableWithMixedPendingState` holds a + committed child and an unresolved ParentEvent simultaneously before `Clear()` + — the A5 mixed-state shape. + +Each fails against broken behaviour non-vacuously (they establish nonzero +counts before the removal and assert zero after, so a no-op removal fails). + +### A4 — sufficient ✓ + +The `RestoreShadow` comment now states the correction plainly (route 7's D1 +already gives creature-parented children a nonzero cell, so the clause **is** a +live behaviour change for that class), and contract §7 Half B now carries the +explicit watch instruction (`:549`, `:554`). Given the population is bounded by +`ShadowObjectRegistry.UpdatePosition`'s unregistered-entity no-op +(`ShadowObjectRegistry.cs:696-697`), naming it in the connected gate is the +right resolution — a synthetic test cannot settle it, and the gate is where it +gets observed. + +### A8 — confirmed non-blocking ✓ + +`AcceptCreateObjectRelation` remains public with an optional resolver. With A1's +pre-check now at both production sites, a future producer regression is caught +before any mutation, so the cost of tightening the API is not worth the ~12 +test call sites. + +--- + +## Non-blocking observations + +**B2 — LOW — the gate and the producer test different oracles.** The upstream +gate (`RuntimeEntityObjectLifetime.cs:797`) uses `Entities.TryGetActive` +(active-record table); `AcceptLateBoundCreateObjectRelation` uses +`_liveEntities.TryGetSnapshot` (the `InboundPhysicsStateController._snapshots` +map). Active ⇒ snapshot holds because `AddActive` is fed from the snapshot +`AcceptCreate` wrote, but there is a narrow inversion inside `TryDeleteEntity`: +`TryDelete` removes `_snapshots[guid]` (`InboundPhysicsStateController.cs:101-102`) +several statements before `RemoveActive` (`RuntimeEntityObjectLifetime.cs:~2094`). +Reaching the `else` branch through it would need a re-entrant child CreateObject +inside that window — not reachable from a single-threaded pump. Worth one +sentence in the remark, since the remark's argument is phrased in the gate's +predicate but enforced with a different one. + +**B3 — LOW — the structural invariant is host-scoped and the remark does not +say so.** `RuntimeLiveEntitySessionController.cs:144` calls +`Entities.RegisterEntity` (`beginInitialResidence: false`) when +`_worldProjection is null`, which bypasses the parent-deferral gate entirely. +That host has no `EquippedChildRenderController`, so the producer is +unreachable there — but the remark reads as an unconditional claim. One clause +("in the graphical host; the content-less direct host has no CreateObject-carried +relation producer at all") would make it audit-proof. + +**B4 — INFO — the refusal is unconditionally destructive.** `RejectProjection` +discards the relation, where `Resolve`'s equivalent staleness rule +(`ParentAttachmentState.cs:484-498`) is conditional: a relation naming a +*newer*-than-live parent generation is retained until that generation arrives. +Unreachable today, because a staged relation can only have been staged on exact +equality — but if the staging rules ever loosen, discard is the wrong arm for +the packet-ahead case. + +**B5 — INFO — `CommitProjection`'s XML doc slightly overclaims.** "non-tearing +on its own terms for ANY caller" is true of *this method's* tables, not of a +caller's canonical state: a caller that commits canonically first and then calls +`CommitProjection` still tears when the internal check refuses. That is exactly +the shape A1 removed, and both production sites now pre-check, so the guard is +genuine belt-and-braces — the sentence just shouldn't imply it protects callers +who get the ordering wrong. + +**B6 — LOW — unbounded stderr on a per-packet path.** Both new +`Console.Error.WriteLine` sites (`AcceptLateBoundCreateObjectRelation`'s `else`, +`CanCommitIncarnation`'s refusal) log unconditionally. If the invariant ever +breaks for a repeating producer, this spams once per packet. A log-once-per-guid +latch would suit the "should be structurally unreachable — investigate if seen" +framing better. + +**B7 — INFO — test 9's scope.** The three tests exercise `RemoveChild`, +`RemoveObject`, and `Clear` — not `DeleteGeneration`/`EndGeneration`, the two +paths carrying the `WaitOwner is Parent` retention rule +(`ParentAttachmentState.cs:819-821`, `:844-846`). That rule is pre-existing and +unchanged by this fix, so it is legitimately out of scope; the ledger claim +simply should not be read as covering generation boundaries. + +--- + +## What a PASS needs + +**One change:** `ChildPositionSequence: 1` → `0` in +`PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit`'s +`wrongRelation`, then re-run the ordering sabotage and confirm the failure names +a snapshot assertion rather than the staged-projection one. + +Everything else in this round is PASS-quality. B2/B3 are one-clause comment +improvements; B4–B7 are informational. diff --git a/docs/research/2026-08-05-issue-319-architecture-review.md b/docs/research/2026-08-05-issue-319-architecture-review.md new file mode 100644 index 00000000..89c78f01 --- /dev/null +++ b/docs/research/2026-08-05-issue-319-architecture-review.md @@ -0,0 +1,421 @@ +# Issue #319 — independent architecture / adversarial review (2026-08-05) + +**Verdict: FAIL.** + +Reviewed: the uncommitted working tree at branch +`claude/acdream-physics-divergence-5aa784`, HEAD `af828a8a` (`git diff HEAD` +plus the untracked contract doc). Production delta measured independently: +**91 non-comment lines added, 24 removed** across five files — the +implementer's claim 3 is exact. + +Verification performed for this review (not inherited): + +- `dotnet build -c Release` — green (exit 0). +- `AcDream.Runtime.Tests` filtered to `ParentAttachmentStateTests` + + `RuntimeEntityChildCellPropagationTests` — 32/32 pass. +- `AcDream.App.Tests` filtered to `EquippedChildProjectionWithdrawalTests` + + `LiveEntityHydrationControllerTests` + `LiveEntityPresentationControllerTests` + + `LiveEntityRuntimeTests` — 206/206 pass. + +The key fix (F1's staged half) is correct, well-anchored, and its tests are +genuinely sabotage-sensitive. The bookkeeping (AP-142 clause (f), AP-132 +clarification, new AP-146, issue #320, route-7 §7 supersession note) is the +most thorough in the campaign so far. **The FAIL rests on two things: the +commit-time tripwire is wired at the wrong point in the transaction so that +when it fires it tears the commit it was built to protect (A1), and the +deferred late-bind branch — the larger and more novel half of F1 — is +production-unreachable, carries a freshness hole its ParentEvent sibling does +not, and its one test reaches it only by bypassing the production seam under +an incorrect stated rationale (A2, A3, A6).** + +--- + +## A1 — MAJOR — the tripwire throws *after* the canonical parent commit has already landed; when it fires it produces a torn transaction, which is the one outcome F1 pinned against + +**`src/AcDream.App/Rendering/EquippedChildRenderController.cs:955-963`** + +```csharp +if (candidateKind is ParentProjectionCandidateKind.Staged) +{ + if (!_liveEntities.CommitStagedParent(relation, out _) // canonical commit + || !Relations.CommitProjection(relation, ResolveLiveParentInstance)) // throws here + { + return default; + } +``` + +**`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:408-412`** — +identical ordering: + +```csharp +if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _) + || !relations.CommitProjection(staged, _resolveParentInstance)) +``` + +`CommitStagedParent`/`TryCommitParent` is the *canonical* half. It runs +`InboundPhysicsStateController.TryCommitParent` → `ApplyParent` +(`InboundPhysicsStateController.cs:1347-1373`), which nulls the child's +snapshot `Position`, writes `ParentGuid`/`ParentLocation`/`PlacementId`, and +stamps POSITION_TS. Only *then* does `CommitProjection` +(`ParentAttachmentState.cs:651-661`) evaluate the tripwire and throw. + +**Failure scenario.** Any input that reaches the tripwire with a mismatch: +the child's canonical snapshot has already been rewritten as parented (its +world Position destroyed, POSITION_TS advanced), `_lastAcceptedByChild` has +*not* been written, and the relation is still sitting in `_stagedByChild`. +The exception then unwinds through `PrepareAndTryRealize` → `Tick()` / +the inbound sink. Net state: a child that the canonical layer believes is +parented, with no committed relation for D1/D2 to find, and a staged relation +that `Resolve`'s early return (`ParentAttachmentState.cs:457-458`) will +now block forever for that child. + +That is strictly worse than the silent mis-keyed commit #319 shipped. The +contract's F1 pinned outcome was "**never a silent success under a mismatched +key**"; the XML doc on `CommitProjection` promises it "refuses loudly rather +than silently filing the relation under a key D1/D2 can never find again." +Neither is what the code does — it half-files, then throws. + +**On reachability, stated honestly.** I traced every path that can advance a +parent's live `InstanceSequence` while a staged relation naming that parent +survives, and found none reachable today: + +- Both producers set the sequence from the live snapshot at stage time + (`EquippedChildRenderController.cs:845-857`; `ParentAttachmentState.Resolve` + `:473-498`). +- The only incarnation bump is a `NewGeneration` CreateObject, and + `RuntimeEntityObjectLifetime.cs:1004-1010` calls + `ParentAttachments.EndGeneration(...)` → `RemoveParentReferences(_stagedByChild, guid)` + (`ParentAttachmentState.cs:825`), purging every staged relation naming that + parent. +- Delete removes both the gate and the snapshot + (`InboundPhysicsStateController.cs:101-102`) and is immediately followed by + `DeleteGeneration` (`RuntimeEntityObjectLifetime.cs:2088-2090`), which purges + the same way — so after a delete the producers *defer* rather than stage a + stale key, and the next create is `InitialGeneration` (no `EndGeneration` + needed). + +So the mismatch is unreachable — **but only as an emergent property of a +three-file invariant chain that nothing records**, and one link is +order-fragile: `AcceptCreate` publishes the new incarnation into `_snapshots` +*before* `RegisterEntityCore` reaches `EndGeneration`, with `RemoveActive` and +`WithdrawCommittedChildrenToCellless` (which publishes deltas to synchronous +App observers) executing inside that window. + +This is exactly route 3's N3 shape +(`docs/research/2026-08-04-c4-route-3-retail-review-round2.md:346-362`): +"Refusing to fake success is right; converting a possibly-transient condition +into a process-killing throw on the host that must survive K4's 30-session / +two-hour endurance profile is the wrong end of that trade." The tripwire's own +comment asserts a diagnosis it cannot establish — "a mismatch here can only be +a producer regression" — when a parent replacement whose purge ordering ever +changed would produce the same mismatch. On the headless site the throw lands +in `OnParentUpdated`, an inbound sink; `HeadlessSessionHost.cs:52/64` will +quarantine it, i.e. one bot session of 30 dies rather than the process — still +a lost endurance row, and the torn canonical state above is what it dies +holding. + +**Fix direction (cheap, no redesign).** Move the incarnation check *above* +`CommitStagedParent`/`TryCommitParent` at both sites and make it a refusal, +not a throw: log loudly (the codebase's existing refusal idiom) and return +`false`, so the staged relation is rejected (`RejectProjection`) rather than +left to block `Resolve`. That satisfies F1's pinned outcome exactly — no +silent success under a mismatched key — without a fatal, and without tearing +the transaction. If the project wants a hard assertion, it belongs in a +`Debug.Assert`/test-only seam, not on the live packet path. + +--- + +## A2 — MAJOR — a deferred late-bind relation skips `accept`, so it carries no child-freshness gate at all, and is retained across the child's own generation boundary + +**`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:506-524`** (the +`if (!lateBind)` wrapper around the `accept(update)` call) and **`:412-426`** +(`DeferCreateObjectRelation` enqueues with `WaitOwner = Parent`). + +For every ParentEvent-sourced relation, `accept` is +`TryApplyParent`/`TryAcceptParentForProjection` → the child's +`PhysicsTimestampGate.TryAcceptPositionChannelEvent` +(`InboundPhysicsStateController.cs:258-273`). That call does two things: it +*gates* the relation on the child's POSITION_TS, and it *advances* the child's +gate to `update.ChildPositionSequence` — which is precisely what makes the +subsequent `TryCommitParent` gate +(`InboundPhysicsStateController.cs:299-312`: `gate.PositionTimestamp != +positionSequence || child.PositionSequence != positionSequence`) satisfiable. + +A late-bind relation skips both. Its only remaining gate is that same +`TryCommitParent` equality — against a `ChildPositionSequence` captured when +the relation was *enqueued*, possibly many packets earlier. + +Compounding it: `DeleteGeneration` and `EndGeneration` both run +`FilterChildCandidates(guid, relation => relation.WaitOwner is +ParentAttachmentWaitOwner.Parent)` (`:819-821`, `:844-846`) — i.e. they +**retain** relations whose `WaitOwner` is `Parent`. `DeferCreateObjectRelation` +sets `WaitOwner = Parent` at enqueue, and `Resolve`'s re-queue would set it +anyway, so a deferred late-bind relation survives its own child's delete or +replacement. + +**Failure scenario.** Child C's CreateObject/`CreateParentUpdate` defers a +late-bind relation naming parent P (P unaddressable). C is then replaced by a +new generation (`EndGeneration(C, n+1)` — an ordinary re-observe shape) or +deleted and its GUID recycled. The relation survives. P later becomes +addressable → `RetryWaitingDescendants(P)` → `Resolve(C, …)` → the late-bind +branch adopts P's *current* incarnation and stages, with no `accept` gate to +reject the stale `ChildPositionSequence`. Two outcomes, both bad: + +- `TryCommitParent`'s POSITION_TS equality fails (the new child generation has + a different stamp) → `CommitStagedParent` returns false → + `PrepareAndTryRealize` returns `default` → **the relation is stranded in + `_stagedByChild` permanently.** `Resolve`'s early return (`:457-458`) then + blocks every subsequent legitimate parent relation for that child for the + rest of the session, and `CopyPendingProjectionChildrenTo` retries it every + frame forever. +- Or the stamps happen to coincide and the attach commits — binding a dead + wire event's relation onto a new child incarnation and whatever object now + holds P's GUID. This is AP-132's documented GUID-reuse hazard, applied on the + one producer that now has no gate at all. + +Note the pre-fix behaviour self-healed: `AcceptCreateObjectRelation` +(`:391-394`) is an unconditional `_stagedByChild[child] = relation` +assignment, so a newer relation always superseded a stuck one. Routing through +the queue removes that self-healing property. + +**Reachability caveat, stated honestly:** see A6 — the deferred branch appears +to be production-unreachable today, so this is latent, not live. It is still a +MAJOR defect in newly added code, because the code's stated purpose is to be +the fail-safe. + +**Fix direction.** Either (a) subject late-bind relations to the same child +POSITION_TS gate (call `accept` and let it advance the stamp — the retail +argument for skipping it is about the *parent* incarnation, not the *child* +timestamp), or (b) if the branch stays as a pure fail-safe, do not enqueue it +with `WaitOwner = Parent` and add an explicit child-incarnation field so the +generation filters can drop it with its child. + +--- + +## A3 — MEDIUM — the deferred relation carries a placeholder `ParentInstanceSequence = 0` that the generation filters read as a wire-named incarnation + +**`src/AcDream.App/Rendering/EquippedChildRenderController.cs:838-844`** builds +the relation with the literal `ParentInstanceSequence: 0` and hands that exact +value to `DeferCreateObjectRelation` when the parent is unknown. The +`LateBindParentInstance` flag records "this 0 is meaningless" — but only +`Resolve` reads the flag. + +`EndGeneration` (`ParentAttachmentState.cs:828-833`) and `DeleteGeneration` +(`:853-857`) both filter the unresolved queue with predicates that compare +`relation.ParentInstanceSequence` against a real generation via +`PhysicsTimestampGate.IsNewer`. For a late-bind relation with the placeholder +0 and a *player* parent (`TotalLogins` ≥ 1, never 0): + +- `EndGeneration(P, 8)`: `0 == 8` false; `IsNewer(8, 0)` false → **dropped.** +- `DeleteGeneration(P, 7)`: `IsNewer(7, 0)` false → **dropped.** + +**Failure scenario.** A late-bind relation whose semantic is "attach to +whatever currently holds this GUID" is judged as "names generation 0, which is +older than the replacement" and silently discarded at exactly the moment the +replacement it should bind to arrives. Player-class only — #319's own failure +signature, reintroduced in a narrower window. + +Not reachable today only because a `NewGeneration` disposition requires a +pre-existing snapshot, and a pre-existing snapshot means the producer would +have taken the *staged* branch rather than deferring. That coupling is +undocumented and is not a property either file states. + +**Fix direction.** Make `FilterParentCandidates`'s callers retain +`LateBindParentInstance` relations unconditionally (they name no generation to +compare), or carry a sentinel the filters can recognise. + +--- + +## A4 — MEDIUM — the `RestoreShadow` gate is *not* behaviour-preserving at HEAD; contract §3.2's premise is wrong for creature-parented children + +**`src/AcDream.App/World/LiveEntityPresentationController.cs:229`** + +The contract argues (§3.2) that `RestoreShadow` "no-ops today on +`record.FullCellId == 0`", so the new `HasCommittedParent` clause is inert at +HEAD and only matters post-fix. **That premise holds only for +player-parented children.** Route 7's D1 already re-cells *creature*-parented +children to a nonzero cell — that is exactly the class route 7 shipped and +gated. So today an NPC's wielded weapon that has a shadow registration and +crosses a Hidden→Visible edge reaches `ShadowPositionSynchronizer.Sync` and +gets its broadphase row refreshed; with this gate it no longer does. + +The change is in the *right* direction — route 7's P4 record +(`RuntimeEntityDirectory.cs:451-465`) says a committed child never owns a +broadphase row, and `ShadowObjectRegistry.UpdatePosition` +(`ShadowObjectRegistry.cs:696-697`) no-ops for an unregistered entity, so the +live population is bounded by "children that already carry a registration." +But this is a live behaviour change on a shipped path, justified in the +contract by a premise that does not hold, and route 7's connected gate never +exercised it. + +**Failure scenario if the premise is wrong in the other direction:** a +formerly-world object that is picked up and equipped keeps a suspended +registration in `_shadows` that `RestoreShadow` will now never restore, while +`_suspendedShadowOwners` keeps its key (`:203` is only reached when `restored` +is true). `Forget`/`Clear` (`:111-127`) converge it at teardown, so this is +bounded — but it is a state the contract did not analyse because it assumed +the population was empty. + +**Fix direction.** Not a code change necessarily — but the creature-parented +half must be named in the connected gate (§7 Half B) as a thing to watch, and +the contract's §3.2 "correct today" claim corrected. The test +`CommittedChild_HiddenThenVisible_NeverInstallsShadowRow` does assert against +the real `ShadowObjectRegistry` (the fixture pre-registers the entity at +`LiveEntityPresentationControllerTests.cs:700-710`), so the AP-145/#318 rule +is honoured — good. + +--- + +## A5 — MEDIUM — unbounded accumulation of deferred late-bind relations (the un-written §6 test 9 would have caught this) + +`_unresolvedByChild` is removed for a child only by `RemoveChild`/`RemoveObject` +(`ParentAttachmentState.cs:782`, `:878`) or by `Clear()` (`:890`), and per +parent guid by `RemoveObject(parentGuid)` (`:788-798`) and the two generation +filters. A late-bind relation naming a parent GUID that **never becomes +addressable and is never itself deleted** (an equip whose holder stays outside +visibility) is removed by none of them and is retained across the child's own +delete by the `WaitOwner is Parent` rule (A2). + +Each such relation is a permanent `+1` on `PendingRelationCount` and a +permanent per-parent-spawn scan cost in `ChildrenWaitingForParent` +(`:715-719`, a `queue.Any(lambda)` per child). Over a two-hour, 30-session +endurance run this accumulates monotonically. `Clear()` converges it at reset, +so a teardown-only ledger assertion would pass — which is exactly why the +missing §6 test 9 is not the whole answer here. + +**Fix direction.** Bound the unresolved queue per child (the existing +`Enqueue` has the same shape, so this is a pre-existing class the new producer +widens), or age deferred late-bind relations out. + +--- + +## A6 — MEDIUM — the deferred branch is production-unreachable for *both* producers, and its only test reaches it by bypassing the production seam under an incorrect stated rationale + +Implementer claim 1 is **verified** for the raw CreateObject path: +`RuntimeEntityObjectLifetime.cs:797-812` defers the whole CreateObject when +`beginInitialResidence && parentGuid != 0u && !Entities.TryGetActive(parentGuid, out _)`, +and `TryGetActive` ⇒ `TryGetSnapshot` (the snapshot is written by +`AcceptCreate` before `AddActive`). So `OnSpawn` never sees an unaddressable +parent. + +**But the same gate covers `CreateParentUpdate`.** The test's own justification +(`EquippedChildProjectionWithdrawalTests.cs`, the doc comment on +`OnCreateParentAccepted_ParentNotYetKnown_DefersThenLateBindsOnArrival`) says +`CreateParentUpdate`'s producer "has no equivalent parent-addressability +precondition." That is wrong: `CreateParentUpdate` is manufactured by +`BuildSameGenerationEvents` inside the very `RegisterEntityCore` call whose +line-797 gate reads `incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid` — +a same-generation CreateObject carrying a Parent hits the identical deferral. +The test reaches the deferred branch only because it calls +`fixture.Live.TryApplyCreateParent(...)` and `fixture.Controller.OnCreateParentAccepted(...)` +directly, below the routing layer that would have prevented it. + +So `DeferCreateObjectRelation`, the `LateBindParentInstance` field, and +`Resolve`'s late-bind branch — roughly half of F1's added lines and all of its +novel state — are **dead in production**, and they carry A2's freshness hole +and A3's placeholder-sequence hazard. A fail-safe that resurrects relations +across generation boundaries with no timestamp gate is not a safer state than +the assertion it replaces. + +**Fix direction.** Either establish a real production path (and then test it +through the production seam), or reduce the branch to something with no +independent failure modes — e.g. refuse the relation outright with a loud log +when the parent is unaddressable, which is what the layer above already +guarantees cannot happen. + +--- + +## A7 — LOW — the hydration gate is correctly scoped + +**`src/AcDream.App/World/LiveEntityHydrationController.cs:561-562`** — verified +behaviour-preserving at HEAD and necessary post-fix, on stronger grounds than +the contract gave: + +- `LiveEntityRecord.ProjectionCellId` (`LiveEntityRuntime.cs:387-389`) is + `WorldEntity is not null ? FullCellId : Snapshot.Position?.LandblockId ?? + FullCellId`. A committed child always has a null snapshot `Position` + (`ApplyParent` nulls it, `InboundPhysicsStateController.cs:1358/1366`), so + all three sources of `projectionCellId` collapse to `FullCellId` — 0 today, + the parent's cell post-fix. The gate is therefore exactly a no-op at HEAD + for both parent classes and exactly required after. +- The `continue` skips all three downstream branches (`CreateSupersessionRecovery`, + `RebucketLiveEntity`, `SpatialRecovery`), which is correct: none of them was + reachable for this population before, so nothing legitimate is newly dropped. +- The predicate matches the precedent it cites + (`RuntimeSetPositionState.IsAffectedCollisionResident`), and stale committed + entries for a replaced child are cleared by `RemoveCommittedChild` inside + both generation paths. + +No finding beyond noting the gate is GUID-keyed while the loop iterates +canonical records; that is safe today only because `_lastAcceptedByChild` is +purged on every child generation change. + +--- + +## A8 — LOW — `AcceptCreateObjectRelation` remains a public, unguarded producer + +`ParentAttachmentState.cs:391-394` still accepts any `ParentInstanceSequence` +and is called raw from twelve test sites. The tripwire is the only thing +standing between a future caller and #319 verbatim — and per A1 that tripwire +is optional (`resolveParentInstance` defaults to `null`) and fires after the +canonical commit. Consider making the parameter required, or making the +late-bound wrapper the only public entry. + +--- + +## Implementer claims — adjudicated + +| claim | verdict | +|---|---| +| 1. `OnSpawn`'s deferred branch is structurally unreachable in production | **VERIFIED** (`RuntimeEntityObjectLifetime.cs:797-812`). But it is unreachable for the `CreateParentUpdate` producer too, which the diff and its test both deny — see A6. Not acceptable as an untested fail-safe in its current shape. | +| 2. Sabotage: restoring the literal `0` fails the player row via the tripwire before the value assertion | **CONCERN REFUTED.** With the tripwire removed and the literal `0` restored, `CommitProjection` returns true and files `(parentGuid, 0)`, so `Assert.Equal(parentIncarnation, committedInstance)` fails on its own; the D1 assertion (`childCanonical.FullCellId == parent.Canonical.FullCellId`) and the D2 assertion also fail independently, because `CommitAcceptedParentCellless`'s `parent.Incarnation == parentInstanceSequence` gate and `ChildrenAttachedToParent(guid, Incarnation)` both miss. The matrix proves what it claims. | +| 3. 91 added / 24 removed, net +67 vs a 35–80 estimate | **VERIFIED exactly.** The overshoot is entirely the tripwire plumbing plus the second commit site — i.e. the two things A1 says should be restructured. | + +## §6 gaps — do they block? + +- **Test 5 (residence non-event)** — **does not block.** Contract §3.3's premise + is verified at `RuntimeEntityObjectLifetime.cs:2751-2752` + (`if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u, 0u)` + immediately before `InitialCreateResidences.Begin`), so `Begin`'s + `FullCellId != 0` refusal is genuinely unreachable and no production code on + that path changed. +- **Test 6 (unwield classification population)** — **does not block.** No + production code on the classifier path changed, and the contract already + recorded (`af828a8a`) that the short-circuit OR at + `RuntimeAuthoritativePositionRouteClassifier.cs:391` labels the drop + `teleport-ts` either way, so the observable outcome is unchanged. +- **Test 9 (ledger convergence)** — **BLOCKS.** It is the one missing test whose + subject is exactly where the new state lives, and A2/A5 are precisely + ledger-convergence defects: a late-bind relation that outlives its child's + generation, blocks `Resolve` for that child forever, and accumulates in + `_unresolvedByChild` with no bound. A convergence test written against the + new deferred population (child deleted with a deferred relation pending; + parent guid never arriving; teardown *and mid-session* counts) would have + surfaced both. + +## Route 7 invariants + +Re-checked against the diff and found intact: removal propagates ZERO +(untouched; a new dual-parent-class withdrawal test was added); +`RebucketLiveEntityPresentationOnly` is not made a canonical writer (the D4 +demotion stands, `LiveEntityRuntime.cs:1033-1060` untouched); no per-tick +cross-cell rebuild is introduced; no new `SetFullCell` call site exists; the +ParentEvent path's AP-132 incarnation gating is preserved verbatim in +`Resolve`'s `else if` arm. + +## What a PASS would need + +1. **A1** — move the incarnation check above the canonical commit at both sites + and make it a logged refusal returning `false` (rejecting the staged + relation), not a throw. +2. **A2/A3/A6** — either give the deferred branch a real production path and a + test that reaches it through production routing, or reduce it to a loud + refusal with no independent state. If it stays, close the missing child + freshness gate and the placeholder-sequence filter hazard. +3. **Test 9** — a ledger-convergence test over the new deferred population, + asserting both mid-session boundedness and teardown convergence. +4. **A4** — correct the contract's §3.2 premise and name the creature-parented + shadow behaviour change in the connected gate's Half B watch list. + +Nothing here requires redesign; items 1 and 3 are small, item 2 is a scoping +decision. diff --git a/docs/research/2026-08-05-issue-319-contract.md b/docs/research/2026-08-05-issue-319-contract.md new file mode 100644 index 00000000..6cce1959 --- /dev/null +++ b/docs/research/2026-08-05-issue-319-contract.md @@ -0,0 +1,674 @@ +# Issue #319 contract — the player-parented child's canonical cell (2026-08-05) + +**Scope:** fix the hard-coded `ParentInstanceSequence: 0` that files every +CreateObject-carried parent relation under the wrong key for player parents +(`EquippedChildRenderController.OnSpawn`), restore route 7's D1/D2 propagation +for player-parented children (local AND remote players), structurally gate the +two call sites whose inertness today is an accident of the zero cell, and pin +the child's source of truth for a client-authoritative parent. **This slice +does NOT make the local player's canonical cell track ordinary movement** — +that is Decision 1's call, argued in §2, filed as its own follow-up. + +Pinned at HEAD **`af828a8a`**, clean tree, branch +`claude/acdream-physics-divergence-5aa784`. (The dispatch named `2687d893`; +the branch advanced one docs commit — `af828a8a`, the gate-4 probe-label +closure — before this contract was written. Nothing in that commit changes +this contract's premises; it strengthens §6's probe rule.) **Line numbers are +as-of `af828a8a` and WILL go stale; every citation also names the symbol — +trust the symbol.** + +Predecessor documents, binding where they still apply: + +- [`2026-08-05-local-player-child-propagation.md`](2026-08-05-local-player-child-propagation.md) + — **the settling investigation. Its §§1–6 evidence is BINDING**; this + contract re-verified its load-bearing claims at HEAD and confirms them + (one nuance on §5.1 item 3, resolved in §3.3 below). +- [`2026-08-04-c4-route-7-contract.md`](2026-08-04-c4-route-7-contract.md) + — the regressing route's contract. Its §3 "must REMAIN true" invariants + all still bind and are re-asserted in §5; its D1/D2/D3/D4 designs are NOT + re-opened. +- [`2026-08-05-c4-closeout-handoff.md`](2026-08-05-c4-closeout-handoff.md) + — carries the CORRECTED route-7 gate criterion (positive equality + assertion, dual parent class). §7 of this contract instantiates it. +- [`2026-08-04-retail-parent-cell-propagation.md`](2026-08-04-retail-parent-cell-propagation.md) + — retail `set_parent`/`enter_cell` recursion, settled. Not re-derived. +- `docs/ISSUES.md` #319 — the defect record. This contract does not restate + its root-cause chain; it builds on it. + +--- + +## 0. The defect, established — binding, do not re-derive + +`EquippedChildRenderController.cs:134` (`OnSpawn`) hardcodes +`ParentInstanceSequence: 0` into `Relations.AcceptCreateObjectRelation`. +Correct for creatures/statics (genuinely sequence 0); wrong for players, +whose `ObjectInstance` is `Character.TotalLogins` (ACE +`Player_Networking.cs:34-37`), parsed into `RuntimeEntityRecord.Incarnation` +(`CreateObject.cs:749` → `WorldSession.cs:233` → +`RuntimeEntityRecord.cs:43`). The relation files under `(playerGuid, 0)` +while the record carries `TotalLogins`, so both route-7 write sites miss: + +- **D1 attach re-cell** — `RuntimeEntityObjectLifetime.CommitAcceptedParentCellless`'s + `parent.Incarnation == parentInstanceSequence` gate (`:1537`) → false. +- **D2 crossing propagation** — `RuntimeEntityDirectory.PropagateFullCellToChildren`'s + `ChildrenAttachedToParent(guid, current.Incarnation)` (`:478-480`) → + `Array.Empty()`, forever. + +`TryCommitParent` never validates the sequence; the attach succeeds +silently. Scope: the local player and every remote player, for every +CreateObject-carried equip (i.e. every login and every first-observe). +User-visible consequence NIL — verified in the investigation §5, not +assumed. `ParentEvent`-carried relations (mid-session equip) are unaffected: +`ParentAttachmentState.Enqueue`/`Resolve` carry and validate the wire's real +sequence (`ParentAttachmentState.cs:396-411`, `:440-454`). + +--- + +## 1. Retail ground truth — does retail distinguish a player parent? + +**No. The player/non-player split is entirely an acdream artifact of the +(guid, incarnation) relation key meeting a wire message that carries no +parent instance sequence.** Verified for this contract, not inherited: + +| claim | anchor | status | +|---|---|---| +| The CreateObject attach path looks the parent up **by GUID alone** in the live object table and calls `set_parent` — no instance-sequence read anywhere in the attach | `ACCObjectMaint::CreateObject` @0x00558870: `PhysicsDesc::get_parent_id` @0x00558a18, `CObjectMaint::GetObjectA(this, parent_id)` @0x00558a2d, `CPhysicsObj::set_parent(result, arg2, location)` @0x00558a3e | ✓ read at the pseudo-C | +| The reverse direction (parent's CreateObject naming its children) is the same shape: per-child hash lookup by GUID, `GetNullObject` **placeholder** when the child is not yet constructed, then `set_parent` — again no instance gate | `CObjectMaint::SetChildren` @0x00509370: hash walk @0x005093b2-0x005093ca, `GetNullObject` @0x005093e6, `set_parent` @0x005093f8 | ✓ read at the pseudo-C | +| `set_parent` / `enter_cell` / `leave_cell` / `change_cell` contain no player test of any kind — they operate on `CPhysicsObj*` uniformly | `set_parent` @0x00515A90 (order enumerated in route-7 contract §2 row 3); `enter_cell` @0x00510ed0; `leave_cell` @0x00510f50; `change_cell` @0x00513390 | ✓ (route-7 contract §2, re-affirmed; no player branch exists in any of the four bodies) | +| Retail's `ObjectInstance` sequence lives in the physics-descriptor timestamp block and gates **message staleness**, never the parent relation — the relation is by live object pointer | timestamp block slot 8; AP-132's retail half ("retail's queue-by-GUID replay is pointer-only", @0x004535D0 ~92310-92326, `QueueBlobForObject` @0x005092D0) | ✓ (AP-132, register) | +| Retail's player cell is NEVER stale: `SetPositionInternal` writes the player's own cell on every physics tick, so "parent cell propagation from a stale parent cell" is unrepresentable in retail | `CPhysicsObj::SetPositionInternal` @0x00515330 (propagation research, binding) | ✓ | +| **CORRECTION (retail-conformance review, 2026-08-05): this row understated its own anchor.** `SetPositionInternal` @0x00515330 does not merely write the mover's own cell — in the same-cell branch it writes `this->m_position.objcell_id = objcell_id` @0x00515385, THEN walks `this->children` and writes each child's cell id directly (`*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD, `CPartArray::SetCellID` @0x005153CC, looping @0x005153AE–@0x005153D8); the cross-cell branch delegates to `change_cell` @0x00515372. Retail's D2 equivalent (per-tick child cell propagation) lives INSIDE THE SAME FUNCTION as the player's own per-tick cell write — a strengthening of the equality invariant and of AP-146, not a contradiction. (AP-142 clause (b) already cited this range; only this table's summary understated it.) | same, ranges above | ✓ | + +Consequences, pinned: + +1. **The retail-faithful semantic for a CreateObject-carried relation is + "attach to the current holder of the parent GUID"** — late-bound, no + wire incarnation to honor, because the wire supplies none. acdream's + mapping: the relation adopts the parent's **live incarnation at + resolve/commit time**. This is NOT a weakening of AP-132's incarnation + posture: AP-132 gates relations whose wire event **names** a specific + parent incarnation (`ParentEvent.ParentInstanceSequence`); a CreateObject + relation names none, so adopting the live value honors exactly what the + server sent — a GUID. AP-132's row gains a clarifying sentence in the + fix commit (§4 F4). +2. **The local player's coarse canonical cell is an acdream divergence with + no register row.** Retail writes the player's cell per tick; acdream's + canonical `FullCellId` for the local player is written only at login + activation (`RuntimeSetPositionState.cs:2741-2745`), accepted inbound + Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → + `RuntimeEntityRecord.cs:234`), and teleport/portal commit + (`RuntimeSetPositionState.cs:5001-5007`; + `LocalPlayerTeleportController.cs:255`). Ordinary WASD passes a + landblock id (`LocalPlayerProjectionController.Project`, `:85-98`, low + 16 bits forced to `0xFFFF` in BOTH branches) that + `LiveEntityRuntime.RebucketLiveEntity` explicitly preserves the old cell + for (`:935-938`). Register rule 1 applies: **this divergence gets its + row in the fix commit regardless of Decision 1's direction** (§4 F4). + +--- + +## 2. DECISION 1 — the child follows the parent's CANONICAL cell; the key fix alone is correct and complete for #319; the player-cell-tracking question is real, pre-existing, and files separately + +**The call is (c)** — neither (a) nor (b) as framed. (b)'s framing +("staleness is harmless everywhere") is false without the §3 gates; (a)'s +framing ("stale is strictly worse than zero, so #319 must make the player's +cell track") rests on an argument that dissolves under the consumer +enumeration below. The pinned design: + +> **The committed child's canonical `FullCellId` EQUALS its committed +> parent's canonical `FullCellId` at every stable observation point — no +> more, no less.** That is route 7's §3.1 headline invariant verbatim, and +> it is an EQUALITY invariant, not a freshness invariant. The parent's own +> canonical-cell freshness is a property of the PARENT's record, owned by +> the parent's own write paths — pre-existing, unchanged by this fix, and +> documented as its own divergence (§1 item 2). #319's fix makes the child +> inherit the parent's value exactly; it does not, and must not, invent a +> different cell authority for the child than the parent itself has. + +### 2.1 Why (a)'s "stale defeats 45+ liveness sites" argument fails for the child + +The 45+ `FullCellId != 0` predicates (route-7 contract §0 item 6) read a +**spatial root's** cell. A committed child is structurally excluded from +every one of them by a NON-cell clause, verified at HEAD: + +- `LiveEntityRuntime.GetRootObjectClockDisposition` (`:2602-2612`) and + `HasSpatialRuntimeProjection` (`:3340-3345`): both require + `ProjectionKind is LiveEntityProjectionKind.World`; a committed child is + `Attached` — excluded on the first clause regardless of cell value. +- The collision-retirement sweep `RuntimeSetPositionState.IsAffectedCollisionResident` + (`:3930-3946`): triple-gated — spatial-roots-only iteration + (`ParkCollisionResidents` `:3749-3756`), `_physics.IsSpatialRoot` + (`:3942`), and an explicit + `!ParentAttachments.HasCommittedParent(record.ServerGuid)` (`:3944-3945`). + A committed child can never be parked by a retiring prefix, at any cell + value. +- Route 7's own P4 record (`RuntimeEntityDirectory.cs:451-465`): a committed + child is never a spatial root, joins no workset, has no shadow row. + +What remains is exactly four consumers that can read a CHILD's cell, and +each is resolved individually: + +1. **`LiveRenderProjectionJournal.Project` (`:270-277`)** — prefers + `record.FullCellId` over the `entity.ParentCellId` fallback when nonzero. + Traced to its consumers at HEAD: the dynamics **draw visibility** routes + read `record.Source.ParentCellId` (the fresh, TickChild-maintained + presentation field) — `RenderScenePViewFrameProduct.BuildDynamicLastRoute` + (`:1441-1481`, `ParentCell(in record)` for the indoor + `SphereVisibleInCell` test) and `BuildOutsideDynamicRoutes` (`:1356-1361`, + same source). `Residency.FullCellId` feeds only (i) the indoor per-cell + candidate index `_cellDynamics` (`ArchRenderScene.AddToIndices` `:639`), + consumed by the look-in candidate enumeration + (`BuildLookInRoutes` → `LoadCell` `includeDynamics: true`, `:1319-1322`), + and (ii) the `RenderSceneShadowRuntime` residency assertion (`:403`). + **Decisive fact: the LOCAL PLAYER's own record already sits in this exact + index under this exact staleness today** — the player's `FullCellId` is + nonzero-stale during ordinary play, and the player renders correctly + everywhere, because visibility is `Source.ParentCellId`-driven. Post-fix + the child is filed **beside its parent, under the same value** — equality + with the parent in the render residency index is the consistent state, + not a new staleness class. (P-R1 in §6 pins this with the shadow-runtime + assertion in mind.) +2. **The hydration filter** (`LiveEntityHydrationController.OnLandblockLoaded` + `:551-557`) — wakes undesirably; gated structurally in §3.1. +3. **`RestoreShadow`** (`LiveEntityPresentationController.cs:216-236`) — + wakes undesirably; gated structurally in §3.2. +4. **The unwield/drop Position classification** + (route-7 contract §11; `RuntimeAuthoritativePositionRouteClassifier`) — + wakes DESIRABLY: a committed child's pre-merge cell becomes + "deterministically the parent's (nonzero whenever the parent is + celled)", which is **route 7 §11's own stated intent and retail's own + predicate population** (retail's `unset_parent` does no cell work, so a + wielded child's unwield Position reaches `MoveOrTeleport` with the + parent's nonzero cell). #319 had silently re-created the pre-route-7 + cell-less population for player-parented children; the fix restores the + §11 direction. Note the `af828a8a` finding: at a drop ACE also advances + TELEPORT_TS and the classifier is a short-circuit OR + (`RuntimeAuthoritativePositionRouteClassifier.cs:391`), so the observed + label stays `teleport-ts` either way. One test pins the stale-cell + flavor (§6 test 6). + +Additionally, `RuntimeTraceRecorder.OnEntity` +(`GameRuntimeEvents.cs:246-253`) stops recording `0` for the player's +weapon — a diagnostic improvement, not a risk. + +**Conclusion:** with the two §3 gates in place, no consumer of the CHILD's +cell behaves worse with stale-but-equal than with zero, several behave +better, and the route-7 invariant is restored exactly. The (a) framing's +"wrong answer looks right" applies to the PARENT's record — which this fix +does not touch and which is exactly as stale before and after. + +### 2.2 Why the player-cell-tracking half does NOT ride in #319 + +Making `LocalPlayerProjectionController.Project` pass the exact +`movement.CellId` (available in both branches) instead of the coarsened +landblock would touch, at minimum: + +- the deliberate landblock-preserve contract at + `LiveEntityRuntime.cs:935-938` (a generic rebucket rule, not + player-specific — changing its input population changes it for the one + caller that relies on it); +- `Rebucketed` delta publication cadence: today the player NEVER publishes + a `Rebucketed` entity delta during WASD (the preserve path early-outs at + `CommitRebucket`'s `previous == fullCellId`, `RuntimeEntityObjectLifetime.cs:1965-1972`); + exact-cell commits would publish per crossing (EnvCell crossings are the + high-frequency case) — a consumer enumeration in route 7's P8 class; +- the accepted-Position classification inputs for the LOCAL player (route + 2 and the 4b-3 `PreMergeCommittedCellId` measurement at + `TryApplyPosition:1801-1814`): a fresh committed cell changes the + pre-merge population on live correction paths that AP-136/AP-138 spent + four review rounds pinning; +- the portal-space freeze interaction + (`LocalPlayerProjectionController.Project:100-103` — "the teleport owner + alone projects the destination while the local controller deliberately + retains its frozen source cell"): a canonical exact-cell writer must not + race the teleport owner; +- the `isOrdinaryRoot` family (`LiveEntityRuntime.cs:915-918`, `:3213`, + `:3323`) and the animation-scheduler local-player exclusion + (`LiveEntityAnimationScheduler.cs:183-227`). + +That is a local-player movement-spine slice with its own contract, its own +review, and its own gate — calibrated against the campaign, comfortably +larger than route 7's 127 production lines once its verification surface is +counted, for zero behavioral need identified today. **It files as a +follow-up issue in the fix commit** (§4 F4), carrying one specific hazard +this contract identified and could NOT close (§9 item 2): whether the local +player is a `RuntimePhysicsState` spatial root, and if so whether a +stale-cell landblock retirement (a player WASD-ing beyond the streaming +radius from its last teleport, with no intervening teleport or inbound +Position) can sweep the player into `ParkCollisionResidents`. The child is +provably immune (the `HasCommittedParent` gate); the player is not obviously +so, and the connected routes that pass today teleport between stops, which +refreshes the cell and may be masking it. + +--- + +## 3. DECISION 2 — the three inert-because-zero sites, one verdict each + +### 3.1 Hydration filter — WAKES WRONGLY; add the structural gate in this slice + +`LiveEntityHydrationController.OnLandblockLoaded` computes +`projectionCellId = projection?.ProjectionCellId ?? Snapshot.Position?.LandblockId ?? candidate.FullCellId` +(`:551-553`) and admits candidates on `projectionCellId != 0` + landblock +match + `SetupTableId` (`:554-557`). A committed child today falls through +all three sources to `0` (its `ProjectionCellId` is unset, its +`Snapshot.Position` is null per route-7 §0 item 10) and is skipped. With a +nonzero canonical cell it becomes a candidate whenever its (= the parent's) +canonical landblock loads — e.g. at every login, where the equipment's cell +names the activation landblock — and takes the full legacy +`RebucketLiveEntity` branch (`:594-596`), which writes +`entity.ParentCellId` (`LiveEntityRuntime.cs:885-898`) AND reaches +`CommitRebucket` — **a second canonical cell writer for a child, the exact +two-writer defect route 7 exists to remove**, plus a one-frame presentation +overwrite that TickChild then repairs. + +**Waking is not desirable, and the current inertness is an accident of the +bug.** Pinned: the candidate loop excludes records with a committed parent +— gate on `ParentAttachments.HasCommittedParent(candidate.ServerGuid)` (or +the equivalent projection-kind test), the same structural exclusion the +retirement sweep already uses (`RuntimeSetPositionState.cs:3944-3945`). +Rationale is retail-anchored, not defensive: a retail child is never +independently re-placed by cell load — `update_object`'s `parent != 0` +early-out (@0x00515D40) means the parent's own propagation is the only +mechanism, and acdream's analog (D2) needs no hydration assist. The gate is +correct TODAY (it changes nothing for a zero-cell child) and required +post-fix; it lands in the same commit as the key fix, before it in +sequence. + +**CORRECTION (retail review round 2, D1, 2026-08-05): the "falls through to 0" +and "correct TODAY (changes nothing for a zero-cell child)" claims above are +true only for a PLAYER-class parent — false for a creature/static-class +parent.** Pre-fix, `EquippedChildRenderController.OnSpawn`'s hardcoded +`ParentInstanceSequence: 0` genuinely MATCHES a creature-class parent's real +incarnation (creatures/statics are sequence 0), so D1's +`parent.Incarnation == parentInstanceSequence` gate already passes for that +class at HEAD, the relation commits, and the child already carries a nonzero +canonical `FullCellId` pre-fix — meaning a creature-parented child is ALREADY +a hydration candidate today and already takes the legacy `RebucketLiveEntity` +branch this gate removes. §3.2's twin premise received this same correction +(architecture review A4); this paragraph did not, until now. The gate's +direction is still correct (route 7's P4/D4), and it is still required in +this slice — but it is a live behaviour change for the creature class, not +merely a post-fix necessity for the player class. See the §7 gate's Half B +for the connected-gate watch step this requires. + +### 3.2 `RestoreShadow` — WAKES WRONGLY; add the structural gate in this slice + +`LiveEntityPresentationController.RestoreShadow` (`:216-236`) no-ops today +on `record.FullCellId == 0` (`:220`). Its other two guards do NOT protect a +committed child: `IsSpatiallyProjected` is set `true` by the +presentation-only rebucket the child takes every frame +(`LiveEntityRuntime.cs:1043`, inside `RebucketLiveEntityPresentationOnly`), +and `IsSpatiallyVisible` follows the child's ordinary visibility. On a +Hidden→Visible edge (`RetailHiddenTransition.BecameVisible`, `:187-204` — +reachable for a child both via its own transition and via the parent's +unhide cascading through `_setDirectChildrenNoDraw`), a nonzero cell would +install a `ShadowObjects` broadphase row for an equipped weapon at the +parent's canonical cell — contradicting route 7's P4 record ("a committed +child never joins a workset or shadow list", +`RuntimeEntityDirectory.cs:451-465`), AP-142's model, and retail (a child's +broadphase state is established at attach/unparent edges only; route-7 +contract §0 trap 5a). An invisible-but-solid weapon row at a possibly-stale +cell is the #184 shape. + +**Pinned:** `RestoreShadow` refuses records with a committed parent (same +predicate as §3.1; checking `ProjectionKind` alone is acceptable if the +implementer shows it is equivalent for every reachable record). Same +commit, same rationale: correct today, required post-fix. + +### 3.3 `RuntimeInitialCreateResidenceState.Begin` — waking is a NON-EVENT; verify with a test, no production change + +The investigation flagged `Begin`'s `record.FullCellId != 0u` refusal +(`:583`) and `TryConvertToCellessRoute`'s (`:1041`) as unknowns. Resolved +at HEAD: **the refusal is a defensive invariant, not a reachable gate.** +`Begin`'s only production caller, +`RuntimeEntityObjectLifetime.InitializeAcceptedCreateResidence` +(`:2736-2771`), ZEROES a nonzero cell first — `:2751-2752`, +`if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u, 0u)` +— before calling `Begin`. So a re-CreateObject that opens a fresh residence +on a record inheriting a nonzero child cell zeroes it (flowing through the +D2 chokepoint, correctly zeroing any grandchildren per AP-142 clause (a)), +opens the `Parented` residence, and D1 re-cells at the attach commit — +the same sequence a first create runs. `TryConvertToCellessRoute`'s refusal +concerns an OPEN lease on a record with a committed cell — for a committed +child the lease was already forgotten at the attach commit's cancellation +prefix, so the arm is unreachable for the waking population. + +**Pinned:** no production change at this site. One test (§6 test 5) drives +a re-CreateObject against an attached, non-zero-cell child (same +incarnation → the `TryApplyAttachedAppearance` path; new incarnation → the +replacement path with `InitializeAcceptedCreateResidence`) and asserts +byte-identical outcomes to today's, plus the zero-then-re-cell sequence +staying inside the transaction (no observable cell-less escape — route 7's +D1 atomicity clause). + +--- + +## 4. The fix, pinned + +### F1 — the key: CreateObject-carried relations late-bind to the parent's live incarnation + +`EquippedChildRenderController.OnSpawn` stops writing the literal `0`: + +- **Parent snapshot known at accept time** → stage the relation with the + parent's live `InstanceSequence` — the identical lookup + `ResolveRelations` already hands to `Resolve` + (`EquippedChildRenderController.cs:834-836`, + `_liveEntities.TryGetSnapshot`). +- **Parent not yet known** → do NOT stage under a guessed key. Route the + relation through the existing unresolved/deferred machinery (the + `RetryWaitingDescendants` / `WaitOwner.Parent` shape) so it resolves when + the parent arrives, adopting the parent's incarnation THEN. Retail + anchor for the late-bind semantic: `GetObjectA`/`GetNullObject` by GUID + (§1) — the wire named a GUID, not an incarnation; matching "the current + holder" is the faithful mapping. The implementer chooses the mechanism + (a wildcard-incarnation relation resolved in `Resolve`, or deferring the + `AcceptCreateObjectRelation` call itself); the pinned constraints: + **no relation is ever staged or committed with an incarnation that does + not equal the parent's live incarnation at that moment, and the + `Resolve` early-return on staged relations + (`ParentAttachmentState.cs:425-426`) must not strand a deferred + CreateObject relation.** +- **The commit-time tripwire** (the investigation's own recommendation, + adopted): the parent-relation commit path asserts + `relation.ParentInstanceSequence == parentRecord.Incarnation` whenever + the parent is active — a mismatch REFUSES the commit loudly (throw or + logged refusal per the codebase's commit-refusal idiom, implementer's + choice — pinned outcome: never a silent success under a mismatched key). + This is what converts any future producer regression from "silently + inert propagation" into a failing test/session. + +This one change fixes the local player's login equipment AND every remote +player's observed equipment (same producer). `ParentEvent`-path behavior is +untouched. + +### F2 — the two structural gates (§3.1, §3.2) + +Both are committed-parent exclusions at sites that must never handle an +attached child. Both are behavior-preserving at HEAD (the excluded +population currently reaches neither site's active arm) and land in the +same commit so the key fix never ships without them. Sabotage rule: each +gate's test must fail with the gate deleted AND the key fix present (§6 +tests 3–4). + +### F3 — probe truthfulness + +`ACDREAM_PROBE_CHILD_CELL` stays as-is (it is TEMPORARY, C5c strips it), +but the fix commit re-runs the §7 gate expectations against it. No probe +code change is required for this slice: the corrected gate criterion is a +positive read of the child's cell, not a probe-line count. + +### F4 — bookkeeping, in the fix commit (register rules 1–2 binding) + +- **AP-142 gains a clause** (or a new AP row if the reviewer prefers — + implementer's call, one of the two): the CreateObject-carried relation + late-binds to the parent's live incarnation because the wire carries no + parent instance sequence; retail's attach is GUID-only + (`GetObjectA` @0x00558a2d, `SetChildren` @0x005093f8, `GetNullObject` + placeholder @0x005093e6). AP-132's row gains the clarifying sentence + distinguishing the two producers (ParentEvent = wire-named incarnation, + gated; CreateObject = no wire incarnation, late-bound). +- **NEW register row — the local player's coarse canonical cell** (§1 item + 2): intentional-architecture-or-stopgap classification to be argued in + the row itself; anchors `CPhysicsObj::SetPositionInternal` @0x00515330 + vs the three acdream writers; risk column names the §2.2 park-sweep + question and the child-inherits-the-coarseness consequence (the child's + cell is stale-but-equal wherever the parent's is). +- **Follow-up issue filed**: local-player canonical cell tracking — + carrying §2.2's enumeration and §9 item 2's unresolved spatial-root + question as its first verification step. +- **ISSUES.md**: #319 → Recently closed with the commit SHA; the entry's + "do NOT rush the fix" block is answered by this contract's §2/§3. +- **Route-7 contract**: append a dated supersession note under its §7 gate + (the criterion correction is already recorded in the closeout handoff; + the note points here). +- **AP-142 clause (b)/(c) untouched**; no row deletions. + +--- + +## 5. What must REMAIN true (route 7 §3, re-asserted for this slice) + +1. Route 7 invariants 1–13 hold verbatim. Specifically re-tested here: + equality invariant (1), child never self-simulating (2), no placement + machinery on the route (3), **no `ConstrainTo`** (4), `TryCommitParent` + keeps zero `LeaveWorld` calls (6), presentation advances and is + asserted (7), **no per-crossing child shadow/cross-cell rebuild** (8), + ledger convergence (13). +2. **The removal path still propagates ZERO** (AP-142 clause (a)) — the + key fix must not perturb withdrawal/delete/EndGeneration edges; the §6 + matrix re-runs them with a player-range parent. +3. **`RebucketLiveEntityPresentationOnly` does not become a canonical + writer again** — the D4 demotion stands; the presentation rebucket + stays keyed on the child guid alone. +4. **The `ParentEvent` path's incarnation gating (AP-132) is unchanged** — + a wire-named stale incarnation still discards/queues exactly as today. +5. **No new canonical cell writer is introduced.** The fix adds zero + `SetFullCell` call sites; it only makes the existing D1/D2 sites find + their children. +6. **The dormant-residence deferrals are untouched** (route 7 invariant 9); + the continuation executor's parent replay path benefits from F1 + automatically (it routes through the same commit pair). + +--- + +## 6. Test plan + +Rules (route 5 §7 / route 7 §6, verbatim where they apply): assert the +layer that broke; positive facts, not absences; every new test fails +against a broken implementation; sabotage-verify both halves of dual-layer +assertions; never derive a test's workload from the constant under test. + +**The structural rule this issue adds — MANDATORY: every scenario runs as a +dual-parent-class matrix.** One parent in the player range (`0x5…`, spawned +with a nonzero incarnation — use a value > 1 so an off-by-one cannot pass), +one in the creature/dynamic range (`0x8…`, incarnation 0), asserting +IDENTICAL outcomes. #319 exists precisely because every prior test and both +captured gate logs used sequence-0 parents; a matrix in which the two +classes can diverge silently is the defect's habitat. Where a fixture +helper spawns parents, the helper takes the incarnation as a parameter — +no baked-in zero. + +Focused tests (`tests/AcDream.App.Tests` for the controller/producer, +`tests/AcDream.Runtime.Tests` for the propagation surface): + +1. **The key itself (F1), CreateObject path, matrix.** A CreateObject-carried + equip against a live parent with incarnation N (N=0 creature, N=7 + player): relation commits under `(parentGuid, N)` + (`TryGetCommittedParent` returns N), D1 re-cells the child to the + parent's exact cell at attach (`cause=attach` behavior — assert the cell + equality, not the probe), and a subsequent parent canonical-cell change + through each writer family reaches the child (D2). Sabotage: restore the + literal `0` and confirm the PLAYER row fails while the creature row + passes — the test must be able to see exactly #319. +2. **The deferred-parent flavor (F1), matrix.** Child CreateObject arrives + BEFORE the parent's: relation stays unresolved (never staged under a + guessed key — assert no committed relation exists), parent arrives with + incarnation N, relation resolves and commits under N, child celled. + Companion: parent arrives cell-less, later gains a cell → D2 catch-up + re-cells the child (route 7 §6 test 1's companion, now for the + CreateObject producer). +3. **Hydration gate (§3.1).** An attached child with nonzero canonical + cell; its landblock reloads; assert the child is NOT a hydration + candidate (no legacy rebucket, `entity.ParentCellId` untouched by the + hydration pass, no `CommitRebucket` invocation for the child). Sabotage: + delete the gate → test fails. +4. **Shadow gate (§3.2).** Attached child, nonzero cell, driven through a + Hidden→Visible edge: assert NO `ShadowObjects` row exists for the child + after the transition (assert against the engine's shadow table, not a + cache — the AP-145/#318 lesson). Sabotage: delete the gate → fails. +5. **Residence non-event (§3.3).** Re-CreateObject shapes against an + attached nonzero-cell child (same-incarnation refresh; new-incarnation + replacement): outcomes byte-identical to the zero-cell baseline; the + replacement path's zero-then-re-cell stays inside one transaction (no + observable cell-less window — reuse route 7 test 1's observation + technique). +6. **Unwield classification population (§2.1 item 4).** An attached child + whose parent cell is nonzero (and deliberately DIFFERENT from the wire + Position's cell, the stale flavor) receives an unparent Position: + classification takes the TELEPORT_TS/distance route (never the + cell-less arm), the drop commits at the WIRE position, and — the §11 + guard — the 4b-3 synthetic `PreMergeCommittedCellId == 0` fixtures are + NOT relabeled: they remain synthetic. +7. **Withdrawal/delete matrix (invariant 2).** Pickup of the parent, + delete of the parent, `EndGeneration` — child (and a grandchild) go + cell-less, both parent classes. +8. **The commit-time tripwire (F1).** A relation carrying a wrong + incarnation reaching the commit path is REFUSED loudly; assert the + refusal is observable (exception or logged refusal + no committed + relation), not a silent success. +9. **Ledger convergence** (route 7 invariant 13): teardown/reset with a + player-parented committed child present. + +The existing route-7 suites run unmodified — zero expectation changes +outside the new tests is the tripwire that F1/F2 changed no non-child +behavior. + +--- + +## 7. The gate (connected, user-run) — the CORRECTED criterion, instantiated + +Release build, `ACDREAM_RETAIL_UI=1`, `ACDREAM_PROBE_CHILD_CELL=1`, live +ACE, graceful close. Run BOTH halves; a session that runs only the +creature half is a not-run for this issue (process rule (g): the gate must +be able to see the defect — the defect is player-class-only). + +**Half A — player parent (the local player, `0x5000000A`):** + +1. Login with equipment. **PASS requires a `[child-cell]` line for the + player's child at attach-or-activation** (`cause=attach` if the player + was celled first, else the D2 catch-up line when login activation + commits the player's first cell) **AND the child's `FullCellId` read + equal to the player's, nonzero.** Today this line does not exist + (`c4-gates.log:238`, `c5-gates.log:343` — attach with no probe line); + its appearance is the direct #319 signal. +2. Carry across ≥2 landblock boundaries and back, plus one + EnvCell-to-EnvCell dungeon traversal. **PASS: child `FullCellId` EQUALS + the player's at every checkpoint — a zero is a FAILURE, not a silence.** + Expectation set honestly by §2: during WASD the player's canonical cell + does not change, so `cause=propagate` lines are NOT expected at these + crossings for the player half — equality (both records carrying the + same value) is the assertion. This expectation is itself part of the + gate record: if `propagate` lines DO appear here, something changed the + player's cell writers and the session gets investigated, not celebrated. +3. Portal recall / portal transit while equipped. **PASS: `cause=propagate` + fires for the player's child at the arrival commit** (the teleport + writer is the player's live cell-change edge), child equal to the + player's destination cell, equipment present and following. +4. Unequip/re-equip mid-session (the ParentEvent path): `cause=attach` + with the true incarnation — this was the investigation §7 prediction; + observing it closes that loop. +5. Reconnect with equipment: re-attach produces the same line set as step 1. + +**Half B — creature parent (`0x7…`/`0x8…`, e.g. observe an armed NPC or +`@teleto` a wielding creature):** the route-7 recipe unchanged — +`cause=propagate` in double digits across several crossings, child equal +to parent at checkpoints. This half proves the fix did not perturb the +already-working class. **ADDED (architecture review A4, 2026-08-05):** +this half must ALSO watch the creature-parented weapon's shadow/collision +row across a Hidden→Visible edge (`@hide`/`@unhide` an armed NPC, or a +sit/stand-equivalent state toggle if available) — F2's `RestoreShadow` +gate (`LiveEntityPresentationController.cs`) is NOT behavior-preserving +at HEAD for this class the way the contract's §3.2 originally claimed: +route 7's D1 already gives a creature-parented child a nonzero cell, so +this gate is a live behavior change here (the weapon's broadphase row no +longer refreshes on that edge), not an inert one. Confirm no #184-shaped +regression (a stale-but-solid collider) and no crash; the change is +believed correct per route 7's P4 record but was never exercised by a +connected session before this note. **ADDED (retail review round 2, D1, +2026-08-05):** this half must ALSO carry an armed creature/NPC across a +landblock UNLOAD and RELOAD (walk it out of streaming range and back, or +force a landblock reload if the harness supports it) and confirm the +weapon is still attached and correctly placed afterward — F2's hydration +gate (`LiveEntityHydrationController.OnLandblockLoaded`) is likewise NOT +behavior-preserving at HEAD for the creature class (§3.1's correction, +same D1-already-nonzero fact): pre-fix a creature-parented child was +already a hydration candidate on landblock reload and took the legacy +`RebucketLiveEntity` path this gate now excludes. This is precisely route +7's "left behind at a boundary" regression shape if the gate's direction +were wrong; confirm it is not. + +Secondary check only: probe-line volume. Primary: the equality reads. +Regressions to watch: route 7's list verbatim (weapon at origin/stale +ground position, invisible while equipped, left behind at a boundary, +invisible-but-solid, culled against parent) — none is expected, since the +presentation path is untouched. + +--- + +## 8. Size estimate and the split call + +Calibrated against the campaign (route 7: ~127 production lines; route 3: +~418): + +| piece | non-comment production lines | +|---|---| +| F1 key + deferred late-bind + commit tripwire | 25–60 | +| F2 hydration gate | 4–12 | +| F2 shadow gate | 4–10 | +| F3 | 0 | +| **total** | **~35–80** | + +Tests are the larger share (~250–450 lines, dominated by the matrix). + +**ONE slice, no split.** F1 without F2 wakes the two sites; F2 without F1 +is inert scaffolding; both are tiny. **If Decision 1 had gone to (a), the +answer would be different in kind, and it is worth stating for the record: +folding player-cell tracking in would put the slice at route-3 scale or +above** (the §2.2 enumeration: projection-controller change, rebucket +contract change, `Rebucketed` publication audit, route-2/4b-3 +classification-input audit, portal-space race analysis, spatial-root/park +analysis, plus its own connected gate) **— and it would still be the wrong +bundling**, because the child fix is complete and gate-verifiable without +it, and the player-cell question deserves its own retail-conformance +argument rather than riding a key repair. + +Stop and report rather than pushing through when: + +1. F1's deferred path needs new relation-state machinery beyond a + wildcard/deferral flag (the unresolved-queue shape should suffice; if + `Resolve`'s staged early-return forces a redesign of + `ParentAttachmentState`'s state machine, that is a conversation, not an + ad-hoc build). +2. Any test in §6 requires touching a canonical cell writer. +3. The §3.2 gate turns out to mask a child shadow row that something DOES + legitimately create today (that would contradict route 7's P4 record — + file, do not fix inline). +4. The complete Release suite deviates from its measured baseline beyond + the two named flakes (#302, #308). Baseline at `e0f96a55` was 11,090 / + 4 / 0 — MEASURE at the implementation HEAD, never inherit. + +--- + +## 9. Open items — reported honestly, not smoothed + +1. **The look-in render index nuance (§2.1 item 1) is argued from code, + not from a live A/B.** The claim "the local player already exhibits + this exact staleness in `_cellDynamics` with no observed symptom" is + solid at HEAD reading, but the `RenderSceneShadowRuntime:403` + residency assertion (`OwnerLandblockId != expected.LandblockId` throw) + was not chased to its trigger population. P-R1: the implementer runs + one indoor equipped session with the fix and confirms no shadow-runtime + assertion fires. If one does, that is a finding about the PLAYER's + record as much as the child's — report, do not patch the child. +2. **Whether the local player is a `RuntimePhysicsState` spatial root was + NOT established** (§2.2). If it is, the pre-existing stale player cell + can theoretically be swept by `ParkCollisionResidents` after a long + teleport-free WASD run — unexercised by the connected routes, which + teleport between stops. This rides in the follow-up issue as its first + verification step; it is NOT #319's blast radius (the fix does not + touch the player's record). +3. **The exact deferral mechanism for a parent-unknown CreateObject + relation is implementer's choice** within F1's pinned constraints; this + contract did not verify how `RetryWaitingDescendants` interacts with a + relation that was deliberately NOT staged (today it retries realize for + staged-but-unrealizable children). If the existing machinery cannot + carry it, stop condition 1 applies. +4. **Everything else in the investigation was confirmed at HEAD**: the + hardcoded 0 and its structural cause (the wire carries no parent + instance for CreateObject), both miss sites, the `Resolve` early-return, + the commit's missing validation, the three writers of the player's + canonical cell, the landblock-preserve, the hydration/shadow wake + mechanics (including `IsSpatiallyProjected = true` via the + presentation-only rebucket at `LiveEntityRuntime.cs:1043`), and the + render fallback. One investigation unknown was RESOLVED in the child's + favor: §5.1 item 3's residence question — the caller zeroes before + `Begin` (`RuntimeEntityObjectLifetime.cs:2751-2752`), §3.3. diff --git a/docs/research/2026-08-05-issue-319-retail-review-round2.md b/docs/research/2026-08-05-issue-319-retail-review-round2.md new file mode 100644 index 00000000..34662b79 --- /dev/null +++ b/docs/research/2026-08-05-issue-319-retail-review-round2.md @@ -0,0 +1,281 @@ +# Issue #319 — retail-conformance DELTA review, round 2 (2026-08-05) + +**Verdict: PASS, with one MAJOR documentation/gate-coverage finding (D1) that +must be closed before the connected gate runs — it does not require a code +change.** + +Scope: delta only, against my round-1 report +([`2026-08-05-issue-319-retail-review.md`](2026-08-05-issue-319-retail-review.md)). +Same working tree, same base HEAD `af828a8a`, uncommitted. Reviewed +`git diff HEAD` (14 files, +934/-20) plus the three untracked docs. + +Independent gates re-run for this round: + +- `dotnet build AcDream.slnx -c Release` — exit 0. +- `AcDream.Runtime.Tests` Release — **1176/1176**, 0 skipped. +- `AcDream.App.Tests` Release — **4127 passed, 3 skipped, 0 failed**. + +Matches the coordinator's reported numbers. A green suite is not evidence; +every finding below comes from source or pseudo-C. + +--- + +## 1. The coordinator's primary question: does deleting our deferral diverge +## from retail's queue-by-GUID replay? + +**No. Answered structurally, and it needs no register row.** + +Retail's mechanism is real: `QueueBlobForObject` @0x005092D0 buckets a +missing-parent blob under the parent's GUID on `CObjectMaint` and replays it +when that GUID is (re)created (AP-132's retail half). The question is whether +acdream still has that mechanism after the App-layer deferral was deleted. + +It does, and the deleted code was never it. acdream's port of retail's +per-GUID blob bucket lives in `ParentAttachmentState` and is **untouched by +this diff**: + +- `_deferredCreatesByParent` (`ParentAttachmentState.cs:22-23`) — raw + CreateObjects waiting on a parent GUID, filled by `EnqueueDeferredCreate` + (`:87-119`) from `RuntimeEntityObjectLifetime.RegisterEntityCore:798-812`. +- `_deferredAcceptedRelationsByParent` (`:34-35`) — accepted relations waiting + on a parent GUID. Its own doc comment already names the anchor: "Shares the + SAME per-guid 'blobs waiting on guid X' shape … retail's + `QueueBlobForObject`/`CObjectMaint` bucket does not distinguish a raw Create + blob from any other blob type queued against the same guid." +- Replay on parent arrival is live: `DetachDeferredCreates` / + `DetachDeferredAcceptedRelations` are driven from + `RuntimeInitialCreateContinuationExecutor.ReplayDeferredChildren:1250-1275`, + whose comment cites retail's atomic per-parent detach (pseudo-C ~93617) and + whose `RestoreDeferredCreates` path explicitly preserves retail's "blobs live + on `CObjectMaint`, not on the object instance, so they survive the object and + replay against a recreated GUID." + +So the deleted `DeferCreateObjectRelation` was a **third, redundant queue** at +the App producer layer, sitting *downstream* of the layer that already +implements retail's mechanism — and downstream of the very gate that makes it +unreachable. Removing it removes duplication, not a retail behaviour. + +Verified again for this round that the gate covers both wire shapes: +`RegisterEntityCore:798` reads +`incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u`, and +`SameGenerationCreateObjectEvents` (the sole source of `CreateParentUpdate`) +are produced inside `AcceptCreate`, reached only after that gate passes. The +implementer's structural-unreachability proof is correct for both producers, +which is what round 1's R1 found independently. + +**No register row is owed.** The divergence that exists here — acdream gating +queued relations on parent incarnation where retail's replay is pointer-only — +is AP-132, already filed, and this diff does not widen it. `_unresolvedByChild` +(the ParentEvent queue) is untouched. + +**Retail-side consequence of the simplification:** with deferral gone, the fix +is exactly "bind to the parent's live incarnation at accept time," and the +parent is guaranteed addressable at that moment by the layer above. That is a +*closer* mapping of `GetObjectA(this, parent_id)` @0x00558a2d than the deferred +version was — retail resolves the GUID once, at the attach, against whatever +`CObjectMaint` currently holds; it does not carry a pending relation forward at +the attach site either. `GetNullObject` @0x005093e6 (the `SetChildren` +placeholder) is retail's only "not yet constructed" accommodation, and it lives +on the *parent-names-children* direction, which acdream does not implement at +this producer. Nothing retail does was lost. + +## 2. The refusal paths leave retail-correct state — verified + +Two refusal sites, both new: + +**(a) `AcceptLateBoundCreateObjectRelation`'s else branch** +(`EquippedChildRenderController.cs:866-873`). Logs to stderr, mutates nothing. +No relation is staged, queued, or committed; the child keeps its own snapshot +and cell. That is a retail-representable state (an unattached object), and the +round-2 test `OnCreateParentAccepted_ParentNotYetKnown_RefusesWithoutStateOrCrash` +additionally pins that a later parent arrival does **not** retroactively attach +it — honest about the consequence rather than implying a recovery that does not +exist. + +**(b) `CanCommitIncarnation`** (`ParentAttachmentState.cs:588-604`, called at +`EquippedChildRenderController.cs:983` and +`RuntimeLiveEntitySessionController.cs:414`). Verified the full refusal +sequence: + +- It is genuinely pure — reads `resolveParentInstance`, writes nothing. +- Both production call sites now evaluate it **before** their canonical + mutation (`CommitStagedParent` / `TryCommitParent`), so the A1 torn + transaction is structurally impossible, not merely unlikely. +- On refusal both call `RejectProjection` (`:649-658`), which removes the + staged relation **only if it matches exactly** — so nothing is stranded to + block `Resolve` forever, and a concurrently-replaced staged relation is not + clobbered. +- The App site returns `CanAdvanceWireQueue: true`, and since the staged + relation is gone the enclosing `while` loop's next `TryGetStagedProjection` + fails and breaks. No infinite loop, and the recovery branch still runs. +- `CommitProjection` re-checks the same precondition as its own first mutation- + free step (`:625-626`), so the method is non-tearing for any caller, not only + the two that pre-check. + +The switch from `throw` to logged `false` also moots round 1's **R6** in the +way that matters: I flagged that I could not exhaustively prove +`_inbound._snapshots[parent].InstanceSequence` never leads +`_activeByGuid[parent].Incarnation` inside a re-Create transaction. That gap +still exists as a fact, but its consequence changed from "destructive throw on +an unproven window" to "a logged refusal and an unattached child" — an outcome +retail can represent. Round 1's R6 is **withdrawn as a risk** and downgraded to +the observation in §5 below. + +## 3. Round-1 findings — disposition + +| round 1 | status | +|---|---| +| **R1** (both deferred branches unreachable) | **Resolved by deletion.** The unreachable code is gone; the remaining else branch logs. The `AcceptLateBoundCreateObjectRelation` `` block (`:57-78`) states the structural argument correctly, including that it is "not an empirical absence, a structural one." | +| **R2** (sentinel-0 collides with `EndGeneration`/`DeleteGeneration` filters) | **Moot.** `LateBindParentInstance` and `DeferCreateObjectRelation` are gone; no relation with a placeholder incarnation ever enters `_unresolvedByChild`. Verified `FilterParentCandidates` (`:1002-1018`) now only ever sees wire-named incarnations. | +| **R3** (two wrong test rationales) | **Corrected, but incompletely — see D2.** Both now cite the zero-`FullCellId` inertness and acknowledge `HasCommittedParent` is child-keyed and committed pre-fix. | +| **R4** (D1's comment made vacuous) | **Fixed correctly.** `RuntimeEntityObjectLifetime.cs:1531-1539` now distinguishes the ParentEvent producer (protection ACTIVE, AP-132) from the CreateObject producer (vacuous-by-design, enforced by `CanCommitIncarnation`). Accurate. | +| **R5** (cross-file "above") | **Fixed.** `LiveEntityPresentationController.cs:211` now names `LiveEntityHydrationController.OnLandblockLoaded`. | +| **R6** (tripwire throw provability) | **Withdrawn as a risk** — see §2. | +| **R7** (hydration gate broader than @0x00515D40) | **Fixed.** `LiveEntityHydrationController.cs:179-187` now records that the exclusion covers the whole candidate loop and rests on route 7's P4 record, not on @0x00515D40 alone. | + +## 4. Contract §1 row 5 correction — verified correct, cite it freely + +`docs/research/2026-08-05-issue-319-contract.md:82`. Every address in the +correction was re-read at the pseudo-C for this round and is exact: + +- `this->m_position.objcell_id = objcell_id` @0x00515385 (same-cell branch) ✓ +- child loop `*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD ✓ +- `CPartArray::SetCellID` @0x005153CC ✓ +- loop bounds @0x005153AE–@0x005153D8 (`do { … } while (ebx_1 < this->children->num_objects)`) ✓ +- cross-cell branch `CPhysicsObj::change_cell(this, curr_cell)` @0x00515372 ✓ +- function header `CPhysicsObj::SetPositionInternal(CPhysicsObj*, CTransition const*)` @0x00515330 ✓ (the 4-arg overload at @0x00515BD0 is a different function; the row cites the right one) + +The characterisation — "retail's D2 equivalent lives INSIDE THE SAME FUNCTION +as the player's own per-tick cell write" — is accurate and is the strongest +single anchor for both the equality invariant and AP-146. Safe for future +sessions to cite. + +--- + +## 5. New findings this round + +### D1 — MAJOR (documentation + gate coverage; no code change required). The A4 creature-parent correction was applied to `RestoreShadow` and NOT to the hydration gate, which carries the identical live behaviour change — and no gate watch item covers it. + +**Sites:** `src/AcDream.App/World/LiveEntityHydrationController.cs:569-570` +(the gate and its comment block `:551-569`); +`docs/research/2026-08-05-issue-319-contract.md:247-276` (§3.1, uncorrected); +`docs/research/2026-08-05-issue-319-contract.md:544-559` (§7 Half B watch item, +covers only `RestoreShadow`); `docs/ISSUES.md:13370-13374` (records A4 for §3.2 +only). + +The architecture review's A4 established that §3.2's "no-ops today on +`FullCellId == 0`" premise holds **only for player-parented children**, because +route 7's D1 already re-cells CREATURE-parented children to a nonzero cell — so +the `RestoreShadow` gate is a live behaviour change for that class. That +correction is applied at `LiveEntityPresentationController.cs:212-220` and +mirrored into the contract's Half B recipe. + +**The identical argument applies to the hydration gate, and nothing records +it.** Verified chain at HEAD for a creature/static parent (incarnation 0): + +1. `OnSpawn` stages the relation with the hardcoded `0`, which **matches** the + parent's real incarnation. +2. D1's gate `parent.Incarnation == parentInstanceSequence` + (`RuntimeEntityObjectLifetime.cs:1541-1543`) passes → `SetFullCell(child, + parent.FullCellId, …)`. The child's canonical cell is **nonzero at HEAD**. +3. `ProjectionCellId => WorldEntity is not null ? FullCellId : …` + (`LiveEntityRuntime.cs:387-389`) → nonzero for a realized child. +4. `OnLandblockLoaded`'s candidate filter (`:571-577`) admits on + `projectionCellId != 0` + landblock match + `SetupTableId is not null`. An + equipped weapon satisfies all three when the parent's landblock loads. +5. It therefore enters the second loop and takes one of + `ProjectExact(CreateSupersessionRecovery)` (`:599`), + `RebucketLiveEntity` (`:611`), or `ProjectExact(SpatialRecovery)` (`:618`). + `RebucketLiveEntity` writes `entity.ParentCellId` unconditionally + (`LiveEntityRuntime.cs:885-898`, no attached-child guard) and calls + `_spatial.RebucketLiveEntity` — a **second spatial writer** competing with + route 7 D4's `RebucketEquippedChildPresentation`. + +So the hydration gate removes a live code path for creature-parented children, +exactly as the shadow gate does. The direction is right (route 7's P4/D4, and +retail's `update_object` `parent != 0` early-out @0x00515D40 for the canonical +half), but three things are wrong as it stands: + +- The gate's own comment (`:551-569`) still frames the change as affecting only + "a #319-fixed (nonzero) child", i.e. the player class. +- The contract's §3.1 still asserts "A committed child today falls through all + three sources to `0`" and "The gate is correct TODAY (it changes nothing for + a zero-cell child)" — **false for the creature class**, and it is the + sentence a future session will read as the justification. +- **Half B of the connected gate watches the shadow row but not the hydration + path.** A creature-parented weapon that stops being re-placed on landblock + load is precisely route 7's "left behind at a boundary" regression shape, and + nothing in §7 asks the runner to look for it. + +**Correct behaviour:** mirror A4 into the hydration gate's comment and contract +§3.1, and add a Half B step that carries an armed NPC across a landblock +boundary (the parent's landblock unloading and reloading) and confirms the +weapon still follows. Blast radius is one paragraph of docs plus one gate step, +not code. + +One link in the chain I did **not** close empirically and am flagging rather +than guessing: whether a realized attached child reliably has +`InitialHydrationCompleted == true` (`TryMarkInitialHydrationCompleted`, +`LiveEntityRuntime.cs:2913-2928`, requires only `WorldEntity != null` and +`ResourcesRegistered`, with no `ProjectionKind` filter). This only decides +*which* of the three second-loop branches the child took at HEAD — candidacy, +and therefore the behaviour change, holds either way. + +### D2 — MINOR. The R3 comment corrections are precise for the player row and imprecise for the creature row, in tests whose second row IS the creature class. + +`tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs:150-158` +and `tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs:102-109`. + +Both corrected comments end with "what was unreachable pre-fix is the NONZERO +`FullCellId` this candidate loop actually gates on … the child's cell stays 0 +forever for a player parent." True for the player row +(`0x50000123u`/`0x50000456u`); **false for the creature row** +(`0x70000099u`/`0x70000199u`), where D1 already produced a nonzero cell at +HEAD — the same D1 fact that drives A4. Correct behaviour: qualify the sentence +per parent class, the way `LiveEntityPresentationController.cs:212-220` now +does for the production comment. + +### D3 — LOW / observation. Two new unconditional `Console.Error.WriteLine` sites on paths reachable at wire cadence. + +`EquippedChildRenderController.cs:868-873` and +`ParentAttachmentState.cs:601-606`. Both are "should be structurally +unreachable" refusals, so volume is expected to be zero — but neither is +rate-limited or routed through a diagnostic owner, and CLAUDE.md's rule 5 +prefers a subsystem diagnostic owner over ad-hoc writes. If either ever fires +on a per-frame retry path it becomes a log flood on a host that must survive +long endurance sessions (Slice K4's own constraint). Not blocking; worth a +follow-up rather than a change in this slice. + +### D4 — OBSERVATION (favourable). The ledger-convergence gap I judged non-blocking in round 1 was closed anyway. + +Contract §6 test 9 now exists as three dual-parent-class tests +(`ParentAttachmentStateTests`: child removal, parent removal, full teardown +with mixed pending state), each asserting all four tables converge to zero. +Notably the teardown test deliberately leaves a live `_unresolvedByChild` +ParentEvent entry — proving convergence for the queue this fix did **not** +touch, which is the right target now that the deferred queue is gone. The +round-2 `PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit` +test is also well-aimed: it asserts at the **canonical** layer +(`snapshot.ParentGuid` null, `snapshot.Position` non-null) rather than at the +relation table, which is the layer the A1 tear actually corrupted. + +Contract §6 tests 5 and 6 remain unwritten; my round-1 judgement stands +(test 5's premise is code-verified at +`RuntimeEntityObjectLifetime.cs:2750-2752`; test 6 pins a flavour the +short-circuit OR makes unobservable). Neither blocks. + +--- + +## 6. Errors in the CONTRACT itself, round 2 + +One, and it is D1's second bullet: **§3.1 (`:247-276`) was not given the A4 +correction its twin §3.2 received.** Its "the gate is correct TODAY (it changes +nothing for a zero-cell child)" is false for creature-parented children and is +the sentence most likely to be cited later. §3.2's body text (`:279-289`, "no-ops +today on `record.FullCellId == 0`") has the same residue, though the §7 Half B +note now overrides it — §3.1 has no such override anywhere. + +Everything else re-checked this round — §1's table including the new row-5 +correction, §4 F1/F2's pinned constraints, §5's invariants, §7's corrected +criterion and its new Half B addendum, AP-142 clause (f), AP-132's amendment, +AP-146, and #320 — remains accurate against source and pseudo-C. diff --git a/docs/research/2026-08-05-issue-319-retail-review.md b/docs/research/2026-08-05-issue-319-retail-review.md new file mode 100644 index 00000000..242eb3bf --- /dev/null +++ b/docs/research/2026-08-05-issue-319-retail-review.md @@ -0,0 +1,380 @@ +# Issue #319 — independent retail-conformance review (2026-08-05) + +**Verdict: PASS.** + +Reviewed: the uncommitted working tree at base HEAD `af828a8a`, branch +`claude/acdream-physics-divergence-5aa784` — `git diff HEAD` (13 files, ++709/-30) plus the untracked contract +`docs/research/2026-08-05-issue-319-contract.md`. + +Review only. No file in the tree was modified by this review except this +report. + +Independent gates run for this review (a green suite is not evidence, but a +red one is): + +- `dotnet build AcDream.slnx -c Release` — exit 0. +- `AcDream.Runtime.Tests` Release — 1170/1170, 0 skipped. +- `AcDream.App.Tests` Release — 4125 passed, 3 skipped, 0 failed. + +--- + +## 1. The central retail claim — independently verified, and the contract +## UNDERSTATES its own anchor + +Every retail claim below was read at +`docs/research/named-retail/acclient_2013_pseudo_c.txt` for this review. None +was inherited from the contract or the investigation. + +| contract claim | address | verified | +|---|---|---| +| CreateObject's attach is GUID-only | `ACCObjectMaint::CreateObject` @0x00558870; `PhysicsDesc::get_parent_id` @0x00558a18 → `if (eax_20 != 0)` @0x00558a1f → `CObjectMaint::GetObjectA(this, eax_20)` @0x00558a2d → `get_parent_location_id` @0x00558a31 → `CPhysicsObj::set_parent(result, arg2, eax_22)` @0x00558a3e | ✓ **exact**. `GetObjectA` takes `(this, guid)`. There is no instance-sequence read, comparison, or argument anywhere in the block. | +| The reverse direction is the same shape | `CObjectMaint::SetChildren` @0x00509370: `unparent_children` @0x00509379, hash-bucket walk on `PhysicsDesc::get_child_id` @0x005093b2–@0x005093ca, `GetNullObject(this, get_child_id(...), 1)` placeholder @0x005093e6, `set_parent(hash_next_1, arg2, get_child_location_id(...))` @0x005093f8 | ✓ **exact**, including the `GetNullObject` placeholder for a not-yet-constructed child. No instance field participates. | +| No player branch in the relation path | `set_parent` @0x00515A90 (3-arg) and @0x00515B50 (4-arg, Frame); `enter_cell` @0x00510ED0; `change_cell` @0x00513390 | ✓. Both `set_parent` overloads operate on `CPhysicsObj*` uniformly: `add_child` → `unset_parent` → `leave_world` → `parent = edi` → `if (cell != 0) change_cell` → `UpdateChild` → `recalc_cross_cells`. The only conditional in either body is the parent's `state & 0x4000` no-draw cascade @0x00515B26. `enter_cell`'s only guard is `part_array != 0` @0x00510ED8. **No player test exists in any of them.** | +| Retail's player cell is written per tick | `CPhysicsObj::SetPositionInternal` @0x00515330 | ✓ — and **stronger than the contract states**, see below. | +| `update_object` early-outs on a parented object | @0x00515D40: `if ((this_3->parent != 0 \|\| (this_3->cell == 0 \|\| (this_3->state & 0x1000000) != 0))) { transient_state &= ~0x80; return; }` | ✓ **exact**. A parented child is never independently updated or re-placed. | + +**The contract's §1 row 5 cites the weaker half of its own best anchor.** +`SetPositionInternal` @0x00515330 does not merely write the mover's own cell: +in the same-cell branch it writes `this->m_position.objcell_id = objcell_id` +@0x00515385, then **walks `this->children` and writes each child's cell id +directly** — `*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD, +followed by `CPartArray::SetCellID` @0x005153CC, looping @0x005153AE– +@0x005153D8; the cross-cell branch delegates to `change_cell` @0x00515372. +That is retail's D2 equivalent living **inside the same per-tick function** as +the player's own cell write. It is a strengthening, not a contradiction: the +child-equals-parent equality invariant and AP-146's "retail's player cell is +never stale" both get a single anchor. (AP-142 clause (b) already cites this +range; only §1's summary table understates it.) + +### Conclusion on the central claim + +**Retail does NOT distinguish a player parent, and there is no +instance-sequence check anywhere in the relation path — confirmed +independently.** acdream's player/non-player split is exactly what the +contract says it is: an artifact of keying committed relations by +`(guid, incarnation)` against a wire message that carries only a GUID. +**Late-binding to whoever currently holds the GUID is the faithful mapping**, +because "the current holder of the GUID" is literally what `GetObjectA` / +`GetNullObject` return. The fix shape is right. + +--- + +## 2. Scope expansion (`OnCreateParentAccepted`) — JUSTIFIED, and the fix is +## correct there + +The contract's Scope line named only `EquippedChildRenderController.OnSpawn`. +The implementer also fixed `OnCreateParentAccepted`. Verified: + +- `CreateParentUpdate` is declared at + `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs:1399-1405` + as `(ChildGuid, ParentGuid, ParentLocation, PlacementId, + ChildInstanceSequence, ChildPositionSequence)`. Its producer, + `BuildSameGenerationEvents` (`:1240-1248`), fills + `ChildInstanceSequence` from `ts.Instance` where `ts` is + `incoming.Physics.Timestamps` and `incoming` **is the child**. So + `ChildInstanceSequence` is the CHILD's own timestamp, exactly as the + implementer reports. **No parent instance sequence exists on this shape.** +- The codebase already knew this: `InboundPhysicsStateController. + TryApplyCreateParent`'s pre-existing doc comment (`:216-218`) reads + "Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only + the child's shared POSITION_TS participates in freshness." +- Pre-fix, `OnCreateParentAccepted` hardcoded `ParentInstanceSequence: 0` + into the identical `Relations.AcceptCreateObjectRelation` call. **Byte- + identical defect.** Shipping the key fix on one producer and not the other + would have left #319 half-open for every same-generation ObjDesc refresh of + a player-parented child. + +Routing both through one `AcceptLateBoundCreateObjectRelation` +(`EquippedChildRenderController.cs:836-857`) is the right shape and the right +commit. + +--- + +## 3. Findings + +### R1 — MINOR (incorrect load-bearing comment). Both deferred branches are production-unreachable, not just `OnSpawn`'s. + +`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:875-881` + +The test's XML doc asserts: + +> `CreateParentUpdate`'s producer … has no equivalent parent-addressability +> precondition, so this is the reachable shape for #319's deferred branch. + +**Verified false.** The only production driver of `OnCreateParentAccepted` is +`LiveEntityHydrationController.OnCreate`'s `result.SameGenerationEvents is +{ } refresh` branch (`LiveEntityHydrationController.cs:340-341` → +`LiveEntityNetworkUpdateController.cs:380` → +`LiveEntitySameGenerationUpdateRouter.Apply` → `OnParent` → `:400-401`). +`SameGenerationCreateObjectEvents` are produced only inside +`InboundPhysicsStateController.AcceptCreate` (`:70-90`), reached only from +`RuntimeEntityObjectLifetime.RegisterEntityCore` — and `RegisterEntityCore`'s +deferral gate runs **before** `PreviewCreateDisposition`: + +``` +RuntimeEntityObjectLifetime.cs:798-812 +uint parentGuid = incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u; +if (beginInitialResidence && parentGuid != 0u + && !Entities.TryGetActive(parentGuid, out _)) +{ Entities.ParentAttachments.EnqueueDeferredCreate(...); return DeferredForParent: true; } +``` + +The `??` chain covers both the flattened `ParentGuid` and the nested +`Physics.Parent.Guid`, i.e. both shapes `OnSpawn` and `OnCreateParentAccepted` +read. The App graphical host is the `beginInitialResidence: true` caller +(`LiveEntityRuntime.cs:583`). So by the time either producer runs, the parent +is active. The implementer's `OnSpawn` finding is correct; the extension of it +to `OnCreateParentAccepted` is not. + +Retail address contradicted: none — this is a reachability claim about +acdream, not about retail. + +**Not a code defect.** The deferred machinery is correct, and it is genuinely +defensive: the deferral gate reads `Entities.TryGetActive` (the +`_activeByGuid` map) while the late-bind reads `TryGetSnapshot` (the +`_inbound._snapshots` map) — two distinct stores, so a producer-side fallback +is warranted rather than an assertion. Correct behaviour: the comment should +say the deferred branch is defensive against store drift and is not exercised +by any production path today, and the test should be labelled as covering the +mechanism rather than a reachable production shape. + +### R2 — MINOR (latent bug, unreachable today by R1). The late-bind sentinel `0` is filtered as if it were a wire-named incarnation. + +`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:828-834` (`EndGeneration`) +and `:853-857` (`DeleteGeneration`), via `FilterParentCandidates` (`:1002-1018`). + +`DeferCreateObjectRelation` (`:412-426`) queues the relation into +`_unresolvedByChild` carrying `ParentInstanceSequence: 0` plus +`LateBindParentInstance = true`. The `0` is a **meaningless placeholder** — +that is the whole premise of the fix. But `EndGeneration`'s retain predicate +treats it as a wire-named value: + +``` +relation => relation.ParentInstanceSequence == replacementGeneration + || PhysicsTimestampGate.IsNewer(replacementGeneration, + relation.ParentInstanceSequence) +``` + +`IsNewer(oldStamp, newStamp)` (`AcDream.Core/Physics/PhysicsTimestampGate.cs:58-63`) +returns true when `newStamp` is newer. For a player parent replaced at +generation 7: `0 == 7` false, `IsNewer(7, 0)` false → **the late-bind relation +is dropped**, which is the exact opposite of its own semantic ("attach to +whoever currently holds the GUID" — the replacement generation is precisely +whom it should attach to) and of retail's GUID-keyed blob replay +(`QueueBlobForObject`, AP-132's retail half: replay on GUID (re)creation with +only an addressability check). + +Not reachable in production today because of R1, and **not a regression** — +pre-fix a CreateObject relation went straight to `_stagedByChild`, which +`EndGeneration` clears unconditionally via `RemoveParentReferences` +(`:822-827`). Correct behaviour when the path becomes reachable: both +`FilterParentCandidates` retain predicates should keep +`relation.LateBindParentInstance` unconditionally, or the deferred relation +should carry a nullable incarnation rather than a `0` sentinel that collides +with a legitimate creature-parent value. + +### R3 — MINOR (incorrect comment, two sites; the same wrong rationale twice). + +`tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs:149-153` +and `tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs:101-105`. + +Both comments justify the fixture setup with: + +> …giving it a nonzero canonical `FullCellId` via D1/D2 (exercised +> elsewhere) — impossible to reach before #319's fix for a player-class +> parent. + +Two errors: + +1. **"the relation never committed for a player-class parent at all" is + false.** `HasCommittedParent` is keyed by the CHILD guid + (`ParentAttachmentState.cs:571-572`: `_lastAcceptedByChild.ContainsKey(childGuid)`), + and pre-fix `CommitProjection` succeeded under `(playerGuid, 0)` — the + contract's own §0 says so verbatim ("`TryCommitParent` never validates the + sequence; the attach succeeds silently"). A player-parented child **did** + have a committed parent at HEAD. What was inert at HEAD is the child's + zero `FullCellId`, which is the contract's actual §3.1/§3.2 argument. +2. **The nonzero cell in both fixtures does not come from D1/D2.** The + presentation fixture materialises at `0x01010001u` + (`LiveEntityPresentationControllerTests.cs:690`, seeded from the spawn's + own `ServerPosition` at `:744`); the hydration fixture's record carries + `Cell` from its own spawn. Neither test calls + `CommitAcceptedParentCellless`. + +The tests themselves are **valid and their sabotage claims hold** — I +verified independently that `ProjectionCellId => WorldEntity is not null ? +FullCellId : …` (`LiveEntityRuntime.cs:387-389`) makes a committed child's +candidate cell exactly `FullCellId`, so it is 0 at HEAD and the parent's cell +post-fix; the gates are therefore behavior-preserving at HEAD, exactly as the +contract argued. Only the stated rationale is wrong. Correct behaviour: cite +the zero-`FullCellId` inertness, not a non-existent absence of a committed +relation. + +### R4 — MINOR (comment made partially false by this diff; the comment itself is untouched). + +`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1529-1531` (D1): + +> The committed relation (not the guid alone) resolves the parent so a stale +> or superseded incarnation can never re-cell the child. + +After F1 a CreateObject-carried relation is, by construction, always equal to +the parent's live incarnation at commit (and the new tripwire at +`ParentAttachmentState.cs:651-661` enforces it). For that producer the +sentence now describes a protection that is vacuous rather than active. It +remains true for the `ParentEvent` producer, whose incarnation the wire names +(AP-132). The diff changed the semantics of a comment it did not edit — the +exact class the review brief flags. Correct behaviour: qualify the sentence to +name the ParentEvent producer. + +### R5 — LOW (comment misdirection). + +`src/AcDream.App/World/LiveEntityPresentationController.cs:228` — "the same +predicate the hydration gate above uses". The hydration gate is in a different +file (`LiveEntityHydrationController.OnLandblockLoaded:561`), not "above". + +### R6 — OBSERVATION, partly UNVERIFIABLE (flagged rather than guessed). The tripwire converts a silent skip into a production throw. + +`ParentAttachmentState.CommitProjection` (`:639-661`) now throws +`InvalidOperationException` when the parent is addressable and the relation's +incarnation disagrees. The contract explicitly authorised this ("throw or +logged refusal … pinned outcome: never a silent success"), so it is in scope. +I enumerated the false-positive surface as far as source allows: + +- Only the **Staged** branch of `PrepareAndTryRealize` + (`EquippedChildRenderController.cs:954-960`) reaches it in the graphical + host; the Recovery branch does not. +- That branch is gated by `ValidateParentProjection` (`:986-996`), which + requires a live `LiveEntityRecord` for the parent. +- A staged relation is staged either at the parent's live incarnation + (`AcceptLateBoundCreateObjectRelation:846-853`), or after `Resolve`'s + equality check (`ParentAttachmentState.cs:484-498`), or by the late-bind + adoption (`:473-483`). +- A parent generation change removes every staged/recovery/committed + reference to that parent (`EndGeneration:822-827`, + `DeleteGeneration:847-852`). +- In the headless host (`RuntimeLiveEntitySessionController.cs:410`) relations + originate only from `Enqueue(ParentEvent.Parsed)` and pass `Resolve`'s + equality gate, so the tripwire is a no-op there. + +**What I could NOT establish:** whether `_inbound._snapshots[parent]. +InstanceSequence` (the tripwire's source, reached via `TryGetSnapshot`) can +ever lead `_activeByGuid[parent].Incarnation` inside a re-CreateObject +transaction — `AcceptCreate` writes `_snapshots[guid] = incoming` +(`InboundPhysicsStateController.cs:77`) before `RegisterEntityCore` completes +the record replacement, and the two stores are documented as "related but +distinct" (`:1198-1210`). I found no call into `CommitProjection` inside that +window, but I did not prove the absence exhaustively. Reporting as +unverified. If the connected gate ever produces this exception, the message +already names #319 and the two incarnations, which is the right diagnostic. + +### R7 — OBSERVATION. The hydration gate is broader than its cited retail anchor. + +`LiveEntityHydrationController.cs:551-562` excludes a committed child from the +**entire** candidate loop, so `ProjectExact(CreateSupersessionRecovery)` +(`:595`) and `ProjectExact(SpatialRecovery)` (`:614`) are also refused for it, +not only the legacy `RebucketLiveEntity → CommitRebucket` branch (`:607`). + +This is what the contract pinned ("the candidate loop excludes records with a +committed parent", §3.1) and it is behavior-preserving at HEAD (verified: a +committed child's `projectionCellId` is `FullCellId` = 0 today). But the +retail anchor in the comment, `update_object`'s `parent != 0` early-out +@0x00515D40, speaks to **physics re-placement**, not to render-projection +recovery. The broader exclusion actually rests on route 7's P4 record (a +committed child is never a spatial root, joins no workset, has no shadow row) +plus the fact that a child's projection is driven exclusively through +`EquippedChildRenderController`'s realize/retry path. Worth one sentence in +AP-142 clause (f) or the contract rather than a code change — as written the +comment claims more anchoring than @0x00515D40 provides. + +--- + +## 4. The three unwritten contract tests — NONE blocks + +- **§6 test 5 (residence non-event).** Not blocking. I verified §3.3's premise + directly in source rather than relying on the test: + `RuntimeEntityObjectLifetime.InitializeAcceptedCreateResidence:2750-2752` + reads `if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u, + 0u);` immediately before `InitialCreateResidences.Begin`. `Begin`'s + `FullCellId != 0` refusal is therefore a defensive invariant, not a + reachable gate, exactly as §3.3 concluded. The zero-then-re-cell atomicity + claim remains argued-not-measured, but no production change was made at + that site. +- **§6 test 6 (unwield classification population).** Not blocking. The + contract itself recorded (from `af828a8a`) that ACE advances TELEPORT_TS at + a drop and the classifier is a short-circuit OR + (`RuntimeAuthoritativePositionRouteClassifier.cs:391`), so the observable + label is `teleport-ts` either way. The test would pin a flavour, not a + behaviour the fix changes. +- **§6 test 9 (ledger convergence).** Not blocking, and I checked why: + `RestoreShadow`'s refusal for a committed child is byte-identical pre- and + post-fix (the `FullCellId == 0` clause already refused every committed child + at HEAD). The one ledger asymmetry that exists — + `_suspendedShadowOwners.Add` on `BecameHidden` + (`LiveEntityPresentationController.cs:172`) with no matching removal on + `BecameVisible` because `RestoreShadow` returns false — is **pre-existing + and unchanged**, and converges through `Forget`/`Clear` (`:117`, `:125`). + +The connected §7 gate (both halves, player class mandatory) remains the real +acceptance test and has not run. That is correctly recorded in ISSUES.md's +#319 status line, which honestly says "FIX IMPLEMENTED, awaiting the connected +acceptance gate … NOT YET COMMITTED". + +--- + +## 5. Register and bookkeeping — verified row by row + +- **AP-146 (new row, `retail-divergence-register.md:174`).** Retail anchor + `CPhysicsObj::SetPositionInternal` @0x00515330 **verified** — the address + resolves to the `(CPhysicsObj*, CTransition const*)` overload (the 4-arg + `SetPositionInternal` at @0x00515BD0 is a different function; the row cites + the right one), and the cell write is unconditional across both branches + (`m_position.objcell_id = …` @0x00515385 same-cell, `change_cell` + @0x00515372 cross-cell). The three acdream writers, the + `LocalPlayerProjectionController` landblock coarsening, and the + `LiveEntityRuntime.cs:935-938` preserve are all cited. The risk column names + the unresolved `ParkCollisionResidents` question **openly** rather than + smoothing it. Register rule 1 is satisfied: this was a standing + undocumented divergence and the diff files it. Header count 102 → 103, + correct. +- **AP-142 clause (f).** Every retail address in the clause + (@0x00558a18/@0x00558a2d/@0x00558a3e, @0x00509370/@0x005093e6) was + re-verified above and is exact. The clause states the acdream side honestly + (the `(guid, incarnation)` key is acdream's, retail has none) and names the + tripwire. Correct. +- **AP-132 amendment.** The added sentence distinguishes the two producers + correctly and does not weaken the row's own gate. Verified against the wire + types: `ParentEvent.Parsed` carries `ParentInstanceSequence`; + `CreateParentUpdate` carries `ChildInstanceSequence`. Correct. +- **Route-7 contract §7 supersession blockquote.** Accurate — the old + criterion is genuinely unfalsifiable under #319 (a zero-cell child emits no + probe line, which the old criterion read as clean) and it points at the + corrected positive-equality criterion. Correct. +- **#320.** A faithful transcription of contract §2.2's five-item enumeration + plus §9 item 2 as the mandatory first verification step, with the correct + "do not implement without a fresh retail-conformance argument" gate. It + inherits exactly what #319 deliberately excluded — nothing more, nothing + less. + +--- + +## 6. Errors in the CONTRACT itself + +Two, neither material to the verdict: + +1. **§1 row 5 understates its own anchor** (see §1 of this review). + `SetPositionInternal` @0x00515330 also walks `this->children` writing each + child's `objcell_id` @0x005153AE–@0x005153D8 — retail's per-tick D2 + equivalent in the same function. This *strengthens* the equality invariant + and AP-146; citing only the mover's own write leaves the strongest + available evidence on the table. +2. **§4 F4's "ISSUES.md: #319 → Recently closed with the commit SHA" was not + executed** — correctly, since the connected gate has not run and the tree + is uncommitted. The contract's instruction was written assuming the fix + commit and the gate land together. Deviation is honest and recorded in the + issue's own status line. + +Everything else in the contract that this review touched — §0's defect chain, +§1's five rows, §2.1's four-consumer enumeration, §3.1/§3.2's wake mechanics, +§3.3's residence non-event, §4 F1's pinned constraints, §5's invariants — was +confirmed against source or pseudo-C. diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 87f4b7c6..b8924217 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -70,6 +70,13 @@ public sealed class EquippedChildRenderController : IDisposable private readonly Func _tickAttached; private readonly Func _reconcileAttached; private int _activePoseCompositionVisits; + /// + /// #319 D3/B6 (retail + architecture review round 2, 2026-08-05): + /// per-child log-once latch for 's + /// unaddressable-parent refusal - see the identical rationale on + /// ParentAttachmentState._loggedIncarnationRefusals. + /// + private readonly HashSet _loggedUnaddressableParentRefusals = []; internal int LastFullPoseCompositionVisits { get; private set; } internal int LastReconcilePoseCompositionVisits { get; private set; } @@ -126,13 +133,12 @@ public sealed class EquippedChildRenderController : IDisposable // 0x0051DDD0) default an absent AnimationFrame to zero. // Parent remains a complete relation in that case. uint placementId = spawn.PlacementId ?? 0u; - Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + AcceptLateBoundCreateObjectRelation( parentGuid, spawn.Guid, parentLocation, placementId, - ParentInstanceSequence: 0, - spawn.PositionSequence)); + spawn.PositionSequence); } ResolveAndTryRealize(spawn.Guid); @@ -796,17 +802,100 @@ public sealed class EquippedChildRenderController : IDisposable { lock (_datLock) { - Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + // CreateParentUpdate is the same-generation ObjDesc-refresh + // shape of a CreateObject-carried relation (BuildSameGenerationEvents, + // InboundPhysicsStateController.cs) - it carries the parent's + // GUID and location only, never an instance sequence, for the + // identical structural reason OnSpawn's raw CreateObject path + // does not (#319 F1). + AcceptLateBoundCreateObjectRelation( update.ParentGuid, update.ChildGuid, update.ParentLocation, update.PlacementId, - ParentInstanceSequence: 0, - update.ChildPositionSequence)); + update.ChildPositionSequence); ResolveAndTryRealize(update.ChildGuid); } } + /// + /// #319 F1: late-binds a CreateObject-carried parent relation (raw + /// CreateObject's Physics.Parent via , or + /// the same-generation envelope via + /// ) to the parent's LIVE + /// incarnation, rather than assuming sequence zero. Neither wire shape + /// carries a parent instance sequence - retail's own attach is + /// GUID-only (PhysicsDesc::get_parent_id @0x00558a18 -> + /// GetObjectA @0x00558a2d -> set_parent @0x00558a3e), + /// so "the current holder of the guid" is the faithful mapping. The + /// parent's snapshot lookup here mirrors 's + /// own lookup (). + /// + /// + /// #319 A6 (architecture review, 2026-08-05): a queued/late-bind + /// deferral for an unaddressable parent was tried here and REMOVED. + /// RuntimeEntityObjectLifetime.RegisterEntityCore's + /// EnqueueDeferredCreate gate (`:797-812`) defers the ENTIRE + /// CreateObject - both this raw shape (`incoming.ParentGuid`) and the + /// same-generation envelope + /// (`incoming.Physics.Parent.Guid`, same `??` chain) - whenever its + /// parent is not yet addressable, and does so BEFORE either wire shape + /// is ever produced (`AcceptCreateCore`/`BuildSameGenerationEvents` are + /// reached only after that gate passes). So this method never observes + /// an unaddressable parent in production for EITHER producer - not an + /// empirical absence, a structural one. A deferral queue here carried + /// three independent defects (a missing child POSITION_TS gate, a + /// placeholder-incarnation collision with the generation filters, + /// unbounded mid-session accumulation) while being exercised by nothing + /// but a test that called this class's methods directly, bypassing the + /// production routing that makes the branch unreachable. If the else + /// branch below is ever reached, that upstream invariant has broken; + /// log loudly rather than reconstructing unreachable machinery to + /// paper over it. (B2, architecture review round 2: the upstream gate + /// tests Entities.TryGetActive - the active-record table - while + /// this method's own lookup below tests _liveEntities.TryGetSnapshot + /// - the inbound snapshot table. Active implies a snapshot exists, + /// because AddActive is fed from the same snapshot + /// AcceptCreate just wrote; the only known inversion window is + /// inside TryDeleteEntity, where the snapshot is removed several + /// statements before the active record, and reaching this method + /// through that window would require a re-entrant child CreateObject + /// inside it - not reachable from a single-threaded pump.) (B3: this + /// unreachability argument is scoped to the GRAPHICAL host - the + /// content-less direct/headless host has no + /// EquippedChildRenderController at all, so it has no + /// CreateObject-carried relation producer to which this argument would + /// even apply.) + /// + private void AcceptLateBoundCreateObjectRelation( + uint parentGuid, + uint childGuid, + uint parentLocation, + uint placementId, + ushort childPositionSequence) + { + if (_liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn parentSpawn)) + { + Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + parentGuid, + childGuid, + parentLocation, + placementId, + parentSpawn.InstanceSequence, + childPositionSequence)); + } + else if (_loggedUnaddressableParentRefusals.Add(childGuid)) + { + Console.Error.WriteLine( + $"equipment: parent 0x{parentGuid:X8} unaddressable for child " + + $"0x{childGuid:X8} at CreateObject-carried relation accept - " + + "refusing (#319 A6; should be structurally unreachable - see " + + "RuntimeEntityObjectLifetime.RegisterEntityCore's " + + "EnqueueDeferredCreate gate). Logged once for this child; " + + "further refusals for the same child are suppressed."); + } + } + private bool TryResolveExactAttachment( AttachedChild child, out WorldEntity parent) @@ -831,12 +920,20 @@ public sealed class EquippedChildRenderController : IDisposable Relations.Resolve( childGuid, guid => _liveEntities.TryGetSnapshot(guid, out _), - guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn) - ? spawn.InstanceSequence - : null, + ResolveLiveParentInstance, _acceptParent); } + /// + /// The parent's LIVE incarnation, or null if not currently addressable. + /// Shared by 's late-bind lookup and + /// 's #319 F1 commit-time tripwire. + /// + private ushort? ResolveLiveParentInstance(uint parentGuid) => + _liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn spawn) + ? spawn.InstanceSequence + : null; + private bool ResolveAndTryRealize(uint childGuid) { bool projected = false; @@ -896,8 +993,25 @@ public sealed class EquippedChildRenderController : IDisposable childCanonical.PositionAuthorityVersion; if (candidateKind is ParentProjectionCandidateKind.Staged) { + // #319 A1 (architecture review, 2026-08-05): the incarnation + // tripwire MUST be evaluated before either half of the commit + // runs. The original shape called CommitStagedParent (the + // canonical half) first and let CommitProjection's internal + // check throw second - by then the canonical layer had already + // been rewritten as parented, so a mismatch tore the + // transaction (canonically parented, no committed relation, a + // staged relation blocking Resolve forever) instead of + // refusing cleanly. This read-only pre-check runs before any + // mutation on either side. + if (!Relations.CanCommitIncarnation(relation, ResolveLiveParentInstance)) + { + Relations.RejectProjection(relation); + return new ProjectionPreparationResult( + CanAdvanceWireQueue: true, + Projected: false); + } if (!_liveEntities.CommitStagedParent(relation, out _) - || !Relations.CommitProjection(relation)) + || !Relations.CommitProjection(relation, ResolveLiveParentInstance)) { return default; } @@ -1660,6 +1774,7 @@ public sealed class EquippedChildRenderController : IDisposable _pendingReparentRemovalByChild.Clear(); _pendingPoseLossRemovalByChild.Clear(); _pendingOrphanRemovalByChild.Clear(); + _loggedUnaddressableParentRefusals.Clear(); Relations.Clear(); } diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 6930cbe2..c23c6e45 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -548,6 +548,45 @@ AppearanceSynchronization: continue; } + // #319 F2 (contract §3.1): a committed child is never + // independently re-placed by landblock load - retail's + // update_object `parent != 0` early-out (@0x00515D40) means + // only the parent's own D1/D2 propagation may move it. Without + // this gate, a #319-fixed (nonzero) child's cell would make it + // a hydration candidate here and take the full legacy + // RebucketLiveEntity -> CommitRebucket branch below - a SECOND + // canonical cell writer for a child, the exact defect route 7 + // exists to remove. Same predicate the collision-retirement + // sweep already uses (RuntimeSetPositionState.IsAffectedCollisionResident). + // Retail review R7 (2026-08-05): the exclusion below covers + // the ENTIRE candidate loop (ProjectExact's CreateSupersessionRecovery + // and SpatialRecovery branches too, not only the legacy + // RebucketLiveEntity branch @0x00515D40 speaks to). The + // broader scope rests on route 7's P4 record (a committed + // child is never a spatial root, joins no workset, has no + // shadow row) plus the fact a child's projection is driven + // exclusively through EquippedChildRenderController's + // realize/retry path - not on @0x00515D40 alone. + // CORRECTION (retail review round 2, D1, 2026-08-05): this gate + // is NOT a no-op at HEAD for every parent class. D1's attach + // re-cell (RuntimeEntityObjectLifetime.cs's + // `parent.Incarnation == parentInstanceSequence` gate) already + // matches for a CREATURE/static-range parent pre-fix (the + // hardcoded 0 IS that parent's real incarnation), so a + // creature-parented child already carries a nonzero canonical + // cell at HEAD and was ALREADY a hydration candidate here, + // taking RebucketLiveEntity's unconditional + // `entity.ParentCellId` write and spatial rebucket - a second + // writer competing with route 7 D4's presentation-only + // rebucket. This gate removes that live path for the creature + // class too, not only the player class the #319 fix newly + // affects. Direction is correct (route 7's P4/D4); see the + // #319 contract's §7 Half B for the connected-gate watch step + // this requires (carry an armed NPC across a landblock + // reload). + if (_runtime.ParentAttachments.HasCommittedParent(candidate.ServerGuid)) + continue; + uint projectionCellId = projection?.ProjectionCellId ?? candidate.Snapshot.Position?.LandblockId ?? candidate.FullCellId; diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index 5fe0bd32..4949dffb 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -217,7 +217,25 @@ public sealed class LiveEntityPresentationController : IDisposable { if (!record.IsSpatiallyProjected || !record.IsSpatiallyVisible - || record.FullCellId == 0) + || record.FullCellId == 0 + // #319 F2 (contract §3.2): a committed child never owns an + // independent broadphase row (route 7's P4 record, + // RuntimeEntityDirectory.cs:451-465). IsSpatiallyProjected and + // IsSpatiallyVisible do NOT exclude a child - its presentation- + // only rebucket sets IsSpatiallyProjected=true every frame + // (LiveEntityRuntime.RebucketLiveEntityPresentationOnly) - so + // parentage must be checked directly, the same predicate + // LiveEntityHydrationController.OnLandblockLoaded's gate uses. + // CORRECTION (architecture review A4, 2026-08-05): the + // contract's §3.2 premise ("no-ops today on FullCellId == 0") + // holds only for PLAYER-parented children - route 7's D1 + // already re-cells CREATURE-parented children to a nonzero + // cell, so this clause IS a live behavior change for that + // class (an NPC's wielded weapon no longer refreshes its + // shadow row on a Hidden->Visible edge). Right direction per + // route 7's P4 record; the connected gate's Half B must watch + // for it explicitly (issue #319 contract §7). + || _liveEntities.ParentAttachments.HasCommittedParent(record.ServerGuid)) { return false; } diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 06caa9cc..67538f2a 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -34,6 +34,19 @@ public sealed class ParentAttachmentState private readonly Dictionary> _deferredAcceptedRelationsByParent = []; private ulong _nextDeferredCreateAdmissionId; + /// + /// #319 D3/B6 (retail + architecture review round 2, 2026-08-05): + /// per-child log-once latch for 's + /// refusal. The refusal should be structurally unreachable in + /// production; if the invariant it depends on ever breaks for a + /// repeating producer, an unconditional log would spam once per + /// packet on a host that must survive long endurance sessions (Slice + /// K4). One line per distinct child is enough to make the break + /// discoverable without a flood. Does not affect + /// 's correctness-table purity - it + /// touches only this bookkeeping set, never the relation tables. + /// + private readonly HashSet _loggedIncarnationRefusals = []; /// /// Round 5 R5-2: cancellation-aware detach/restore window state, shared @@ -568,7 +581,59 @@ public sealed class ParentAttachmentState } } - public bool CommitProjection(ParentAttachmentRelation relation) + /// + /// #319 A1 (architecture review, 2026-08-05): pure, side-effect-free + /// precondition for the commit-time incarnation tripwire. Callable + /// BEFORE any canonical or relation-table mutation so a mismatch can be + /// refused without ever tearing a transaction — the original shape threw + /// from inside , which callers reached + /// AFTER already committing the canonical half + /// (RuntimeEntityObjectLifetime.TryCommitParent), leaving a child + /// the canonical layer believed was parented with no committed relation + /// and a staged relation blocking forever — the + /// exact torn-transaction outcome F1 pinned against. Logs and returns + /// on a mismatch; never throws (Route 3's N3 + /// principle: a possibly-transient condition must not become fatal on a + /// host that must survive long endurance sessions). Returns + /// (no refusal) when + /// is null or the parent is not + /// currently addressable — the tripwire only fires when the parent IS + /// live and DISAGREES with the relation's named incarnation. + /// + public bool CanCommitIncarnation( + ParentAttachmentRelation relation, + Func? resolveParentInstance) + { + if (resolveParentInstance is null + || resolveParentInstance(relation.ParentGuid) is not { } liveParentInstance + || liveParentInstance == relation.ParentInstanceSequence) + { + return true; + } + + if (_loggedIncarnationRefusals.Add(relation.ChildGuid)) + { + Console.Error.WriteLine( + $"[parent-attach] refused: child=0x{relation.ChildGuid:X8} names " + + $"parent 0x{relation.ParentGuid:X8} incarnation " + + $"{relation.ParentInstanceSequence}, but the parent's live " + + $"incarnation is {liveParentInstance} (#319 tripwire). " + + "Logged once for this child; further refusals for the same " + + "child are suppressed."); + } + return false; + } + + /// + /// Stages -> committed transition for an accepted parent relation. + /// Calls as its own first check + /// (before any mutation here) so this method is non-tearing on its own + /// terms for ANY caller, in addition to the two production call sites + /// that also pre-check before their canonical commit. + /// + public bool CommitProjection( + ParentAttachmentRelation relation, + Func? resolveParentInstance = null) { if (!_stagedByChild.TryGetValue( relation.ChildGuid, @@ -578,6 +643,9 @@ public sealed class ParentAttachmentState return false; } + if (!CanCommitIncarnation(relation, resolveParentInstance)) + return false; + RemoveCommittedChild(relation.ChildGuid); _lastAcceptedByChild[relation.ChildGuid] = relation; var parent = new ParentIncarnation( @@ -794,6 +862,10 @@ public sealed class ParentAttachmentState _recoveryByChild.Remove(childGuid); RemoveCommittedChild(childGuid); _unresolvedByChild.Remove(childGuid); + // #319 D3/B6: a recycled guid's next incarnation deserves its own + // refusal log rather than silent suppression from a prior, now-dead + // incarnation's latch entry. + _loggedIncarnationRefusals.Remove(childGuid); } public void Clear() @@ -812,6 +884,7 @@ public sealed class ParentAttachmentState foreach (List children in _committedChildrenByParent.Values) children.Clear(); _committedChildrenByParent.Clear(); + _loggedIncarnationRefusals.Clear(); } private void RemoveDeferredChildCreates(uint childGuid) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 37377f05..94471e8f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1528,7 +1528,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // @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. + // superseded incarnation can never re-cell the child. (Retail + // review R4, 2026-08-05: this protection is ACTIVE for the + // ParentEvent producer, whose incarnation the wire names and + // AP-132 gates on staleness. For the CreateObject producer, #319's + // fix makes the committed relation's incarnation always equal the + // parent's live value at commit time by construction - the + // commit-time tripwire in ParentAttachmentState.CanCommitIncarnation + // enforces it - so this sentence is vacuous-by-design there, not a + // live protection against a reachable staleness.) if (Entities.ParentAttachments.TryGetCommittedParent( canonical.ServerGuid, out uint parentGuid, diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 8486fab5..03eb06ef 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -405,9 +405,20 @@ public sealed class RuntimeLiveEntitySessionController return false; } + // #319 A1 (architecture review, 2026-08-05): same ordering fix as + // the graphical host's PrepareAndTryRealize - the incarnation + // tripwire must run before TryCommitParent's canonical mutation, + // never after, so a mismatch refuses cleanly instead of tearing the + // transaction. + if (!relations.CanCommitIncarnation(staged, _resolveParentInstance)) + { + relations.RejectProjection(staged); + return false; + } + ulong positionAuthorityVersion = canonical.PositionAuthorityVersion; if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _) - || !relations.CommitProjection(staged)) + || !relations.CommitProjection(staged, _resolveParentInstance)) { return false; } diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 6726e5e7..451f1b92 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -711,12 +711,18 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); Assert.Null(snapshot.Position); Assert.False(fixture.Live.TryGetRecord(child.ServerGuid, out _)); + // #319: the parent was spawned with generation 1 - the committed + // relation must carry the PARENT's live incarnation (late-bound at + // accept time), not a hardcoded 0. This assertion previously pinned + // the exact #319 defect (it passed for a nonzero-incarnation parent + // only because AcceptCreateObjectRelation ignored the parent + // entirely and always staged 0). var relation = new ParentAttachmentRelation( parent.ServerGuid, child.ServerGuid, 0, 0, - 0, + 1, 1); Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation)); @@ -763,12 +769,15 @@ public sealed class EquippedChildProjectionWithdrawalTests fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); + // #319: the parent was spawned with generation 1 - see the sibling + // fix note in NoPositionCreateParent_CommitsAfterParentPartArrayValidation + // above. var expected = new ParentAttachmentRelation( parent.ServerGuid, childSpawn.Guid, ParentLocation: 0, PlacementId: 0, - ParentInstanceSequence: 0, + ParentInstanceSequence: 1, ChildPositionSequence: 0); Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected)); fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); @@ -780,6 +789,222 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.NotNull(child.WorldEntity); } + /// + /// #319 F1, the key itself. Dual-parent-class matrix: a player-range + /// parent (nonzero incarnation) and a creature-range parent (sequence + /// 0). Drives the REAL producer () + /// rather than the test helper CommitRenderedRelation most other + /// tests in this file use (which hand-crafts the relation and never + /// exercises the hardcoded-0 bug). Verifies the relation commits under + /// the PARENT's exact live incarnation (not 0) and that both D1 (attach + /// re-cell) and D2 (crossing propagation) reach the child. + /// + [Theory] + [InlineData(0x50000777u, (ushort)9)] + [InlineData(0x70000280u, (ushort)0)] + public void OnSpawn_CreateObjectRelation_LateBindsToParentLiveIncarnationAndPropagatesCell( + uint parentGuid, + ushort parentIncarnation) + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation); + Assert.NotEqual(0u, parent.Canonical.FullCellId); + + WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( + 0x7000028Fu, + generation: 1); + childSpawn = childSpawn with + { + Position = null, + ParentGuid = parent.ServerGuid, + ParentLocation = 0, + PlacementId = null, + PositionSequence = 0, + Physics = childSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(parent.ServerGuid, 0u), + AnimationFrame = null, + }, + }; + fixture.Live.RegisterLiveEntity(childSpawn); + + // SABOTAGE PROBE: restoring the literal `ParentInstanceSequence: 0` + // at EquippedChildRenderController.OnSpawn makes the player row + // (parentIncarnation=9) fail this exact assertion while the + // creature row (parentIncarnation=0) keeps passing - the precise + // #319 signature. + fixture.Controller.OnSpawn(childSpawn); + + Assert.True(fixture.Live.ParentAttachments.TryGetCommittedParent( + childSpawn.Guid, + out uint committedParentGuid, + out ushort committedInstance)); + Assert.Equal(parent.ServerGuid, committedParentGuid); + Assert.Equal(parentIncarnation, committedInstance); + + // D1: attach re-cells the child to the parent's exact cell, inside + // the same synchronous OnSpawn call (CommitAcceptedParentCellless + // runs regardless of whether the render projection itself is ready + // to materialize). + Assert.True(fixture.Live.TryGetCanonical( + childSpawn.Guid, + out RuntimeEntityRecord childCanonical)); + Assert.Equal(parent.Canonical.FullCellId, childCanonical.FullCellId); + + // D2: a later parent canonical-cell write propagates to the + // committed child. + const uint newCell = 0x01020001u; + const uint newLandblock = 0x0102FFFFu; + fixture.Spatial.AddLandblock(new LoadedLandblock( + newLandblock, new LandBlock(), Array.Empty())); + Assert.True(fixture.EntityObjects.CommitRebucket( + parent.Canonical, newCell, newLandblock)); + Assert.Equal(newCell, childCanonical.FullCellId); + } + + /// + /// #319 A1 (architecture review, 2026-08-05), the ordering fix's own + /// end-to-end proof. Dual-parent-class matrix. A relation whose named + /// incarnation mismatches the parent's live value must be refused + /// BEFORE the canonical commit runs, not after - the original shape + /// let CommitStagedParent (the canonical half: nulls the + /// child's snapshot Position, writes ParentGuid, advances POSITION_TS) + /// run first, then threw from inside CommitProjection, leaving + /// the child canonically parented with no committed relation. Stages + /// the mismatch directly (the real producer cannot construct one - it + /// always resolves the parent's TRUE live incarnation) to isolate the + /// ordering guarantee itself. + /// + [Theory] + [InlineData(0x50000821u, (ushort)6)] + [InlineData(0x70000299u, (ushort)0)] + public void PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit( + uint parentGuid, + ushort parentIncarnation) + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation); + const uint childGuid = 0x700002A0u; + fixture.RegisterOnly(childGuid, generation: 1, hasPosition: true); + + // #319 B1 (architecture review round 2, 2026-08-05): ChildPositionSequence + // MUST equal the fixture's actual gate value (RegisterOnly/SpawnData + // leaves Timestamps.Position and the top-level PositionSequence at 0, + // and unlike NoPositionCreateParent_CommitsAfterParentPartArrayValidation + // this test never calls TryApplyCreateParent/TryApplyParent to advance + // it). A mismatched value here would make TryCommitParent's OWN + // POSITION_TS gate (InboundPhysicsStateController.cs:299-312) refuse + // the canonical commit regardless of ordering, so the three snapshot + // assertions below would pass vacuously under a REVERTED ordering too + // - the test would "fail under sabotage" only on the unrelated staged- + // projection assertion, proving nothing about A1's tearing fix. + var wrongRelation = new ParentAttachmentRelation( + parent.ServerGuid, + childGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: (ushort)(parentIncarnation + 1), + ChildPositionSequence: 0); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(wrongRelation); + + fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid); + + // The canonical layer must NEVER observe this child as parented - + // if the ordering regressed, snapshot.Position would be null and + // ParentGuid would be set even though no relation ever committed. + Assert.True(fixture.Live.TryGetSnapshot( + childGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Null(snapshot.ParentGuid); + Assert.NotNull(snapshot.Position); + Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent( + childGuid, + out _, + out _)); + // The refused relation does not linger and block Resolve forever - + // it is rejected, not stranded. + Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection( + childGuid, + out _)); + } + + /// + /// #319 A6 (architecture review, 2026-08-05). A prior revision of this + /// fix deferred a CreateObject-carried relation whose parent was not + /// yet addressable through a queue. Both reviews independently proved + /// that shape is structurally unreachable in production for BOTH + /// producers (raw CreateObject via OnSpawn AND the same- + /// generation envelope via + /// OnCreateParentAccepted): + /// RuntimeEntityObjectLifetime.RegisterEntityCore's + /// EnqueueDeferredCreate gate defers the ENTIRE CreateObject + /// whenever its parent is not yet active, and does so BEFORE either + /// wire shape is ever produced - the earlier version of this test + /// only reached the deferred branch by calling + /// OnCreateParentAccepted directly, bypassing that upstream + /// gate. The deferral queue carried three independent defects (a + /// missing child POSITION_TS gate, a placeholder-incarnation collision + /// with the generation filters, unbounded accumulation) while never + /// being exercised by production routing, so it was removed rather + /// than fixed in place. This test now pins the CORRECTED behavior: an + /// unaddressable parent at accept time is refused outright (logged, + /// no state mutation, no crash) - dual-parent-class matrix. + /// + [Theory] + [InlineData(0x50000811u, (ushort)4)] + [InlineData(0x70000291u, (ushort)0)] + public void OnCreateParentAccepted_ParentNotYetKnown_RefusesWithoutStateOrCrash( + uint parentGuid, + ushort parentIncarnation) + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + const uint childGuid = 0x7000029Fu; + fixture.RegisterOnly(childGuid, generation: 1, hasPosition: false); + fixture.CompleteFirstEntry(); + + var update = new CreateParentUpdate( + childGuid, + parentGuid, + ParentLocation: 0, + PlacementId: 0, + ChildInstanceSequence: 1, + ChildPositionSequence: 1); + + // The parent has not spawned yet - the relation must not be staged, + // queued, or committed under any key, and the call must not throw. + Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); + fixture.Controller.OnCreateParentAccepted(update); + Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection( + childGuid, + out _)); + Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent( + childGuid, + out _, + out _)); + Assert.Equal(0, fixture.Live.ParentAttachments.UnresolvedRelationCount); + + // The parent later becoming addressable does NOT retroactively + // attach the refused relation - there is nothing left to retry + // (the earlier deferred-queue design would have found and + // committed it here; the corrected design does not). + LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation); + fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid); + Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent( + childGuid, + out _, + out _)); + } + [Fact] public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 3d37fce9..24aa2c1c 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -127,6 +127,77 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(1, fixture.Ready.PublishCount); } + /// + /// #319 F2 (contract §3.1). Dual-parent-class matrix: a player-range + /// parent with a nonzero incarnation and a creature/static-range parent + /// at sequence 0. #319 existed precisely because every pre-existing + /// test in this file used only sequence-0 (non-player) parents - a + /// matrix in which the two classes could diverge silently is the + /// defect's habitat. + /// + [Theory] + [InlineData(0x50000123u, (ushort)7)] + [InlineData(0x70000099u, (ushort)0)] + public void CommittedChild_IsExcludedFromLandblockLoadCandidates( + uint parentGuid, + ushort parentInstanceSequence) + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + + // Simulate the post-#319-fix state: a committed parent relation + // exists for this child (D1/D2, exercised elsewhere, would give it + // a nonzero canonical cell equal to the parent's). Pre-fix the + // relation DID commit for a player-class parent too - HasCommittedParent + // is child-keyed and CommitProjection succeeded under (playerGuid, 0) + // (retail review R3). + // + // The two InlineData rows differ in what was reachable PRE-fix + // (retail review round 2, D2 - qualified per parent class): + // - PLAYER row (0x50000123u): the relation committed under the + // WRONG key (playerGuid, 0), so D1's `parent.Incarnation == + // parentInstanceSequence` gate never matched and the child's + // FullCellId stayed 0 forever pre-fix - this candidate gate + // this test exercises never saw a live population for this row + // before the fix. + // - CREATURE row (0x70000099u): the hardcoded 0 genuinely MATCHES + // a creature-class parent's real incarnation, so D1 already + // passed pre-fix and this child ALREADY carried a nonzero + // FullCellId at HEAD - this row's scenario was already LIVE + // pre-fix, and the child was already a hydration candidate + // taking the legacy RebucketLiveEntity path this gate now + // excludes (contract §3.1's correction). Both rows exercise the + // SAME gate; only their pre-fix reachability differs. + var relation = new ParentAttachmentRelation( + parentGuid, + Guid, + ParentLocation: 0, + PlacementId: 0, + parentInstanceSequence, + ChildPositionSequence: 1); + fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation)); + + // Decoy: the legacy RebucketLiveEntity branch below would overwrite + // this back to the canonical cell (0x01010001u, matching `Cell`) if + // this candidate were not excluded - see + // LiveEntityRuntime.RebucketLiveEntity's unconditional + // `entity.ParentCellId = spatialCellOrLandblockId` write. + entity.ParentCellId = 0x02020002u; + + fixture.Controller.OnLandblockLoaded(Cell); + + // #319 F2: a committed child is never independently re-placed by + // landblock load (retail's update_object `parent != 0` early-out, + // @0x00515D40) - the decoy must survive untouched. Sabotage: + // deleting the `HasCommittedParent` gate in + // LiveEntityHydrationController.OnLandblockLoaded makes this fail + // for BOTH rows (the decoy gets corrected back to 0x01010001u). + Assert.Equal(0x02020002u, entity.ParentCellId); + } + [Fact] public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index dc5c0caa..b5864daf 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -6,6 +6,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; @@ -78,6 +79,71 @@ public sealed class LiveEntityPresentationControllerTests Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity); } + /// + /// #319 F2 (contract §3.2). Dual-parent-class matrix: a player-range + /// parent with a nonzero incarnation and a creature/static-range parent + /// at sequence 0. Neither IsSpatiallyProjected nor + /// IsSpatiallyVisible excludes a committed child (its + /// presentation-only rebucket sets IsSpatiallyProjected true + /// every frame), so the Hidden -> Visible edge must not install a + /// shadow row for it - route 7's P4 record: a committed child never + /// owns an independent broadphase row. + /// + [Theory] + [InlineData(0x50000456u, (ushort)3)] + [InlineData(0x70000199u, (ushort)0)] + public void CommittedChild_HiddenThenVisible_NeverInstallsShadowRow( + uint parentGuid, + ushort parentInstanceSequence) + { + Fixture fixture = new( + PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions); + + // Simulate the post-#319-fix state: a committed parent relation + // exists for this child, giving it a nonzero canonical FullCellId + // via D1/D2 (exercised elsewhere). Pre-fix the relation DID commit + // for a player-class parent too (HasCommittedParent is child-keyed; + // retail review R3). + // + // Per-row pre-fix reachability (retail review round 2, D2): + // - PLAYER row (0x50000456u): the relation committed under the + // WRONG key, D1 never matched, FullCellId stayed 0 forever + // pre-fix - RestoreShadow's `FullCellId == 0` clause already + // refused this row's population at HEAD. + // - CREATURE row (0x70000199u): the hardcoded 0 matches a + // creature-class parent's real incarnation, so D1 already + // passed pre-fix and this child ALREADY carried a nonzero + // FullCellId at HEAD - RestoreShadow's `FullCellId == 0` clause + // did NOT refuse this row pre-fix (architecture review A4); the + // `HasCommittedParent` clause added by this fix is what now + // refuses it, a live behavior change for this class. + var relation = new ParentAttachmentRelation( + parentGuid, + Fixture.Guid, + ParentLocation: 0, + PlacementId: 0, + parentInstanceSequence, + ChildPositionSequence: 1); + fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation)); + + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + Assert.Equal(0, fixture.Shadows.TotalRegistered); + + Assert.True(fixture.Runtime.TryApplyState( + new SetState.Parsed(Fixture.Guid, 0u, 1, 2), + out _, + out _)); + Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid)); + + // #319 F2: sabotage - deleting the `HasCommittedParent` gate in + // LiveEntityPresentationController.RestoreShadow makes this fail + // for BOTH rows (TotalRegistered becomes 1, the #184 shape: an + // invisible-but-solid weapon row). + Assert.Equal(0, fixture.Shadows.TotalRegistered); + Assert.True(fixture.Entity.IsDrawVisible); + } + [Fact] public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController() { diff --git a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs index 7a1f3811..11e0518d 100644 --- a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs @@ -35,6 +35,223 @@ public sealed class ParentAttachmentStateTests Assert.Equal(parentGuid, child.ParentGuid); } + /// + /// #319 A1 (architecture review, 2026-08-05). Dual-parent-class matrix: + /// is a PURE, + /// side-effect-free precondition - it must refuse a mismatch without + /// mutating ANY state, so a caller can check it before either half of a + /// commit runs (never a throw; the original shape threw from inside + /// , reached only + /// after the canonical commit had already landed, tearing the + /// transaction). + /// + [Theory] + [InlineData(0x50000210u, (ushort)5)] + [InlineData(0x70000211u, (ushort)0)] + public void CanCommitIncarnation_MismatchedIncarnation_RefusesWithoutMutatingState( + uint parentGuid, + ushort liveParentIncarnation) + { + const uint childGuid = 0x70000212u; + var relations = new ParentAttachmentState(); + var wrongRelation = new ParentAttachmentRelation( + parentGuid, + childGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: (ushort)(liveParentIncarnation + 1), + ChildPositionSequence: 1); + relations.AcceptCreateObjectRelation(wrongRelation); + + Assert.False(relations.CanCommitIncarnation( + wrongRelation, + guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null)); + // Purely a read - the relation is untouched, still staged, no + // committed parent exists. + Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged)); + Assert.Equal(wrongRelation, staged); + Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _)); + + // A NULL resolver (parent not currently addressable) must not + // refuse - the tripwire only fires "whenever the parent is + // active" (contract F1). + Assert.True(relations.CanCommitIncarnation(wrongRelation, _ => null)); + } + + /// + /// #319 A1. itself + /// must be non-tearing for ANY caller (not just the two production call + /// sites that also pre-check): a mismatch returns false and mutates + /// nothing, never throws. + /// + [Theory] + [InlineData(0x50000213u, (ushort)5)] + [InlineData(0x70000214u, (ushort)0)] + public void CommitProjection_MismatchedIncarnation_ReturnsFalseWithoutThrowingOrMutating( + uint parentGuid, + ushort liveParentIncarnation) + { + const uint childGuid = 0x70000215u; + var relations = new ParentAttachmentState(); + var wrongRelation = new ParentAttachmentRelation( + parentGuid, + childGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: (ushort)(liveParentIncarnation + 1), + ChildPositionSequence: 1); + relations.AcceptCreateObjectRelation(wrongRelation); + + Assert.False(relations.CommitProjection( + wrongRelation, + guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null)); + Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _)); + Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged)); + Assert.Equal(wrongRelation, staged); + + // A matching resolver commits normally. + Assert.True(relations.CommitProjection( + wrongRelation, + guid => guid == parentGuid + ? (ushort)(liveParentIncarnation + 1) + : (ushort?)null)); + Assert.True(relations.TryGetCommittedParent( + childGuid, + out uint committedParent, + out ushort committedInstance)); + Assert.Equal(parentGuid, committedParent); + Assert.Equal((ushort)(liveParentIncarnation + 1), committedInstance); + } + + /// + /// #319 contract §6 test 9 (ledger convergence) - BLOCKS per the + /// architecture review, since a stranded/accumulating relation is + /// precisely a ledger-convergence defect. Dual-parent-class matrix. + /// Removing the CHILD must zero every one of + /// 's four tables + /// (staged/recovery/committed/unresolved), not just the committed + /// entry. + /// + [Theory] + [InlineData(0x50000900u, (ushort)3)] + [InlineData(0x70000901u, (ushort)0)] + public void LedgerConvergence_ChildRemoval_ZeroesEveryTable( + uint parentGuid, + ushort parentIncarnation) + { + const uint childGuid = 0x70000902u; + var relations = new ParentAttachmentState(); + var relation = new ParentAttachmentRelation( + parentGuid, + childGuid, + ParentLocation: 0, + PlacementId: 0, + parentIncarnation, + ChildPositionSequence: 1); + relations.AcceptCreateObjectRelation(relation); + Assert.True(relations.CommitProjection(relation)); + // MarkProjected(Recovery) simulates a completed realize/render + // pass, which clears the retry-candidate bookkeeping - the + // permanent committed record in _lastAcceptedByChild is untouched + // by it, which is the table this test cares about. + relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery); + + Assert.Equal(1, relations.CommittedRelationCount); + Assert.Equal(0, relations.RecoveryRelationCount); + Assert.NotEmpty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation)); + + relations.RemoveChild(childGuid); + + Assert.Equal(0, relations.CommittedRelationCount); + Assert.Equal(0, relations.RecoveryRelationCount); + Assert.Equal(0, relations.StagedRelationCount); + Assert.Equal(0, relations.UnresolvedRelationCount); + Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation)); + Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _)); + Assert.False(relations.HasCommittedParent(childGuid)); + } + + /// + /// #319 contract §6 test 9, the PARENT-removal companion. Dual-parent- + /// class matrix. Removing the PARENT must converge the committed + /// child's entry via 's + /// parent-reference sweep. + /// + [Theory] + [InlineData(0x50000920u, (ushort)2)] + [InlineData(0x70000921u, (ushort)0)] + public void LedgerConvergence_ParentRemoval_ZeroesEveryTable( + uint parentGuid, + ushort parentIncarnation) + { + const uint childGuid = 0x70000922u; + var relations = new ParentAttachmentState(); + var relation = new ParentAttachmentRelation( + parentGuid, + childGuid, + ParentLocation: 0, + PlacementId: 0, + parentIncarnation, + ChildPositionSequence: 1); + relations.AcceptCreateObjectRelation(relation); + Assert.True(relations.CommitProjection(relation)); + relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery); + + relations.RemoveObject(parentGuid); + + Assert.Equal(0, relations.CommittedRelationCount); + Assert.Equal(0, relations.RecoveryRelationCount); + Assert.Equal(0, relations.StagedRelationCount); + Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation)); + Assert.False(relations.HasCommittedParent(childGuid)); + } + + /// + /// #319 contract §6 test 9, full-teardown companion. Dual-parent-class + /// matrix. must converge every + /// table to zero with a player-parented committed child AND a still- + /// unresolved ParentEvent present simultaneously - the mid-session + /// accumulation shape the architecture review's A5 finding was + /// concerned with (now moot for the deleted deferred-CreateObject + /// queue, but the pre-existing ParentEvent `_unresolvedByChild` queue + /// this fix left untouched still needs its own convergence proof). + /// + [Theory] + [InlineData(0x50000930u, (ushort)9)] + [InlineData(0x70000931u, (ushort)0)] + public void LedgerConvergence_Teardown_ZeroesEveryTableWithMixedPendingState( + uint parentGuid, + ushort parentIncarnation) + { + const uint committedChildGuid = 0x70000932u; + const uint unresolvedChildGuid = 0x70000933u; + var relations = new ParentAttachmentState(); + var relation = new ParentAttachmentRelation( + parentGuid, + committedChildGuid, + ParentLocation: 0, + PlacementId: 0, + parentIncarnation, + ChildPositionSequence: 1); + relations.AcceptCreateObjectRelation(relation); + Assert.True(relations.CommitProjection(relation)); + relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery); + relations.Enqueue(new ParentEvent.Parsed( + parentGuid, unresolvedChildGuid, 0, 0, parentIncarnation, 1)); + + Assert.True(relations.CommittedRelationCount > 0); + Assert.True(relations.UnresolvedRelationCount > 0); + + relations.Clear(); + + Assert.Equal(0, relations.CommittedRelationCount); + Assert.Equal(0, relations.RecoveryRelationCount); + Assert.Equal(0, relations.StagedRelationCount); + Assert.Equal(0, relations.UnresolvedRelationCount); + Assert.False(relations.HasCommittedParent(committedChildGuid)); + Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation)); + } + [Fact] public void MultipleQueuedRelationsRemainOrderedAndNewestAcceptedWins() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs index 678450c8..847bc2fd 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs @@ -252,6 +252,40 @@ public sealed class RuntimeEntityChildCellPropagationTests Assert.Equal(0u, grandchild.FullCellId); } + /// + /// #319 route-7 invariant 2 re-run, dual-parent-class. The propagation + /// mechanism itself (D1/D2/D3) was always guid-range-agnostic - these + /// tests construct relations directly via + /// rather than through the buggy producer - but #319 exists precisely + /// because no existing test in this file used a PLAYER-range parent + /// guid. Closes that gap for the removal path (AP-142 clause (a)): the + /// key fix must not perturb withdrawal for either parent class. + /// + [Theory] + [InlineData(0x50000601u, (ushort)8)] + [InlineData(0x70040609u, (ushort)0)] + public void Withdrawal_PickupOfParent_ZeroesCommittedChildren_DualParentClass( + uint parentGuid, + ushort parentInstance) + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + const uint childGuid = 0x7004060Au; + lifetime.RegisterEntity(Spawn(parentGuid, parentInstance)); + lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false)); + Assert.True(CommitAttachment(lifetime, parentGuid, parentInstance, childGuid, 2)); + Assert.True(lifetime.Entities.TryGetActive( + childGuid, out RuntimeEntityRecord child)); + Assert.NotEqual(0u, child.FullCellId); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(parentGuid, InstanceSequence: parentInstance, PositionSequence: 2), + acknowledgeProjection: null, + out _)); + + Assert.Equal(0u, child.FullCellId); + } + [Fact] public void Withdrawal_CommitWithdrawalOfParent_ZeroesCommittedChildren() {