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

29 KiB
Raw Permalink Blame History

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.LeaveWorldSetPosition.ForgetSuspendObjectClockSetFullCell(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:

Assert.Equal(newCell, child.WorldEntity!.ParentCellId!.Value);

TickChild sets that field at line 403:

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.

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.

_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 ResolveTryCommitParentCommitProjectionCommitAcceptedParentCellless 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 EnqueueDormants 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.