acdream/docs/research/2026-08-04-c4-route-7-retail-review.md
Erik cd3129e9d6 fix(physics): C4 route 7 — child cell propagation moves from a render tick into Runtime
Retail re-cells children when their parent crosses a cell, recursively, to
unbounded depth. acdream did it from a RENDER tick, so headless parented
children were cell-less forever and the canonical cell had two writers. This
slice makes Runtime the sole authority and demotes App's tick to
presentation-only. Contract:
docs/research/2026-08-04-c4-route-7-contract.md; the research that unblocked
it is docs/research/2026-08-04-retail-parent-cell-propagation.md (ca96ea5e).

Retail: SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop.

THE TRAP, recorded because it nearly shipped: the depth-1 loop
@0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array
id only, deliberately not the cell pointer), NOT the propagation. An
implementer who finds it first concludes "depth-1, id-only" and strands every
equipped item at a landblock boundary — the #184 class. The clincher against
that reading: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.

Route 7 performs NO placement (DoPickupEvent @0x00452240 = unset_parent +
leave_world; DoParentEvent @0x00452290 = set_parent + SetPlacementFrame), so
it arms ConstrainTo nowhere — the leash rule INVERTS relative to routes
2/4/5, and both reviewers confirmed nothing arms.

Propagation is an ITERATIVE WORKLIST, not recursion. The first implementation
recursed with a depth-64 cap; both reviews independently found the cap left a
truncated tail at a stale NON-ZERO cell — permanently unrecoverable, logged
only under a probe flag, and on the withdraw path exactly the #184 shape
AP-142 clause (a) exists to reject. Shipping a fresh #184 instance inside the
slice that fixes stranded children was not acceptable, so the cap was removed
rather than tuned. The worklist retires the cap, the constant, its register
clause, and the failure mode together. Termination: every record on the stack
is already at the target pair, so nothing can be pushed twice and a hostile
A->B->A cycle collapses without a visited set.

The child write deliberately bypasses the public RuntimeEntityDirectory
.SetFullCell and calls the record method directly. This is LOAD-BEARING:
the public method re-enters PropagateFullCellToChildren, which opens with
_propagationWorklist.Clear() — routing children through it mid-drain would
wipe the shared stack and silently drop every unprocessed sibling. Any future
side effect added to the public SetFullCell must be mirrored by hand at that
call site.

Deliberate divergence, recorded not disguised: retail's removal path leaves
children with a null cell pointer but a STALE nonzero objcell_id @0x005133c1.
acdream does not reproduce it, because FullCellId != 0 is the liveness
predicate at 45+ sites — faithful porting would mark dead children live.
AP-142 records this; clause (d) records that acdream cannot gate propagation
on HasPartArray the way enter_cell gates on part_array @0x00510ed8, because
the flag's only writers are graphical and headless never sets it — the reason
is Slice J LAYERING, not a semantic difference (retail's part_array is itself
a mesh-construction product, single assignment site makeAnimObject
@0x0050e930 -> CPartArray::CreateSetup @0x0050e93e).

D7 adopts retail's unset_parent-before-leave_world order @0x0045227f ->
@0x00452286, applied to BOTH pickup paths including the dormant executor
replay. Its inertness was verified by reverting it and finding all 12
propagation tests still green — reported honestly rather than papered over
with a manufactured test, and independently confirmed by both reviewers.

ClassifyLeaveWorld and its request/cause types are DELETED: retail has no
classification here, and method-per-cause IS the retail dispatch shape.
Wiring it would have forced a vacuous teleport-sequence predicate with the
#307 shape.

Two review rounds plus a coordinator-required third pass; 5 MAJORs. One was a
handoff failure worth recording: enter_cell's part_array guard was correctly
identified as load-bearing by the research, dropped by the contract when it
enumerated the writes, and inherited as an omission by the code — a right
finding that evaporated across two handoffs with nobody re-reading the source.
Another was a test that survived deleting the entire behaviour it claimed to
pin, because its assertion read a field written unconditionally one line
earlier.

NoProjection is structurally unreachable from TickChild (TryResolveExactAttachment
performs a strictly stronger form of the same guard one call earlier). Kept as
a fail-safe, unit-tested directly, and documented in two places rather than
wrapped in a fabricated end-to-end test.

