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>
This commit is contained in:
Erik 2026-08-04 23:53:05 +02:00
parent 19ebf043e3
commit cd3129e9d6
24 changed files with 4500 additions and 105 deletions

View file

@ -65,6 +65,13 @@ public sealed class RuntimeLiveEntitySessionController
/// </summary>
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
private bool _initialLoginCompleteSent;
// A6 (architecture review): D5's ResolveAndCommitChildAttachment ran on
// every accepted spawn and every ParentEvent, allocating three
// this-capturing closures per call. These capture nothing per-call
// (only `this`), so cache them once instead of per invocation.
private readonly Func<uint, bool> _isChildGuidKnown;
private readonly Func<uint, ushort?> _resolveParentInstance;
private readonly Func<ParentEvent.Parsed, bool> _acceptParentEvent;
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
@ -78,6 +85,15 @@ public sealed class RuntimeLiveEntitySessionController
_log = log ?? (_ => { });
_worldProjection = worldProjection;
_acceptedPositionDrive = acceptedPositionDrive;
_isChildGuidKnown = guid => Entities.Entities.TryGetSnapshot(guid, out _);
_resolveParentInstance = guid =>
Entities.Entities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null;
_acceptParentEvent = candidate => Entities.TryApplyParent(
candidate,
acknowledgeProjection: null,
out _);
}
public LiveEntitySessionSink CreateSink() => new(
@ -138,6 +154,9 @@ public sealed class RuntimeLiveEntitySessionController
canonical,
canonical.ServerGuid
== _runtime.PlayerIdentity.ServerGuid);
// D5: this spawn may be the parent a standalone ParentEvent
// already named before its own CreateObject arrived.
RetryChildrenWaitingForParent(canonical.ServerGuid);
if (_worldProjection is null
&& canonical.ServerGuid
== _runtime.PlayerIdentity.ServerGuid
@ -309,11 +328,146 @@ public sealed class RuntimeLiveEntitySessionController
out _,
out _);
private void OnParentUpdated(ParentEvent.Parsed update) =>
_ = Entities.TryApplyParent(
update,
acknowledgeProjection: null,
out _);
private void OnParentUpdated(ParentEvent.Parsed update)
{
Entities.Entities.ParentAttachments.Enqueue(update);
ResolveAndCommitChildAttachment(update.ChildGuid);
}
/// <summary>
/// C4 route 7 D5: the headless parent-realize drive. Resolves a queued
/// standalone <see cref="ParentEvent.Parsed"/> through the SAME staged
/// -&gt; committed protocol the graphical
/// <c>EquippedChildRenderController.ResolveAndTryRealize</c> /
/// <c>PrepareAndTryRealize</c> pair runs —
/// <see cref="ParentAttachmentState.Resolve"/>, then
/// <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/> -&gt;
/// <see cref="ParentAttachmentState.CommitProjection"/> -&gt;
/// <see cref="RuntimeEntityObjectLifetime.CommitAcceptedParentCellless"/>
/// (which carries D1's attach re-cell) — so a direct/no-window host
/// gets the same canonical child-cell commit the graphical host has
/// always had. Deliberately does NOT drive pose composition, the
/// render bucket, or <c>ValidateParentProjection</c>'s self-parenting
/// / part-array / <c>Setup.HoldingLocations</c> checks — see AP-143.
///
/// <para>
/// KNOWN GAP, stated rather than silently left implicit (retail-
/// conformance review R6): if <paramref name="childGuid"/> has a
/// PENDING initial-create residence when the relation resolves to
/// staged, <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/>'s
/// gate (<c>InboundPhysicsStateController.TryCommitParent</c>'s
/// <c>gate.PositionTimestamp == positionSequence</c> check) is not yet
/// satisfied and this method returns <see langword="false"/>. Nothing
/// re-drives it: the residence executor's own parent-attach tail
/// (<c>RuntimeInitialCreateContinuationExecutor.CommitParentAttachment</c>)
/// deliberately does not commit the relation either — that has always
/// been the graphical host's job — and headless has no
/// <c>EquippedChildRenderController</c>-equivalent post-drain retry.
/// The relation stays staged and the child stays cell-less until SOME
/// other event re-invokes <see cref="ResolveAndCommitChildAttachment"/>
/// for the same child (a later ParentEvent, or a spawn naming the same
/// parent guid via <see cref="RetryChildrenWaitingForParent"/> — which
/// does not cover this case either, since the relation is already
/// staged, not unresolved). Not a regression (headless committed
/// nothing on this path before D5 existed), and the invariant-9
/// dormant-residence deferrals themselves are untouched — but a
/// headless ParentEvent arriving during a child's own pending initial
/// residence is NOT closed by this slice.
/// </para>
/// </summary>
private bool ResolveAndCommitChildAttachment(uint childGuid)
{
ParentAttachmentState relations = Entities.Entities.ParentAttachments;
relations.Resolve(
childGuid,
_isChildGuidKnown,
_resolveParentInstance,
_acceptParentEvent);
if (!relations.TryGetStagedProjection(
childGuid,
out ParentAttachmentRelation staged))
{
return false;
}
if (!Entities.Entities.TryGetActive(
childGuid,
out RuntimeEntityRecord canonical))
{
return false;
}
ulong positionAuthorityVersion = canonical.PositionAuthorityVersion;
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|| !relations.CommitProjection(staged))
{
return false;
}
bool committed = Entities.CommitAcceptedParentCellless(
canonical,
positionAuthorityVersion,
acknowledgeProjection: null);
if (committed && PhysicsDiagnostics.ProbeChildCellEnabled)
{
Console.WriteLine(FormattableString.Invariant(
$"[child-cell] parent=0x{staged.ParentGuid:X8} child=0x{canonical.ServerGuid:X8} new=0x{canonical.FullCellId:X8} cause=headless-attach"));
}
return committed;
}
/// <summary>
/// D5 companion: a ParentEvent can precede the parent's own CreateObject
/// (retail: the standalone parent handler queues by parent guid). Retry
/// every child waiting on the guid that just became addressable.
///
/// <para>
/// A6 (architecture review): unlike the graphical
/// <c>EquippedChildRenderController.RetryWaitingDescendants</c> →
/// <c>ParentAttachmentState.ChildrenWaitingForParent</c>, this drive's
/// OWN <c>Resolve</c>/<c>TryGetStagedProjection</c>/<c>CommitProjection</c>
/// sequence in <see cref="ResolveAndCommitChildAttachment"/> consumes a
/// relation out of <c>_stagedByChild</c> within the SAME synchronous
/// call it was staged in, for the ordinary case. **Correction (B1/B2,
/// round-3 review): this is NOT an absolute "never populates
/// _stagedByChild" claim** — the R6 gap documented on
/// <see cref="ResolveAndCommitChildAttachment"/> is exactly the
/// counter-example: a relation CAN be left sitting in
/// <c>_stagedByChild</c> across calls when the child has a pending
/// initial-create residence, because <c>TryCommitParent</c>'s gate
/// isn't satisfied yet. What is true is narrower: THIS retry method
/// never needs <c>ChildrenWaitingForParent</c>'s STAGED/RECOVERY sweeps
/// to find that dangling relation, because it re-resolves through
/// <c>childGuid</c> directly via <c>ResolveAndCommitChildAttachment</c>
/// on every retry rather than needing a separate discovery query for
/// already-staged children — only the UNRESOLVED sweep matters for
/// discovering a NEW parent guid becoming addressable. Scanning the
/// shared (heavier) <c>ChildrenWaitingForParent</c> on every accepted
/// headless spawn would pay for two sweeps and a <c>HashSet</c>
/// allocation this discovery step never needs;
/// <c>ChildrenUnresolvedForParent</c> scans only
/// <c>_unresolvedByChild</c>. **Correction (B4, round-3 review): the
/// first round shared a reused scratch buffer across calls for this
/// query, which broke reentrancy safety a fresh-array return had —
/// a reentrant call into this method (or into
/// <see cref="ResolveAndCommitChildAttachment"/>'s loop below) could
/// clear/refill the SAME shared list the outer call was still
/// iterating. Reverted to a fresh return per call, matching
/// <c>ChildrenWaitingForParent</c>'s own allocation shape**, since this
/// query already only allocates when it has something to return (most
/// parent guids have no unresolved children waiting on them). This is
/// a narrower claim than "0 B" either way — the per-child
/// <c>queue.Any(lambda)</c> predicate check still allocates a closure
/// per call, same as the pre-existing graphical sweep; a full
/// incremental parent-guid-&gt;children index would remove both
/// allocations and is not done in this slice.
/// </para>
/// </summary>
private void RetryChildrenWaitingForParent(uint parentGuid)
{
IReadOnlyList<uint> waiting = Entities.Entities.ParentAttachments
.ChildrenUnresolvedForParent(parentGuid);
for (int i = 0; i < waiting.Count; i++)
ResolveAndCommitChildAttachment(waiting[i]);
}
private void OnTeleportStarted(uint rawSequence)
{