A player-parented child never received a canonical cell. Its FullCellId stayed 0 for its whole attached lifetime, so it could not follow the player across a boundary. Scope was wider than the local player: every REMOTE player's equipment too. ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0 for a parented CreateObject. Correct for creatures and statics, which really are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins (ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while the record carried TotalLogins, so both route-7 write sites — D1's attach re-cell and D2's propagation lookup — keyed on an incarnation that never matched. TryCommitParent did not validate the sequence, so the attach succeeded and printed normally. Silent. A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call route 7 deleted was keyed on the child guid alone and was structurally immune to a wrong parent key. THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them. Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id @0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e, with SetChildren @0x00509370 hash-walking by guid — and neither set_parent overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any player test or instance-sequence read. Our player/non-player split was purely an artifact of keying relations by (guid, incarnation) against a wire message that carries no parent incarnation. Late-binding to whoever currently holds the guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and OnCreateParentAccepted, the second carrying the byte-identical defect and not named in the contract's scope line. THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I offered: every one of the 45 FullCellId liveness predicates excludes a committed child on a NON-cell clause first, so the child inherits only the parent record's existing staleness, which is already present today with no symptom. The key fix alone restores child-equals-parent for every parent class. TWO SITES GATED, inert only because the cell was zero and would have woken wrongly: the hydration candidate loop (a nonzero-cell child would take the legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route 7's exact defect class) and RestoreShadow (would install a broadphase row for the weapon, the #184 shape, contradicting route 7's P4). Retail anchor: update_object's parent != 0 early-out @0x00515D40 — children are never independently re-placed. THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for an unaddressable parent, carrying a missing child-freshness gate (A2), a sentinel-0 collision with the generation filters (A3), and unbounded accumulation (A5). Both reviewers then proved the deferred branch unreachable for BOTH producers — RegisterEntityCore defers the entire CreateObject one layer above, reading the same ?? chain, and CreateParentUpdate is produced only inside AcceptCreateCore, after that gate passes. The machinery was deleted rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24 while gaining the A1 fix. Retail confirmed the deletion does not diverge: acdream's real port of retail's per-guid replay (QueueBlobForObject) is a different, untouched layer, and the deleted queue was a third redundant one downstream of it. THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw InvalidOperationException AFTER the canonical half had committed, so the one time it fired it left the child parented with no committed relation and a staged one blocking Resolve — a torn transaction, the exact outcome the contract pinned against. Now a pure CanCommitIncarnation precondition checked BEFORE the commit at both sites, with a logged refusal instead of a throw. Route 3's N3 principle (do not make a transient fatal on a host that must survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands alone. TEST QUALITY, the recurring lesson in its most refined form. The A1 test initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence meant TryCommitParent's own gate refused in either ordering, so the three assertions carrying A1's meaning passed both ways and only an incidental staging assertion failed. It failed on stranding, not tearing. Corrected, the sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the parent's guid in it — proving the canonical mutation happened before the catch. "Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is the real question. The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8… incarnation 0, identical outcomes, sabotage-verified in both directions) is the structural fix for how this survived a full dual review and two connected sessions: every prior test and both captured gate logs used sequence-0 parents. Register: AP-142 clause (f); AP-132 amended to distinguish the two producers; new row AP-146 for the local player's coarse canonical cell (retail writes it per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8, so retail's per-tick child propagation lives in the same function). That divergence had no row at all, a standing rule-1 violation now corrected. Follow-up #320 filed for making the player's cell track ordinary movement — deliberately excluded here: it touches the landblock-preserve contract, the Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race. Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed. Diagnostic refusals are latched per child guid and the latch clears on Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather than being silently suppressed. Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed (baseline 11,090 at52175aa1, +22). Neither known flake fired. STILL OWED: the connected gate, with the CORRECTED positive criterion — assert the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is a failure, not a silence), run with BOTH a player and a creature parent, plus the new step carrying an armed creature across a landblock unload/reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
650 lines
28 KiB
C#
650 lines
28 KiB
C#
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #319 route-7 invariant 2 re-run, dual-parent-class. The propagation
|
|
/// mechanism itself (D1/D2/D3) was always guid-range-agnostic - these
|
|
/// tests construct relations directly via <see cref="CommitAttachment"/>
|
|
/// rather than through the buggy producer - but #319 exists precisely
|
|
/// because no existing test in this file used a PLAYER-range parent
|
|
/// guid. Closes that gap for the removal path (AP-142 clause (a)): the
|
|
/// key fix must not perturb withdrawal for either parent class.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(0x50000601u, (ushort)8)]
|
|
[InlineData(0x70040609u, (ushort)0)]
|
|
public void Withdrawal_PickupOfParent_ZeroesCommittedChildren_DualParentClass(
|
|
uint parentGuid,
|
|
ushort parentInstance)
|
|
{
|
|
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
|
Bind(lifetime, 1UL);
|
|
const uint childGuid = 0x7004060Au;
|
|
lifetime.RegisterEntity(Spawn(parentGuid, parentInstance));
|
|
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
|
|
Assert.True(CommitAttachment(lifetime, parentGuid, parentInstance, childGuid, 2));
|
|
Assert.True(lifetime.Entities.TryGetActive(
|
|
childGuid, out RuntimeEntityRecord child));
|
|
Assert.NotEqual(0u, child.FullCellId);
|
|
|
|
Assert.True(lifetime.TryApplyPickup(
|
|
new PickupEvent.Parsed(parentGuid, InstanceSequence: parentInstance, PositionSequence: 2),
|
|
acknowledgeProjection: null,
|
|
out _));
|
|
|
|
Assert.Equal(0u, child.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 -> 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);
|
|
}
|
|
}
|