Headless regression test — the direct gate for this defect, which FAILED
before this work because no code path existed:
RuntimeLiveEntitySessionControllerTests
.DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell.

Probe: ACDREAM_PROBE_CHILD_CELL=1 emits [child-cell] lines at all four write
sites (attach / headless-attach / propagate / withdraw / delete). TEMPORARY.

Complete Release suite MEASURED at 11,079 passed / 4 skipped / 0 failed
(baseline 11,063 at cff52c44, +16). An allocation flake appeared once under
load and was proven NOT this slice by reachability — RuntimeCollisionReportingState
contains zero SetFullCell and zero ParentAttachments references.

STILL OWED: the two-client connected gate (equip/unequip, carry across
landblock boundaries, pickup, loot, reconnect) with ACDREAM_PROBE_CHILD_CELL=1,
and a session counts only if [child-cell] cause=propagate lines appear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:53:05 +02:00

504 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# C4 route 7 — retail-conformance review (independent)
**Date:** 2026-08-04
**Reviewer role:** independent retail-conformance reviewer, review-only (no edits,
no commits, no fixes).
**Subject:** the uncommitted working-tree diff at branch
`claude/acdream-physics-divergence-5aa784`, HEAD `ca96ea5e`
(`git diff HEAD` + the two untracked files
`docs/research/2026-08-04-c4-route-7-contract.md` and
`tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs`).
The route-7 contract and the route-3 scoping docs are inputs, not subject.
---
## VERDICT: **FAIL**
Narrow fail. The retail mechanism this slice ports is **correct** — I re-read
every cited address in `docs/research/named-retail/acclient_2013_pseudo_c.txt`
and the propagation research is accurate on all of them (see §A). The directory
chokepoint is genuinely the sole funnel (§B). `ConstrainTo` is never armed (§C).
`TryCommitParent` still holds exactly zero `LeaveWorld` calls (§D). D7 adopts
retail's real order and is genuinely inert (§E).
The fail is on three counts, all small to fix:
1. **R1 (MAJOR)** — retail's `enter_cell` gates the ENTIRE cell write *and the
recursion* on `this->part_array != 0` @0x00510ed8. The shipped propagation
has no analogue and **no register row**. The propagation research itself
flagged this guard as "Guard, load-bearing" (§3); the contract's §0 item 1
enumerated `enter_cell`'s five writes and silently dropped the guard, and the
code inherited the omission. Register rule 1: a deviation found without a row
is a bug twice over.
2. **R2 (MAJOR)** — D4's presentation half is **untested**. The one App-layer
assertion is satisfied by a line that runs before, and independently of, the
demoted call. `RebucketEquippedChildPresentation` has zero references in
`tests/` repo-wide. This is exactly the layer contract §6 test 10 named
("the child's render entity moved buckets (spatial index / visibility
state)") and exactly the #184 / route-4a-R1 regression D4's own text calls its
specific risk.
3. **R3 (MAJOR)**`TickChild` discards the demoted call's `bool`, and the new
`HasCommittedParent` guard introduces a silent-false path the old
`RebucketLiveEntity` did not have. Contract D4 said "**assert** the record has
a committed parent relation"; the code returns `false` and the caller ignores
it. Route 5's A1 defect class, verbatim.
Everything else is MINOR. Build is green (`dotnet build AcDream.slnx -c Debug`:
0 errors) and the focused Runtime suites pass (21/21, including all 12 new
propagation tests and the 2 new headless D5 tests). Per process rule 5 that is
not evidence, and it is not what this verdict rests on.
---
## A. Retail ground truth — verified first-hand, not inherited
Every claim below was re-read from
`docs/research/named-retail/acclient_2013_pseudo_c.txt` at the stated address.
| Claim under review | Verdict |
|---|---|
| `change_cell` @0x00513390 delegates: `leave_cell` @0x0051339f, `enter_cell` @0x005133af, early return @0x005133b5; removal tail @0x005133c1 zeroes only **this**'s `objcell_id`, `cell = nullptr` @0x005133d8. **No child loop of its own.** | ✓ CONFIRMED verbatim |
| `enter_cell` @0x00510ed0 self-recurses over `children->objects.data[i]` @0x00510f03; per level writes `CObjCell::add_object` @0x00510ee2, `objcell_id` @0x00510f1e, part-array cell id @0x00510f2b, `cell` pointer @0x00510f35, lights @0x00510f3e. Unbounded depth. | ✓ CONFIRMED verbatim |
| `leave_cell` @0x00510f50 mirrors it: self-recursion @0x00510f84, `remove_object` @0x00510f5e, `cell = nullptr` @0x00510fa7, and **never** writes `objcell_id` (source of the stale-id asymmetry). Per-level guard `this->cell != 0` @0x00510f5b prunes an already-cell-less subtree. | ✓ CONFIRMED verbatim |
| **The depth-1 loop @0x0051539c-@0x005153d8 is the SAME-CELL fast path.** It sits inside `if (this->cell == curr_cell)` @0x0051536d; the `else` @0x00515372 is `change_cell`. The loop writes child `+0x4c` (`m_position.objcell_id`) @0x005153bd and child `+0x10`'s part-array cell id @0x005153cc**id only, never `+0x90` (`cell`)**, and **not** recursive. | ✓ CONFIRMED — this is the design's load-bearing claim and it holds |
| `update_object` @0x00515d10 early-returns on `parent != 0` @0x00515d40 (`this_3->parent != 0 \|\| this_3->cell == 0 \|\| state & 0x1000000`), clearing `transient_state & ~0x80` and returning. A child never runs its own tick. | ✓ CONFIRMED verbatim |
| `DoPickupEvent` @0x00452240 = timestamp gate, stamp write @0x00452278, `unset_parent` @0x0045227f, `leave_world` @0x00452286. **No placement, no `ConstrainTo`, no `HandleReceivedPosition`.** | ✓ CONFIRMED — the `unset_parent`-before-`leave_world` ORDER is unambiguous |
| `DoParentEvent` @0x00452290 = gate, `SetParentedState(1)` for a non-player parent gaining its first child @0x004522f4, `set_parent` @0x00452305, `SetPlacementFrame` @0x00452313. No placement, no `ConstrainTo`. | ✓ CONFIRMED verbatim |
| `set_parent` @0x00515a90 has **exactly ONE** `leave_world` @0x00515ac1, after `unset_parent` @0x00515aba; then `parent =` @0x00515ac6, `if (edi->cell != 0)` @0x00515ad1, `change_cell` @0x00515ad6, `UpdateChild` @0x00515b0e, `recalc_cross_cells` @0x00515b15, NoDraw inheritance @0x00515b26. | ✓ CONFIRMED — one, and only one, `leave_world` |
| Per-move path calls the **non-recursive** `calc_cross_cells` @0x0051551b / shadow rebuild @0x0051553e-4c; the recursive `recalc_cross_cells` @0x00515a30 runs at attach only. | ✓ CONFIRMED |
**Research-document defects found (2, both minor, neither changes the verdict):**
- **The "read verbatim" blocks are normalized, not verbatim.** e.g. research §2
prints `if ((state & 0x1000) == 0)` where the file reads
`if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x10) == 0)`. Same bit,
but the doc's own §7 ledger calls these "read directly from the pseudo-C
(verbatim, cited)". Paraphrase presented as transcript. Harmless here; a
hazard if a future reader diffs against the file.
- **The `DoPickupEvent` / `DoParentEvent` gates are NOT readable from this
source.** Binary Ninja lowered both to `if (-((eax_4 - eax_4)) != 0)` — a
literal always-false expression — because the x87/flag-based wrapped-sequence
comparison was lost. The contract's §2 row cites "gate
@0x0045224B-0x00452274" as if it were legible; only its *shape* (a
wrap-aware sequence compare against `update_times[0]`) is. **Flagged as
unverifiable rather than guessed.** It does not affect D7 (the ORDER is
legible) or D6 (whose argument is that acdream already enforces the
POSITION_TS gate in `InboundPhysicsStateController`, which I verified at
`InboundPhysicsStateController.cs:180-186/:263-271/:308-314`).
---
## B. D2's chokepoint claim — independently verified
Contract §12 open question 1 asks whether any canonical cell write bypasses the
funnel. Repo-wide grep for `SetFullCell` / `RefreshDerivedState` /
`CanonicalLandblockId =` over `src/**` (excluding bin/obj) returns:
- `RuntimeEntityRecord.SetFullCell` (`RuntimeEntityRecord.cs:244-251`) — two
callers only: `RuntimeEntityDirectory.SetFullCell` (`:348-356`, now hooked)
and `RuntimeEntityRecord.RefreshDerivedState` (`:230-242`).
- `RefreshDerivedState` — reached from the record constructor (`:29`, no
children possible) and `RuntimeEntityDirectory.RefreshSnapshot` (`:234-247`,
now hooked with an explicit `previousCell` compare).
- `LiveEntityRecord.FullCellId` / `.CanonicalLandblockId` setters
(`LiveEntityRuntime.cs:189-204`) route to `_directory.SetFullCell`;
`LiveEntityRecord.RefreshDerivedState` (`:353-354`) routes to
`_directory.RefreshSnapshot`. **No App-side bypass.**
- All eight producers (`CommitRebucket`, `RuntimePhysicsState.CommitCanonicalCell`,
`RuntimeSetPositionState`'s four writes, the withdrawal family, the executor's
`:2188`) call `Entities.SetFullCell`.
**Conclusion: the chokepoint holds. No hole.** I also confirmed
`canonicalLandblockId` is uniformly `(cell & 0xFFFF0000) | 0xFFFF` at every
producer, so D2's skip-on-equal-`FullCellId` cannot leave a stale landblock id.
Cycle termination and re-entrancy were checked by construction: the propagation
writes fields and bumps a version only, never mutates the relation tables it is
iterating, and `ChildrenAttachedToParent` returns the stored `List<uint>` or
`Array.Empty` (0 B).
---
## C. Route 7 arms nothing — verified
`ConstrainTo` / `ConstrainPhase` / `PositionManager` / park / service-window /
`CanAttemptDestination` appear **nowhere** in the diff's production hunks. The
D8 inversion is respected. `RuntimeEntityChildCellPropagationTests
.NeverArmPartition_D8_...` asserts the SetPosition operation ledger is unchanged
across attach + five crossings + pickup + delete, and the committed-relation
count converges to zero. ✓
## D. T8 — `TryCommitParent` `LeaveWorld` omission preserved
`RuntimeEntityObjectLifetime.TryCommitParent` (`:1400-1453`) still has zero
`Physics.CollisionReports.LeaveWorld` calls; the F4 comment (`:1430-1437`) is
intact and its retail citation (@0x00515A90's single `leave_world`) is correct
per §A. No second `LeaveWorld` was added anywhere in the parent commit family. ✓
## E. D7's inertness — verified, and the implementer's honest report is CORRECT
The implementer reported that reverting D7's ordering leaves all 12 propagation
tests green, and reported it rather than manufacturing a test. **That is true,
and "unobservable" is genuinely true against today's code**, for a reason worth
writing down:
- `EndChildProjection(guid)``RemoveCommittedChild(guid)` removes the entity
as a **child** (`_lastAcceptedByChild` + its parent's committed list). It does
**not** touch `_committedChildrenByParent[(guid, incarnation)]` — the entity's
own children. `RemoveCommittedParentReferences` would, and is not called.
- The only work between the two positions is
`CollisionReports.LeaveWorld``SetPosition.Forget``SuspendObjectClock`
`SetFullCell(0,0)`. I checked every `ChildrenAttachedToParent` consumer
(`RuntimeSetPositionState.cs:6015-6063`, `ArmLostFamilyDeadlines` /
`CancelLostFamilyDeadlines`): both enumerate `operation.Record`'s **own**
children, i.e. the picked-up entity's subtree, which `EndChildProjection` does
not alter. Nothing else reads the child-relation table in that window.
- Even in a hostile A→B/B→A cycle both orders converge to the same values.
So: **no observable difference exists that anyone failed to construct.** The
correctness is contingent on "no consumer reads the child-relation table between
those two points" — a property of today's code, not a semantic equivalence — so
adopting retail's real order is right on principle and the change should stand.
See R5 for the half that was missed.
---
## Findings
### R1 — MAJOR — retail's `part_array != 0` guard is dropped, with no register row
**File:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:381-403`
(`PropagateFullCellToChildren`) and
`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1517-1535` (D1).
**Retail address contradicted:** `CPhysicsObj::enter_cell` @0x00510ed8.
Retail:
```
00510ed0 void __thiscall CPhysicsObj::enter_cell(CPhysicsObj* this, CObjCell* arg2)
00510ed8 if (this->part_array != 0) <-- the ENTIRE body, including the
00510ee2 CObjCell::add_object(...) child recursion, is inside this
00510f03 enter_cell(child, arg2) guard
00510f1e objcell_id = ...
00510f35 this->cell = arg2
```
A child with a null `part_array` receives **nothing** — no `add_object`, no
`objcell_id`, no `cell` pointer — and **its whole subtree is skipped**, because
the recursion is inside the guard. The propagation research called this out
explicitly and named it load-bearing (§3, "Guard, load-bearing"). The route-7
contract's §0 item 1 lists the five writes and omits the guard entirely, and the
shipped propagation writes the child's cell unconditionally.
**Why it matters in acdream specifically:** `FullCellId` is the residency/liveness
predicate at 45+ sites (the contract's own §0 item 6). Writing a nonzero cell for
an object retail would leave nowhere makes it "resident" to every one of those
predicates — the #184 shape, arrived at from the other direction. The graphical
host's `ValidateParentProjection` checks `parent.HasPartArray`
(`EquippedChildRenderController.cs:902`) — the **parent**'s, not the child's —
so it is not the analogue.
**Correct behaviour:** either port the per-level guard (acdream has a
`HasPartArray` notion on the graphical record, but not on the canonical one, so
this may be a deliberate impossibility headless), **or** file a register row
stating that acdream's canonical record has no part-array concept, that the
guard is therefore not reproducible at the canonical layer, and what the
consequence is. Register rule 1 requires the row in the same commit. Right now
neither exists, and AP-142 — which is otherwise a careful, honest row — reads as
if the port were complete.
### R2 — MAJOR — D4's presentation half has zero effective coverage
**File:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:334-374`
(assertions at `:368-373`), against
`src/AcDream.App/Rendering/EquippedChildRenderController.cs:403` vs `:409-415`.
The test's only presentation assertion is:
```csharp
Assert.Equal(newCell, child.WorldEntity!.ParentCellId!.Value);
```
`TickChild` sets that field at **line 403**:
```csharp
child.Entity.ParentCellId = parent.ParentCellId; // :403 — unconditional
...
if (TryResolveExactAttachment(child, out parent)
&& parent.ParentCellId is { } parentCellId) // :409
{
_liveEntities.RebucketEquippedChildPresentation(...); // :412 — the demoted call
}
```
The assertion is therefore satisfied whether or not
`RebucketEquippedChildPresentation` runs, returns `false`, or is deleted. Grep
confirms `RebucketEquippedChildPresentation` has **zero** references under
`tests/`. The spatial bucket (`_spatial.RebucketLiveEntity`), the visibility
resolution (`IsLiveEntityProjectionResident`), and
`PublishProjectionVisibilityChanged` — the things
`RebucketLiveEntityPresentationOnly` actually does — are unasserted.
Contract §6 test 10 required exactly this and required a sabotage check. The
test's own doc comment claims sabotage verification, but the sabotage described
("reverting TickChild to call the public `RebucketLiveEntity` again makes the
final assertion fail, because that call always bumps `SpatialAuthorityVersion`")
only exercises the **canonical** half. The presentation half — "left behind at a
landblock boundary", D4's named specific risk, and #184's layer — is unguarded.
**Correct behaviour:** assert the spatial index / visibility state moved (the
route-4a R1 lesson), and sabotage-verify by no-op'ing
`RebucketEquippedChildPresentation`.
### R3 — MAJOR — the demoted call's status is discarded, and its new guard can fail silently
**File:** `src/AcDream.App/Rendering/EquippedChildRenderController.cs:412-415`;
`src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101`.
```csharp
internal bool RebucketEquippedChildPresentation(uint serverGuid, uint parentCellId)
{
if (!_directory.ParentAttachments.HasCommittedParent(serverGuid))
return false; // NEW failure mode
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
return false; // NEW failure mode
...
}
```
`TickChild` ignores the return and still returns `true` (`:417`). The old
`RebucketLiveEntity` had no committed-relation precondition, so both of these are
paths that previously could not exist. `EndChildProjection` /
`RemoveCommittedChild` run at several points while a projection is still
installed (`EquippedChildRenderController.cs:270`, `:1175`, `:1274`, `:1316`;
`RuntimeInitialCreateContinuationExecutor.cs:1998`, `:2189`), so a tick landing in
that window now silently stops moving the draw bucket, and the pose loop reports
success.
Contract D4 pinned "the entry point is child-scoped (**assert** the record has a
committed parent relation…)". A silent `false` swallowed by the caller is the
route-5 A1 class the contract's own predecessor list names: *"an App glue site
discarding the Runtime seam's status and advancing presentation on write-nothing
outcomes."*
**Correct behaviour:** either throw/assert on the guard (it is an invariant, not a
condition), or propagate the `false` into `TickChild`'s result so the existing
`WithdrawForPoseLoss` path handles it. Note this finding is only *observable*
today because of R2 — with a real bucket assertion, a fix for R3 would be
test-covered.
### R4 — MINOR — contract §6 test 2 shipped 2 of 4 writer families; P2/P4/P5/P6/P8 are unstated
**File:** `tests/AcDream.Runtime.Tests/Entities/RuntimeEntityChildCellPropagationTests.cs`.
Shipped: (a) `CommitRebucket` and (d) the wire merge (`RefreshSnapshot`).
**Missing:** (b) `RuntimePhysicsState.CommitCanonicalCell` (the simulation commit
— the writer that actually fires per tick in live play) and (c) a
`RuntimeSetPositionState` canonical placement commit. I verified by reading that
both reach `Entities.SetFullCell` (`RuntimePhysicsState.cs:2148`,
`RuntimeSetPositionState.cs:2743/:3660/:5003/:5224`), so the hook does fire — the
gap is regression coverage, not present-tense correctness. P5 (per-writer-family
re-entrancy) has no test at all; P6 (0 B/crossing) has none; P2 asserts only
`ObjectClock.IsActive` on one path, not "no workset / not a spatial root / body
inactive after N crossings and a teleport"; P4 (the child broadphase story) and
P8 (the `Rebucketed` consumer enumeration) are stated nowhere in the tree.
For P8 I did the enumeration myself: `RuntimeEntityChange.Rebucketed` has **no**
production consumer that branches on it (only `GameRuntimeEvents.cs:43`'s enum
member and two test assertions). D2's silent-publication default is safe. That
should be written into the commit, per §5, rather than left for a reviewer.
### R5 — MINOR — D7 is half-applied; the executor's replayed pickup keeps the inverted order
**File:** `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:2183-2189`.
```csharp
_entities.AdvancePositionAuthority(canonical);
_physics.CollisionReports.LeaveWorld(canonical); // leave_world
... SetPosition.Forget / SuspendObjectClock / SetFullCell(0,0)
_entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); // unset_parent LAST
```
This is the dormant-residence replay of the **same** wire event
(`SmartBox::DoPickupEvent` @0x00452240), whose retail order is `unset_parent`
@0x0045227f **before** `leave_world` @0x00452286. D7 corrected
`RuntimeEntityObjectLifetime.TryApplyPickup` and left this one. Inert for the
same reason (§E), but the new comment at
`RuntimeEntityObjectLifetime.cs:1304-1306` — "This was inverted;
EndChildProjection now runs first" — now reads as a codebase-wide statement that
is only half true. (The `TryApplyPosition` unparent edge at `:1908` **is**
correctly scoped out as route 4's surface in contract §9; this one is not.)
### R6 — MINOR — D5's deferred-residence flavor is neither driven nor declared a non-goal
**File:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-379`.
`ResolveAndCommitChildAttachment` runs `Resolve``TryCommitParent`
`CommitProjection``CommitAcceptedParentCellless` synchronously. When the
child has a pending initial residence, `Resolve`'s accept callback lands in
`RuntimeEntityObjectLifetime.TryApplyParent`'s dormant branch
(`:1354-1391`), which advances the gate via `TryAcceptDeferredParent` and
`EnqueueDormant`s a `Parent` continuation, returning `true` — so the relation is
**staged**, and the immediately following `TryCommitParent` is gated on
`gate.PositionTimestamp == positionSequence && child.PositionSequence ==
positionSequence` (`InboundPhysicsStateController.cs:308-314`), which the
deferred merge has not yet satisfied. The drive returns `false`.
Nothing re-drives it: the executor's `CommitParentAttachment` deliberately does
**not** commit the attach ("it is the App-layer `EquippedChildRenderController`'s
job" — `RuntimeInitialCreateContinuationExecutor.cs:2110-2123`), and headless has
no such controller and no post-drain retry hook. So headless, a ParentEvent
arriving during a pending initial residence leaves the child staged and
cell-less indefinitely.
Not a regression (headless committed nothing before), and the invariant-9
deferrals are correctly untouched. But the plan doc's new closure text and D5's
doc comment both read as if the headless gap is closed. Contract D5 required
"state what is deliberately not driven"; this case is not stated. **Flagging the
`TryCommitParent` rejection as inferred-from-reading, not executed** — no test
covers it.
### R7 — MINOR — AP-143 under-describes what the headless drive skips
**File:** `docs/architecture/retail-divergence-register.md:173`;
`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-379`.
AP-143 records only the `Setup.HoldingLocations` check. The graphical
`ValidateParentProjection` (`EquippedChildRenderController.cs:894-919`) also
rejects **self-parenting** (`:897-898`) and requires the parent to have a
**part array** (`:902` — retail's @0x00510ed8 guard, and the closest thing
acdream has to it). The headless drive performs neither. Self-parenting turns
out to be inert by construction (the D1 gate sees `parent.FullCellId == 0`
because the child was just zeroed, and D2's skip-on-equal terminates the
one-node cycle), but the row should say what it skips, not one of three things.
Contract §6 gave zero coverage of the headless rejection paths.
### R8 — MINOR — the headless drive is not the "same protocol" for nested/recovery cases
**File:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:381-393`.
- The graphical retry is `_relationRecoveryOrder.RealizeDescendants(parentGuid,
Relations.ChildrenWaitingForParent, ResolveAndTryRealize)`
(`EquippedChildRenderController.cs:925-931`) — a **transitive, parent-first**
descent. `RetryChildrenWaitingForParent` iterates the direct waiters on one
guid only, so after committing a child, grandchildren waiting on **that** child
are not retried in the same pass. The doc comment claims it "mirrors
`RetryWaitingDescendants`'s role"; it mirrors a subset.
- The drive never calls `MarkProjected`, so `_recoveryByChild` retains every
committed relation headless, and it never handles the recovery branch the
graphical `ResolveAndTryRealize` handles (`:835-844`). Benign today (recovery
is a re-projection concept that does not exist headless, and the entries are
cleared by `EndChildProjection`/`EndGeneration`/`Clear`), but undeclared.
Given AP-142 explicitly ships unbounded-depth recursion as a design commitment,
a depth-limited headless realize is worth a sentence somewhere.
### R9 — MINOR — a doc comment now describes the wrong test method
**File:** `tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs:316-331`
and `:426-431`.
The pre-existing `<summary>` for
`FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` (the C3c-R1 F6
latch) was left in place and the two new tests were inserted **after** it, so:
- `DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell` now carries
**two** `<summary>` elements, the first of which describes drive-controller
route latching.
- `FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner` now carries the
D5 *deferred-parent* summary.
Exactly the endemic stale-comment class this campaign keeps hitting, in the
commit that was supposed to be checking for it.
### R10 — MINOR — AP-142 clause (b) overstates the equivalence
**File:** `docs/architecture/retail-divergence-register.md:172`;
`src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:391-395`.
The skip guard (`child.FullCellId == fullCellId → continue`) does two things:
it subsumes retail's same-cell id refresh (accurate, as the row says) **and** it
prunes the child's entire subtree. Retail's `enter_cell` has no such pruning —
it recurses unconditionally (@0x00510f03) and only `leave_cell` prunes
(@0x00510f5b, on `cell != 0`). So acdream matches retail exactly on the removal
side and diverges on the entry side. Currently unreachable-by-construction (after
D4 nothing writes a grandchild's cell independently of its parent), but the row
claims a clean equivalence it does not have, and this is precisely the class of
"row asserting behaviour the code lacks" in the other direction.
### R11 — MINOR — the demotion drops `SynchronizePhysicsBodyActiveState` on a child's first projection
**File:** `src/AcDream.App/World/LiveEntityRuntime.cs:923-927` (legacy branch,
no longer reached for children) vs `:995-1067` (presentation-only branch).
The legacy `RebucketLiveEntity` ran
`record.SuspendObjectClock(); SynchronizePhysicsBodyActiveState(record);` when
`!wasProjected && !isOrdinaryRoot` — true on a child's first tick after realize.
`RebucketLiveEntityPresentationOnly` does neither. The canonical clock is
suspended by Runtime (`CommitAcceptedParentCellless`'s
`Entities.SuspendObjectClock`), and `SynchronizePhysicsBodyActiveState` no-ops
when `record.PhysicsBody is null` (the equipped-item case), so this is very
likely inert — but P2's "body (if any) inactive" is the obligation that would
have proven it, and it is unproven. **Flagged as unverified rather than
asserted.**
---
## Register discipline — assessed row by row
| Row | Assessment |
|---|---|
| **AP-142** (parented-child single-field cell model) | Clauses (a) and (c) describe shipped behaviour accurately (verified: `PropagateFullCellToChildren` + `WithdrawCommittedChildrenToCellless` + the D1 half). Clause (b) overstates — see R10. **Missing the `part_array` guard clause — see R1.** Anchors all correct. |
| **AP-143** (headless holding-location skip) | Accurately describes the skip and the reason. Under-describes the scope — see R7. The claim "repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `HoldingLocations`" is consistent with what I saw; the row correctly refuses to extend the bake format. |
| **AP-136** writer-list shrink | ✓ Correct: line 289 now reads "…demoted the third, `EquippedChildRenderController.TickChild`, to a presentation-only bucket move that no longer touches `record.FullCellId`". Matches the code. |
| **AP-138** writer-list shrink | ✓ Correct, same shape. |
| `RuntimeSetPositionState.cs:4536-4548` doc comment | ✓ Corrected; singular "writer" now, projection materializer only. Matches the code. |
| `RuntimeRemotePlacementDriveController.cs:1613-1626` doc comment | ✓ Corrected. Matches the code. |
| 4b-3 contract closure note | ✓ Honest and correct. It states plainly that the old `cause=cellless` recipe no longer fires, explains why that is the retail-faithful direction, marks test 4 as a synthetic fixture that must not be relabelled, and says the replacement provocation is UNESTABLISHED rather than inventing one. This is the best-written doc change in the diff. |
| Placement-cutover plan gap statement | ✓ Correctly moved to past tense and closed, with the one caveat that "the direct headless regression test … now passes" is true for the non-deferred flavor only (R6). |
| No row deletions | ✓ AP-124/131/132/133/135 untouched. |
---
## Contract defects found
1. **§0 item 1 dropped `enter_cell`'s `part_array != 0` guard** (@0x00510ed8),
which the research it cites called load-bearing. The contract enumerated the
five writes and not the gate around them; the implementation inherited the
omission. This is the root of R1 — process rule 1 ("the contract causes the
defect") in action.
2. **§2's `DoPickupEvent`/`DoParentEvent` gate citations are not readable from
the named-retail source** (BN lowered both to a literal always-false
expression). The contract presents them as verified. Nothing downstream
depends on it, but "✓" is the wrong mark.
3. **§4 D4 said "assert"; §6 test 10 said "assert the child's render entity
moved buckets".** The implementation did neither, and the contract's own
sabotage instruction ("break D2's propagation and confirm THIS test fails on
the canonical half **while presentation still moves** — if it stays green,
the test is asserting the wrong layer") was applied to the canonical half
only. The contract was right; it was not followed. R2/R3.
4. **§5's proof obligations are not stated anywhere in the working tree.** P4 and
P8 in particular were explicitly "must prove, not assume". P8's answer is
benign (I checked), P4's is unwritten.
## Propagation research defects found
Two, both cosmetic, listed in §A: the "verbatim" blocks are normalized
paraphrase, and §7's read/inferred ledger is otherwise scrupulous and correct.
**The verdict, the addresses, the recursion claim, the same-cell-fast-path
disambiguation, the `update_object` clincher, and the cross-cells trap are all
correct.** The research is the strongest artifact in this slice; the contract
weakened one of its findings on the way through (R1).
---
## What would flip this to PASS
- R1: a `part_array` register clause (or the guard, if a canonical analogue
exists).
- R2: an App test that asserts the spatial bucket / visibility state moved, with
the demoted call sabotage-verified.
- R3: assert-or-propagate on `RebucketEquippedChildPresentation`'s two guards.
R4R11 are worth folding in but none of them alone blocks the slice.