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

@ -318,6 +318,241 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits);
}
/// <summary>
/// C4 route 7 D4 (review round 2, A1/R2 remediation): TickChild's
/// rebucket is now presentation-only — Runtime's D1/D2 propagation is
/// the canonical writer. Drives the parent's canonical cell change
/// through a Runtime producer (<c>CommitRebucket</c>, standing in for
/// any of D2's four writer families) BEFORE any render tick runs,
/// proving the canonical half needs no tick at all; then ticks and
/// proves the DRAW BUCKET itself moved — the actual
/// <c>GpuWorldState</c> spatial index membership, not the
/// <c>ParentCellId</c> mirror field (which <c>TickChild</c> writes
/// unconditionally before the rebucket call and therefore proves
/// nothing about it — the original review's A1 finding). Both
/// landblocks are registered so membership is checked by an actual
/// per-landblock query (<c>CopyLiveEntitiesNearLandblock</c>), not by a
/// field read.
/// </summary>
[Fact]
public void TickChild_D4_PresentationBucketMovesToTheDestinationLandblock()
{
using var fixture = new ControllerFixture((_, _, _) =>
new(ExactProjectionWithdrawalDisposition.Completed, Failure: null));
const uint oldLandblock = 0x0101FFFFu;
const uint newCell = 0x01020001u;
const uint newLandblock = 0x0102FFFFu;
fixture.Spatial.AddLandblock(new LoadedLandblock(
newLandblock, new LandBlock(), Array.Empty<WorldEntity>()));
LiveEntityRecord parent = fixture.Spawn(0x70000260u, generation: 1);
LiveEntityRecord child = fixture.Spawn(
0x70000261u,
generation: 1,
LiveEntityProjectionKind.Attached);
fixture.InstallAttached(parent, child);
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
var relation = new ParentAttachmentRelation(
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1);
fixture.CommitRenderedRelation(relation);
// Sanity: the child's draw bucket starts in the OLD landblock (set
// by materialization), not the new one — equality after the tick
// cannot be coincidental.
var buffer = new List<KeyValuePair<uint, WorldEntity>>();
fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer);
Assert.Contains(buffer, kv => kv.Value == child.WorldEntity);
fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer);
Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity);
// D2: the parent's canonical cell write propagates to the
// committed child's canonical cell with NO render tick involved.
Assert.True(fixture.EntityObjects.CommitRebucket(
parent.Canonical, newCell, newLandblock));
Assert.Equal(newCell, child.Canonical.FullCellId);
ulong childSpatialVersionBeforeTick =
child.Canonical.SpatialAuthorityVersion;
// The parent's OWN presentation already followed its canonical
// cell (a route-7-unrelated concern) so TickChild's pose loop reads
// the same destination; give the child a stale draw bucket first.
parent.WorldEntity!.ParentCellId = newCell;
child.WorldEntity!.ParentCellId = 0x01010001u;
fixture.Controller.Tick();
// D4: the render tick moved the actual spatial bucket — the child
// left the OLD landblock's live-entity set and joined the NEW one.
fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer);
Assert.Contains(buffer, kv => kv.Value == child.WorldEntity);
fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer);
Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity);
// ...and did not re-write the canonical cell.
Assert.Equal(
childSpatialVersionBeforeTick,
child.Canonical.SpatialAuthorityVersion);
}
/// <summary>
/// C4 route 7 D4 (review round 2, A2/R3 remediation; round-3 review
/// B1/B2/N5 fix): the KNOWN-benign decline. The committed relation can
/// unwind (pickup / Position-unparent) while <c>_attachedByChild</c>
/// still holds the child, ahead of <c>OnChildBecameUnparented</c>'s own
/// teardown. TickChild must not silently discard that disposition and
/// must not move the draw bucket on a write-nothing outcome, but the
/// tick itself still "succeeds" (the pose composed and published)
/// rather than being torn down as a pose-loss failure.
///
/// <para>
/// CORRECTED (B1/B2/N5, round-3 review): the first round's "bucket
/// didn't move" assertion was non-discriminating — the parent's cell
/// never changed in that version, so the child's bucket would have
/// stayed in the same landblock whether or not the NotAttached guard
/// fired at all. This version moves the PARENT's canonical cell to a
/// real second landblock BEFORE removing the relation, so if the guard
/// were bypassed the child WOULD move there — the assertion can now
/// actually fail.
/// </para>
/// </summary>
[Fact]
public void TickChild_D4_NotAttachedDisposition_SkipsTheBucketMoveWithoutFailingTheTick()
{
using var fixture = new ControllerFixture((_, _, _) =>
new(ExactProjectionWithdrawalDisposition.Completed, Failure: null));
const uint oldLandblock = 0x0101FFFFu;
const uint newCell = 0x01020001u;
const uint newLandblock = 0x0102FFFFu;
fixture.Spatial.AddLandblock(new LoadedLandblock(
newLandblock, new LandBlock(), Array.Empty<WorldEntity>()));
LiveEntityRecord parent = fixture.Spawn(0x70000262u, generation: 1);
LiveEntityRecord child = fixture.Spawn(
0x70000263u,
generation: 1,
LiveEntityProjectionKind.Attached);
fixture.InstallAttached(parent, child);
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
var relation = new ParentAttachmentRelation(
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1);
fixture.CommitRenderedRelation(relation);
// A real destination now exists: move the parent's canonical cell
// (D2 propagates it to the still-committed child too) and its
// presentation field, so TickChild's pose loop WOULD send the
// child to newLandblock if the NotAttached guard did not fire.
Assert.True(fixture.EntityObjects.CommitRebucket(
parent.Canonical, newCell, newLandblock));
parent.WorldEntity!.ParentCellId = newCell;
// Runtime already unwound the relation (pickup/Position-unparent),
// but the App's _attachedByChild still holds the child (the
// pre-OnChildBecameUnparented window A2 names).
Assert.True(fixture.Live.ParentAttachments.HasCommittedParent(
child.ServerGuid));
fixture.Live.ParentAttachments.EndChildProjection(child.ServerGuid);
Assert.False(fixture.Live.ParentAttachments.HasCommittedParent(
child.ServerGuid));
int visitsBefore = fixture.Controller.LastFullPoseCompositionVisits;
fixture.Controller.Tick();
// The tick still ran and composed the pose (not treated as pose
// loss)...
Assert.Equal(visitsBefore + 1, fixture.Controller.LastFullPoseCompositionVisits);
Assert.Contains(child.WorldEntity!.Id, fixture.Controller.AttachedEntityIds);
// ...but the draw bucket did NOT move to the new landblock — a real
// destination existed and the guard correctly declined
// (NotAttached), so RebucketLiveEntityPresentationOnly never ran.
var buffer = new List<KeyValuePair<uint, WorldEntity>>();
fixture.Spatial.CopyLiveEntitiesNearLandblock(oldLandblock, 0, buffer);
Assert.Contains(buffer, kv => kv.Value == child.WorldEntity);
fixture.Spatial.CopyLiveEntitiesNearLandblock(newLandblock, 0, buffer);
Assert.DoesNotContain(buffer, kv => kv.Value == child.WorldEntity);
}
/// <summary>
/// C4 route 7 D4 (round-3 review, N6): the OTHER new disposition value
/// — <see cref="EquippedChildPresentationRebucketDisposition.NoProjection"/>.
///
/// <para>
/// CORRECTED (round-3 sabotage pass): the first version of this test
/// called <c>Controller.Tick()</c> and asserted <c>ProjectionPoseReady</c>
/// did not fire, expecting to exercise TickChild's
/// <c>if (disposition is NoProjection) return false;</c> guard at
/// <c>EquippedChildRenderController.cs:419-427</c>. Sabotaging that exact
/// guard (forcing the branch to be skipped) did NOT break the test —
/// investigation showed why: <c>TickChild</c>'s own top-of-method gate,
/// <c>TryResolveExactAttachment</c> (called at both
/// <c>EquippedChildRenderController.cs:379</c> and again at <c>:412</c>
/// immediately before the rebucket call), requires
/// <c>_liveEntities.IsCurrentRecord(child.ChildRecord)</c> to hold — and
/// <c>IsCurrentRecord</c> (<c>LiveEntityRuntime.cs:3336-3338</c>) is
/// itself defined as <c>_projections.TryGetCurrent(guid, out current) &amp;&amp;
/// ReferenceEquals(current, record)</c> — the EXACT SAME store lookup
/// <c>RebucketEquippedChildPresentation</c>'s own <c>NoProjection</c>
/// guard (<c>LiveEntityRuntime.cs:1165</c>) performs for the same guid,
/// one call later with nothing in between that could invalidate it.
/// Whenever <c>RebucketEquippedChildPresentation</c> would see no current
/// projection, <c>TryResolveExactAttachment</c> already failed first and
/// <c>TickChild</c> already returned <see langword="false"/> at
/// <c>:442</c>, never reaching the rebucket call at all. The
/// <c>NoProjection</c> check inside <c>RebucketEquippedChildPresentation</c>
/// is therefore defensive/unreachable from this call site as currently
/// structured, not a live withdrawal trigger — left in place because it
/// is the correct contract for the method taken on its own (any other
/// caller, or a future refactor that removes/reorders the
/// <c>TryResolveExactAttachment</c> gate, could reach it), but it is not
/// exercisable end-to-end through <c>TickChild</c> today.
/// </para>
///
/// <para>
/// This test therefore calls <c>RebucketEquippedChildPresentation</c>
/// directly — the same technique already used by
/// <c>ControllerFixture.RemoveProjectionOnly</c> to reach the abnormal
/// state — proving the disposition VALUE is correct in isolation, rather
/// than asserting a TickChild-level trigger that does not exist. The
/// earlier "pose-loss recovery also no-ops here" finding (reported, not
/// fixed, in the prior revision of this comment) is superseded by this
/// finding: since TickChild never reaches <c>NoProjection</c> from this
/// state, <c>WithdrawForPoseLoss</c>'s interaction with it is moot.
/// </para>
/// </summary>
[Fact]
public void RebucketEquippedChildPresentation_D4_NoLiveProjection_ReturnsNoProjectionDisposition()
{
using var fixture = new ControllerFixture((_, _, _) =>
new(ExactProjectionWithdrawalDisposition.Completed, Failure: null));
LiveEntityRecord parent = fixture.Spawn(0x70000264u, generation: 1);
LiveEntityRecord child = fixture.Spawn(
0x70000265u,
generation: 1,
LiveEntityProjectionKind.Attached);
fixture.InstallAttached(parent, child);
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
var relation = new ParentAttachmentRelation(
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1);
fixture.CommitRenderedRelation(relation);
// The relation stays committed (unlike the NotAttached test) — only
// the projection itself vanishes, which is the ABNORMAL case
// NoProjection exists to catch.
Assert.True(fixture.Live.ParentAttachments.HasCommittedParent(
child.ServerGuid));
fixture.RemoveProjectionOnly(child);
EquippedChildPresentationRebucketDisposition disposition =
fixture.Live.RebucketEquippedChildPresentation(
child.ServerGuid,
0x01010001u);
Assert.Equal(
EquippedChildPresentationRebucketDisposition.NoProjection,
disposition);
}
[Fact]
public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist()
{
@ -1481,6 +1716,31 @@ public sealed class EquippedChildProjectionWithdrawalTests
map.Add(child.ProjectionKey!.Value, attached);
}
/// <summary>
/// C4 route 7 D4 test support (N6, round-3 review): removes ONLY
/// <paramref name="record"/>'s <c>LiveEntityRuntime</c> projection
/// entry (via the same private <c>_projections</c> store
/// <c>MaterializeLiveEntity</c>'s own rollback paths use), leaving
/// any separately-installed <c>_attachedByChild</c> entry (from
/// <see cref="InstallAttached"/>) untouched. Constructs the
/// deliberately abnormal "stale AttachedChild, no live projection"
/// state <see cref="EquippedChildPresentationRebucketDisposition.NoProjection"/>
/// exists to catch — no production path is expected to reach it, so
/// this reflection is the test's own construction, not a stand-in
/// for a real caller.
/// </summary>
internal void RemoveProjectionOnly(LiveEntityRecord record)
{
FieldInfo projectionsField = typeof(LiveEntityRuntime).GetField(
"_projections",
BindingFlags.Instance | BindingFlags.NonPublic)!;
object projections = projectionsField.GetValue(Live)!;
MethodInfo removeActive = projections.GetType().GetMethod(
"RemoveActive",
BindingFlags.Instance | BindingFlags.Public)!;
Assert.True((bool)removeActive.Invoke(projections, [record])!);
}
internal void CommitRenderedRelation(ParentAttachmentRelation relation)
{
Live.ParentAttachments.AcceptCreateObjectRelation(relation);

View file

@ -0,0 +1,616 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Tests.Entities;
/// <summary>
/// C4 route 7 (pickup / parent / delete). Focused Runtime tests for D1
/// (attach re-cell), D2 (crossing propagation at the directory funnel), D3
/// (withdrawal/delete/EndGeneration edges), and D7 (pickup ordering). See
/// docs/research/2026-08-04-c4-route-7-contract.md.
/// </summary>
public sealed class RuntimeEntityChildCellPropagationTests
{
private const uint Landblock = 0xA9C60000u;
private const uint Cell = Landblock | 0x0001u;
[Fact]
public void Attach_ParentCelled_ChildEndsAtParentCellAndStaysSuspended()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040001u;
const uint childGuid = 0x70040002u;
RuntimeEntityRecord parent =
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
// A4 (architecture review): D1's re-cell runs BEFORE this commit's
// publish, so the Withdrawn delta it emits carries the child's
// ACTUAL resulting (non-zero, parent) cell rather than the stale
// zero every Withdrawn from this method carried before D1 existed.
var deltas = new List<RuntimeEntityDelta>();
using IDisposable subscription = lifetime.Events.Subscribe(
new RecordingEntityObserver(deltas));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
// D1: the child ends at the parent's exact cell in the SAME
// synchronous transaction — never observably cell-less afterward.
Assert.Equal(parent.FullCellId, child.FullCellId);
Assert.Equal(parent.CanonicalLandblockId, child.CanonicalLandblockId);
Assert.NotEqual(0u, child.FullCellId);
// Invariant 2 / P2: the child is parent-suspended, never a
// self-simulating object (retail update_object @0x00515D40).
Assert.False(child.ObjectClock.IsActive);
Assert.Null(child.Snapshot.Position);
RuntimeEntityDelta withdrawn = Assert.Single(
deltas,
d => d.Change is RuntimeEntityChange.Withdrawn
&& d.Entity.Identity.ServerGuid == childGuid);
Assert.Equal(parent.FullCellId, withdrawn.Entity.CellId);
Assert.NotEqual(0u, withdrawn.Entity.CellId);
}
private sealed class RecordingEntityObserver(List<RuntimeEntityDelta> destination)
: IRuntimeEntityObjectObserver
{
public void OnEntity(in RuntimeEntityDelta delta) => destination.Add(delta);
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
}
[Fact]
public void Attach_ParentCellless_ChildStaysCellless_ThenLaterParentCellCommitRecellsViaPropagation()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040101u;
const uint childGuid = 0x70040102u;
lifetime.RegisterEntity(Spawn(parentGuid, 1, includePosition: false));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
parentGuid, out RuntimeEntityRecord parent));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.Equal(0u, parent.FullCellId);
// @0x00515AD1: parent->cell == 0 -> the child stays cell-less too.
Assert.Equal(0u, child.FullCellId);
// The deferred-attach catch-up retail gets for free from
// propagation: a LATER parent cell commit re-cells the child
// through D2, with no separate mechanism.
Assert.True(lifetime.CommitRebucket(parent, Cell, Landblock | 0xFFFFu));
Assert.Equal(Cell, child.FullCellId);
}
[Fact]
public void PropagationChokepoint_RecursesThroughGrandchildAndIsIdempotentOnASameCellCommit()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040201u;
const uint childGuid = 0x70040202u;
const uint grandchildGuid = 0x70040203u;
RuntimeEntityRecord parent =
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.True(lifetime.Entities.TryGetActive(
grandchildGuid, out RuntimeEntityRecord grandchild));
Assert.Equal(parent.FullCellId, child.FullCellId);
Assert.Equal(parent.FullCellId, grandchild.FullCellId);
// The production writer (CommitRebucket) crosses the parent's cell.
uint newCell = parent.FullCellId + 0x0002u;
uint newLandblock = (newCell & 0xFFFF0000u) | 0xFFFFu;
Assert.True(lifetime.CommitRebucket(parent, newCell, newLandblock));
Assert.Equal(newCell, child.FullCellId);
// Unbounded-depth recursion: the grandchild follows too.
Assert.Equal(newCell, grandchild.FullCellId);
// Idempotence: a same-cell re-commit does not churn the child's
// SpatialAuthorityVersion (D2's explicit short-circuit).
ulong childVersionBefore = child.SpatialAuthorityVersion;
ulong grandchildVersionBefore = grandchild.SpatialAuthorityVersion;
Assert.True(lifetime.CommitRebucket(parent, newCell, newLandblock));
Assert.Equal(childVersionBefore, child.SpatialAuthorityVersion);
Assert.Equal(grandchildVersionBefore, grandchild.SpatialAuthorityVersion);
}
[Fact]
public void PropagationChokepoint_TerminatesAHostileTwoCycleInsteadOfLoopingForever()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint aGuid = 0x70040301u;
const uint bGuid = 0x70040302u;
RuntimeEntityRecord a =
lifetime.RegisterEntity(Spawn(aGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(bGuid, 1, includePosition: false));
// A hostile wire commits BOTH directions: B is A's child, and A is
// ALSO B's child (a relation cycle no single-relation check like
// self-parenting rejection catches).
Assert.True(CommitAttachment(lifetime, aGuid, 1, bGuid, 2));
Assert.True(CommitAttachment(lifetime, bGuid, 1, aGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(bGuid, out RuntimeEntityRecord b));
uint newCell = a.FullCellId + 0x0002u;
// Must terminate (not stack-overflow / infinite-loop) and still
// propagate once around the cycle.
Assert.True(lifetime.CommitRebucket(a, newCell, (newCell & 0xFFFF0000u) | 0xFFFFu));
Assert.Equal(newCell, a.FullCellId);
Assert.Equal(newCell, b.FullCellId);
}
[Fact]
public void RefreshSnapshot_WireMergeCellChange_PropagatesToCommittedChild()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040401u;
const uint childGuid = 0x70040402u;
RuntimeEntityRecord parent =
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.Equal(parent.FullCellId, child.FullCellId);
WorldSession.EntitySpawn moved = parent.Snapshot with
{
Position = parent.Snapshot.Position!.Value with
{
LandblockId = parent.Snapshot.Position!.Value.LandblockId + 0x0002u,
},
};
lifetime.Entities.RefreshSnapshot(parent, moved, refreshPosition: true);
Assert.NotEqual(0u, parent.FullCellId);
Assert.Equal(parent.FullCellId, child.FullCellId);
}
[Fact]
public void P1_SnapshotMutationOfACommittedChild_LeavesItsCanonicalCellTrackingTheParent()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040501u;
const uint childGuid = 0x70040502u;
RuntimeEntityRecord parent =
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
uint expectedCell = parent.FullCellId;
// A representative snapshot-mutation family: ObjDesc. The child's
// Snapshot.Position stays null (contract §0 item 10), so
// RefreshDerivedState's cell stamp can never fire from the child's
// own merge and cannot fight the propagation.
var objDesc = new ObjDescEvent.Parsed(
childGuid,
new CreateObject.ModelData(
BasePaletteId: null,
SubPalettes: [],
TextureChanges: [],
AnimPartChanges: []),
InstanceSequence: 1,
ObjDescSequence: 2);
Assert.True(lifetime.TryApplyObjDesc(
objDesc, acknowledgeProjection: null, out _));
Assert.Null(child.Snapshot.Position);
Assert.Equal(expectedCell, child.FullCellId);
}
[Fact]
public void Withdrawal_PickupOfParent_ZeroesCommittedChildrenRecursively()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040601u;
const uint childGuid = 0x70040602u;
const uint grandchildGuid = 0x70040603u;
lifetime.RegisterEntity(Spawn(parentGuid, 1));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.True(lifetime.Entities.TryGetActive(
grandchildGuid, out RuntimeEntityRecord grandchild));
Assert.NotEqual(0u, child.FullCellId);
Assert.NotEqual(0u, grandchild.FullCellId);
Assert.True(lifetime.TryApplyPickup(
new PickupEvent.Parsed(parentGuid, InstanceSequence: 1, PositionSequence: 2),
acknowledgeProjection: null,
out _));
Assert.Equal(0u, child.FullCellId);
Assert.Equal(0u, grandchild.FullCellId);
}
[Fact]
public void Withdrawal_CommitWithdrawalOfParent_ZeroesCommittedChildren()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040701u;
const uint childGuid = 0x70040702u;
RuntimeEntityRecord parent =
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.NotEqual(0u, child.FullCellId);
Assert.True(lifetime.CommitWithdrawal(parent));
Assert.Equal(0u, parent.FullCellId);
Assert.Equal(0u, child.FullCellId);
}
[Fact]
public void Delete_ZeroesChildrenBeforeRelationsAreTornDown_P7()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040801u;
const uint childGuid = 0x70040802u;
const uint grandchildGuid = 0x70040803u;
lifetime.RegisterEntity(Spawn(parentGuid, 1));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.True(lifetime.Entities.TryGetActive(
grandchildGuid, out RuntimeEntityRecord grandchild));
Assert.NotEqual(0u, child.FullCellId);
Assert.NotEqual(0u, grandchild.FullCellId);
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(parentGuid, 1),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance));
lifetime.CompleteAcceptedDelete(acceptance);
// The children's own leave-world edge ran before DeleteGeneration
// removed the relation ledger (P7) — both the direct and the
// grand-attached child went cell-less. DeleteObject unparents only
// the deleted object's DIRECT children (retail unparent_children):
// the child's own relation to the deleted parent is gone, but the
// grandchild's relation to the (still-alive, merely cell-less)
// child is untouched — exactly retail's shape.
Assert.Equal(0u, child.FullCellId);
Assert.Equal(0u, grandchild.FullCellId);
Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid));
Assert.True(lifetime.Entities.ParentAttachments.HasCommittedParent(grandchildGuid));
}
[Fact]
public void EndGeneration_ReplacementParentGeneration_ZeroesFormerlyCommittedChildren()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040901u;
const uint childGuid = 0x70040902u;
lifetime.RegisterEntity(Spawn(parentGuid, 1));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.NotEqual(0u, child.FullCellId);
// A new CreateObject generation for the SAME parent guid — the
// replacement-generation path has the same stranding shape as
// delete (D3).
lifetime.RegisterEntity(Spawn(parentGuid, 2));
Assert.Equal(0u, child.FullCellId);
Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid));
}
[Fact]
public void PickupOfTheChildItself_D7_RelationGoneCellZeroClockSuspendedAndOwnChildrenAlsoCellless()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040A01u;
const uint childGuid = 0x70040A02u;
const uint grandchildGuid = 0x70040A03u;
lifetime.RegisterEntity(Spawn(parentGuid, 1));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
lifetime.RegisterEntity(Spawn(grandchildGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(CommitAttachment(lifetime, childGuid, 1, grandchildGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.True(lifetime.Entities.TryGetActive(
grandchildGuid, out RuntimeEntityRecord grandchild));
// Pick up the CHILD itself (not the parent) — retail's
// unset_parent-then-leave_world order (D7). PositionSequence 3:
// the attach commit already consumed 2.
Assert.True(lifetime.TryApplyPickup(
new PickupEvent.Parsed(childGuid, InstanceSequence: 1, PositionSequence: 3),
acknowledgeProjection: null,
out _));
Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(childGuid));
Assert.Equal(0u, child.FullCellId);
Assert.False(child.ObjectClock.IsActive);
// The picked-up child's OWN children went cell-less too — the
// pickup's SetFullCell(0,0) is a value like any other at D2's
// chokepoint.
Assert.Equal(0u, grandchild.FullCellId);
}
[Fact]
public void NeverArmPartition_D8_RouteSevenEventsNeverEngagePlacementOrParkMachinery()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint parentGuid = 0x70040B01u;
const uint childGuid = 0x70040B02u;
lifetime.RegisterEntity(Spawn(parentGuid, 1));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
int placementOpsBefore =
lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount;
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
parentGuid, out RuntimeEntityRecord parent));
for (int i = 0; i < 5; i++)
{
uint nextCell = parent.FullCellId + 0x0002u;
Assert.True(lifetime.CommitRebucket(
parent, nextCell, (nextCell & 0xFFFF0000u) | 0xFFFFu));
}
Assert.True(lifetime.TryApplyPickup(
new PickupEvent.Parsed(childGuid, InstanceSequence: 1, PositionSequence: 3),
acknowledgeProjection: null,
out _));
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(parentGuid, 1),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance));
lifetime.CompleteAcceptedDelete(acceptance);
// No placement, park, or ConstrainTo machinery engaged at any point
// (P3 / D8): attach, five crossings, pickup, and delete leave the
// placement operation ledger exactly where it started.
Assert.Equal(
placementOpsBefore,
lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount);
// Ledger convergence (invariant 13): both relations are gone.
Assert.Equal(
0,
lifetime.CaptureOwnership().CommittedParentRelationCount);
}
/// <summary>
/// Round-3 remediation: the recursion + depth cap were replaced outright
/// by an iterative worklist (both the retail and architecture reviews
/// independently found the same defect — a capped recursive version
/// left a truncated tail at a STALE NONZERO cell forever, the #184
/// shape verbatim, on the withdraw path). Builds a chain well beyond
/// the OLD 64-level cap (200 nodes) and proves the ENTIRE chain — not
/// just a prefix — follows the parent on BOTH a write (crossing) and a
/// withdraw (zero) with no truncation and no
/// <see cref="StackOverflowException"/>.
/// </summary>
[Fact]
public void PropagationChokepoint_DeepChain_FullyPropagatesOnBothWriteAndWithdraw()
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const int chainLength = 200; // well beyond the retired 64-level cap
var guids = new uint[chainLength + 1];
for (int i = 0; i <= chainLength; i++)
guids[i] = 0x70050000u + (uint)i;
lifetime.RegisterEntity(Spawn(guids[0], 1));
for (int i = 1; i <= chainLength; i++)
lifetime.RegisterEntity(Spawn(guids[i], 1, includePosition: false));
for (int i = 0; i < chainLength; i++)
{
Assert.True(CommitAttachment(
lifetime, guids[i], 1, guids[i + 1], childPositionSequence: 2));
}
Assert.True(lifetime.Entities.TryGetActive(
guids[0], out RuntimeEntityRecord root));
// D1's attach re-cell already propagated the ORIGINAL cell down the
// whole chain one attach at a time — confirm the tail matches the
// root before testing the crossing, so the assertions below cannot
// pass by coincidence.
uint originalCell = root.FullCellId;
Assert.True(lifetime.Entities.TryGetActive(
guids[chainLength], out RuntimeEntityRecord tailBeforeCrossing));
Assert.Equal(originalCell, tailBeforeCrossing.FullCellId);
Assert.NotEqual(0u, originalCell);
// WRITE path: the whole 200-deep chain follows a crossing, tail
// included — no truncation, no stack overflow.
uint newCell = originalCell + 0x0002u;
Assert.True(lifetime.CommitRebucket(
root, newCell, (newCell & 0xFFFF0000u) | 0xFFFFu));
for (int i = 0; i <= chainLength; i++)
{
Assert.True(lifetime.Entities.TryGetActive(
guids[i], out RuntimeEntityRecord record));
Assert.Equal(newCell, record.FullCellId);
}
// WITHDRAW path: the whole chain follows a withdrawal to zero too —
// this is the #184-shaped half both reviews flagged (a truncated
// tail left at a STALE NONZERO cell is exactly the "resident but
// isn't" defect AP-142 clause (a) exists to reject).
Assert.True(lifetime.CommitWithdrawal(root));
for (int i = 0; i <= chainLength; i++)
{
Assert.True(lifetime.Entities.TryGetActive(
guids[i], out RuntimeEntityRecord record));
Assert.Equal(0u, record.FullCellId);
}
}
// ---------------------------------------------------------------
// Harness
// ---------------------------------------------------------------
private static RuntimeEntityObjectLifetime EngineLifetime()
{
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(
Landblock,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return new RuntimeEntityObjectLifetime(engine);
}
private static void Bind(RuntimeEntityObjectLifetime lifetime, ulong generation)
{
var token = new RuntimeGenerationToken(generation);
lifetime.BindEventContext(() => token, static () => 1UL);
}
/// <summary>
/// Drives the SAME staged -&gt; committed protocol the graphical
/// <c>EquippedChildRenderController.PrepareAndTryRealize</c> and the
/// headless D5 drive both run: seed the staged relation, consume the
/// child's POSITION_TS gate, commit the relation
/// (<see cref="RuntimeEntityObjectLifetime.TryCommitParent"/>), mark it
/// committed (<see cref="ParentAttachmentState.CommitProjection"/>),
/// then run the cell-less edge that D1 extends
/// (<see cref="RuntimeEntityObjectLifetime.CommitAcceptedParentCellless"/>).
/// </summary>
private static bool CommitAttachment(
RuntimeEntityObjectLifetime lifetime,
uint parentGuid,
ushort parentInstance,
uint childGuid,
ushort childPositionSequence,
uint parentLocation = 0u,
uint placementId = 0u)
{
var relation = new ParentAttachmentRelation(
parentGuid,
childGuid,
parentLocation,
placementId,
parentInstance,
childPositionSequence);
lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation);
var update = new ParentEvent.Parsed(
parentGuid,
childGuid,
parentLocation,
placementId,
parentInstance,
childPositionSequence);
if (!lifetime.TryApplyParent(update, acknowledgeProjection: null, out _))
return false;
if (!lifetime.TryCommitParent(relation, acknowledgeProjection: null, out _))
return false;
if (!lifetime.Entities.ParentAttachments.CommitProjection(relation))
return false;
if (!lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord canonical))
return false;
return lifetime.CommitAcceptedParentCellless(
canonical,
canonical.PositionAuthorityVersion,
acknowledgeProjection: null);
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation,
bool includePosition = true)
{
CreateObject.ServerPosition? position = includePosition
? new CreateObject.ServerPosition(Cell, 10f, 20f, 7f, 1f, 0f, 0f, 0f)
: null;
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.Gravity,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: null,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
SetupTableId: null,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "route-7 fixture",
ItemType: null,
MotionState: null,
MotionTableId: null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}

