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 atcff52c44, +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:
parent
19ebf043e3
commit
cd3129e9d6
24 changed files with 4500 additions and 105 deletions
|
|
@ -403,9 +403,52 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
child.Entity.ParentCellId = parent.ParentCellId;
|
||||
CaptureParentPresentation(child, parent);
|
||||
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
|
||||
// C4 route 7 D4: the canonical cell is Runtime's D1/D2
|
||||
// propagation write, not this render tick's job any more — move
|
||||
// only the graphical draw bucket to match. The disposition is
|
||||
// consumed explicitly (review A2/R3, route 5's A1 class): a
|
||||
// silently-discarded bool must not advance presentation on a
|
||||
// write-nothing outcome.
|
||||
if (TryResolveExactAttachment(child, out parent)
|
||||
&& parent.ParentCellId is { } parentCellId)
|
||||
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
|
||||
{
|
||||
EquippedChildPresentationRebucketDisposition disposition =
|
||||
_liveEntities.RebucketEquippedChildPresentation(
|
||||
child.ChildGuid,
|
||||
parentCellId);
|
||||
if (disposition is
|
||||
EquippedChildPresentationRebucketDisposition.NoProjection)
|
||||
{
|
||||
// Defensive: as this call site is structured today,
|
||||
// this branch cannot actually fire. TryResolveExactAttachment
|
||||
// (just above, and again at the top of this method) requires
|
||||
// _liveEntities.IsCurrentRecord(child.ChildRecord), which is
|
||||
// the exact same _projections.TryGetCurrent(guid) lookup
|
||||
// RebucketEquippedChildPresentation's own NoProjection guard
|
||||
// performs for the same guid one call later — if the
|
||||
// projection were gone, TryResolveExactAttachment would
|
||||
// already have failed and this method would already have
|
||||
// returned false above, never reaching here (round-3 review
|
||||
// N6; verified by sabotage — see
|
||||
// RebucketEquippedChildPresentation_D4_NoLiveProjection_ReturnsNoProjectionDisposition
|
||||
// in EquippedChildProjectionWithdrawalTests.cs, which tests
|
||||
// the disposition value directly rather than through this
|
||||
// unreachable path). Kept as a fail-safe in case a future
|
||||
// refactor reorders or removes the TryResolveExactAttachment
|
||||
// gate above.
|
||||
return false;
|
||||
}
|
||||
// Moved: normal case. NotAttached: the known-benign
|
||||
// in-flight unparent/pending-residence window —
|
||||
// OnChildBecameUnparented (or the residence conductor) owns
|
||||
// the child's fate. Displaced (B6, round-3 review): a newer
|
||||
// operation already superseded this exact rebucket attempt
|
||||
// mid-flight, so the record is current and healthy, just not
|
||||
// via THIS call — not evidence of a problem. Both leave the
|
||||
// bucket wherever the more current operation put it, and
|
||||
// this tick still reports success because the pose itself
|
||||
// composed and published correctly.
|
||||
}
|
||||
ProjectionPoseReady?.Invoke(child.ChildGuid);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,44 @@ internal enum LiveEntityMaterializationResidence
|
|||
AwaitRuntimePlacement,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 7 A2/R3 remediation:
|
||||
/// <see cref="LiveEntityRuntime.RebucketEquippedChildPresentation"/>'s typed
|
||||
/// outcome. <see cref="Moved"/> is the ordinary case. <see cref="NotAttached"/>
|
||||
/// is the KNOWN-benign decline — the child's Runtime relation already unwound
|
||||
/// (pickup/Position-unparent, or an active initial-create residence) while
|
||||
/// <c>EquippedChildRenderController._attachedByChild</c> still holds it, ahead
|
||||
/// of <c>OnChildBecameUnparented</c>'s own teardown — callers must not treat it
|
||||
/// as success.
|
||||
///
|
||||
/// <para>
|
||||
/// Round-3 review (B6): <see cref="NoProjection"/> and <see cref="Displaced"/>
|
||||
/// were originally ONE case and are now split, because they mean different
|
||||
/// things and warrant different caller behavior. <see cref="NoProjection"/> —
|
||||
/// no <c>LiveEntityRecord</c>/<c>WorldEntity</c> exists for this guid AT ALL
|
||||
/// despite <c>_attachedByChild</c> holding the key — is NOT expected and is
|
||||
/// treated as a genuine pose-loss failure. <see cref="Displaced"/> — the
|
||||
/// spatial rebucket STARTED but a re-entrant caller superseded this exact
|
||||
/// projection operation before it finished (<c>IsCurrentProjectionOperation</c>
|
||||
/// went false mid-call, inside <c>RebucketLiveEntityPresentationOnly</c>) —
|
||||
/// means the record was current a moment ago and some OTHER, newer operation
|
||||
/// already committed a different outcome for it. Unlike
|
||||
/// <see cref="NoProjection"/>, this is treated as BENIGN, the same as
|
||||
/// <see cref="NotAttached"/> (see <c>TickChild</c>): a superseded operation
|
||||
/// is not evidence anything is wrong with the entity, only that a fresher
|
||||
/// write already ran — tearing it down as pose loss would act on a stale
|
||||
/// snapshot against a projection a newer, presumably-valid operation just
|
||||
/// updated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal enum EquippedChildPresentationRebucketDisposition
|
||||
{
|
||||
Moved,
|
||||
NotAttached,
|
||||
NoProjection,
|
||||
Displaced,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
|
||||
/// Spatial bucketing is deliberately absent: registering or removing meshes,
|
||||
|
|
@ -978,12 +1016,14 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
|
||||
/// <summary>
|
||||
/// C3c: the graphical-bucket-only projection of a conductor-owned
|
||||
/// initial placement — called ONLY from
|
||||
/// initial placement — called from
|
||||
/// <see cref="TryApplyInitialCreateCompletionPresentation"/> (the
|
||||
/// completion receipt at the initial-create residence boundary), never
|
||||
/// from the public <see cref="RebucketLiveEntity"/> (C3c-R1 review R2:
|
||||
/// post-residence moves take the full legacy branch there).
|
||||
/// Deliberately never calls <c>CommitRebucket</c>,
|
||||
/// completion receipt at the initial-create residence boundary) and,
|
||||
/// since C4 route 7 D4, from <see cref="RebucketEquippedChildPresentation"/>
|
||||
/// (an attached child, whose canonical cell Runtime's D1/D2 propagation
|
||||
/// already owns). Never from the public <see cref="RebucketLiveEntity"/>
|
||||
/// (C3c-R1 review R2: post-residence moves take the full legacy branch
|
||||
/// there). Deliberately never calls <c>CommitRebucket</c>,
|
||||
/// <c>SuspendObjectClock</c>, or <c>ResetObjectClockForEnterWorld</c> —
|
||||
/// Runtime's SetPosition commit already owns all of those for the
|
||||
/// residence-driven placement this receipt projects. May place into a
|
||||
|
|
@ -1064,6 +1104,91 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 7 D4: the equipped-child render-tick presentation-only
|
||||
/// rebucket. Runtime's D1/D2 propagation
|
||||
/// (<see cref="RuntimeEntityDirectory.SetFullCell"/>) is now the
|
||||
/// canonical writer for a committed child's cell — the render tick only
|
||||
/// needs to move the DRAW bucket to match. Deliberately the same C3c
|
||||
/// shape as <see cref="RebucketLiveEntityPresentationOnly"/>: no
|
||||
/// <c>CommitRebucket</c>, no clock edges, because a committed child's
|
||||
/// <c>ObjectClock</c> stays suspended for its whole attached lifetime
|
||||
/// (retail <c>update_object</c>'s <c>parent != 0</c> early-out,
|
||||
/// @0x00515D40) and Runtime already owns the canonical spatial
|
||||
/// authority. Guarded to a record with a currently COMMITTED parent
|
||||
/// relation, AND (review A9) the same active-initial-create-residence
|
||||
/// gate <see cref="RebucketLiveEntity"/> itself honours, so this can
|
||||
/// never become a general bypass of the legacy branch for an ordinary
|
||||
/// root entity. Returns a typed disposition rather than a bare bool
|
||||
/// (review A2/R3) — <see cref="EquippedChildPresentationRebucketDisposition.NotAttached"/>
|
||||
/// is the KNOWN-benign case (the Runtime relation already unwound while
|
||||
/// <c>_attachedByChild</c> still holds the child, ahead of
|
||||
/// <c>OnChildBecameUnparented</c>'s own teardown); the caller must not
|
||||
/// discard the result.
|
||||
///
|
||||
/// <para>
|
||||
/// R11 (retail-conformance review, LOW; DOWNGRADED at the round-3
|
||||
/// review, N7 — an earlier draft of this note overstated "proven"):
|
||||
/// unlike the legacy <see cref="RebucketLiveEntity"/>, this path never
|
||||
/// calls <c>SynchronizePhysicsBodyActiveState</c>. VERY LIKELY inert,
|
||||
/// not fully proven: a committed child's <c>Snapshot.Position</c> is
|
||||
/// always null (contract §0 item 10 / P1), and two of the production
|
||||
/// <c>PhysicsBody</c> constructors reachable from a live projection
|
||||
/// (<c>DatLiveEntityProjectionMaterializer</c>'s static scheduler,
|
||||
/// <c>ProjectileController</c>) both require a non-null
|
||||
/// <c>spawn.Position</c> before calling <c>GetOrCreatePhysicsBody</c>,
|
||||
/// ruling them out for a committed child. A THIRD constructor —
|
||||
/// <c>RuntimeRemoteFirstEntryState.cs:425</c>, the remote first-entry
|
||||
/// conductor's own body construction for an entity's OWN
|
||||
/// initial-create residence — is NOT ruled out with the same rigor: it
|
||||
/// does not gate on <c>spawn.Position</c>, and a committed child CAN
|
||||
/// hold an initial-create residence lease during route 7's dormant-
|
||||
/// residence deferral window (invariant 9). The argument for inertness
|
||||
/// there is that <c>CommitAcceptedParentCellless</c> calls
|
||||
/// <c>ForgetInitialCreateResidence</c> before this method's caller
|
||||
/// (<c>TickChild</c>) can ever run — by the time a child has an
|
||||
/// <c>AttachedChild</c> entry at all, its residence should already be
|
||||
/// cancelled — but this is NOT independently traced end-to-end against
|
||||
/// <see cref="RuntimeRemoteFirstEntryState"/>'s own state machine, and
|
||||
/// <c>SuspendObjectClock</c> does not itself synchronize a body's
|
||||
/// Active transient bit, so IF a body existed in that window the
|
||||
/// dropped call would not be a no-op. Flagged honestly as unverified
|
||||
/// rather than asserted; the correct fix if this is ever found reachable
|
||||
/// is to add the call back or route the synchronization through the
|
||||
/// residence-cancellation edge that already runs first.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal EquippedChildPresentationRebucketDisposition RebucketEquippedChildPresentation(
|
||||
uint serverGuid,
|
||||
uint parentCellId)
|
||||
{
|
||||
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
return EquippedChildPresentationRebucketDisposition.NoProjection;
|
||||
}
|
||||
if (!_directory.ParentAttachments.HasCommittedParent(serverGuid))
|
||||
return EquippedChildPresentationRebucketDisposition.NotAttached;
|
||||
if (record.MaterializationResidence is
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
||||
&& HasActiveInitialCreateResidence(record.Canonical))
|
||||
{
|
||||
return EquippedChildPresentationRebucketDisposition.NotAttached;
|
||||
}
|
||||
// B6 (round-3 review): a `false` here ALWAYS means the projection
|
||||
// operation was superseded mid-flight (RebucketLiveEntityPresentationOnly's
|
||||
// only `return false` sites are both guarded by
|
||||
// `!IsCurrentProjectionOperation`) — the record was current when
|
||||
// this method started, so this is Displaced, never NoProjection.
|
||||
return RebucketLiveEntityPresentationOnly(
|
||||
serverGuid,
|
||||
record,
|
||||
entity,
|
||||
parentCellId)
|
||||
? EquippedChildPresentationRebucketDisposition.Moved
|
||||
: EquippedChildPresentationRebucketDisposition.Displaced;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c: applies one initial-Create ExecutorCompleted receipt's
|
||||
/// presentation — the graphical binding point for a residence-driven
|
||||
|
|
@ -2340,6 +2465,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
/// after the parent accepted the child (PartArray + holding-location
|
||||
/// validation). The POSITION_TS is consumed before this step, matching
|
||||
/// retail; invalid parent relationships retain the old world projection.
|
||||
/// Since C4 route 7 D1, this also completes retail's second half — if
|
||||
/// the committed parent is celled, the child is re-celled to the
|
||||
/// parent's exact values in the SAME transaction (see
|
||||
/// <see cref="RuntimeEntityObjectLifetime.CommitAcceptedParentCellless"/>).
|
||||
/// A cell-less parent leaves the child cell-less, exactly as before,
|
||||
/// now by the retail-cited gate instead of by omission; a later parent
|
||||
/// cell commit re-cells the child through D2's propagation.
|
||||
/// </summary>
|
||||
internal bool CommitAcceptedParentCellless(
|
||||
LiveEntityRecord record,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue