fix(physics): bind a parented child to the parent's live incarnation (#319)

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 at 52175aa1, +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>
This commit is contained in:
Erik 2026-08-05 11:56:31 +02:00
parent af828a8a2a
commit 392c1e22c1
19 changed files with 3076 additions and 20 deletions

View file

@ -711,12 +711,18 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Equal(parent.ServerGuid, snapshot.ParentGuid);
Assert.Null(snapshot.Position);
Assert.False(fixture.Live.TryGetRecord(child.ServerGuid, out _));
// #319: the parent was spawned with generation 1 - the committed
// relation must carry the PARENT's live incarnation (late-bound at
// accept time), not a hardcoded 0. This assertion previously pinned
// the exact #319 defect (it passed for a nonzero-incarnation parent
// only because AcceptCreateObjectRelation ignored the parent
// entirely and always staged 0).
var relation = new ParentAttachmentRelation(
parent.ServerGuid,
child.ServerGuid,
0,
0,
0,
1,
1);
Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation));
@ -763,12 +769,15 @@ public sealed class EquippedChildProjectionWithdrawalTests
fixture.Live.RegisterLiveEntity(childSpawn);
fixture.Controller.OnSpawn(childSpawn);
// #319: the parent was spawned with generation 1 - see the sibling
// fix note in NoPositionCreateParent_CommitsAfterParentPartArrayValidation
// above.
var expected = new ParentAttachmentRelation(
parent.ServerGuid,
childSpawn.Guid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: 0,
ParentInstanceSequence: 1,
ChildPositionSequence: 0);
Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected));
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
@ -780,6 +789,222 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.NotNull(child.WorldEntity);
}
/// <summary>
/// #319 F1, the key itself. Dual-parent-class matrix: a player-range
/// parent (nonzero incarnation) and a creature-range parent (sequence
/// 0). Drives the REAL producer (<see cref="EquippedChildRenderController.OnSpawn"/>)
/// rather than the test helper <c>CommitRenderedRelation</c> most other
/// tests in this file use (which hand-crafts the relation and never
/// exercises the hardcoded-0 bug). Verifies the relation commits under
/// the PARENT's exact live incarnation (not 0) and that both D1 (attach
/// re-cell) and D2 (crossing propagation) reach the child.
/// </summary>
[Theory]
[InlineData(0x50000777u, (ushort)9)]
[InlineData(0x70000280u, (ushort)0)]
public void OnSpawn_CreateObjectRelation_LateBindsToParentLiveIncarnationAndPropagatesCell(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
Assert.NotEqual(0u, parent.Canonical.FullCellId);
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
0x7000028Fu,
generation: 1);
childSpawn = childSpawn with
{
Position = null,
ParentGuid = parent.ServerGuid,
ParentLocation = 0,
PlacementId = null,
PositionSequence = 0,
Physics = childSpawn.Physics!.Value with
{
Position = null,
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
AnimationFrame = null,
},
};
fixture.Live.RegisterLiveEntity(childSpawn);
// SABOTAGE PROBE: restoring the literal `ParentInstanceSequence: 0`
// at EquippedChildRenderController.OnSpawn makes the player row
// (parentIncarnation=9) fail this exact assertion while the
// creature row (parentIncarnation=0) keeps passing - the precise
// #319 signature.
fixture.Controller.OnSpawn(childSpawn);
Assert.True(fixture.Live.ParentAttachments.TryGetCommittedParent(
childSpawn.Guid,
out uint committedParentGuid,
out ushort committedInstance));
Assert.Equal(parent.ServerGuid, committedParentGuid);
Assert.Equal(parentIncarnation, committedInstance);
// D1: attach re-cells the child to the parent's exact cell, inside
// the same synchronous OnSpawn call (CommitAcceptedParentCellless
// runs regardless of whether the render projection itself is ready
// to materialize).
Assert.True(fixture.Live.TryGetCanonical(
childSpawn.Guid,
out RuntimeEntityRecord childCanonical));
Assert.Equal(parent.Canonical.FullCellId, childCanonical.FullCellId);
// D2: a later parent canonical-cell write propagates to the
// committed child.
const uint newCell = 0x01020001u;
const uint newLandblock = 0x0102FFFFu;
fixture.Spatial.AddLandblock(new LoadedLandblock(
newLandblock, new LandBlock(), Array.Empty<WorldEntity>()));
Assert.True(fixture.EntityObjects.CommitRebucket(
parent.Canonical, newCell, newLandblock));
Assert.Equal(newCell, childCanonical.FullCellId);
}
/// <summary>
/// #319 A1 (architecture review, 2026-08-05), the ordering fix's own
/// end-to-end proof. Dual-parent-class matrix. A relation whose named
/// incarnation mismatches the parent's live value must be refused
/// BEFORE the canonical commit runs, not after - the original shape
/// let <c>CommitStagedParent</c> (the canonical half: nulls the
/// child's snapshot Position, writes ParentGuid, advances POSITION_TS)
/// run first, then threw from inside <c>CommitProjection</c>, leaving
/// the child canonically parented with no committed relation. Stages
/// the mismatch directly (the real producer cannot construct one - it
/// always resolves the parent's TRUE live incarnation) to isolate the
/// ordering guarantee itself.
/// </summary>
[Theory]
[InlineData(0x50000821u, (ushort)6)]
[InlineData(0x70000299u, (ushort)0)]
public void PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
const uint childGuid = 0x700002A0u;
fixture.RegisterOnly(childGuid, generation: 1, hasPosition: true);
// #319 B1 (architecture review round 2, 2026-08-05): ChildPositionSequence
// MUST equal the fixture's actual gate value (RegisterOnly/SpawnData
// leaves Timestamps.Position and the top-level PositionSequence at 0,
// and unlike NoPositionCreateParent_CommitsAfterParentPartArrayValidation
// this test never calls TryApplyCreateParent/TryApplyParent to advance
// it). A mismatched value here would make TryCommitParent's OWN
// POSITION_TS gate (InboundPhysicsStateController.cs:299-312) refuse
// the canonical commit regardless of ordering, so the three snapshot
// assertions below would pass vacuously under a REVERTED ordering too
// - the test would "fail under sabotage" only on the unrelated staged-
// projection assertion, proving nothing about A1's tearing fix.
var wrongRelation = new ParentAttachmentRelation(
parent.ServerGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(parentIncarnation + 1),
ChildPositionSequence: 0);
fixture.Live.ParentAttachments.AcceptCreateObjectRelation(wrongRelation);
fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid);
// The canonical layer must NEVER observe this child as parented -
// if the ordering regressed, snapshot.Position would be null and
// ParentGuid would be set even though no relation ever committed.
Assert.True(fixture.Live.TryGetSnapshot(
childGuid,
out WorldSession.EntitySpawn snapshot));
Assert.Null(snapshot.ParentGuid);
Assert.NotNull(snapshot.Position);
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
// The refused relation does not linger and block Resolve forever -
// it is rejected, not stranded.
Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection(
childGuid,
out _));
}
/// <summary>
/// #319 A6 (architecture review, 2026-08-05). A prior revision of this
/// fix deferred a CreateObject-carried relation whose parent was not
/// yet addressable through a queue. Both reviews independently proved
/// that shape is structurally unreachable in production for BOTH
/// producers (raw CreateObject via <c>OnSpawn</c> AND the same-
/// generation <see cref="CreateParentUpdate"/> envelope via
/// <c>OnCreateParentAccepted</c>):
/// <c>RuntimeEntityObjectLifetime.RegisterEntityCore</c>'s
/// <c>EnqueueDeferredCreate</c> gate defers the ENTIRE CreateObject
/// whenever its parent is not yet active, and does so BEFORE either
/// wire shape is ever produced - the earlier version of this test
/// only reached the deferred branch by calling
/// <c>OnCreateParentAccepted</c> directly, bypassing that upstream
/// gate. The deferral queue carried three independent defects (a
/// missing child POSITION_TS gate, a placeholder-incarnation collision
/// with the generation filters, unbounded accumulation) while never
/// being exercised by production routing, so it was removed rather
/// than fixed in place. This test now pins the CORRECTED behavior: an
/// unaddressable parent at accept time is refused outright (logged,
/// no state mutation, no crash) - dual-parent-class matrix.
/// </summary>
[Theory]
[InlineData(0x50000811u, (ushort)4)]
[InlineData(0x70000291u, (ushort)0)]
public void OnCreateParentAccepted_ParentNotYetKnown_RefusesWithoutStateOrCrash(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
const uint childGuid = 0x7000029Fu;
fixture.RegisterOnly(childGuid, generation: 1, hasPosition: false);
fixture.CompleteFirstEntry();
var update = new CreateParentUpdate(
childGuid,
parentGuid,
ParentLocation: 0,
PlacementId: 0,
ChildInstanceSequence: 1,
ChildPositionSequence: 1);
// The parent has not spawned yet - the relation must not be staged,
// queued, or committed under any key, and the call must not throw.
Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
fixture.Controller.OnCreateParentAccepted(update);
Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection(
childGuid,
out _));
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
Assert.Equal(0, fixture.Live.ParentAttachments.UnresolvedRelationCount);
// The parent later becoming addressable does NOT retroactively
// attach the refused relation - there is nothing left to retry
// (the earlier deferred-queue design would have found and
// committed it here; the corrected design does not).
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid);
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
}
[Fact]
public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange()
{

View file

@ -127,6 +127,77 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(1, fixture.Ready.PublishCount);
}
/// <summary>
/// #319 F2 (contract §3.1). Dual-parent-class matrix: a player-range
/// parent with a nonzero incarnation and a creature/static-range parent
/// at sequence 0. #319 existed precisely because every pre-existing
/// test in this file used only sequence-0 (non-player) parents - a
/// matrix in which the two classes could diverge silently is the
/// defect's habitat.
/// </summary>
[Theory]
[InlineData(0x50000123u, (ushort)7)]
[InlineData(0x70000099u, (ushort)0)]
public void CommittedChild_IsExcludedFromLandblockLoadCandidates(
uint parentGuid,
ushort parentInstanceSequence)
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
// Simulate the post-#319-fix state: a committed parent relation
// exists for this child (D1/D2, exercised elsewhere, would give it
// a nonzero canonical cell equal to the parent's). Pre-fix the
// relation DID commit for a player-class parent too - HasCommittedParent
// is child-keyed and CommitProjection succeeded under (playerGuid, 0)
// (retail review R3).
//
// The two InlineData rows differ in what was reachable PRE-fix
// (retail review round 2, D2 - qualified per parent class):
// - PLAYER row (0x50000123u): the relation committed under the
// WRONG key (playerGuid, 0), so D1's `parent.Incarnation ==
// parentInstanceSequence` gate never matched and the child's
// FullCellId stayed 0 forever pre-fix - this candidate gate
// this test exercises never saw a live population for this row
// before the fix.
// - CREATURE row (0x70000099u): the hardcoded 0 genuinely MATCHES
// a creature-class parent's real incarnation, so D1 already
// passed pre-fix and this child ALREADY carried a nonzero
// FullCellId at HEAD - this row's scenario was already LIVE
// pre-fix, and the child was already a hydration candidate
// taking the legacy RebucketLiveEntity path this gate now
// excludes (contract §3.1's correction). Both rows exercise the
// SAME gate; only their pre-fix reachability differs.
var relation = new ParentAttachmentRelation(
parentGuid,
Guid,
ParentLocation: 0,
PlacementId: 0,
parentInstanceSequence,
ChildPositionSequence: 1);
fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation);
Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation));
// Decoy: the legacy RebucketLiveEntity branch below would overwrite
// this back to the canonical cell (0x01010001u, matching `Cell`) if
// this candidate were not excluded - see
// LiveEntityRuntime.RebucketLiveEntity's unconditional
// `entity.ParentCellId = spatialCellOrLandblockId` write.
entity.ParentCellId = 0x02020002u;
fixture.Controller.OnLandblockLoaded(Cell);
// #319 F2: a committed child is never independently re-placed by
// landblock load (retail's update_object `parent != 0` early-out,
// @0x00515D40) - the decoy must survive untouched. Sabotage:
// deleting the `HasCommittedParent` gate in
// LiveEntityHydrationController.OnLandblockLoaded makes this fail
// for BOTH rows (the decoy gets corrected back to 0x01010001u).
Assert.Equal(0x02020002u, entity.ParentCellId);
}
[Fact]
public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady()
{

View file

@ -6,6 +6,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -78,6 +79,71 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
}
/// <summary>
/// #319 F2 (contract §3.2). Dual-parent-class matrix: a player-range
/// parent with a nonzero incarnation and a creature/static-range parent
/// at sequence 0. Neither <c>IsSpatiallyProjected</c> nor
/// <c>IsSpatiallyVisible</c> excludes a committed child (its
/// presentation-only rebucket sets <c>IsSpatiallyProjected</c> true
/// every frame), so the Hidden -&gt; Visible edge must not install a
/// shadow row for it - route 7's P4 record: a committed child never
/// owns an independent broadphase row.
/// </summary>
[Theory]
[InlineData(0x50000456u, (ushort)3)]
[InlineData(0x70000199u, (ushort)0)]
public void CommittedChild_HiddenThenVisible_NeverInstallsShadowRow(
uint parentGuid,
ushort parentInstanceSequence)
{
Fixture fixture = new(
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
// Simulate the post-#319-fix state: a committed parent relation
// exists for this child, giving it a nonzero canonical FullCellId
// via D1/D2 (exercised elsewhere). Pre-fix the relation DID commit
// for a player-class parent too (HasCommittedParent is child-keyed;
// retail review R3).
//
// Per-row pre-fix reachability (retail review round 2, D2):
// - PLAYER row (0x50000456u): the relation committed under the
// WRONG key, D1 never matched, FullCellId stayed 0 forever
// pre-fix - RestoreShadow's `FullCellId == 0` clause already
// refused this row's population at HEAD.
// - CREATURE row (0x70000199u): the hardcoded 0 matches a
// creature-class parent's real incarnation, so D1 already
// passed pre-fix and this child ALREADY carried a nonzero
// FullCellId at HEAD - RestoreShadow's `FullCellId == 0` clause
// did NOT refuse this row pre-fix (architecture review A4); the
// `HasCommittedParent` clause added by this fix is what now
// refuses it, a live behavior change for this class.
var relation = new ParentAttachmentRelation(
parentGuid,
Fixture.Guid,
ParentLocation: 0,
PlacementId: 0,
parentInstanceSequence,
ChildPositionSequence: 1);
fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation);
Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation));
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
out _,
out _));
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
// #319 F2: sabotage - deleting the `HasCommittedParent` gate in
// LiveEntityPresentationController.RestoreShadow makes this fail
// for BOTH rows (TotalRegistered becomes 1, the #184 shape: an
// invisible-but-solid weapon row).
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
}
[Fact]
public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController()
{

View file

@ -35,6 +35,223 @@ public sealed class ParentAttachmentStateTests
Assert.Equal(parentGuid, child.ParentGuid);
}
/// <summary>
/// #319 A1 (architecture review, 2026-08-05). Dual-parent-class matrix:
/// <see cref="ParentAttachmentState.CanCommitIncarnation"/> is a PURE,
/// side-effect-free precondition - it must refuse a mismatch without
/// mutating ANY state, so a caller can check it before either half of a
/// commit runs (never a throw; the original shape threw from inside
/// <see cref="ParentAttachmentState.CommitProjection"/>, reached only
/// after the canonical commit had already landed, tearing the
/// transaction).
/// </summary>
[Theory]
[InlineData(0x50000210u, (ushort)5)]
[InlineData(0x70000211u, (ushort)0)]
public void CanCommitIncarnation_MismatchedIncarnation_RefusesWithoutMutatingState(
uint parentGuid,
ushort liveParentIncarnation)
{
const uint childGuid = 0x70000212u;
var relations = new ParentAttachmentState();
var wrongRelation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(liveParentIncarnation + 1),
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(wrongRelation);
Assert.False(relations.CanCommitIncarnation(
wrongRelation,
guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null));
// Purely a read - the relation is untouched, still staged, no
// committed parent exists.
Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged));
Assert.Equal(wrongRelation, staged);
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
// A NULL resolver (parent not currently addressable) must not
// refuse - the tripwire only fires "whenever the parent is
// active" (contract F1).
Assert.True(relations.CanCommitIncarnation(wrongRelation, _ => null));
}
/// <summary>
/// #319 A1. <see cref="ParentAttachmentState.CommitProjection"/> itself
/// must be non-tearing for ANY caller (not just the two production call
/// sites that also pre-check): a mismatch returns false and mutates
/// nothing, never throws.
/// </summary>
[Theory]
[InlineData(0x50000213u, (ushort)5)]
[InlineData(0x70000214u, (ushort)0)]
public void CommitProjection_MismatchedIncarnation_ReturnsFalseWithoutThrowingOrMutating(
uint parentGuid,
ushort liveParentIncarnation)
{
const uint childGuid = 0x70000215u;
var relations = new ParentAttachmentState();
var wrongRelation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(liveParentIncarnation + 1),
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(wrongRelation);
Assert.False(relations.CommitProjection(
wrongRelation,
guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null));
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged));
Assert.Equal(wrongRelation, staged);
// A matching resolver commits normally.
Assert.True(relations.CommitProjection(
wrongRelation,
guid => guid == parentGuid
? (ushort)(liveParentIncarnation + 1)
: (ushort?)null));
Assert.True(relations.TryGetCommittedParent(
childGuid,
out uint committedParent,
out ushort committedInstance));
Assert.Equal(parentGuid, committedParent);
Assert.Equal((ushort)(liveParentIncarnation + 1), committedInstance);
}
/// <summary>
/// #319 contract §6 test 9 (ledger convergence) - BLOCKS per the
/// architecture review, since a stranded/accumulating relation is
/// precisely a ledger-convergence defect. Dual-parent-class matrix.
/// Removing the CHILD must zero every one of
/// <see cref="ParentAttachmentState"/>'s four tables
/// (staged/recovery/committed/unresolved), not just the committed
/// entry.
/// </summary>
[Theory]
[InlineData(0x50000900u, (ushort)3)]
[InlineData(0x70000901u, (ushort)0)]
public void LedgerConvergence_ChildRemoval_ZeroesEveryTable(
uint parentGuid,
ushort parentIncarnation)
{
const uint childGuid = 0x70000902u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
// MarkProjected(Recovery) simulates a completed realize/render
// pass, which clears the retry-candidate bookkeeping - the
// permanent committed record in _lastAcceptedByChild is untouched
// by it, which is the table this test cares about.
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
Assert.Equal(1, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.NotEmpty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
relations.RemoveChild(childGuid);
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Equal(0, relations.UnresolvedRelationCount);
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
Assert.False(relations.HasCommittedParent(childGuid));
}
/// <summary>
/// #319 contract §6 test 9, the PARENT-removal companion. Dual-parent-
/// class matrix. Removing the PARENT must converge the committed
/// child's entry via <see cref="ParentAttachmentState.RemoveObject"/>'s
/// parent-reference sweep.
/// </summary>
[Theory]
[InlineData(0x50000920u, (ushort)2)]
[InlineData(0x70000921u, (ushort)0)]
public void LedgerConvergence_ParentRemoval_ZeroesEveryTable(
uint parentGuid,
ushort parentIncarnation)
{
const uint childGuid = 0x70000922u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
relations.RemoveObject(parentGuid);
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
Assert.False(relations.HasCommittedParent(childGuid));
}
/// <summary>
/// #319 contract §6 test 9, full-teardown companion. Dual-parent-class
/// matrix. <see cref="ParentAttachmentState.Clear"/> must converge every
/// table to zero with a player-parented committed child AND a still-
/// unresolved ParentEvent present simultaneously - the mid-session
/// accumulation shape the architecture review's A5 finding was
/// concerned with (now moot for the deleted deferred-CreateObject
/// queue, but the pre-existing ParentEvent `_unresolvedByChild` queue
/// this fix left untouched still needs its own convergence proof).
/// </summary>
[Theory]
[InlineData(0x50000930u, (ushort)9)]
[InlineData(0x70000931u, (ushort)0)]
public void LedgerConvergence_Teardown_ZeroesEveryTableWithMixedPendingState(
uint parentGuid,
ushort parentIncarnation)
{
const uint committedChildGuid = 0x70000932u;
const uint unresolvedChildGuid = 0x70000933u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
committedChildGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, unresolvedChildGuid, 0, 0, parentIncarnation, 1));
Assert.True(relations.CommittedRelationCount > 0);
Assert.True(relations.UnresolvedRelationCount > 0);
relations.Clear();
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Equal(0, relations.UnresolvedRelationCount);
Assert.False(relations.HasCommittedParent(committedChildGuid));
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
}
[Fact]
public void MultipleQueuedRelationsRemainOrderedAndNewestAcceptedWins()
{

View file

@ -252,6 +252,40 @@ public sealed class RuntimeEntityChildCellPropagationTests
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()
{