acdream/docs/research/2026-08-05-issue-319-contract.md
Erik 392c1e22c1 fix(physics): bind a parented child to the parent's live incarnation (#319)
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 <noreply@anthropic.com>
2026-08-05 11:56:31 +02:00

40 KiB
Raw Permalink Blame History

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.mdthe settling investigation. Its §§16 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 — 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 — 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 — 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:749WorldSession.cs:233RuntimeEntityRecord.cs:43). The relation files under (playerGuid, 0) while the record carries TotalLogins, so both route-7 write sites miss:

  • D1 attach re-cellRuntimeEntityObjectLifetime.CommitAcceptedParentCellless's parent.Incarnation == parentInstanceSequence gate (:1537) → false.
  • D2 crossing propagationRuntimeEntityDirectory.PropagateFullCellToChildren's ChildrenAttachedToParent(guid, current.Incarnation) (:478-480) → Array.Empty<uint>(), 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.RefreshSnapshotRuntimeEntityRecord.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 (BuildLookInRoutesLoadCell 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 CommitRebucketa 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 34).

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 12 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 113 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 2560
F2 hydration gate 412
F2 shadow gate 410
F3 0
total ~3580

Tests are the larger share (~250450 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.