View file

@ -1668,8 +1668,10 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests
// AwaitFreshPosition - RuntimeAuthoritativePositionRouteClassifier.
// ClassifyAcceptedPosition (the only classifier ApplyPositionAction ever
// calls) has no branch that returns AwaitFreshPosition; that disposition
// is produced exclusively by ClassifyCreate (Parented/PickedUp) and
// ClassifyLeaveWorld (neither of which ApplyPositionAction calls). A
// is produced exclusively by ClassifyCreate (Parented/PickedUp) - the
// route-7 contract deleted the classifier's other AwaitFreshPosition
// producer, ClassifyLeaveWorld, which had zero production callers (see
// docs/research/2026-08-04-c4-route-7-contract.md D6). A
// parented/picked entity's raw Position wire events are retained as
// Position continuations exactly like any other entity's and are
// classified with the SAME Remote/LocalPlayer logic once drained - the

View file

@ -331,28 +331,6 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests
Assert.False(route.ConstrainAfterRouting);
}
[Theory]
[InlineData(RuntimeLeaveWorldCause.Pickup)]
[InlineData(RuntimeLeaveWorldCause.Parent)]
internal void PickupAndParent_LeaveWorldAndAwaitLaterFreshPosition(
RuntimeLeaveWorldCause cause)
{
RuntimeAuthoritativePositionRoute route =
RuntimeAuthoritativePositionRouteClassifier.ClassifyLeaveWorld(
new RuntimeLeaveWorldRouteRequest(
Authority(),
RuntimePositionEntityKind.Projectile,
cause,
default));
Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition,
route.Disposition);
Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative,
route.OperationKind);
Assert.True(route.LeaveWorld);
Assert.False(route.PerformsSetPosition);
}
[Fact]
public void HiddenAndNoDrawDoNotGatePlacement_ButReportingRemainsSeparate()
{

View file

@ -313,6 +313,112 @@ public sealed class RuntimeLiveEntitySessionControllerTests
projection.LastPositionDisposition);
}
/// <summary>
/// C4 route 7 headless gate (D5) — the direct regression test for the
/// route-6 scoping's stranded-equipped-child defect: a committed
/// child's canonical FullCellId must equal its parent's, driven purely
/// through <see cref="RuntimeLiveEntitySessionController.OnParentUpdated"/>
/// with no App/graphical layer involved. Fails without D1/D2/D5.
/// </summary>
[Fact]
public void DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell()
{
using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
CommitLandblockCollision(runtime, 0x01010000u);
CommitLandblockCollision(runtime, 0x01020000u);
RuntimeFirstEntryDriveController drive = CreateDrive(runtime);
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport());
var controller = new RuntimeLiveEntitySessionController(
runtime,
session,
worldProjection: new FixtureWorldProjection());
LiveEntitySessionSink sink = controller.CreateSink();
const uint parentGuid = 0x70000020u;
const uint childGuid = 0x70000021u;
sink.Spawned(SpawnAt(parentGuid, incarnation: 1, 0x01010001u));
drive.DriveAll();
DrainPlacementFifo(runtime);
sink.Spawned(SpawnAt(childGuid, incarnation: 1, 0x01020001u));
drive.DriveAll();
DrainPlacementFifo(runtime);
Assert.True(runtime.EntityObjects.Entities.TryGetActive(
parentGuid, out RuntimeEntityRecord parent));
Assert.True(runtime.EntityObjects.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.NotEqual(0u, parent.FullCellId);
// Distinct starting cells — equality below can only come from D5's
// commit, never from coincidence.
Assert.NotEqual(parent.FullCellId, child.FullCellId);
sink.ParentUpdated(new ParentEvent.Parsed(
parentGuid,
childGuid,
ParentLocation: 0u,
PlacementId: 0u,
ParentInstanceSequence: 1,
ChildPositionSequence: 2));
Assert.Equal(parent.FullCellId, child.FullCellId);
Assert.NotEqual(0u, child.FullCellId);
}
/// <summary>
/// D5 companion: the deferred flavor — a standalone ParentEvent naming a
/// parent whose own CreateObject has not arrived yet must commit once
/// that guid becomes addressable (<c>OnSpawned</c>'s
/// <c>RetryChildrenWaitingForParent</c>).
/// </summary>
[Fact]
public void DirectSink_D5_DeferredParentEventCommitsOnceTheParentBecomesAddressable()
{
using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
CommitLandblockCollision(runtime, 0x01010000u);
CommitLandblockCollision(runtime, 0x01020000u);
RuntimeFirstEntryDriveController drive = CreateDrive(runtime);
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport());
var controller = new RuntimeLiveEntitySessionController(
runtime,
session,
worldProjection: new FixtureWorldProjection());
LiveEntitySessionSink sink = controller.CreateSink();
const uint parentGuid = 0x70000022u;
const uint childGuid = 0x70000023u;
sink.Spawned(SpawnAt(childGuid, incarnation: 1, 0x01020001u));
drive.DriveAll();
DrainPlacementFifo(runtime);
Assert.True(runtime.EntityObjects.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
uint childOriginalCell = child.FullCellId;
// The ParentEvent arrives BEFORE the parent's own CreateObject.
sink.ParentUpdated(new ParentEvent.Parsed(
parentGuid,
childGuid,
ParentLocation: 0u,
PlacementId: 0u,
ParentInstanceSequence: 1,
ChildPositionSequence: 2));
Assert.Equal(childOriginalCell, child.FullCellId);
sink.Spawned(SpawnAt(parentGuid, incarnation: 1, 0x01010001u));
drive.DriveAll();
DrainPlacementFifo(runtime);
Assert.True(runtime.EntityObjects.Entities.TryGetActive(
parentGuid, out RuntimeEntityRecord parent));
Assert.Equal(parent.FullCellId, child.FullCellId);
Assert.NotEqual(0u, child.FullCellId);
}
/// <summary>
/// C3c-R1 review F6: the drive controller outlives its session routes,
/// so "session reset precedes a new route" is an asserted latch, not a
@ -639,10 +745,22 @@ public sealed class RuntimeLiveEntitySessionControllerTests
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation)
ushort incarnation) =>
SpawnAt(guid, incarnation, 0x01010001u);
/// <summary>
/// C4 route 7 D5 gate: <see cref="Spawn"/> parametrized over the wire
/// landblock, so a parent and a child can be seeded at DISTINCT cells —
/// equality after the D5 commit can then only come from the
/// propagation write, never from a coincidental shared default.
/// </summary>
private static WorldSession.EntitySpawn SpawnAt(
uint guid,
ushort incarnation,
uint landblockId)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
landblockId,
10f,
10f,
5f,