acdream/docs/research/2026-08-04-c4-route-7-architecture-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

493 lines
28 KiB
Markdown
Raw Permalink 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 — independent ARCHITECTURE / ADVERSARIAL review (2026-08-04)
**Verdict: FAIL** — remediation is narrow and mechanical (two test gaps plus one
incomplete proof enumeration). No defect was found in the shipped propagation
mechanism itself; the failure is that the route's single highest-risk,
user-visible half (the D4 presentation transfer) has **no test that can fail**,
which is the exact recurring defect class route 5's review rounds named and
which §3 item 7 / §6 test 10 of the contract explicitly pinned.
Scope reviewed: uncommitted working-tree diff at HEAD `ca96ea5e`, branch
`claude/acdream-physics-divergence-5aa784`. Route-7 contract and route-3
scoping docs treated as inputs, not as change under review.
Gates re-run by the reviewer:
- `dotnet build tests/AcDream.Runtime.Tests` — 0 warnings, 0 errors.
- `dotnet test AcDream.Runtime.Tests`**1,156 passed / 0 failed**.
- `dotnet test AcDream.App.Tests --filter EquippedChild`**36 passed / 0 failed**.
- New `RuntimeEntityChildCellPropagationTests` + the two `DirectSink_D5` tests —
**14 passed**.
---
## 0. Judgement on the four implementer claims + the `SetFullCell` blast radius
### Claim 1 — "D7's reorder is unobservable" → **VERIFIED. Genuinely unobservable.**
`EndChildProjection(guid)` (`ParentAttachmentState.cs:749-754`) touches exactly
three tables keyed by the **child** guid: `_stagedByChild`, `_recoveryByChild`,
and (via `RemoveCommittedChild`) `_lastAcceptedByChild` plus the removal of that
guid from **its parent's** `_committedChildrenByParent` list. It never touches
`_committedChildrenByParent[(update.Guid, incarnation)]` — the picked-up
entity's own children list.
The three calls now bracketed by the move are
`Physics.CollisionReports.LeaveWorld`, `Physics.SetPosition.Forget`, and
`Entities.SuspendObjectClock`. I enumerated every `ParentAttachments` reader
reachable from them:
- `RuntimeSetPositionState.cs:6027` / `:6049` (`ArmLostFamilyDeadlines` /
`CancelLostFamilyDeadlines`) read `ChildrenAttachedToParent(operation.Record
.ServerGuid, …)` — the record **as a parent**, unaffected.
- `RuntimeSetPositionState.cs:3944` (`IsAffectedCollisionResident`
`HasCommittedParent`) reads the record **as a child** and IS order-sensitive —
but it is reached only from `ParkCollisionResidents`, a landblock-quiescence
entry point, never synchronously from `Forget`.
- `CancelExactLostKey` iterates operations by `RuntimeEntityKey`, not by relation.
No other reader exists. The honest "reverted the order, all 12 tests still
passed" report is the correct outcome and should be recorded as such, not
papered over with a manufactured test. **One consistency gap follows from it —
see A5.**
### Claim 2 — "`RuntimeEntityChange.Rebucketed` has NO consumer" → **FALSE as stated. Enumeration incomplete. See A3.**
The implementer enumerated `IRuntimeEntityObjectObserver` implementations and
found empty stubs. But `GameRuntimeEventHub` (`GameRuntimeEventHub.cs:45`)
*implements* `IRuntimeEntityObjectObserver` and its `OnEntity`
(`:239-254`) **fans the delta out to every `IRuntimeEventObserver`**, and
`RuntimeTraceRecorder.OnEntity` (`GameRuntimeEvents.cs:246-254`) is a
non-stub shipped consumer that records `delta.Change` and
`delta.Entity.CellId`. The `IHeadlessBotPolicy` implementations
(`HeadlessBotPolicy.cs:48/151/281/516`) are the empty stubs; they are not the
whole set. No functional break is demonstrated (the trace recorder is
diagnostic and equipped-child `Rebucketed` deltas were graphical-only), so the
publication default itself stands — but P8 was answered against the wrong
interface and must be restated.
### Claim 3 — "a committed child never becomes a spatial root" → **VERIFIED, for the right reason (which the claim did not state).**
The reason is not the parent relation: it is the **projection kind**.
`LiveEntityRuntime.HasSpatialRuntimeProjection` (`:3251-3256`) requires
`record.ProjectionKind is LiveEntityProjectionKind.World`, and
`EquippedChildRenderController.TryRealize` materializes children with
`LiveEntityProjectionKind.Attached` (`:569`). So
`RefreshSpatialRuntimeIndexes``AcknowledgeSpatialProjection(canonical,
spatial: false)``RemoveSpatialProjection` on every tick, and
`_spatialRoots` (`RuntimePhysicsState.cs:697-711`) never gains the child.
Headless never calls `AcknowledgeSpatialProjection` on any route-7 path at all
(its only callers are `LiveEntityRuntime.cs:3272` and
`RuntimeSetPositionState.cs:2756/3664/5138`, none of which route 7 reaches).
So no child broadphase gap, and the §0 trap-5a "no per-crossing shadow rebuild"
pin holds. **Note for the record:** had the child been kind `World`, the
`FullCellId != 0` clause of `HasSpatialRuntimeProjection` would have made D1/D5
newly promote children into the physics workset — the claim is right, but it is
one line of `ProjectionKind` away from being wrong, and P4's write-up should say
so.
### Claim 4 — "P5/P6 are safe by construction, no dedicated tests" → **ACCEPTABLE for the propagation path; NOT acceptable as a blanket statement for the slice. See A6.**
- **P6 (zero alloc) on the propagation path: verified.**
`ChildrenAttachedToParent` (`ParentAttachmentState.cs:648-658`) returns the
stored `List<uint>` or the cached `Array.Empty<uint>()`; both convert to
`IReadOnlyList<uint>` by reference conversion (no boxing).
`PropagateFullCellToChildren` (`RuntimeEntityDirectory.cs:381-405`) is an
index loop with no LINQ, no closures, no temporaries. 0 B confirmed by
inspection. `ParentIncarnation` is a `readonly record struct`
(`ParentAttachmentState.cs:923`) so the dictionary probe does not box.
- **P5 (re-entrancy) on the propagation path: verified.** The only work is
`EnsureKnown`, `record.SetFullCell` field writes, `TryGetActive`, and an
optional `Console.WriteLine`. Nothing calls back into `RuntimeSetPositionState`
/ `RuntimePhysicsState` / any observer, so the recursion cannot re-enter a
mid-commit owner. I also confirmed the live `List<uint>` returned by
`ChildrenAttachedToParent` cannot be mutated during the loop (nothing on the
propagation path touches the relation tables), so the swap-remove in
`RemoveCommittedChild` (`:853-859`) is not an aliasing hazard here.
- **But the slice's OTHER new path is not zero-alloc and was not measured** —
D5's per-spawn `ChildrenWaitingForParent` scan. See A6.
### The `SetFullCell` blast radius — **enumerated; correct at every site, with one structural caveat (A7).**
`RuntimeEntityDirectory.SetFullCell` (`:348-355`) now has a side effect for
every caller. Full production caller set, each checked:
| site | value written | children now follow — correct? |
|---|---|---|
| `RuntimeEntityObjectLifetime.cs:1314` (`TryApplyPickup`) | 0,0 | ✔ retail `leave_world`'s recursive `leave_cell(this,0)` @0x005155E6 |
| `:1484` (`WithdrawCommittedChildrenToCellless`) | 0,0 | ✔ explicit D3 edge |
| `:1509` then `:1530` (`CommitAcceptedParentCellless`) | 0,0 then parent's | ✔ grandchildren are zeroed then restored inside the same synchronous call |
| `:1945` (`CommitRebucket`) | new cell | ✔ D2's headline case |
| `:1991` (`CommitWithdrawal`) | 0,0 | ✔ |
| `:2737` (`InitializeAcceptedCreateResidence`) | 0,0 | ✔ vacuous — a fresh incarnation has no committed children (`_committedChildrenByParent` is keyed by `(guid, instance)`) |
| `RuntimeInitialCreateContinuationExecutor.cs:2188` (dormant pickup replay) | 0,0 | ✔ same shape as `TryApplyPickup` |
| `RuntimePhysicsState.cs:2148` (`CommitCanonicalCell`) | new cell | ✔ guarded by an equality early-out at `:2145`, so no probe on a same-cell commit |
| `RuntimeSetPositionState.cs:2743` / `:3660` (placement commits) | new cell | ✔ both guarded by `record.FullCellId != result.CellId` |
| `:5003` (residency restore) | resident cell | ✔ re-propagates children the park zeroed |
| `:5224` (`WithdrawCanonical`) | 0,0 | ✔ park/lost-cell; self-heals through `:5003` |
| `LiveEntityRuntime.cs:192` / `:200` (record property setters) | mixed | test-only (no production assignment exists) — but see A7 |
No caller means "clear only this record": every zeroing site is a
retail `leave_world`/`change_cell(null)` analogue, and the two "restore" sites
re-propagate. The `RefreshSnapshot` half (`:231-246`) is correctly gated on an
actual change and `RefreshDerivedState` has exactly the two callers the contract
claimed (record ctor `RuntimeEntityRecord.cs:29` and `RefreshSnapshot`) — I
re-verified this repo-wide, so **contract open question 1 is answered: no
canonical cell write bypasses the hook.**
**Termination**: verified by construction and by test. The skip is evaluated in
the *parent's* loop before descending, so an A→B→A wire cycle writes A, writes
B, then finds A already equal and stops.
`PropagationChokepoint_TerminatesAHostileTwoCycle…` pins it. A self-parent
(reachable headless, where D5 skips `ValidateParentProjection`'s
self-parent rejection at `EquippedChildRenderController.cs:897-898`) also
terminates on the first skip.
**Ordering**: children are written *before* every parent-side dependent step —
before `CellCommitted` (`RuntimePhysicsState.cs:2152`), before
`AdvancePlacementCommit`/`AcknowledgeSpatialProjection`
(`RuntimeSetPositionState.cs:2746-2756`), and before
`AcknowledgeProjectionAndPublish` in `CommitRebucket`. There is no synchronous
callback anywhere inside the recursion, so no observer can see a
partially-propagated tree. This matches retail (`enter_cell`'s child recursion
runs inside `change_cell`, before the caller's tail).
---
## 1. Findings
### A1 — MAJOR. The only test for the D4 demotion's presentation half cannot fail if that half is deleted outright.
**Where:** `tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:322-362`
(`TickChild_D4_CanonicalCellIsRuntimesWriteNotTheRenderTicks`), against
`src/AcDream.App/Rendering/EquippedChildRenderController.cs:403` and `:409-415`.
The test's two assertions are:
1. `Assert.Equal(newCell, child.WorldEntity!.ParentCellId!.Value)` — "the render
tick moved only the DRAW bucket".
2. `Assert.Equal(childSpatialVersionBeforeTick, child.Canonical.SpatialAuthorityVersion)`
— "and did not re-write the canonical cell".
Assertion 1 does **not** test the bucket. `TickChild` writes
`child.Entity.ParentCellId = parent.ParentCellId` **unconditionally at
`:403`**, before the rebucket call, and `AttachedChild.Entity` is the same
`WorldEntity` instance the test reads (the fixture passes
`child.WorldEntity!` into the `AttachedChild` ctor —
`EquippedChildProjectionWithdrawalTests.cs:1529`). Assertion 2 is a negative
that is trivially satisfied by doing nothing.
**Concrete failure scenario:** delete lines `:409-415` entirely (or let
`RebucketEquippedChildPresentation` return `false` on its first guard — see A2).
The child's canonical cell is still right, `ParentCellId` still mirrors, both
assertions still pass, the whole App suite stays green — and the equipped weapon
is **left behind at the previous landblock's draw bucket** when the player
crosses a boundary: exactly the "left behind at a landblock boundary (the
demotion's specific risk)" regression the contract's §7 gate names, and the
`#184` invisible-but-solid family.
`RebucketEquippedChildPresentation` has **zero** test references repo-wide
(grep across `tests/` returns nothing), so this is the slice's only coverage of
the presentation transfer.
Contract §6 test 10 asked for precisely what is missing: *"assert the child's
render entity moved buckets (spatial index / visibility state)"*. The
implementer's sabotage note covers the canonical half only (reverting to
`RebucketLiveEntity` fails assertion 2 — I confirmed that is true, because
`RuntimeEntityRecord.SetFullCell` at `:246-251` bumps `SpatialAuthorityVersion`
unconditionally); the presentation half's sabotage was not run.
**Fix direction:** assert against the spatial layer, not the mirror field. The
fixture already reaches `LiveEntityRuntime`, so either (a) assert
`record.IsSpatiallyVisible` / the spatial index's resident cell for the child
key after the tick with the child pre-seeded into a *different* bucket, or
(b) assert `RebucketEquippedChildPresentation`'s observable effect through the
fake `_spatial` (a recorded `RebucketLiveEntity(key, entity, cell)` call with
the new cell). Then run the prescribed sabotage: stub the call out and confirm
the test goes red.
### A2 — MAJOR. The new App entry point can silently write nothing, and its status is discarded. (Route 5's A1 defect class, recurring.)
**Where:** `src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101`
(`RebucketEquippedChildPresentation`) and
`src/AcDream.App/Rendering/EquippedChildRenderController.cs:409-415`.
The method returns `false` — writing nothing — on three conditions
(`!HasCommittedParent`, no current projection, no `WorldEntity`). `TickChild`
**discards the return value**, then unconditionally returns `true` and raises
`ProjectionPoseReady?.Invoke(child.ChildGuid)` at `:416`, i.e. it advances
presentation on a write-nothing outcome. That is verbatim the defect the route-7
contract lists among the route-5 classes it "addresses by name" (§preamble:
*"an App glue site discarding the Runtime seam's status and advancing
presentation on write-nothing outcomes"*).
The `HasCommittedParent` guard is **new**; it did not gate the old
`RebucketLiveEntity` call. It is reachable-false while `_attachedByChild` still
holds the child, because Runtime clears `_lastAcceptedByChild` (via
`EndChildProjection`, `RuntimeEntityObjectLifetime.cs:1307` on pickup and
`:1908` on the Position-unparent) **before** the App's
`EquippedChildRenderController.OnChildBecameUnparented` (`:264-284`) runs, and
that method can defer the teardown across frames
(`AdvanceUnparentTransition` + `_pendingOrphanRemovalByChild`, retried at
`:342`). During that window every `Tick()` composes the pose, mirrors
`ParentCellId`, publishes the pose, raises `ProjectionPoseReady`, and moves
**no bucket at all**.
Today that window is benign (the child is on its way out). The finding is that
the benign-ness is accidental and untested: nothing distinguishes "guard
correctly declined" from "guard wrongly declined", and A1 means no test would
notice either way.
**Fix direction:** consume the bool. Either treat `false` as a `TickChild`
failure (which already has a defined path — `Tick()`'s `failed` list →
`WithdrawForPoseLoss` at `:296-297`), or, if declining is legitimate for the
unparent window, make that explicit: return a small disposition
(`Moved` / `NotAttached`) and assert the `NotAttached` case in a test rather
than inferring it. Do not leave a silent bool on the floor.
### A3 — MEDIUM. P8's consumer enumeration is against the wrong interface; a non-stub `OnEntity` consumer ships.
**Where:** `src/AcDream.Runtime/GameRuntimeEventHub.cs:45` and `:239-254`;
`src/AcDream.Runtime/GameRuntimeEvents.cs:104` and `:246-254`.
See claim 2 above. `GameRuntimeEventHub` is itself an
`IRuntimeEntityObjectObserver` that forwards to every `IRuntimeEventObserver`,
and `RuntimeTraceRecorder.OnEntity` records `(delta.Change,
delta.Entity.CellId)` for every entity delta including `Rebucketed`.
**Failure scenario (bounded):** any current or future headless gate / trace
assertion that counts entity deltas for an equipped child now sees fewer
`Rebucketed` entries than before the demotion, with no note anywhere saying so.
I found no such assertion today, so this is a proof defect rather than a live
break — but the stated basis for D2's "publish nothing" default ("safe by
inspection, every implementation is an empty stub") is not true and must not be
carried forward as if it were.
**Fix direction:** restate P8 in the commit against **both** observer
interfaces, name `RuntimeTraceRecorder` explicitly as the one real consumer, and
say why a diagnostic trace losing graphical-only child `Rebucketed` entries is
acceptable. No code change needed.
### A4 — MEDIUM. `CommitAcceptedParentCellless` now publishes `Withdrawn` carrying a NON-zero cell.
**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1530-1543`.
D1's re-cell is placed **before** `AcknowledgeProjectionAndPublish(…,
RuntimeEntityChange.Withdrawn, …)`. Prior to this slice, a `Withdrawn` delta
from this method always carried `FullCellId == 0`; it now carries the parent's
cell whenever the parent is celled — i.e. in the ordinary equip case.
**Failure scenario:** any observer that treats `Withdrawn` as "no longer
resident" and reads `delta.Entity.CellId` to decide where to unregister (the
plugin world-state bridge, a future headless bot policy, a future radar/journal
consumer) now gets a delta whose kind and payload disagree. The shipped
consumers are stubs or diagnostic, so nothing breaks today; the risk is that the
invariant "Withdrawn ⇒ cell 0" was previously true and is now silently false,
with no test pinning either reading.
**Fix direction:** cheapest correct answer is to state the new payload contract
in the method's doc comment and add one assertion to the D1 attach test
(`Attach_ParentCelled_…`) pinning the published change kind **and** the
delta's cell, so the pair is deliberate rather than incidental. If any consumer
is later found to need the old shape, the re-cell moves after publication —
which is safe, because the same-transaction argument is about *callers*, not
about the delta.
### A5 — MEDIUM. D7's reorder was applied to one of the two pickup leave-world sites; the sibling still has retail's inverted order.
**Where:** fixed at `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1304-1314`;
**not** fixed at `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:2184-2189`
(`ApplyPickupAction`, the dormant-residence pickup replay), where
`EndChildProjection` still runs **after** `LeaveWorld` / `SetFullCell(0,0)`.
**Failure scenario:** none observable — the same inertness argument that
validates claim 1 applies verbatim to this site. The defect is bookkeeping: D9
and the commit will say the recorded T7 inversion is "retired", while a second
instance of it remains in-tree at the path a pickup takes when it arrives during
a pending initial-create residence. A future reader grepping for the inversion
finds it and re-opens a settled question.
**Fix direction:** either apply the same three-line move at `:2189` (preferred —
it is provably inert and makes both pickup paths one shape), or add one
sentence to the D7 commit text naming `ApplyPickupAction` as a deliberate,
inert survivor.
### A6 — MEDIUM. D5's per-spawn retry is an O(live-relations) LINQ scan with allocations, on the headless spawn path.
**Where:** `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:143`
(`RetryChildrenWaitingForParent` called on **every** accepted spawn) →
`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:618-640`
(`ChildrenWaitingForParent`), plus `:339-388`
(`ResolveAndCommitChildAttachment`).
`ChildrenWaitingForParent` allocates a `HashSet<uint>`, fully enumerates
`_stagedByChild`, `_recoveryByChild` **and** `_unresolvedByChild`, runs
`queue.Any(lambda)` per unresolved child, and returns `.ToArray()`.
`_recoveryByChild` retains an entry for every child ever committed
(`CommitProjection`, `:595`), so it grows with the number of equipped
NPCs/players in view. `ResolveAndCommitChildAttachment` additionally allocates
three `this`-capturing closures per call (`:341-350`).
**Failure scenario:** a 30-session headless host (Slice K's stated target)
loading a dense landblock does one full scan of three dictionaries per accepted
`CreateObject`. With N resident entities of which K are attached children, that
is O(N·(N+K)) work and O(N) allocations across the load, on the path Slice K
measured to a resource ceiling. The graphical host does the same thing
(`EquippedChildRenderController.OnSpawn``RetryWaitingDescendants`), so the
shape is not novel — but the headless host is the one with a per-process
allocation budget and a 30-root gate, and this is new cost there.
**Fix direction:** the retry only needs children whose *unresolved* queue names
this parent. Index that: keep a `Dictionary<uint parentGuid, List<uint> childGuids>`
maintained by `Enqueue`/`Resolve`, or at minimum skip the `_recoveryByChild`
and `_stagedByChild` sweeps (both are already-resolved states that
`ResolveAndCommitChildAttachment` no-ops on). Cache the three callbacks as
fields. Then re-measure the K4 30-session resource envelope, or state
explicitly that this slice does not.
### A7 — LOW. The propagation's idempotence key is `FullCellId` alone, but `SetFullCell` takes an independent `canonicalLandblockId`.
**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:389-395`
(`child.FullCellId == fullCellId``continue`).
Every production writer derives the landblock from the cell —
`(cellId & 0xFFFF0000u) | 0xFFFFu` at `RuntimeEntityRecord.cs:235`,
`RuntimePhysicsState.cs:2150`, `RuntimeSetPositionState.cs:2744/3663/5005` — and
D1 copies the parent's pair verbatim, so the two fields are coupled today and
the skip is safe.
**Failure scenario:** the coupling is not enforced anywhere.
`LiveEntityRecord.CanonicalLandblockId`'s setter
(`src/AcDream.App/World/LiveEntityRuntime.cs:200-203`) calls
`SetFullCell(Canonical, Canonical.FullCellId, value)` — a same-cell,
different-landblock write. It has no production caller today (tests only), but
if one ever appears, children keep the **stale** `CanonicalLandblockId` while
the parent updates, because the skip fires. `LiveRenderProjectionJournal.cs:273`
reads `record.CanonicalLandblockId` to pick the owning landblock, so the
symptom would be a child journaled against the wrong landblock.
**Fix direction:** make the skip test the pair
(`child.FullCellId == fullCellId && child.CanonicalLandblockId == canonicalLandblockId`),
or assert the derivation invariant in `RuntimeEntityRecord.SetFullCell`. One
line either way.
### A8 — LOW. Wire-driven recursion depth is unbounded; a deep attachment chain is a `StackOverflowException` (process kill).
**Where:** `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:381-405`
(mutual recursion `PropagateFullCellToChildren``SetFullCell`).
Termination against cycles is correct (verified above), but depth is bounded
only by the length of the committed parent chain, which is server-supplied.
Nothing in `ParentAttachmentState`, `TryApplyParent`, or `TryCommitParent`
caps chain depth; only self-parenting is rejected, and only in the graphical
`ValidateParentProjection` (which D5 deliberately skips).
**Failure scenario:** a buggy or hostile server commits a 10k-long
A→B→C→… chain; the first cell write on the root overflows the 1 MB stack.
`StackOverflowException` is uncatchable in .NET — the process dies, taking all
30 headless sessions with it. Not reachable against a well-behaved ACE.
**Fix direction:** a depth counter with a hard cap (retail's own recursion is
equally unbounded, so a cap is an acdream divergence — it belongs in AP-142's
clause list) or an explicit iterative worklist over a pre-sized scratch buffer,
which also removes the stack cost entirely and stays 0-alloc after warmup.
### A9 — LOW. The C3c active-residence gate is not carried to the new presentation entry point.
**Where:** `src/AcDream.App/World/LiveEntityRuntime.cs:1085-1101` vs the gate at
`:806-834`.
The public `RebucketLiveEntity` refuses to install a bucket while an
initial-create residence is ACTIVE, with a stated reason ("a re-entrant caller
could install a bucket for a suppressed record before its placement ever
committed"). `RebucketEquippedChildPresentation` guards on
`HasCommittedParent` only.
**Failure scenario:** bounded — `CommitAcceptedParentCellless` calls
`ForgetInitialCreateResidence` (`:1502`) before any `AttachedChild` is
installed, so by the time `TickChild` can run, the child's residence is
cancelled. The gap is that the guard's *reason* is now implicit in call
ordering three files away rather than enforced at the entry point.
**Fix direction:** add `&& !HasActiveInitialCreateResidence(record.Canonical)`
to the new method's guard (cheap, and it makes the "can never become a general
bypass" claim in its doc comment actually true), or cite the
`ForgetInitialCreateResidence` ordering in that doc comment.
### A10 — LOW. Duplicate `<summary>` block; one existing test lost its documentation.
**Where:** `tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs:316-330`.
The new D5 test's doc comment was inserted **after** the closing `</summary>`
of the C3c-R1 F6 block that documented
`FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner`. Both summaries
now attach to `DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell`;
the F6 test is undocumented. Mechanical fix: move the new block below the old
one's member, or move the old block down to `:231`.
---
## 2. Contract obligations checked and found MET
Recorded so the next reviewer does not re-derive them.
- **D2 chokepoint completeness (contract open question 1).** `RefreshDerivedState`
has exactly two callers repo-wide (`RuntimeEntityRecord.cs:29` ctor,
`RuntimeEntityDirectory.cs:239`); `RuntimeEntityRecord.SetFullCell` has
exactly two (`RefreshDerivedState`, `RuntimeEntityDirectory.SetFullCell`).
No canonical cell write bypasses the hook.
- **D3 delete ordering (P7).** `WithdrawCommittedChildrenToCellless` runs at
`:2070` before `DeleteGeneration` at `:2073`, and at `:1005` before
`EndGeneration` at `:1009`. In the `EndGeneration` case `RemoveActive` has
already run, but the relation table is untouched by it and children are
looked up independently, so the read is valid. Recursion to grandchildren
rides D2. Pinned by `Delete_ZeroesChildrenBeforeRelationsAreTornDown_P7` and
`EndGeneration_ReplacementParentGeneration_…`. No double-withdraw: the second
pass would find every child already at 0 and skip.
- **No orphaned teardown sites.** `ParentAttachmentState.RemoveObject` /
`RemoveChild` have zero production callers, so `DeleteGeneration` /
`EndGeneration` are the complete set of relation-teardown edges needing a cell
edge, and both got one.
- **D8 never-arm partition.** No `ConstrainTo`, park, service-window, or
`CanAttemptDestination` code was added; `NeverArmPartition_D8_…` pins the
placement-operation count across attach + 5 crossings + pickup + delete.
(Contract §6 test 6 also asked for park counts and
`RemotePlacementDrivePendingCount`; only `ActiveOperationCount` is asserted —
a thinner pin than specified, noted but not a finding on its own.)
- **§3 item 10 tripwire.** The classifier diff is a pure deletion of
`ClassifyLeaveWorld` + its two types + its one test. `ValidCreateAuthority`
survives; no surviving classifier test changed an expectation. 1,156 Runtime
tests green.
- **D9 bookkeeping.** AP-142 and AP-143 both filed; AP-136's writer list
correctly shrinks to the projection materializer alone, and the two mirrored
doc comments (`RuntimeSetPositionState.cs:4542`,
`RuntimeRemotePlacementDriveController.cs:1619`) were updated in the same
diff. Repo grep for stale "TickChild is a canonical writer" claims returns
only the corrected sites.
- **Headless gate is a real regression test.** Both `DirectSink_D5_…` tests seed
parent and child at **distinct** landblocks and assert `NotEqual(0u, …)`
alongside the equality, so they cannot pass by coincidence or at zero. Both
fail without D1/D2/D5.
- **P1.** `P1_SnapshotMutationOfACommittedChild_…` drives an ObjDesc merge and
asserts `child.Snapshot.Position` stays null and the cell still tracks the
parent — a genuine behavioural pin, not a source-text pin.
---
## 3. Remediation required to convert this to PASS
1. **A1** — rewrite the App test so it asserts the spatial bucket / visibility
move, and run the contract's prescribed sabotage (stub
`RebucketEquippedChildPresentation` out; the test must go red).
2. **A2** — consume `RebucketEquippedChildPresentation`'s status in `TickChild`,
and cover the declining branch with a test.
3. **A3** — restate P8 against `IRuntimeEventObserver` as well, naming
`RuntimeTraceRecorder`.
A4A10 are recommended in the same slice (all are one-to-three-line changes or
commit-text corrections) but none of them alone blocks the route.
The **connected two-client gate has not been run** and remains the acceptance
test for the D4 transfer regardless of the above — with the contract's own
rule that a session showing zero `cause=propagate` lines during the
landblock-crossing step is a not-run, not a pass.