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>
66 KiB
C4 route 7 — pickup / parent / delete: pinned contract (2026-08-04)
Scope: make Runtime the sole writer of a parented child's canonical cell —
port retail set_parent's attach-time change_cell half into the Runtime
parent commit, add retail's parent-cell-crossing propagation step at the
one canonical cell-write funnel, demote App's render-tick child rebucket
(EquippedChildRenderController.TickChild) to presentation-only, give the
headless host the parent-realize commit it has never had, adopt retail's
pickup ordering, and delete the dead ClassifyLeaveWorld classifier entry.
Route 7 performs no placement: there is no SetPosition, no park, no
service window, no leash on this route.
Pinned at HEAD cff52c44, clean tree, branch
claude/acdream-physics-divergence-5aa784. Line numbers in this contract
are as-of cff52c44 and WILL go stale; every citation also names the symbol —
trust the symbol (process rule 6).
Predecessor documents, binding where they still apply:
2026-08-04-retail-parent-cell-propagation.md— the settling research. Its §10 contract requirements are BINDING and restated in §0 below. Do not re-derive the retail mechanism; it is read, cited, and offset-verified there.2026-08-04-retail-child-cell-ownership.md— the earlier child-cell research:set_parentcontains no cell write of its own;unset_parentperforms zero cell work;leave_worldis where a detaching child is scrubbed.2026-08-04-c4-routes-6-7-scoping.md§7 — the route scoping. Its file:line references predate routes 4b-3/5, the OnPosition collapse, and route 6's closure, and are stale throughout; §10 of this contract lists every claim found false or superseded. Its trap list (T1–T8) survives and is resolved item-by-item below.2026-08-04-c4-route-5-contract.mdplus its three dual review rounds — the contract standard, and the recurring defect classes each addressed by name here: an App glue site discarding the Runtime seam's status and advancing presentation on write-nothing outcomes; unrecorded divergences (register rule 1); a pinned obligation left unwired; zero coverage of the presentation layer; negative-only tests.2026-08-04-c4-route-4b-3-contract.md— its 13 "must REMAIN true" invariants; the ones route 7 can even reach are re-asserted in §3.2026-08-04-session-handoff-c4-remaining.md— the six process rules apply verbatim. Rule 1 (the contract causes the defect), rule 4 (assert the layer that broke), and rule 5 (a clean session is not a passed gate) are the load-bearing ones for this route.docs/plans/2026-08-02-placement-cutover.md— the campaign plan. Its corrected route-7 gap statement ("the child's canonical cell has two writers … the same defect seen from two sides") is exactly this contract's scope; the plan also pins T8 (theTryCommitParentLeaveWorldomission is retail-REQUIRED).
Sequencing: routes 4a, 4b-1/2/3, the OnPosition collapse, route 5
(36255af0), and route 6's zero-production closure (1b484937) are all in.
Route 7 is next; route 3 (portal) remains after it.
0. Facts settled before this contract — BINDING, do not re-derive
From 2026-08-04-retail-parent-cell-propagation.md (all addresses verified
against acclient_2013_pseudo_c.txt, struct offsets closed by the
acclient.h walk — no PE byte-decode needed):
- Retail re-cells children when the parent crosses a cell, recursively, to
unbounded depth.
CPhysicsObj::SetPositionInternal@0x00515330 branches onthis->cell == curr_cell@0x0051536d; the cell-CHANGED branch @0x00515372 callschange_cell@0x00513390, which delegates toleave_cell@0x0051339f /enter_cell@0x005133af.change_cellitself has NO child loop — the recursion lives in the delegates.enter_cell@0x00510ed0 self-recurses over children @0x00510f03 and writes the FULL identity per level:CObjCell::add_object@0x00510ee2,objcell_id@0x00510f1e, part-array cell id @0x00510f2b,cellpointer @0x00510f35, lights @0x00510f3e.leave_cell@0x00510f50 mirrors it @0x00510f84. CORRECTION (post-implementation retail-conformance review, R1 MAJOR): this enumeration silently dropped the guard AROUND all five writes and the recursion itself —enter_cell's entire body is gated onthis->part_array != 0@0x00510ed8 (the propagation research's own §3 called this "Guard, load-bearing"). A child with a null part array receives NONE of the five writes and its whole subtree is skipped. This is the contract defect the review traced R1 to — process rule 1, "the contract causes the defect" — and it is why an implementer following this list alone ships an unconditional write. See D2's AP-142 clause (d) for why the guard has no reproducible analogue at acdream's canonical layer (itsHasPartArrayfield is populated only by the graphical mesh pipeline, never headless) and is therefore recorded, not ported. - The depth-1 loop @0x0051539c–0x005153d8 is the SAME-CELL fast path, NOT
the propagation. It refreshes only each direct child's
objcell_id(child+0x4c@0x005153bd) and part-array id @0x005153cc, deliberately not thecellpointer, and only when the parent did NOT change cell. An implementer who finds this loop first will wrongly conclude "depth-1, id-only" and ship a stranded-child bug. This contract says so explicitly: the propagation is theelse@0x00515372, not this loop. - The clincher:
update_object@0x00515d10 early-returns onparent != 0@0x00515d40 — a child never runs its own physics tick, so parent propagation is the ONLY mechanism that maintains a child's cell. - The four binding requirements (research §10): (i) write at attach AND
on every parent cell crossing; (ii) the authoritative write belongs on
the physics-commit path — Runtime's write must be a PROPAGATION STEP, not
a one-shot at
set_parent; (iii) propagation is recursive — a depth-1 implementation needs an explicit stated assumption plus a register row; (iv) write the full identity — id-only leaves the #184 class half-closed. - Two adjacent traps: (a) child cross-cell/shadow lists are NOT
refreshed per parent tick —
SetPositionInternalcalls the non-recursivecalc_cross_cells@0x0051551b; the recursiverecalc_cross_cells@0x00515a30 runs only at attach (set_parent@0x00515b15). Do not rebuild child shadow registrations per crossing. (b) On the removal path (change_cellwith a null target) retail leaves children withcell == nullptrbut a STALE non-zeroobjcell_id@0x005133c1 —leave_cellnever touches child ids. §4 D3 resolves how acdream's single-field model maps this.
From this contract's own HEAD verification:
- acdream DOES use
FullCellId != 0/== 0as a residency/liveness predicate, pervasively — 45+ sites, includingRuntimeInitialCreateResidenceState:583(residence admission),LiveEntityRuntimeisOrdinaryRoot(:915-918) and its two sibling predicates (:3213,:3323),LiveEntityPresentationController:220,RuntimeSetPositionState:2985(the lost predicate),HeadlessLocalPlayerFrameHost:87, and route 4b-3's cell-less classification input (PreMergeCommittedCellId == 0→ theSetPositioncell-less arm). The retail stale-id asymmetry (item 5b) therefore MUST NOT be reproduced literally — see D3. - Route 5 and route 6 landed after the scoping, so the scoping's "route
6 first" ordering and its campaign-plan correction are already satisfied
(
1b484937correcteddocs/plans/2026-08-02-placement-cutover.md:97-116). - The canonical cell has exactly ONE funnel.
RuntimeEntityRecord.SetFullCell(RuntimeEntityRecord.cs:244-251) has exactly two callers:RuntimeEntityDirectory.SetFullCell(RuntimeEntityDirectory.cs:340-346) andRuntimeEntityRecord.RefreshDerivedState(:230-242), andRefreshDerivedStateis itself reached only from the record constructor (:29, no children can exist yet) andRuntimeEntityDirectory.RefreshSnapshot(:231-238). Every producer —CommitRebucket(RuntimeEntityObjectLifetime.cs:1863-1894),RuntimePhysicsState.CommitCanonicalCell(:2138-2160, fed by the ordinary/remote/projectile simulation commits and the remotewriteCellbinding:958-960),RuntimeSetPositionState's four direct writes (:2743,:3660,:5001,:5222), the withdrawal family (SetFullCell(canonical, 0u, 0u)at:1301,:1464,:1921,:2660), and the wire merge (RefreshSnapshot→RefreshDerivedState) — funnels through the directory. This is what makes D2's single-chokepoint design sound rather than a per-caller mapping (the 4b-3 review's "mapping written against one caller's reachable set" defect class). - The per-parent committed-children list already exists in Runtime.
ParentAttachmentState.ChildrenAttachedToParent(parentGuid, parentInstanceSequence)(ParentAttachmentState.cs:623-633) returns the exact live CHILDLIST analog (doc comment already cites retail's live CHILDLIST), maintained byCommitProjection(:546-572) /RemoveCommittedChild(:809-838). Its two existing consumers are the lost-family deadline arm/cancel (RuntimeSetPositionState:6024-6036,:6046-6060). It allocates nothing on the read path (returns the storedList<uint>orArray.Empty). - Parented children's snapshots carry no Position.
InboundPhysicsStateController.ApplyParent(:1347-1373) setsPosition = null(top-level AND PhysicsSpawnData);ApplyAcceptedParent/ApplyAcceptedCreateParentare timestamp-only. So the wire merge'sRefreshDerivedStatecell stamp (Snapshot.Position is { } position,RuntimeEntityRecord.cs:232) cannot fire for a committed child and cannot fight the propagation. P1 pins this with a test.
1. Site inventory — re-located at cff52c44
Every site verified by reading at HEAD, not inherited from the scoping.
1.1 The Runtime commit family (src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs)
| symbol | at HEAD (was, in scoping) | route-7 relevance |
|---|---|---|
TryApplyPickup |
:1249-1312 (was :1226-1245) |
pickup: gate → RefreshSnapshot → ForgetInitialCreateResidence :1293 → AdvancePositionAuthority :1294 → CollisionReports.LeaveWorld :1295 → SetPosition.Forget :1297 → SuspendObjectClock :1300 → SetFullCell(0,0) :1301 → ParentAttachments.EndChildProjection :1302 → publish Withdrawn. T7's inversion lives at :1295-1302 (leave-world work before the unparent). Dormant-residence deferral :1255-1281. |
TryApplyParent |
:1336-1386 |
accepts/stages the standalone ParentEvent; dormant-residence deferral :1342-1379; live path Entities.TryApplyParent → CommitPositionChannelUpdate. Untouched by this slice. |
TryApplyCreateParent |
:1314-1334 |
envelope flavor; untouched. |
TryCommitParent |
:1388-1441 (was :1360-1374) |
the parent-relation commit (retail add_child-success analog). Carries the C0-4(a) cancellation chokepoint :1426-1431 and the F4 deliberate LeaveWorld omission comment :1418-1425 (T8 — do not "fix"). AdvanceParentCommit :1432. D1 does NOT add the re-cell here — see D1 for why it lives on the cell-less commit's successor instead. |
CommitAcceptedParentCellless |
:1443-1474 (was :1377-1408) |
retail set_parent's leave_world edge: cancellations → CollisionReports.LeaveWorld :1462 → SuspendObjectClock :1463 → SetFullCell(0,0) :1464 → publish Withdrawn. D1's extension point: the missing parent->cell != 0 → change_cell half goes immediately after this edge. |
TryApplyPosition's unparent edge |
EndChildProjection at :1838, after RefreshSnapshot :1830 |
the Position-unparent (retail HandleReceivedPosition's unset_parent @0x00454129). Same inversion shape as T7 but on route 4's surface — recorded in §9 as a non-goal, NOT touched here. Also carries 4b-3's PreMergeCommittedCellId measurement :1801-1814 — see §11 for the cross-contract interaction. |
CommitRebucket |
:1863-1894 |
the App rebucket's canonical write; publishes Rebucketed on an actual cell change. After D4, no equipped-child caller remains. |
CommitWithdrawal |
:1896-1929 (was :1845-1860) |
withdrawal-to-cellless with the C0-4(b) symmetric cancellation; SetFullCell(0,0) :1921. D2's propagation covers its children automatically. |
TryAcceptDelete |
:1973-2034 (was :1939-1952) |
ParentAttachments.DeleteGeneration runs at :1996-1998, BEFORE the active record retires (RemoveActive :2005), and the delete path performs NO SetFullCell — so D2's chokepoint never fires for a deleted parent's children and D3's explicit delete edge must run before :1996. |
ForgetInitialCreateResidence / PreferCancellation |
:2620-2637 / :2639-2642 (was :2552-2569 / :2571-2574) |
unchanged by this slice. |
AcknowledgeProjectionAndPublish |
:2255 on (was :2187-2213) |
publication discipline: cancellation receipt first, then currency re-check, host ack, publish. Unchanged. |
CommitChildNoDraw |
:1931-1950 |
retail set_parent's NoDraw inheritance — already ported; untouched. |
1.2 The classifier (src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs)
| symbol | at HEAD | relevance |
|---|---|---|
RuntimeLeaveWorldCause |
:31-36 |
deleted by D6. |
RuntimeLeaveWorldRouteRequest |
:141-145 |
deleted by D6. |
ClassifyLeaveWorld |
:480-510 (was :475-505) |
ZERO production callers at HEAD (re-verified: repo-wide grep returns the definition, one test at RuntimeAuthoritativePositionRouteClassifierTests.cs:335-352, and a comment at RuntimeInitialCreateContinuationExecutorTests.cs:1672). Deleted by D6, with the test. |
ValidCreateAuthority |
:512-516 (was :507-512) |
requires PreviousTeleportSequence == AcceptedTeleportSequence — the #307 predicate shape. T3 verified first-hand: no pickup/parent gate measures a teleport pair (InboundPhysicsStateController.TryApplyPickup :180-186 gates on TryAcceptPositionChannelEvent — retail's POSITION stamp @0x0045224B analog; TryApplyParent :263-271 adds only parent-instance currency; TryCommitParent :308-314 re-checks POSITION_TS currency). Wiring the classifier would force a fabricated, vacuously-equal teleport pair. This is D6's second leg. ValidCreateAuthority itself SURVIVES (the create route uses it); only the leave-world consumer dies. |
1.3 App (src/AcDream.App)
| symbol | at HEAD | relevance |
|---|---|---|
EquippedChildRenderController.TickChild |
Rendering/EquippedChildRenderController.cs:373-413; the rebucket at :406-408 (was :405-408) |
the render-tick canonical writer: after pose composition succeeds, _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId). D4's demotion target — the ONLY RebucketLiveEntity call in the file (re-verified). |
EquippedChildRenderController.PrepareAndTryRealize |
:841-885 |
the graphical realize protocol: CommitStagedParent (→ TryCommitParent) :856 → Relations.CommitProjection :857 → CommitAcceptedParentCellless :869-871 → WithdrawPriorProjection :875-881 → TryRealize. D1's attach re-cell rides inside the Runtime commit this already calls — the App protocol does not grow a fourth call. |
EquippedChildRenderController.ValidateParentProjection |
:887-912 |
retail add_child validation (Setup HoldingLocations via _dats.Get<Setup>) — graphical-only today. D5's headless validation question. |
EquippedChildRenderController.ResolveRelations |
:786-795 |
drives Relations.Resolve with snapshot-lookup callbacks — the resolution shape D5's headless drive reproduces Runtime-side. |
LiveEntityRuntime.RebucketLiveEntity |
World/LiveEntityRuntime.cs:801-977 (scoping's range still accurate) |
the full legacy branch: spatial bucket + CommitRebucket :904-907 + object-clock edges :919-946 (whose own comment already states "parented/attached objects take retail update_object's parent early-out and remain suspended") + visibility publication. |
LiveEntityRuntime.RebucketLiveEntityPresentationOnly |
:993-1065 (was :993-1050) |
the C3c presentation-only shape: spatial bucket + visibility, deliberately no CommitRebucket / clock work, guarded by BeginPresentationOnlySpatialMutation. Private; sole caller TryApplyInitialCreateCompletionPresentation :1107. D4 adds the equipped-child entry point beside it. |
LiveEntityRuntime wrappers |
TryApplyPickup :2312-2316; CommitStagedParent :2330-2336; CommitAcceptedParentCellless :2338-2363 (was :2280-2312) |
the cell-less wrapper's doc (:2338-2343) still says "Commits retail set_parent's cell-less edge" — accurate only for parent->cell == 0; D9 corrects it with D1. |
LiveEntityHydrationController.OnPickup |
World/LiveEntityHydrationController.cs:460-474 (was :455-474) |
TryApplyPickup then _relationships.OnChildBecameUnparented — App-level order unchanged by D7 (D7 reorders INSIDE the Runtime method). |
LiveEntityDeletionController |
World/LiveEntityDeletionController.cs |
purely logical (re-verified: no placement/cell API). Untouched. |
1.4 Headless (src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs)
| symbol | at HEAD | relevance |
|---|---|---|
OnParentUpdated |
:312-316 (was :313-317) |
calls ONLY Entities.TryApplyParent — stages the relation forever. Neither TryCommitParent nor CommitAcceptedParentCellless has any headless caller (re-verified: the only production callers are EquippedChildRenderController.cs:856/:869 and the LiveEntityRuntime wrappers). D5's insertion point. |
OnPickedUp / OnDeleted |
:176-180 / :155-174 |
thin pass-throughs; correct as-is. |
1.5 Confirmed-clean (scoping §7.3, re-verified at HEAD)
All six cancellation choke points live and symmetric (:1141, :1293-1299,
:1426-1431, :1456-1461, :1913-1919, :2007-2017); ordering correct
(AcknowledgeProjectionAndPublish publishes the cancellation first);
receipts host-visible (RuntimeSetPositionState.PublishCancellation →
PublishPlacement, consumed by both graphical sinks and
HeadlessRuntimePlacementProjectionSink); pickup/parent during a pending
residence defer as dormant continuations; grep for
SnapToCell|CommitRebucket|SuspendObjectClock|SetFullCell across
src/AcDream.App + src/AcDream.Headless still returns no
pickup/parent/delete site outside the inventoried ones.
2. Retail ground truth — verified for this contract; verify again yourself
| claim | anchor | status |
|---|---|---|
Pickup = unset_parent + leave_world, gated ONLY on the POSITION stamp; no placement, no rejection path that skips them once the stamp accepts |
SmartBox::DoPickupEvent @0x00452240: gate @0x0045224B-0x00452274, stamp write @0x00452278, unset_parent @0x0045227F, leave_world @0x00452286 |
CORRECTED (retail-conformance review): the gate span itself is UNVERIFIABLE from this source — Binary Ninja lowered both DoPickupEvent's and DoParentEvent's gate comparisons to a literal always-false expression (if (-((eax_4 - eax_4)) != 0)), losing the x87/flag-based wrapped-sequence compare. Only the gate's SHAPE (a wrap-aware sequence compare against update_times[0]) and the ORDER of the writes after it are legible — the write order is what D7 relies on and remains ✓. Was previously marked "✓ (scoping §7.2, re-read)", which overstated what the source supports. |
Parent = set_parent + SetPlacementFrame; SetParentedState(1) for a non-player parent gaining its first child |
SmartBox::DoParentEvent @0x00452290: gate @0x00452296-0x004522C5, @0x004522F4, set_parent @0x00452305, SetPlacementFrame @0x00452313 |
CORRECTED, same basis as the row above: the gate span is unverifiable from this source (same BN lowering artifact); the post-gate write order is legible and unaffected. D6's argument does not depend on this citation either — it rests on acdream already enforcing the POSITION_TS gate in InboundPhysicsStateController (verified directly), not on retail's gate expression being readable. |
set_parent order: add_child success → unset_parent @0x00515ABA → one leave_world @0x00515AC1 → parent = @0x00515AC6 → if (parent->cell != 0) @0x00515AD1 → change_cell @0x00515AD6 → UpdateChild @0x00515B0E → recalc_cross_cells @0x00515B15 → NoDraw inheritance @0x00515B26-38 |
CPhysicsObj::set_parent @0x00515A90 (4-arg overload @0x00515B50 same shape) |
✓ |
unset_parent performs ZERO cell work: remove_child → NoDraw restore → parent = null → update_time → clear_transient_states |
@0x00513470 (@0x00513484/@0x005134AC/@0x005134BF/@0x005134CE) | ✓ (child-cell-ownership doc §4) |
leave_world scrubs the detaching object: remove_shadows_from_cells @0x005155DD, recursive leave_cell(this, 0) @0x005155E6, zeroes only ITS OWN objcell_id @0x005155F4 |
CPhysicsObj::leave_world @0x005155A0 |
✓ |
| The propagation mechanism and its cadence — §0 items 1–3 | @0x00515330 / @0x00513390 / @0x00510ed0 / @0x00510f50 / @0x00515d40 | ✓ (settling research; binding) |
Delete order: exit_world @0x0050846B + leave_world @0x00508472 run BEFORE unparent_children @0x005084B9 — children's cells are nulled by the recursion while still attached, then unparented with no cell restore |
CObjectMaint::DeleteObject @0x00508460 (same pattern in DestroyObjects @0x00508C30) |
✓ |
Cross-cell/shadow: recalc_cross_cells @0x00515A30 recurses children @0x00515A79 but runs only at attach; the per-move tail calls the non-recursive forms only @0x0051551B/@0x0051553E-4C |
settling research §8 | ✓ (binding trap 5a) |
Route 7 never reaches HandleReceivedPosition @0x00453FD0 — DoPickupEvent/DoParentEvent are separate wire handlers; the single remote ConstrainTo arm @0x00454272 is unreachable from this route |
scoping T1, re-affirmed | ✓ — the basis of D8 |
3. What must REMAIN true (process rule 1 — for every path, including every refusal)
- A committed child's canonical
FullCellIdequals its parent's at every stable observation point — after attach (parent celled), after every parent cell crossing (any writer: simulation commit, rebucket, canonical placement, wire merge), after parent teleport, in BOTH hosts. This is the route's headline invariant and the headless gate's assertion (it fails today). - The child never becomes a self-simulating object. Its
ObjectClockstays suspended, it is never a spatial root, it joins no physics workset, and the propagation path never changes any of that (retailupdate_object'sparent != 0early-out @0x00515D40; the existing comment atLiveEntityRuntime.cs:919-922already states this rule for the App side). - No placement machinery engages on this route. No
RuntimeSetPositionStateoperation, no park, noDeferredCell, no service-window pre-flight, no ledger entry — the child cell write is retailchange_cell: a direct identity write, not a placement (T6). TheParkCollisionResidentsoverlap throw stays unreachable andRemotePlacementDrivePendingCountis unaffected by any number of attach/crossing/withdraw events (4b-3 invariants 9/10 extended). ConstrainTois NEVER armed by route 7 — not at attach, not at pickup, not at delete, not on any child, regardless of what routes 2/4a/4b/5 established for their arms (T1; D8's partition).- The six cancellation choke points and their ordering are unchanged:
exactly-once
ForgetInitialCreateResidence→SetPosition.Forget→PreferCancellation, receipt published before the entity delta. TryCommitParentkeeps exactly zeroCollisionReports.LeaveWorldcalls (T8; the F4 comment at:1418-1425and the campaign plan both pin it — retailset_parenthas ONEleave_world@0x00515AC1, and it is the cell-less commit's edge in acdream's staged protocol).- Presentation still advances, and is asserted (process rule 4 / #312's layer): the equipped child renders in the hand, follows the parent across cell boundaries with no frame where it is bucket-stranded, disappears cleanly on unwield/pickup, and its collision leaves the world with it. A child must never be invisible-but-solid (#184) or solid-but-invisible.
- No per-crossing child shadow/cross-cell rebuild (§0 trap 5a). The child's broadphase state is established at attach/unparent edges only.
- The dormant-residence deferrals are untouched: pickup/parent arriving
during a pending initial residence still enqueue
RuntimeInitialCreateContinuationKind.Pickup/Parentcontinuations and replay through the executor (RuntimeInitialCreateContinuationExecutor's parent replay:2110-2126, which already routes throughRuntimeEntityObjectLifetime.TryCommitParent). - Route 1/2/4/5 classification inputs and dispositions are byte-identical.
Route 7 deletes
ClassifyLeaveWorld(zero production callers) and changes NOTHING else in the classifier —ClassifyCreate'sParented/PickedUpresidence handling,ValidCreateAuthority's create-route use, and every accepted-position branch stay untouched. Zero expectation changes in surviving classifier tests is the tripwire. - AP-135's writes, AP-131, #276, and #316 are untouched (§9).
- The lost-family deadline enumeration keeps working:
ArmLostFamilyDeadlines/CancelLostFamilyDeadlinesreadChildrenAttachedToParent— D2/D3 change nothing about relation lifetime, only cell values. - Ledger convergence: teardown, session reset, and generation change with committed children present (attached, mid-crossing, mid-unparent) converge the combined ownership ledger to zero — the J-series suites' shape, driven through the new edges.
4. Design decisions — pinned, not open for redesign
D1 — the attach half: Runtime completes retail set_parent, on the cell-less commit
Retail's attach sequence (§2 row 3) ends with if (parent->cell != 0) change_cell(this, parent->cell). acdream's realize protocol today ends at
the leave_world edge (CommitAcceptedParentCellless) and lets a render
tick supply the re-cell. Pinned:
CommitAcceptedParentCellless(or a successor commit it becomes part of) gains retail's second half: after the cell-less edge's existing writes, if the PARENT's canonical record is active andparent.FullCellId != 0, write the child's full canonical cell identity to the parent's exact values (FullCellId,CanonicalLandblockId) — through the same D2 write path, so attach and crossing are one mechanism, not two. If the parent is cell-less (retailparent->cell == 0@0x00515AD1), the child stays cell-less — exactly today's behavior, now by the retail-cited gate instead of by omission.- Both halves are ONE synchronous Runtime transaction. No caller may
observe the child cell-less between the edge and the re-cell within the
same call; no deferred continuation may interleave. The method needs the
parent's identity to do this — the natural source is the committed
relation (
ParentAttachmentState.TryGetProjection/_lastAcceptedByChildvia a lookup, or a parent parameter threaded from the caller, both of which the realize protocol and the executor's parent replay already hold); implementer's choice, pinned constraint: the parent must be resolved by (guid, incarnation) currency, never by guid alone. - Why not inside
TryCommitParent? Retail's cell write follows theleave_world(@0x00515AC1 precedes @0x00515AD6). acdream's protocol splitsset_parentacrossTryCommitParent(relation commit) thenCommitAcceptedParentCellless(leave-world edge); the re-cell belongs after the second, preserving retail's order. Putting it inTryCommitParentwould re-cell BEFORE the leave-world edge zeroes it — a self-defeating order. The executor's deferred parent replay and the graphical realize both already call the pair in this order; D5's headless drive calls the same pair. UpdateChild(frame composition) and NoDraw inheritance remain where they are (App pose composition;CommitChildNoDraw) — unchanged.recalc_cross_cells@0x00515B15: acdream's analog at attach is the EXISTING behavior (the child's collision reports were force-ended by the cell-less edge; no child broadphase registration exists to rebuild). Pinned: no new cross-cell/shadow machinery is built at attach, and P4 requires the implementer to state the child's actual broadphase state at each edge with the retail anchor.
D2 — the sustaining half: propagation at the one canonical-cell funnel
The load-bearing decision. Retail's trigger is "every mechanism that
changes the parent's cell" — in retail that is one function (change_cell);
in acdream the analog is the one funnel every canonical cell write already
passes through (§0 item 8). Pinned:
- The propagation hook lives at the directory funnel — inside
RuntimeEntityDirectory.SetFullCelland theRefreshSnapshot→RefreshDerivedStatederived write (either by routing the latter through the former or by hooking both; implementer's structural choice, pinned outcome: no canonical cell write can bypass the hook). Per-committer hooks (≥8 sites) are REJECTED — that is the "mapping written against one caller's reachable set" defect class, and one missed site is a stranded child. - The step: when a record's
FullCellIdchanges andParentAttachments.ChildrenAttachedToParent(record.ServerGuid, record.Incarnation)is non-empty, write each active committed child's canonical cell to the parent's new exact values, recursively (a child's own committed children follow — retailenter_cell/leave_cellself-recursion, §0 item 1). Depth-1-only is NOT acceptable without an explicit stated assumption plus a register row (§0 item 4.iii) — and since recursion here is a dictionary probe per level, ship the recursion. - Termination and idempotence: skip a child whose
FullCellIdalready equals the target value. This terminates any wire-induced relation cycle (self-parenting is already rejected atEquippedChildRenderController.ValidateParentProjection:890-891, but A→B→A via wire must still terminate), avoids spuriousSpatialAuthorityVersionchurn, and subsumes retail's same-cell depth-1 id refresh (§0 item 2): with one field playing both retail roles, a same-value restamp is unobservable, so the same-cell fast path needs no separate mechanism. This equivalence is a stated assumption of the single-field model and rides in D9's register row. - What the step writes: the child's canonical
FullCellId+CanonicalLandblockId— acdream's full canonical identity (§0 item 6: the field IS the residency predicate). What it must NOT do: no clock changes, no workset/spatial-root changes, no shadow work, no placement operations, noCollisionReportscalls, no App callbacks. Field writes plus version bumps only — safe to run re-entrantly inside aRuntimeSetPositionState/RuntimePhysicsStatetransaction that is mid-commit on the parent (P5). - Publication: per-child lifetime deltas are NOT published from the
propagation step, matching the physics-commit precedent
(
RuntimePhysicsState.CommitCanonicalCellpublishes no lifetime delta; it firesCellCommitted, which is parent-scoped and unchanged). The attach re-cell (D1) rides inside a commit that already publishes; the crossing propagation is silent. Uniformity note: today's TickChild path DID publishRebucketeddeltas for children viaCommitRebucket(:1889-1893); D4 removes those. P8 requires enumeratingRebucketedconsumers and confirming none needs a per-child delta — if one does, flip this default and publish uniformly from both D1 and D2, and say so in the commit. - Allocation: 0 B on the propagation path (the children list is the stored list; recursion uses the call stack or a pre-sized scratch — Slice I discipline).
- Do not propagate to
_stagedByChild/_recoveryByChild/unresolved relations — retail's CHILDLIST holds committed children only, andChildrenAttachedToParent's own doc already pins this ("must not capture staged, unresolved, or future-generation relations").
D3 — the withdrawal and delete edges (resolves §0 trap 5b under the single-field model)
Retail's removal behavior: leave_cell recursion nulls each child's cell
pointer but leaves a stale non-zero objcell_id; the functional state is
"not resident anywhere." acdream has ONE field, and that field is the
residency/liveness predicate at 45+ sites (§0 item 6). Pinned:
- Withdrawal propagates zero. A parent's
SetFullCell(0,0)(pickup:1301,CommitWithdrawal:1921, cell-less parent commit:1464, residence re-begin:2660) flows through D2's chokepoint like any other value: committed children (and their subtrees) go cell-less. This is the functional mapping of retail's recursiveleave_cell— the retail stale-id residue is NOT reproduced, because reproducing it would leave a child "resident" per every acdream predicate while retail's own gating field (cell == nullptr) says it is not. The id/pointer collapse and this deliberate non-reproduction are recorded in D9's register row. - Delete gets an explicit edge.
TryAcceptDeleteperforms noSetFullCell, andParentAttachments.DeleteGeneration(:1996) removes the relations before the record retires — so the chokepoint alone leaves a deleted parent's children stranded at a stale non-zero cell, which under acdream's predicates means "still resident" (the #184 shape, until each child's own DeleteObject arrives). Pinned: beforeDeleteGenerationruns, the delete path applies the children's leave-world edge — for each active committed child of the exact deleted incarnation (recursively), cell-less via the same D2 write path. Retail order anchor:DeleteObject'sleave_world@0x00508472 runs beforeunparent_children@0x005084B9, i.e. children are still attached when the recursion nulls their cells. The children's RELATIONS are then torn down by the existingDeleteGenerationexactly as today; the children's own records stay alive awaiting their own wire terminal (retail:unparent_childrendoes not destroy children either). EndGeneration(ParentAttachmentState:665-694, the replacement- generation path) — same stranding shape, same fix, same edge, applied at its Runtime call site (RuntimeEntityObjectLifetime:1000).
D4 — the App demotion: TickChild becomes presentation-only (resolves T5)
EquippedChildRenderController.TickChild:406-408stops calling the publicRebucketLiveEntityand calls a new internal equipped-child presentation rebucket onLiveEntityRuntime— theRebucketLiveEntityPresentationOnlyshape (:993-1065: spatial bucket move, visibility resolution, presentation refresh, visibility-change publication,BeginPresentationOnlySpatialMutationguard), with deliberately noCommitRebucket, no clock edges — because after D1/D2, Runtime already owns the canonical commit, which is exactly the C3c precondition that method's doc demands for presentation-only use. (The C3c-R1 R2 warning at:824-832— "post-residence moves take the full legacy branch" — does not apply: it protects entities whose ONLY cell authority would otherwise be the graphical rebucket; an equipped child's authority is now the D1/D2 Runtime write.)- The entry point is child-scoped (assert the record has a committed parent relation, or is called only from the equipped-child controller) so it can never become a general bypass of the legacy branch.
- T5's regression risk is the acceptance test, not a reason to keep the
old writer: route 4a's R1 showed that dropping the bucket move leaves an
entity body-correct but draw-bucket-stale (invisible-but-solid). The
demoted call MUST still move the graphical bucket every time the parent's
ParentCellIdchanges — TickChild's existing cadence (per recomposition, withParentPresentationMatches/CaptureParentPresentationchange detection onLastParentCellId,:426-446) already provides the trigger; only the canonical half is removed. The connected gate's carry-across-landblock step plus the dual-layer tests (§6) enforce it. - All other TickChild effects (pose composition,
ParentCellIdmirror, draw-visibility inheritance,PublishChildPose,ProjectionPoseReady) are untouched.WorldEntity.ParentCellIdremains presentation (AP-133's split is not re-litigated).
D5 — the headless parent-realize drive
RuntimeLiveEntitySessionController.OnParentUpdated (:312-316) grows the
realize that headless never had: after TryApplyParent accepts/stages,
resolve the staged relation (the ParentAttachmentState.Resolve +
TryGetStagedProjection protocol ResolveRelations demonstrates —
snapshot-known + instance-currency callbacks, all Runtime-readable) and run
the SAME commit pair the graphical protocol runs: TryCommitParent →
CommitAcceptedParentCellless-with-D1. Also drive the deferred/recovery
retry the graphical controller performs on parent arrival (children waiting
for a parent that appears later — OnSpawned's projection path), to the
extent the direct host receives those events; state what is deliberately
not driven (pose composition, which is presentation and does not exist
headless).
The validation gap, pinned rather than discovered later: retail's
add_child validates the holding location against the parent's Setup
(CSetup::GetHoldingLocation @0x0050F896); the graphical host ports this
via ValidateParentProjection's DAT read. The headless host reads prepared
collision content, which does not expose Setup.HoldingLocations. Pinned:
the headless drive commits on gate acceptance + relation resolution
alone, skipping the holding-location validation, recorded as a register row
in the same commit (a server-sent invalid location would attach headless
where retail/graphical reject — unreachable against a well-behaved ACE, but
a divergence and it gets its row; precedent: the content-less host's
documented reduced-fidelity registration at
RuntimeLiveEntitySessionController:108-117). If the reviewer finds
HoldingLocations cheaply exposable through existing prepared content, that
retires the row — but do NOT extend the bake format for it in this slice
(stop-and-report if that seems required).
D6 — ClassifyLeaveWorld is DELETED (resolves T2, informed by T3)
Delete ClassifyLeaveWorld (:480-510), RuntimeLeaveWorldRouteRequest
(:141-145), RuntimeLeaveWorldCause (:31-36), and the one pinning test
(RuntimeAuthoritativePositionRouteClassifierTests:335-352). Rationale,
recorded in the commit:
- Retail has no classification here.
DoPickupEvent@0x00452240 andDoParentEvent@0x00452290 are separate wire handlers dispatching directly; they never reachHandleReceivedPosition. Method-per-cause inRuntimeEntityObjectLifetimeIS the retail shape — the scoping's worry ("the cause discriminator is implicit in which method the caller picked") describes retail's own dispatch, not a defect. - The only gate retail has is the POSITION stamp, and acdream already
enforces exactly that, in Runtime, at
InboundPhysicsStateController.TryApplyPickup/TryApplyParent/TryCommitParent(§1.2). Wiring the classifier would ADD a second gate (ValidCreateAuthority's teleport-pair equality) that no pickup/parent path can honestly populate (T3, verified) — a vacuous-or-wrong predicate with the #307 defect shape, plus T2's rejected-classification silent-pickup-drop hazard, for zero behavioral gain. - This closes the scoping's "wire it or delete it" demand in the direction the evidence points; the scoping's lean ("wire it") predates the T3 verification and is overridden with cause (§10).
D7 — pickup ordering adopts retail's (resolves T7)
TryApplyPickup reorders to retail's unset_parent-then-leave_world:
ParentAttachments.EndChildProjection moves ahead of
CollisionReports.LeaveWorld → SetPosition.Forget → SuspendObjectClock
→ SetFullCell(0,0) (anchors @0x0045227F before @0x00452286). The
cancellation sequence, AdvancePositionAuthority, and the publication
discipline are unchanged. Verified inert against the new machinery: the
picked-up entity's own D2 propagation consults ITS children, not its
relation to its parent, so the reorder cannot change propagation; no
in-between callback exists (AcknowledgeProjectionAndPublish runs after
both). This retires the recorded inversion instead of carrying the "not
proven inert" caveat forward. The sibling inversion on the
Position-unparent edge (TryApplyPosition:1830/:1838) is route 4's surface
and is NOT touched — recorded in §9.
D8 — the inverse-leash partition, and the guards that do NOT come along
The route-7 column of the campaign's constraint-arm partition — stated so an implementer arriving from 4b-2/4b-3/5 ("arm on nonzero return, on every placement outcome") cannot carry the rule across:
| event | retail path | ConstrainTo? |
placement? | distance/snap guards? |
|---|---|---|---|---|
| pickup | DoPickupEvent — never reaches HandleReceivedPosition |
never | none | none |
| parent (attach) | DoParentEvent — same |
never | none — change_cell is an identity write |
none |
| parent cell crossing (propagation) | SetPositionInternal child handling — the PARENT's own route arms whatever ITS route arms; the child arms nothing |
never (for the child) | none | none |
| delete | DeleteObject |
never | none | none |
Explicitly NOT imported (T4/T6): AP-87's 4 m BodySnapThreshold, the 96 m
MaxPhysicsDistance, MoveOrTeleport's near/far split, 4b-1's
service-window machinery, and any CanAttemptDestination pre-flight —
pickup/parent/delete have no distance concept and no deferrable Core
condition (retail's change_cell runs no sweep and no AdjustPosition).
If an implementer finds a reason a child cell write CAN defer, that is a
new finding: stop and report.
D9 — register and comment bookkeeping, in the implementation commit
- ONE new AP row — the parented-child cell model (three clauses, all
intentional-architecture): (a) acdream collapses retail's
cell-pointer/objcell_idpair into one canonicalFullCellIdthat is also the residency predicate; consequently (b) the removal path propagates ZERO to children where retail leaves a stale non-zeroobjcell_idunder a null pointer (@0x005133C1 /leave_cell's absent id write — deliberate non-reproduction, D3), and (c) retail's same-cell depth-1 per-tick id refresh (@0x005153BD) is subsumed by the value-idempotent chokepoint (D2) rather than ported as a tick loop. Anchors: @0x00513390, @0x00510ed0, @0x00510f50, @0x0051539c-@0x005153d8, @0x00515d40. - ONE new AP row — headless holding-location validation skip (D5), if the reviewer confirms no cheap prepared-content read exists.
- AP-136's writer list shrinks: "the equipped-child renderer
EquippedChildRenderController.TickChild" dies as a canonical rebucket writer (register line ~287); the surviving non-Position rebucket writer is the projection materializer alone. Update the row and the two doc comments that carry the same claim:RuntimeSetPositionState.cs:4543andRuntimeRemotePlacementDriveController.cs:1617. - Comment corrections (process rule 6, each verified against the code
beside it):
LiveEntityRuntime.CommitAcceptedParentCellless's doc (:2338-2343) — "commits retail set_parent's cell-less edge" gains the D1 second half; the same class'sRebucketLiveEntityPresentationOnlydoc ("called ONLY fromTryApplyInitialCreateCompletionPresentation") updates for the D4 entry point;TickChild's surroundings; theParentAttachments.EndChildProjectiondoc ("after Pickup or a world Position") if D7's reorder makes its phrasing stale; grepTickChild|CommitAcceptedParentCellless|RebucketLiveEntityacrosssrc/+docs/architecture/and re-point every survivor. - No row deletion. AP-124, AP-131, AP-132 (queued-parent incarnation gating), AP-133, AP-135 all survive untouched.
- ISSUES.md: none closed by this slice unless the implementer finds the headless child-cell defect has a filed number (none found at HEAD — it is recorded only in the campaign plan's gap list; update that list's wording when this lands).
5. Proof obligations (must prove, not assume; stated in the implementation commit)
- P1 — no merge fights the propagation. For a committed child, every
snapshot-mutation path (ObjDesc, motion, state, PVP bitfield, parent
re-commit) leaves
Snapshot.Positionnull (§0 item 10), soRefreshDerivedStatenever stamps a child cell from its own snapshot. One test drives each mutation family against an attached child and asserts the canonical cell still tracks the parent. - P2 — the child stays parent-suspended. After attach, after ten
crossings, and after a parent teleport: child
ObjectClocksuspended, not a spatial root, in no workset, noRemoteMotion, body (if any) inactive. (Invariant 2; retail @0x00515D40.) - P3 — no placement-ledger engagement. Attach/crossing/withdraw/delete
sequences leave
RemotePlacementDrivePendingCount, SetPosition operation counts, and park counts at their prior values. - P4 — the child broadphase story, stated. What is a child's
shadow/broadphase registration at attach, across crossings, at
unparent-by-Position, at pickup, at parent delete? The implementer writes
the answer down with the retail anchors (
leave_world'sremove_shadows_from_cells@0x005155DD;recalc_cross_cellsat attach only; §0 trap 5a) and confirms the propagation path performs zero shadow work. If a gap is found (e.g. a child shadow that should exist and does not), it is FILED, not silently fixed in this slice. - P5 — re-entrancy safety of the chokepoint. The propagation runs
inside whatever transaction wrote the parent's cell
(
RuntimeSetPositionStateplacement commit,RuntimePhysicsStatesimulation commit,CommitRebucket, the wire merge). Because it is field-writes-only (D2), it cannot re-enter those owners. Prove with a focused test per writer family plus the existing reset/reentrancy suites green. - P6 — zero allocation on the propagation path (the Slice I discipline): a warmed crossing with N children allocates 0 B.
- P7 — delete-edge ordering. The children's leave-world edge reads
ChildrenAttachedToParentBEFOREDeleteGenerationremoves the relations; a test deletes a parent with an attached (and a grand-attached) child and asserts both went cell-less. - P8 — publication consumers. Enumerate
RuntimeEntityChange.Rebucketedconsumers; confirm none requires the per-child deltas TickChild'sCommitRebucketused to produce, or flip D2's publication default and say so. (This is route 5's A1 lesson applied prospectively: the App/host layer must be shown to tolerate the Runtime seam's chosen silence.)
6. Test plan
Rules (route 5 §7's, verbatim where they apply): assert the layer that
historically broke — presentation and canonical cell, not only
InWorld/clock; assert positive facts, not only negatives; every new test
must fail against a broken implementation (no source-text pins). The
dual-HOST discipline is this route's analog of route 5's dual-kind
theories: every Runtime-level scenario runs against the Runtime owners
directly (headless-shaped) AND through the graphical wrappers, asserting
the same canonical outcome — that is what makes the headless gap a failing
test rather than a host-specific accident.
Focused Runtime tests (tests/AcDream.Runtime.Tests):
- Attach, parent celled (D1): commit pair on a child whose parent has
FullCellId = A→ child ends at A (positive),Withdrawn-then-re-celled within one call (no observable cell-less escape), collision reports force-ended, clock suspended, POSITION_TS consumed. Companion: parent cell-less → child stays cell-less (the @0x00515AD1 gate), and a LATER parent cell commit re-cells the child through D2 (the deferred-attach catch-up retail gets for free from propagation). - Crossing propagation per writer family (D2): parent cell changed via
(a)
CommitRebucket, (b)RuntimePhysicsState.CommitCanonicalCell(simulation commit), (c) a canonical placement commit (RuntimeSetPositionState), (d) the wire merge (RefreshSnapshotwith a Position) → child follows in every case; grandchild follows (recursion); a cycle (A→B committed both ways by hostile wire) terminates. - Same-cell idempotence: a parent commit to its CURRENT cell leaves
child
SpatialAuthorityVersionunchanged (the D2 short-circuit, positive form: the child was already correct). - Withdrawal edges (D3): pickup of the parent,
CommitWithdrawalof the parent, and residence re-begin each zero the child (and grandchild); delete of the parent zeroes children BEFORE relations vanish (P7);EndGenerationsame. - Pickup of the child itself (D7): relation removed before the
leave-world writes (order pinned via the relation table's state at the
cell write — e.g. a propagation-visible probe or the committed-children
list emptiness at
SetFullCell(0,0)time), cell zeroed, clock suspended,Withdrawnpublished with the cancellation receipt first — and the entity's own children (if any) went cell-less too. - Never-arm partition (D8): after attach + five crossings + pickup +
delete, no constraint/
PositionManagerstate exists for parent or child beyond what the parent's OWN route had already armed; arm counts unchanged by every route-7 event. - No-placement invariant (P3) and ledger convergence (invariant 13): teardown/reset/generation-change with children attached, mid-crossing, and mid-unparent.
- Headless parent-realize (D5): through
RuntimeLiveEntitySessionController.OnParentUpdatedwith a live directory: staged → committed → celled at the parent's cell — the test named by the route-6 scoping as failing today (child canonicalFullCellId == parent'sat a stable checkpoint), now in-tree and green; plus the deferred flavor (parent arrives after the relation). - Classifier deletion (D6): surviving classifier tests byte-identical (zero expectation changes — the §3 item 10 tripwire).
App-layer tests (tests/AcDream.App.Tests):
- The demotion keeps presentation whole (T5/#184's layer): drive the
realize + a parent cell change through the graphical stack; assert the
child's render entity moved buckets (spatial index / visibility state),
ParentCellIdmirrors the parent, AND the canonical cell was written by Runtime (not by the presentation path — assertCommitRebucketwas not the writer, e.g. via the presentation-only guard). Sabotage check (manual, TWO runs — corrected at the architecture review, A1): break D2's propagation and confirm THIS test fails on the CANONICAL half while presentation still moves; separately, stub out the presentation rebucket call (RebucketEquippedChildPresentation) and confirm THIS SAME test fails on the PRESENTATION half while the canonical cell is still correct. The first implementation round ran only the first of these two and shipped an assertion (child.WorldEntity.ParentCellId) that TickChild writes unconditionally before the demoted call runs — satisfied whether or not the demoted call executes at all — so the presentation half had zero effective coverage despite the contract asking for it. If EITHER sabotage run leaves the test green, the test is asserting the wrong layer; fix the test. Assert against the actual spatial bucket (e.g. a landblock membership query), not a mirror field TickChild writes elsewhere. - Unwield/pickup teardown: after pickup, the child's projection is gone, no bucket residue, no shadow residue (the invisible-but-solid regression assert, stated positively: the cell is 0, the projection withdrew, the relation is gone).
- P8's publication check as a test where feasible (a consumer-facing
assertion that the graphical host converges without per-child
Rebucketeddeltas).
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'sFullCellIdequals the parent's after a crossing — instantiated for BOTH parent classes) lives in2026-08-05-c4-closeout-handoff.mdand is run at2026-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",dotnet test AcDream.slnx -c Release -m:1. Baseline 11,063 passed / 4 skipped / 0 failed atcff52c44. The count will move (one classifier test deleted, new suites added) — measure and record the new figure; do not inherit the baseline. Two known flakes, never chase and never conflate: #302 (PortalProjectionTests.ClipToRegion_FrameOwnedStore_…, GC-allocation assertion, App.Tests) and #308 (NakEmissionTests.LossSoak_…, wall-clock deadline, Core.Net.Tests, full-suite load only). If either appears, re-run and say which. - Connected two-client gate (user-run) — this route IS user-visible.
Probe:
ACDREAM_PROBE_CHILD_CELL=1,PhysicsDiagnostics-owned, marked TEMPORARY with the existing probe family; one[child-cell]line per Runtime child-cell write with parent guid, child guid, old→new cell, and cause (attach/propagate/withdraw/delete/headless-attach). A session counts as a pass ONLY if the probe shows the propagation executed (process rule 5; 4b-3's gate precedent — a clean-looking session with zerocause=propagatelines during step 2 is a not-run). Recipe (scoping §7.7, carried):- Equip/unequip cycle — weapon then shield, five times, observer watching: in the hand, at the hand, oriented with the hand, clean disappearance on unwield.
- Carry across ≥2 landblock boundaries and back, both directions of
observation; expect
cause=propagatelines at each crossing, child cell always equal to the player's. Include one indoor/dungeon traversal (EnvCell-to-EnvCell crossings are the high-frequency case). - Pickup: drop the weapon, pick it back up — leaves the ground, no ghost, no invisible collider at the drop site.
- Loot an equipped item from a kill (the delete edge under load).
- Reconnect with equipment — re-attaches.
- Portal recall while equipped — equipment present and following after arrival. Regressions to watch: weapon drawn at the world origin or its last ground position; invisible while equipped; left behind at a landblock boundary (the demotion's specific risk); invisible-but-solid at a former position (#184); child culled while the parent is visible or vice versa. Graceful close per the standing ACE session rule.
- Headless gate: the §6 test 8 assertion (child canonical
FullCellIdequals the parent's at a stable checkpoint) — the direct regression test for the defect, which fails today — plus one headless session where the local player equips (via the bot command surface) and crosses a boundary, asserting the same, withcause=headless-attach/propagateprobe lines in the log.
8. Budget and stop conditions
Size estimate at HEAD (supersedes the scoping's §7.6 table, whose shape changed twice — the propagation research added D2/D3, and D6 became a deletion):
| piece | non-comment production lines |
|---|---|
| D1 attach re-cell in the cell-less commit | 40-80 |
| D2 directory-funnel propagation + recursion/idempotence guards | 50-90 |
| D3 delete/EndGeneration explicit edges | 15-35 |
| D4 demotion + internal presentation-only child entry point | 40-80 |
| D5 headless parent-realize drive | 60-110 |
D6 ClassifyLeaveWorld family deletion |
net −60 to −70 |
| D7 reorder | ~3 |
| probe | 15-25 |
| net added | ~165-355 |
Within the scoping's 300-490 envelope (below it, thanks to D6 being a deletion). Tests are the larger share, ~450-750 lines.
Route 7 remains ONE slice and MUST NOT be split — re-validated at HEAD: the Runtime canonical write (D1/D2) and the App demotion (D4) are two halves of one transfer. Landing D4 without D1/D2 leaves every equipped child cell-less/stranded (the #184 shape); landing D1/D2 without D4 creates a per-frame two-writer race on the canonical cell — the exact defect class this campaign exists to remove. D5 rides along because it is the same Runtime commit with a thin driver, and the headless gate is the route's direct regression test.
Stop and report rather than pushing through when:
- Added production lines exceed 550 — the likely cause would be the propagation needing its own publication/receipt machinery (P8 flipping the default into something structural) or the headless resolve needing more of the graphical protocol than the thin drive assumed; either is a decomposition conversation, not an ad-hoc build.
- Any placement, park, service-window, or
ConstrainTomachinery starts looking necessary on this route (D8's last paragraph). - P8 finds a
Rebucketedconsumer that genuinely needs per-child deltas AND publishing them breaks an ordering invariant. - P4 finds a live child broadphase registration that per-crossing propagation would leave stale (that would mean acdream has child shadow state retail does not, and the design changes).
- D5's validation gap turns out to require extending the prepared-content bake format.
- Any surviving classifier test changes expectation (§3 item 10).
- The complete Release suite deviates from baseline beyond the two named flakes.
9. What this slice does NOT do
- AP-131 (shared merge call /
clearParentgating) — C5. The Position-unparent edge's ordering inversion (TryApplyPosition:1830/:1838vs retail @0x00454129) is the same family: recorded here, not touched — it belongs with AP-131's route-4-side correction. - AP-135, #276, #316 — untouched.
- R6-a (retail
DeclareValid'sSetSelectedObjectsplit-recovery selection transfer) — out of C4, per route 6's closure; file separately. UnparentBeforeRouting/ApplyPlacementFrameBeforeRoutingstay recorded-not-consumed (4b-3's non-goal, carried).- No child
PositionManager/interpolation/RemoteMotionmachinery; no child self-simulation of any kind. - No changes to routes 2/4/5/6, the local-player paths, the remote tail, or the continuation executor beyond the parent-replay path already calling the extended commit.
- No re-litigation of AP-133 (
ParentCellId/EffectCellIdsplit) — the child's render-parent field remains presentation. - Route 3 (portal) — after this slice.
10. Stale and false scoping claims — reported, not smoothed (§7 of 2026-08-04-c4-routes-6-7-scoping.md)
Substantively false or superseded (5):
- T5's open question — "whether retail re-cells a child when its parent crosses a cell is NOT established" — is SETTLED (yes, recursively, from the physics commit) by the propagation research, which the scoping demanded before demotion. Superseded, exactly as the scoping asked.
- §7.4's fix shape — "move the parent-cell commit into
TryCommitParent/CommitAcceptedParentCellless" — is INSUFFICIENT as written. An attach-only commit is correct at attach and stale from the parent's first crossing (research §10 item 2). D1+D2 replace it: attach half PLUS the sustaining propagation. The scoping's own §7.6 budget row inherited the insufficiency. - §7.6's "retail's
change_cell+recalc_cross_cellshalf" — portingrecalc_cross_cellsper the commit is WRONG: retail runs the recursive form at attach only; per-move it calls only the non-recursive forms (research §8, binding trap). No cross-cell/shadow rebuild ships. - §7.5's lean ("wire
ClassifyLeaveWorld") is overridden with cause: T3's verification (no pickup/parent gate measures a teleport pair; the only retail gate is POSITION_TS, already enforced inInboundPhysicsStateController) plus retail's separate-wire-handler dispatch make deletion the evidence-backed choice (D6). - §8's "recommended order: route 6 first, then route 7" and the
campaign-plan correction it demanded are MOOT — both landed
(
1b484937; plan lines:97-116corrected).
Stale line references (every RuntimeEntityObjectLifetime citation, plus
several others): TryApplyPickup :1226-1245 → :1249-1312;
TryCommitParent :1360-1374 → :1388-1441; CommitAcceptedParentCellless :1377-1408 → :1443-1474; CommitWithdrawal :1845-1860 → :1896-1929;
TryAcceptDelete :1939-1952 → :1973-2034; ForgetInitialCreateResidence :2552-2569 → :2620-2637; AcknowledgeProjectionAndPublish :2187-2213 →
:2255 on; ClassifyLeaveWorld :475-505 → :480-510;
ValidCreateAuthority :507-512 → :512-516; TickChild rebucket :405-408
→ :406-408; RebucketLiveEntityPresentationOnly :993-1050 → :993-1065;
App wrappers :2280-2312 → :2312-2363 (doc comment :2288-2292 →
:2338-2343); headless OnParentUpdated :313-317 → :312-316;
OnPickup :455-474 → :460-474. The scoping's §7.3 verification table
(cancellation choke points :1074-1094 etc.) is wholly re-verified at the
new locations in §1.5. Its structural claims all still hold; only the
coordinates moved.
Confirmed still true at HEAD: ClassifyLeaveWorld has zero production
callers; RebucketLiveEntity is not presentation-only (canonical
CommitRebucket at :904-907); headless has no realize; the six
cancellation choke points and their C0 fixes; T8's pinned LeaveWorld
omission; LiveEntityDeletionController purely logical; the register's
AP-124 status.
11. Cross-contract finding — route 7 changes route 4b-3's cell-less trigger population (reported honestly)
Route 4b-3's connected gate recorded an honest gap: cause=cellless was
never observed live, and its closure note says "the unwield-to-3D path is
the cheapest reachable trigger" (2026-08-04-c4-route-4b-3-contract.md,
final section). After route 7 that provocation stops working, and that is
the retail-faithful direction:
- Retail:
unset_parentperforms no cell work, so a wielded child's unwield Position reachesMoveOrTeleportwiththis->cell= the parent's cell — NON-zero. Retail's cell-less branch does NOT fire for unwield; it fires only for genuinely never-celled/withdrawn bodies. - acdream today: a parented child's canonical cell is whatever the
render-tick writer last produced — nonzero in the graphical host while
TickChild runs, zero headless and zero in any pre-first-tick window —
so
PreMergeCommittedCellId == 0(the 4b-3 D1 input, measured atTryApplyPosition:1801-1814) could classify an unwield as cell-less. - After D1/D2: a committed child's pre-merge cell is deterministically the parent's (nonzero whenever the parent is celled), so the unwield Position classifies by TELEPORT_TS/distance — matching retail's predicate population exactly.
Consequences to carry: (a) 4b-3's test 4 ("unwield-to-3D shape classifies
SetPosition") remains valid ONLY as a synthetic pre-merge-cell-0 fixture —
it must not be re-labeled as the live unwield behavior; (b) the recorded
live-closure recipe for cause=cellless needs a different provocation
(a genuinely withdrawn body receiving a Position without an intervening
Create — whether ACE ever emits that shape is unestablished); update the
4b-3 contract's closure note in this slice's docs commit rather than
leaving a recipe that can no longer fire. No code in the 4b-3 arm changes.
12. Open questions routed to the reviewers
- D2's chokepoint placement (retail-conformance + architecture): the
directory funnel is argued from §0 item 8's caller closure — verify
independently that no canonical cell write bypasses
RuntimeEntityDirectory.SetFullCell/RefreshSnapshotat HEAD (the load-bearing claim; if a bypass exists, D2 has a hole exactly where the defect class predicts). - D3's delete edge: confirm by reading the App teardown/orphan flow
(
EquippedChildRenderController's_pendingOrphanRemovalByChild,LiveEntityRuntimeTeardownController) that zeroing children's cells at parent delete cannot race a child projection teardown already in flight, and that the child's later own-DeleteObject converges. - D4's entry point: confirm the presentation-only child rebucket
cannot be reached for a non-child record (the general-bypass hazard) and
that
BeginPresentationOnlySpatialMutation's guard semantics hold for the per-frame cadence. - D5's validation gap: confirm no existing prepared-content surface
exposes
Setup.HoldingLocationsbefore accepting the register row; and review what the headless drive deliberately does not drive. - P8's publication decision: adversarially hunt a
Rebucketedconsumer that needs the per-child deltas the demotion removes (route 5's A1 class — the App tolerating the seam's silence must be shown, not assumed). - D7's inertness argument — verify no observer distinguishes the reordered pickup sequence (the claim is argued, with the T7 history, not merely asserted; but it is an ordering change on a live path).
- §11's 4b-3 interaction — confirm the synthetic fixture reading and
that no OTHER consumer of
PreMergeCommittedCellIdchanges population when children stop being cell-less.