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 at52175aa1, +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>
40 KiB
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— 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— 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— retailset_parent/enter_cellrecursion, 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'sparent.Incarnation == parentInstanceSequencegate (:1537) → false. - D2 crossing propagation —
RuntimeEntityDirectory.PropagateFullCellToChildren'sChildrenAttachedToParent(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:
- 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). - 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
FullCellIdfor 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 to0xFFFFin BOTH branches) thatLiveEntityRuntime.RebucketLiveEntityexplicitly 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
FullCellIdEQUALS its committed parent's canonicalFullCellIdat 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) andHasSpatialRuntimeProjection(:3340-3345): both requireProjectionKind is LiveEntityProjectionKind.World; a committed child isAttached— 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:
LiveRenderProjectionJournal.Project(:270-277) — prefersrecord.FullCellIdover theentity.ParentCellIdfallback when nonzero. Traced to its consumers at HEAD: the dynamics draw visibility routes readrecord.Source.ParentCellId(the fresh, TickChild-maintained presentation field) —RenderScenePViewFrameProduct.BuildDynamicLastRoute(:1441-1481,ParentCell(in record)for the indoorSphereVisibleInCelltest) andBuildOutsideDynamicRoutes(:1356-1361, same source).Residency.FullCellIdfeeds only (i) the indoor per-cell candidate index_cellDynamics(ArchRenderScene.AddToIndices:639), consumed by the look-in candidate enumeration (BuildLookInRoutes→LoadCellincludeDynamics: true,:1319-1322), and (ii) theRenderSceneShadowRuntimeresidency assertion (:403). Decisive fact: the LOCAL PLAYER's own record already sits in this exact index under this exact staleness today — the player'sFullCellIdis nonzero-stale during ordinary play, and the player renders correctly everywhere, because visibility isSource.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.)- The hydration filter (
LiveEntityHydrationController.OnLandblockLoaded:551-557) — wakes undesirably; gated structurally in §3.1. RestoreShadow(LiveEntityPresentationController.cs:216-236) — wakes undesirably; gated structurally in §3.2.- 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'sunset_parentdoes no cell work, so a wielded child's unwield Position reachesMoveOrTeleportwith 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 theaf828a8afinding: at a drop ACE also advances TELEPORT_TS and the classifier is a short-circuit OR (RuntimeAuthoritativePositionRouteClassifier.cs:391), so the observed label staysteleport-tseither 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); Rebucketeddelta publication cadence: today the player NEVER publishes aRebucketedentity delta during WASD (the preserve path early-outs atCommitRebucket'sprevious == 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
PreMergeCommittedCellIdmeasurement atTryApplyPosition: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
isOrdinaryRootfamily (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 lookupResolveRelationsalready hands toResolve(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.Parentshape) so it resolves when the parent arrives, adopting the parent's incarnation THEN. Retail anchor for the late-bind semantic:GetObjectA/GetNullObjectby 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 inResolve, or deferring theAcceptCreateObjectRelationcall 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 theResolveearly-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.Incarnationwhenever 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,GetNullObjectplaceholder @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)
- 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),TryCommitParentkeeps zeroLeaveWorldcalls (6), presentation advances and is asserted (7), no per-crossing child shadow/cross-cell rebuild (8), ledger convergence (13). - 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.
RebucketLiveEntityPresentationOnlydoes not become a canonical writer again — the D4 demotion stands; the presentation rebucket stays keyed on the child guid alone.- The
ParentEventpath's incarnation gating (AP-132) is unchanged — a wire-named stale incarnation still discards/queues exactly as today. - No new canonical cell writer is introduced. The fix adds zero
SetFullCellcall sites; it only makes the existing D1/D2 sites find their children. - 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):
- 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)(TryGetCommittedParentreturns N), D1 re-cells the child to the parent's exact cell at attach (cause=attachbehavior — 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 literal0and confirm the PLAYER row fails while the creature row passes — the test must be able to see exactly #319. - 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).
- 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.ParentCellIduntouched by the hydration pass, noCommitRebucketinvocation for the child). Sabotage: delete the gate → test fails. - Shadow gate (§3.2). Attached child, nonzero cell, driven through a
Hidden→Visible edge: assert NO
ShadowObjectsrow 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. - 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).
- 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 == 0fixtures are NOT relabeled: they remain synthetic. - Withdrawal/delete matrix (invariant 2). Pickup of the parent,
delete of the parent,
EndGeneration— child (and a grandchild) go cell-less, both parent classes. - 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.
- 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):
- Login with equipment. PASS requires a
[child-cell]line for the player's child at attach-or-activation (cause=attachif the player was celled first, else the D2 catch-up line when login activation commits the player's first cell) AND the child'sFullCellIdread 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. - Carry across ≥2 landblock boundaries and back, plus one
EnvCell-to-EnvCell dungeon traversal. PASS: child
FullCellIdEQUALS 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, socause=propagatelines 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: ifpropagatelines DO appear here, something changed the player's cell writers and the session gets investigated, not celebrated. - Portal recall / portal transit while equipped. PASS:
cause=propagatefires 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. - Unequip/re-equip mid-session (the ParentEvent path):
cause=attachwith the true incarnation — this was the investigation §7 prediction; observing it closes that loop. - 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:
- 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 ofParentAttachmentState's state machine, that is a conversation, not an ad-hoc build). - Any test in §6 requires touching a canonical cell writer.
- 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).
- The complete Release suite deviates from its measured baseline beyond
the two named flakes (#302, #308). Baseline at
e0f96a55was 11,090 / 4 / 0 — MEASURE at the implementation HEAD, never inherit.
9. Open items — reported honestly, not smoothed
- 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
_cellDynamicswith no observed symptom" is solid at HEAD reading, but theRenderSceneShadowRuntime:403residency assertion (OwnerLandblockId != expected.LandblockIdthrow) 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. - Whether the local player is a
RuntimePhysicsStatespatial root was NOT established (§2.2). If it is, the pre-existing stale player cell can theoretically be swept byParkCollisionResidentsafter 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). - 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
RetryWaitingDescendantsinteracts 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. - 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
Resolveearly-return, the commit's missing validation, the three writers of the player's canonical cell, the landblock-preserve, the hydration/shadow wake mechanics (includingIsSpatiallyProjected = truevia the presentation-only rebucket atLiveEntityRuntime.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 beforeBegin(RuntimeEntityObjectLifetime.cs:2751-2752), §3.3.