Movement: CommitSetPosition's RefreshPositionRows/ReplacePositionRows and the staged apply publish the retail render product from the exact cell list collision just used, the transition's cell_array retail feeds add_shadows_to_cells in CPhysicsObj::SetPositionInternal @0x00515330 (pseudo-C 283526-283539); the separate move-path bbox recompute is deleted. calc_cross_cells @0x00515230 stays the distinct full-recompute path (PhysicsShadowCommitAction.Recalculate). Children (Contract B recursion): ShadowObjectRegistry.AttachChild/DetachChild give an attached object the root's current cells as part entries only, republished whenever the root's array changes, detached at withdrawal and cascaded from the root's Deregister; nested attachment resolves to the root with a bounded, cycle-safe chain. EquippedChildRenderController attaches at realization (FromSetupRenderParts over the child's Setup) and detaches at its single removal funnel. WalkProductionWorldData's dynamic sweep reads TryGetRetailCellArray directly; the 64-hop parent-chain walk and its FindParentLocalId plumbing are deleted. CollisionWorldState.Clear now also clears the retail products. Gates (implementer's isolated worktree at identical content): Release build 0/0; Core 4,970/4,970; App hermetic 6,761/6,761; targeted walk/child/live-entity/placement/comparator 166/166; Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2030 lines
84 KiB
C#
2030 lines
84 KiB
C#
using System.Collections;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using AcDream.Runtime.Entities;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class EquippedChildProjectionWithdrawalTests
|
|
{
|
|
[Fact]
|
|
public void WithdrawalFailure_DoesNotPublishAttachedProjectionRemoval()
|
|
{
|
|
bool published = false;
|
|
|
|
ExactProjectionWithdrawalOutcome outcome =
|
|
EquippedChildRenderController.WithdrawAttachedProjection(
|
|
ChildRecord(),
|
|
positionAuthorityVersion: 1,
|
|
projectionMutationVersion: 2,
|
|
(_, _, _) => new ExactProjectionWithdrawalOutcome(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("injected component cleanup failure")),
|
|
() =>
|
|
{
|
|
published = true;
|
|
return true;
|
|
});
|
|
|
|
Assert.IsType<InvalidOperationException>(outcome.Failure);
|
|
Assert.Equal(ExactProjectionWithdrawalDisposition.Pending, outcome.Disposition);
|
|
Assert.False(published);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExactIncarnationRejection_DoesNotPublishAttachedProjectionRemoval()
|
|
{
|
|
bool published = false;
|
|
|
|
ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection(
|
|
ChildRecord(),
|
|
positionAuthorityVersion: 1,
|
|
projectionMutationVersion: 2,
|
|
(_, _, _) => new ExactProjectionWithdrawalOutcome(
|
|
ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null),
|
|
() =>
|
|
{
|
|
published = true;
|
|
return true;
|
|
});
|
|
|
|
Assert.True(published);
|
|
Assert.Equal(ExactProjectionWithdrawalDisposition.Superseded, outcome.Disposition);
|
|
}
|
|
|
|
[Fact]
|
|
public void SuccessfulWithdrawal_PublishesLocalProjectionRemovalOnce()
|
|
{
|
|
int publications = 0;
|
|
|
|
ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection(
|
|
ChildRecord(),
|
|
positionAuthorityVersion: 1,
|
|
projectionMutationVersion: 2,
|
|
(_, _, _) => new ExactProjectionWithdrawalOutcome(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null),
|
|
() =>
|
|
{
|
|
publications++;
|
|
return true;
|
|
});
|
|
|
|
Assert.Equal(ExactProjectionWithdrawalDisposition.Completed, outcome.Disposition);
|
|
Assert.Equal(1, publications);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogicalReplacement_RemovesExactAttachedMapWithoutWithdrawingReplacement()
|
|
{
|
|
int withdrawals = 0;
|
|
using var fixture = new ControllerFixture(
|
|
(_, _, _) =>
|
|
{
|
|
withdrawals++;
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null);
|
|
});
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000200u, generation: 1);
|
|
LiveEntityRecord oldChild = fixture.Spawn(
|
|
0x70000201u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, oldChild);
|
|
|
|
LiveEntityRecord replacement = fixture.Live.RegisterAndMaterializeProjection(
|
|
ControllerFixture.SpawnData(0x70000201u, generation: 2));
|
|
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.True(fixture.Live.TryGetRecord(0x70000201u, out LiveEntityRecord current));
|
|
Assert.Same(replacement, current);
|
|
Assert.Equal(0, withdrawals);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogicalDelete_NotificationFailureLeavesRetryableTombstoneButNoAttachedLeak()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new(ExactProjectionWithdrawalDisposition.Completed, Failure: null));
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000210u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000211u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
fixture.Controller.ProjectionRemoved += Throw;
|
|
|
|
Assert.Throws<AggregateException>(() => fixture.Live.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(0x70000211u, InstanceSequence: 1),
|
|
isLocalPlayer: false));
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
|
|
fixture.Controller.ProjectionRemoved -= Throw;
|
|
Assert.Equal(1, fixture.Live.RetryPendingTeardowns());
|
|
|
|
static void Throw(LiveEntityRecord _) =>
|
|
throw new InvalidOperationException("injected projection observer failure");
|
|
}
|
|
|
|
[Fact]
|
|
public void PostCommitWithdrawalFailure_RetiresCapturedAttachedMap()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool committed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
Assert.True(committed);
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
new InvalidOperationException("observer failed after commit"));
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000220u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000221u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnChildBecameUnparented(0x70000221u));
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingTopLevelWithdrawal_RetryRunsAcceptedContinuationOnce()
|
|
{
|
|
int attempts = 0;
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
attempts++;
|
|
if (attempts == 1)
|
|
{
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("component cleanup failed"));
|
|
}
|
|
bool committed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
committed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord record = fixture.Spawn(0x70000230u, generation: 1);
|
|
int continuations = 0;
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnChildBecameUnparented(
|
|
0x70000230u,
|
|
() =>
|
|
{
|
|
continuations++;
|
|
Assert.True(fixture.Live.RebucketLiveEntity(
|
|
0x70000230u,
|
|
0x01010001u));
|
|
}));
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(2, attempts);
|
|
Assert.Equal(1, continuations);
|
|
Assert.True(record.IsSpatiallyProjected);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingUnparent_NewerPositionSupersedesRecoveryContinuation()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("component cleanup failed")));
|
|
LiveEntityRecord record = fixture.Spawn(0x70000240u, generation: 1);
|
|
int continuations = 0;
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnChildBecameUnparented(
|
|
0x70000240u,
|
|
() => continuations++));
|
|
|
|
var newer = new WorldSession.EntityPositionUpdate(
|
|
0x70000240u,
|
|
new CreateObject.ServerPosition(
|
|
0x01010001u, 4f, 5f, 6f, 1f, 0f, 0f, 0f),
|
|
Velocity: null,
|
|
PlacementId: null,
|
|
IsGrounded: true,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 2,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 0);
|
|
Assert.True(fixture.Live.TryApplyPosition(
|
|
newer,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out _,
|
|
out _,
|
|
out _));
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(0, continuations);
|
|
Assert.True(record.IsSpatiallyProjected);
|
|
}
|
|
|
|
[Fact]
|
|
public void PostNetworkReconcile_SkipsStableTree_AndUpdatesChangedBranchParentFirst()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null));
|
|
LiveEntityRecord root = fixture.Spawn(0x70000241u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000242u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
LiveEntityRecord grandchild = fixture.Spawn(
|
|
0x70000243u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(root, child);
|
|
fixture.InstallAttached(child, grandchild);
|
|
fixture.Poses.Publish(root.WorldEntity!, Array.Empty<Matrix4x4>());
|
|
|
|
fixture.Controller.Tick();
|
|
Assert.Equal(2, fixture.Controller.LastFullPoseCompositionVisits);
|
|
|
|
fixture.Controller.ReconcileSpatialMutations();
|
|
Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits);
|
|
|
|
root.WorldEntity!.SetPosition(new Vector3(7f, 8f, 9f));
|
|
Assert.True(fixture.Poses.UpdateRoot(root.WorldEntity));
|
|
fixture.Controller.ReconcileSpatialMutations();
|
|
|
|
Assert.Equal(2, fixture.Controller.LastReconcilePoseCompositionVisits);
|
|
Assert.Equal(root.WorldEntity.Position, child.WorldEntity!.Position);
|
|
Assert.Equal(child.WorldEntity.Position, grandchild.WorldEntity!.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void StablePostNetworkReconcile_AllocatesNoTransitionSnapshots()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null));
|
|
LiveEntityRecord root = fixture.Spawn(0x70000244u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000245u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(root, child);
|
|
fixture.Poses.Publish(root.WorldEntity!, Array.Empty<Matrix4x4>());
|
|
fixture.Controller.Tick();
|
|
fixture.Controller.ReconcileSpatialMutations();
|
|
|
|
// #250: the measured window was a 1,000-iteration loop written inline.
|
|
ZeroAllocationProbe.AssertAllocatesNothing(
|
|
"LiveEntityController.ReconcileSpatialMutations",
|
|
() => fixture.Controller.ReconcileSpatialMutations());
|
|
|
|
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) &&
|
|
/// 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()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
fixture.Spawn(0x70000250u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(0x70000251u, generation: 1);
|
|
|
|
fixture.Controller.OnParentEvent(new ParentEvent.Parsed(
|
|
ParentGuid: 0x70000250u,
|
|
ChildGuid: 0x70000251u,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
ParentInstanceSequence: 1,
|
|
ChildPositionSequence: 2));
|
|
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetProjection(
|
|
0x70000251u,
|
|
out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void InvalidParent_ConsumesPositionTimestampButPreservesWorldProjection()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
fixture.Spawn(0x70000252u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(0x70000253u, generation: 1);
|
|
|
|
fixture.Controller.OnParentEvent(new ParentEvent.Parsed(
|
|
ParentGuid: 0x70000252u,
|
|
ChildGuid: 0x70000253u,
|
|
ParentLocation: 1,
|
|
PlacementId: 0,
|
|
ParentInstanceSequence: 1,
|
|
ChildPositionSequence: 2));
|
|
|
|
Assert.True(child.IsSpatiallyProjected);
|
|
Assert.NotEqual(0u, child.FullCellId);
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
child.ServerGuid,
|
|
out WorldSession.EntitySpawn snapshot));
|
|
Assert.NotNull(snapshot.Position);
|
|
Assert.Null(snapshot.ParentGuid);
|
|
Assert.Equal((ushort)2, snapshot.PositionSequence);
|
|
Assert.False(fixture.Live.ParentAttachments.TryGetProjection(
|
|
child.ServerGuid,
|
|
out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void QueuedValidThenInvalidParent_LeavesValidParentCommitted()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord validParent = fixture.Spawn(0x70000259u, generation: 1);
|
|
LiveEntityRecord invalidParent = fixture.Spawn(0x7000025Au, generation: 1);
|
|
const uint childGuid = 0x7000025Bu;
|
|
fixture.Controller.OnParentEvent(new ParentEvent.Parsed(
|
|
validParent.ServerGuid, childGuid, 0, 0, 1, 1));
|
|
fixture.Controller.OnParentEvent(new ParentEvent.Parsed(
|
|
invalidParent.ServerGuid, childGuid, 1, 0, 1, 2));
|
|
|
|
RuntimeEntityRecord childIdentity = fixture.RegisterOnly(
|
|
childGuid,
|
|
generation: 1,
|
|
hasPosition: true);
|
|
LiveEntityRecord child = fixture.Materialize(childIdentity);
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
childGuid,
|
|
out WorldSession.EntitySpawn childSpawn));
|
|
fixture.Controller.OnSpawn(childSpawn);
|
|
|
|
Assert.True(fixture.Live.TryGetSnapshot(childGuid, out childSpawn));
|
|
Assert.Equal(validParent.ServerGuid, childSpawn.ParentGuid);
|
|
Assert.Null(childSpawn.Position);
|
|
Assert.Equal((ushort)2, childSpawn.PositionSequence);
|
|
var valid = new ParentAttachmentRelation(
|
|
validParent.ServerGuid, childGuid, 0, 0, 1, 1);
|
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(valid));
|
|
Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection(
|
|
childGuid,
|
|
out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NoPositionCreateParent_CommitsAfterParentPartArrayValidation()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new ExactProjectionWithdrawalOutcome(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null));
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000254u, generation: 1);
|
|
RuntimeEntityRecord child = fixture.RegisterOnly(
|
|
0x70000255u,
|
|
generation: 1,
|
|
hasPosition: false);
|
|
var update = new CreateParentUpdate(
|
|
child.ServerGuid,
|
|
parent.ServerGuid,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
ChildInstanceSequence: 1,
|
|
ChildPositionSequence: 1);
|
|
|
|
fixture.CompleteFirstEntry();
|
|
Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
|
|
fixture.Controller.OnCreateParentAccepted(update);
|
|
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
child.ServerGuid,
|
|
out WorldSession.EntitySpawn snapshot));
|
|
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,
|
|
1,
|
|
1);
|
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation));
|
|
|
|
fixture.Poses.Publish(
|
|
parent.WorldEntity!,
|
|
Array.Empty<Matrix4x4>());
|
|
fixture.Controller.OnPosePublished(parent.ServerGuid);
|
|
|
|
Assert.True(fixture.Live.TryGetRecord(
|
|
child.ServerGuid,
|
|
out LiveEntityRecord childProjection));
|
|
Assert.NotNull(childProjection.WorldEntity);
|
|
Assert.True(childProjection.IsSpatiallyProjected);
|
|
Assert.Equal(
|
|
LiveEntityProjectionKind.Attached,
|
|
childProjection.ProjectionKind);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpawnParentWithoutAnimationFrame_UsesRetailPlacementZero()
|
|
{
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
new ExactProjectionWithdrawalOutcome(
|
|
ExactProjectionWithdrawalDisposition.Completed,
|
|
Failure: null));
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1);
|
|
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
|
0x70000271u,
|
|
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);
|
|
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: 1,
|
|
ChildPositionSequence: 0);
|
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected));
|
|
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
|
|
fixture.Controller.OnPosePublished(parent.ServerGuid);
|
|
Assert.True(fixture.Live.TryGetRecord(
|
|
childSpawn.Guid,
|
|
out LiveEntityRecord child));
|
|
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
|
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()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1);
|
|
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
|
0x70000273u,
|
|
generation: 1);
|
|
childSpawn = childSpawn with
|
|
{
|
|
Position = null,
|
|
ParentGuid = parent.ServerGuid,
|
|
ParentLocation = 0,
|
|
PlacementId = 0,
|
|
PositionSequence = 0,
|
|
Physics = childSpawn.Physics!.Value with
|
|
{
|
|
Position = null,
|
|
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
|
|
AnimationFrame = 0,
|
|
},
|
|
};
|
|
fixture.Live.RegisterLiveEntity(childSpawn);
|
|
fixture.Controller.OnSpawn(childSpawn);
|
|
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
|
|
fixture.Controller.OnPosePublished(parent.ServerGuid);
|
|
Assert.True(fixture.Live.TryGetRecord(
|
|
childSpawn.Guid,
|
|
out LiveEntityRecord child));
|
|
WorldEntity entity = child.WorldEntity!;
|
|
Assert.Null(entity.PaletteOverride);
|
|
WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData(
|
|
0x70000274u,
|
|
generation: 1);
|
|
grandchildSpawn = grandchildSpawn with
|
|
{
|
|
Position = null,
|
|
ParentGuid = child.ServerGuid,
|
|
ParentLocation = 0,
|
|
PlacementId = 0,
|
|
PositionSequence = 0,
|
|
Physics = grandchildSpawn.Physics!.Value with
|
|
{
|
|
Position = null,
|
|
Parent = new PhysicsAttachment(child.ServerGuid, 0u),
|
|
AnimationFrame = 0,
|
|
},
|
|
};
|
|
fixture.Live.RegisterLiveEntity(grandchildSpawn);
|
|
fixture.Controller.OnSpawn(grandchildSpawn);
|
|
Assert.True(fixture.Live.TryGetRecord(
|
|
grandchildSpawn.Guid,
|
|
out LiveEntityRecord grandchild));
|
|
WorldEntity grandchildEntity = grandchild.WorldEntity!;
|
|
Assert.Equal(
|
|
LiveEntityProjectionKind.Attached,
|
|
grandchild.ProjectionKind);
|
|
|
|
Assert.True(fixture.Live.TryApplyObjDesc(
|
|
new ObjDescEvent.Parsed(
|
|
child.ServerGuid,
|
|
new CreateObject.ModelData(
|
|
BasePaletteId: 0x04000022u,
|
|
[new CreateObject.SubPaletteSwap(
|
|
0x0F000033u,
|
|
Offset: 4,
|
|
Length: 8)],
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.AnimPartChange>()),
|
|
InstanceSequence: 1,
|
|
ObjDescSequence: 1),
|
|
out _));
|
|
Assert.Null(child.Snapshot.Position);
|
|
|
|
Assert.True(fixture.Controller.TryApplyAttachedAppearance(
|
|
child,
|
|
child.ObjDescAuthorityVersion));
|
|
|
|
Assert.Same(entity, child.WorldEntity);
|
|
Assert.True(child.IsSpatiallyProjected);
|
|
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
|
Assert.Equal((uint)0x04000022u, child.Snapshot.BasePaletteId);
|
|
Assert.NotNull(entity.PaletteOverride);
|
|
Assert.Equal(0x04000022u, entity.PaletteOverride!.BasePaletteId);
|
|
PaletteOverride.SubPaletteRange range =
|
|
Assert.Single(entity.PaletteOverride.SubPalettes);
|
|
Assert.Equal(0x0F000033u, range.SubPaletteId);
|
|
Assert.Equal((byte)4, range.Offset);
|
|
Assert.Equal((byte)8, range.Length);
|
|
Assert.Same(grandchildEntity, grandchild.WorldEntity);
|
|
Assert.True(grandchild.IsSpatiallyProjected);
|
|
Assert.Equal(
|
|
LiveEntityProjectionKind.Attached,
|
|
grandchild.ProjectionKind);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ChildWithoutSetup_StillCommitsLogicalParenting()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x7000025Cu, generation: 1);
|
|
RuntimeEntityRecord childIdentity = fixture.RegisterOnly(
|
|
0x7000025Du,
|
|
generation: 1,
|
|
hasPosition: true,
|
|
hasSetup: false);
|
|
LiveEntityRecord child = fixture.Materialize(childIdentity);
|
|
child.HasPartArray = false;
|
|
var update = new ParentEvent.Parsed(
|
|
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1);
|
|
|
|
fixture.Controller.OnParentEvent(update);
|
|
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
child.ServerGuid,
|
|
out WorldSession.EntitySpawn snapshot));
|
|
Assert.Equal(parent.ServerGuid, snapshot.ParentGuid);
|
|
Assert.Null(snapshot.Position);
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(new(
|
|
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1)));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EmbeddedSameGenerationCreateParent_UsesStagedRoute()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x7000025Eu, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(0x7000025Fu, generation: 1);
|
|
PhysicsTimestamps timestamps = new(
|
|
Position: 1,
|
|
Movement: 0,
|
|
State: 0,
|
|
Vector: 0,
|
|
Teleport: 0,
|
|
ServerControlledMove: 0,
|
|
ForcePosition: 0,
|
|
ObjDesc: 0,
|
|
Instance: 1);
|
|
PhysicsSpawnData physics = new(
|
|
RawState: 0x408u,
|
|
Position: null,
|
|
Movement: null,
|
|
AnimationFrame: 0,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: null,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: new PhysicsAttachment(parent.ServerGuid, 0),
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
WorldSession.EntitySpawn incoming = ControllerFixture.SpawnData(
|
|
child.ServerGuid,
|
|
generation: 1) with
|
|
{
|
|
Position = null,
|
|
PositionSequence = 1,
|
|
PlacementId = 0,
|
|
ParentGuid = parent.ServerGuid,
|
|
ParentLocation = 0,
|
|
Physics = physics,
|
|
};
|
|
|
|
LiveEntityRegistrationResult refresh = fixture.Live.RegisterLiveEntity(incoming);
|
|
CreateParentUpdate update = Assert.IsType<CreateParentUpdate>(
|
|
refresh.Inbound.SameGenerationEvents!.Value.Parent);
|
|
Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
|
|
fixture.Controller.OnCreateParentAccepted(update);
|
|
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
child.ServerGuid,
|
|
out WorldSession.EntitySpawn snapshot));
|
|
Assert.Equal(parent.ServerGuid, snapshot.ParentGuid);
|
|
Assert.Null(snapshot.Position);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NewWaitingParent_DoesNotDisplaceCommittedRecovery()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord oldParent = fixture.Spawn(0x70000256u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000257u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
RuntimeEntityRecord waitingParent = fixture.RegisterOnly(
|
|
0x70000258u,
|
|
generation: 1,
|
|
hasPosition: true);
|
|
var oldRelation = new ParentAttachmentRelation(
|
|
oldParent.ServerGuid,
|
|
child.ServerGuid,
|
|
0,
|
|
0,
|
|
1,
|
|
1);
|
|
fixture.Live.ParentAttachments.AcceptCreateObjectRelation(oldRelation);
|
|
Assert.True(fixture.Live.TryApplyParent(new ParentEvent.Parsed(
|
|
oldRelation.ParentGuid,
|
|
oldRelation.ChildGuid,
|
|
oldRelation.ParentLocation,
|
|
oldRelation.PlacementId,
|
|
oldRelation.ParentInstanceSequence,
|
|
oldRelation.ChildPositionSequence), out _));
|
|
Assert.True(fixture.Live.CommitStagedParent(oldRelation, out _));
|
|
Assert.True(fixture.Live.ParentAttachments.CommitProjection(oldRelation));
|
|
fixture.Live.ParentAttachments.MarkProjected(
|
|
oldRelation,
|
|
ParentProjectionCandidateKind.Recovery);
|
|
fixture.InstallAttached(oldParent, child);
|
|
|
|
var newer = new ParentEvent.Parsed(
|
|
waitingParent.ServerGuid,
|
|
child.ServerGuid,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
ParentInstanceSequence: 1,
|
|
ChildPositionSequence: 2);
|
|
fixture.Controller.OnParentEvent(newer);
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection(
|
|
child.ServerGuid,
|
|
out ParentAttachmentRelation staged));
|
|
Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid);
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection(
|
|
child.ServerGuid,
|
|
out staged));
|
|
Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid);
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection(
|
|
child.ServerGuid,
|
|
out ParentAttachmentRelation recovery));
|
|
Assert.Equal(oldParent.ServerGuid, recovery.ParentGuid);
|
|
|
|
fixture.Materialize(waitingParent);
|
|
fixture.Controller.OnWorldEntityRegistered(waitingParent.ServerGuid);
|
|
|
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(staged));
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection(
|
|
child.ServerGuid,
|
|
out ParentAttachmentRelation committedRecovery));
|
|
Assert.Equal(staged, committedRecovery);
|
|
Assert.True(fixture.Live.TryGetSnapshot(
|
|
child.ServerGuid,
|
|
out WorldSession.EntitySpawn snapshot));
|
|
Assert.Equal(waitingParent.ServerGuid, snapshot.ParentGuid);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void OrdinaryRemoval_PendingFailureRetriesExactCaptureOnTick()
|
|
{
|
|
int attempts = 0;
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
attempts++;
|
|
if (attempts == 1)
|
|
{
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("ordinary cleanup failed"));
|
|
}
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000260u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000261u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
fixture.Live.ParentAttachments.AcceptCreateObjectRelation(new(
|
|
parent.ServerGuid,
|
|
child.ServerGuid,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
ParentInstanceSequence: 1,
|
|
ChildPositionSequence: 1));
|
|
|
|
TargetInvocationException error = Assert.Throws<TargetInvocationException>(() =>
|
|
fixture.InvokeOrdinaryRemoval(0x70000261u));
|
|
Assert.IsType<InvalidOperationException>(error.InnerException);
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(2, attempts);
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
Assert.False(fixture.Live.ParentAttachments.TryGetProjection(
|
|
child.ServerGuid,
|
|
out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DetachedRemoval_PendingFailureRetriesAndPreservesRollbackHistory()
|
|
{
|
|
int attempts = 0;
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
attempts++;
|
|
if (attempts == 1)
|
|
{
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("detached cleanup failed"));
|
|
}
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000262u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000263u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
var relation = new ParentAttachmentRelation(
|
|
parent.ServerGuid,
|
|
child.ServerGuid,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
ParentInstanceSequence: 1,
|
|
ChildPositionSequence: 1);
|
|
fixture.Live.ParentAttachments.AcceptCreateObjectRelation(relation);
|
|
Assert.True(fixture.Live.ParentAttachments.CommitProjection(relation));
|
|
fixture.Live.ParentAttachments.MarkProjected(
|
|
relation,
|
|
ParentProjectionCandidateKind.Recovery);
|
|
|
|
TargetInvocationException error = Assert.Throws<TargetInvocationException>(() =>
|
|
fixture.InvokeDetachedRemoval(child.ServerGuid));
|
|
Assert.IsType<InvalidOperationException>(error.InnerException);
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(2, attempts);
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
Assert.True(fixture.Live.ParentAttachments.RestoreLastAccepted(
|
|
child.ServerGuid));
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection(
|
|
child.ServerGuid,
|
|
out ParentAttachmentRelation restored));
|
|
Assert.Equal(relation, restored);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Unparent_WithdrawsCompleteAttachedSubtreeAndRecoversDescendants()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000264u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000265u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
LiveEntityRecord grandchild = fixture.Spawn(
|
|
0x70000266u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
fixture.InstallAttached(child, grandchild);
|
|
var childRelation = new ParentAttachmentRelation(
|
|
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1);
|
|
var grandchildRelation = new ParentAttachmentRelation(
|
|
child.ServerGuid, grandchild.ServerGuid, 0, 0, 1, 1);
|
|
fixture.CommitRenderedRelation(childRelation);
|
|
fixture.CommitRenderedRelation(grandchildRelation);
|
|
|
|
ChildUnparentDisposition result =
|
|
fixture.Controller.OnChildBecameUnparented(child.ServerGuid);
|
|
|
|
Assert.Equal(ChildUnparentDisposition.Completed, result);
|
|
Assert.Empty(fixture.Controller.AttachedEntityIds);
|
|
Assert.False(child.IsSpatiallyProjected);
|
|
Assert.False(grandchild.IsSpatiallyProjected);
|
|
Assert.False(fixture.Live.ParentAttachments.RestoreLastAccepted(
|
|
child.ServerGuid));
|
|
Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection(
|
|
grandchild.ServerGuid,
|
|
out ParentAttachmentRelation recovered));
|
|
Assert.Equal(grandchildRelation, recovered);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingDetachedRemoval_RollbackCancelsRetryAndKeepsProjection()
|
|
{
|
|
int attempts = 0;
|
|
using var fixture = new ControllerFixture((_, _, _) =>
|
|
{
|
|
attempts++;
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("component withdrawal failed"));
|
|
});
|
|
LiveEntityRecord parent = fixture.Spawn(0x70000267u, generation: 1);
|
|
LiveEntityRecord child = fixture.Spawn(
|
|
0x70000268u,
|
|
generation: 1,
|
|
LiveEntityProjectionKind.Attached);
|
|
fixture.InstallAttached(parent, child);
|
|
fixture.CommitRenderedRelation(new(
|
|
parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1));
|
|
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
|
|
fixture.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = child.ServerGuid,
|
|
WielderId = parent.ServerGuid,
|
|
CurrentlyEquippedLocation = EquipMask.MeleeWeapon,
|
|
});
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Objects.MoveItemOptimistic(
|
|
child.ServerGuid,
|
|
newContainerId: 0x50000001u,
|
|
newSlot: 0));
|
|
Assert.True(fixture.Objects.RollbackMove(child.ServerGuid));
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(1, attempts);
|
|
Assert.True(child.IsSpatiallyProjected);
|
|
Assert.Single(fixture.Controller.AttachedEntityIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjection()
|
|
{
|
|
int withdrawals = 0;
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
withdrawals++;
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
const uint guid = 0x7000026Au;
|
|
const uint player = 0x50000001u;
|
|
LiveEntityRecord dropped = fixture.Spawn(guid, generation: 1);
|
|
fixture.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = guid,
|
|
ContainerId = player,
|
|
CurrentlyEquippedLocation = EquipMask.None,
|
|
});
|
|
|
|
Assert.True(fixture.Objects.ApplyConfirmedServerMove(
|
|
guid,
|
|
newContainerId: 0u,
|
|
newWielderId: 0u));
|
|
|
|
Assert.Equal(0, withdrawals);
|
|
Assert.True(dropped.IsSpatiallyProjected);
|
|
Assert.Equal(LiveEntityProjectionKind.World, dropped.ProjectionKind);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void OrphanRollback_PendingFailureRetainsExactRetryOwner()
|
|
{
|
|
int attempts = 0;
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
attempts++;
|
|
if (attempts == 1)
|
|
{
|
|
return new(
|
|
ExactProjectionWithdrawalDisposition.Pending,
|
|
new InvalidOperationException("orphan component cleanup failed"));
|
|
}
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord orphan = fixture.Spawn(0x70000269u, generation: 1);
|
|
|
|
TargetInvocationException error = Assert.Throws<TargetInvocationException>(() =>
|
|
fixture.InvokeOrphanRemoval(orphan));
|
|
Assert.IsType<InvalidOperationException>(error.InnerException);
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(2, attempts);
|
|
Assert.False(orphan.IsSpatiallyProjected);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RecoveryContinuation_PostCommitFailureIsNotReplayed()
|
|
{
|
|
ControllerFixture? fixture = null;
|
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
|
{
|
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
|
record,
|
|
positionVersion,
|
|
projectionVersion);
|
|
return new(
|
|
completed
|
|
? ExactProjectionWithdrawalDisposition.Completed
|
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
|
Failure: null);
|
|
});
|
|
using (fixture)
|
|
{
|
|
LiveEntityRecord record = fixture.Spawn(0x70000270u, generation: 1);
|
|
int continuations = 0;
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnChildBecameUnparented(
|
|
0x70000270u,
|
|
() =>
|
|
{
|
|
continuations++;
|
|
Assert.True(fixture.Live.RebucketLiveEntity(
|
|
0x70000270u,
|
|
0x01010001u));
|
|
throw new InvalidOperationException("observer failed after recovery");
|
|
}));
|
|
|
|
fixture.Controller.Tick();
|
|
|
|
Assert.Equal(1, continuations);
|
|
Assert.True(record.IsSpatiallyProjected);
|
|
}
|
|
}
|
|
|
|
private static LiveEntityRecord ChildRecord() =>
|
|
LiveEntityTestFixture.CreateExactProjectionRecord(
|
|
new WorldSession.EntitySpawn(
|
|
0x70000100u,
|
|
new CreateObject.ServerPosition(
|
|
0x01010001u, 0f, 0f, 0f, 1f, 0f, 0f, 0f),
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: "attached fixture",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: null,
|
|
InstanceSequence: 1));
|
|
|
|
private sealed class ControllerFixture : IDisposable
|
|
{
|
|
private const uint Cell = 0x01010001u;
|
|
private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new();
|
|
private readonly Setup _setup;
|
|
private readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController _firstEntry;
|
|
|
|
private sealed class NullCollisionSource
|
|
: AcDream.Content.IPreparedCollisionSource
|
|
{
|
|
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
AcDream.Content.PreparedAssetPresence.Available;
|
|
|
|
public AcDream.Content.PreparedCollisionReadResult<
|
|
AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
AcDream.Content.PreparedCollisionReadResult<
|
|
AcDream.Core.Physics.FlatSetupCollision>.Missing;
|
|
|
|
public AcDream.Content.PreparedCollisionReadResult<
|
|
AcDream.Core.Physics.FlatGfxObjCollisionAsset>
|
|
ReadGfxObjCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public AcDream.Content.PreparedCollisionReadResult<
|
|
AcDream.Core.Physics.FlatCellStructureCollisionAsset>
|
|
ReadCellStructureCollision(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public AcDream.Content.PreparedCollisionReadResult<
|
|
AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology(
|
|
uint sourceFileId,
|
|
CancellationToken cancellationToken = default) =>
|
|
throw new NotSupportedException();
|
|
|
|
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
|
default;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
internal ControllerFixture(
|
|
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
|
|
withdraw)
|
|
{
|
|
Spatial.AddLandblock(new LoadedLandblock(
|
|
(Cell & 0xFFFF0000u) | 0xFFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
EntityObjects = new RuntimeEntityObjectLifetime();
|
|
EntityObjects.BindEventContext(
|
|
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
|
static () => 1UL);
|
|
_firstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController(
|
|
EntityObjects,
|
|
new AcDream.Runtime.GameRuntimeClock(),
|
|
new NullCollisionSource(),
|
|
() => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback,
|
|
static _ => new AcDream.Runtime.Gameplay.RuntimeLocalPlayerPhysicsActivationPreparation(
|
|
0.48f,
|
|
1.835f,
|
|
AcDream.Runtime.Gameplay.RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
|
Live = new LiveEntityRuntime(
|
|
Spatial,
|
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
|
|
_lifecycle,
|
|
EntityObjects);
|
|
_setup = new Setup
|
|
{
|
|
HoldingLocations =
|
|
{
|
|
[(ParentLocation)0] = new LocationType
|
|
{
|
|
PartId = -1,
|
|
Frame = new Frame { Orientation = Quaternion.Identity },
|
|
},
|
|
},
|
|
};
|
|
IDatReaderWriter dat = DispatchProxy.Create<IDatReaderWriter, NullDatProxy>();
|
|
((NullDatProxy)(object)dat).Setup = _setup;
|
|
Controller = new EquippedChildRenderController(
|
|
dat,
|
|
new object(),
|
|
Objects,
|
|
Live,
|
|
Poses,
|
|
update => Live.TryApplyParent(update, out _),
|
|
withdraw,
|
|
new ShadowObjectRegistry(),
|
|
new PhysicsDataCache());
|
|
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
|
|
Controller.OnLogicalTeardown));
|
|
}
|
|
|
|
internal GpuWorldState Spatial { get; } = new();
|
|
internal RuntimeEntityObjectLifetime EntityObjects { get; }
|
|
|
|
/// <summary>
|
|
/// C3c: completes fresh residences through the first-entry
|
|
/// conductors (a celless route performs no SetPosition, so its drain
|
|
/// completes synchronously), releasing the lease so legacy direct
|
|
/// CreateParent/Parent application stays valid post-flip. The
|
|
/// controller is constructed lazily but binds no notification of its
|
|
/// own tracking value until first use, so it sweeps active leases
|
|
/// directly instead.
|
|
/// </summary>
|
|
internal void CompleteFirstEntry()
|
|
{
|
|
_firstEntry.DriveAll();
|
|
}
|
|
internal ClientObjectTable Objects { get; } = new();
|
|
internal EntityEffectPoseRegistry Poses { get; } = new();
|
|
internal LiveEntityRuntime Live { get; }
|
|
internal EquippedChildRenderController Controller { get; }
|
|
|
|
internal LiveEntityRecord Spawn(
|
|
uint guid,
|
|
ushort generation,
|
|
LiveEntityProjectionKind kind = LiveEntityProjectionKind.World)
|
|
{
|
|
RuntimeEntityRecord canonical =
|
|
RegisterOnly(guid, generation, hasPosition: true);
|
|
return Materialize(canonical, kind);
|
|
}
|
|
|
|
internal RuntimeEntityRecord RegisterOnly(
|
|
uint guid,
|
|
ushort generation,
|
|
bool hasPosition,
|
|
bool hasSetup = true)
|
|
{
|
|
WorldSession.EntitySpawn spawn = SpawnData(guid, generation);
|
|
if (!hasPosition)
|
|
{
|
|
spawn = spawn with
|
|
{
|
|
Position = null,
|
|
Physics = spawn.Physics!.Value with { Position = null },
|
|
};
|
|
}
|
|
if (!hasSetup)
|
|
spawn = spawn with { SetupTableId = null };
|
|
return Assert.IsType<RuntimeEntityRecord>(
|
|
Live.RegisterLiveEntity(spawn).Canonical);
|
|
}
|
|
|
|
internal LiveEntityRecord Materialize(
|
|
RuntimeEntityRecord canonical,
|
|
LiveEntityProjectionKind kind = LiveEntityProjectionKind.World)
|
|
{
|
|
WorldEntity? entity = Live.MaterializeLiveEntity(
|
|
canonical,
|
|
Cell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = canonical.ServerGuid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = Cell,
|
|
},
|
|
kind,
|
|
initializeProjection: null,
|
|
out LiveEntityRecord? projected);
|
|
Assert.NotNull(entity);
|
|
LiveEntityRecord record =
|
|
Assert.IsType<LiveEntityRecord>(projected);
|
|
record.HasPartArray = true;
|
|
return record;
|
|
}
|
|
|
|
internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation)
|
|
{
|
|
// C3c: residence admission freezes the raw create and requires
|
|
// the flattened parser projections to agree with the nested
|
|
// PhysicsDesc block (HasConsistentCreateIdentityAndParent).
|
|
var position = new CreateObject.ServerPosition(
|
|
Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: 0u,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
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: new PhysicsTimestamps(
|
|
Position: 0,
|
|
Movement: 0,
|
|
State: 0,
|
|
Vector: 0,
|
|
Teleport: 0,
|
|
ServerControlledMove: 0,
|
|
ForcePosition: 0,
|
|
ObjDesc: 0,
|
|
Instance: generation));
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: "attached fixture",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: null,
|
|
InstanceSequence: generation,
|
|
Physics: physics);
|
|
}
|
|
|
|
internal void InstallAttached(
|
|
LiveEntityRecord parent,
|
|
LiveEntityRecord child)
|
|
{
|
|
Type attachedType = typeof(EquippedChildRenderController).GetNestedType(
|
|
"AttachedChild",
|
|
BindingFlags.NonPublic)!;
|
|
object attached = Activator.CreateInstance(
|
|
attachedType,
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
|
binder: null,
|
|
args:
|
|
[
|
|
parent,
|
|
child,
|
|
parent.ServerGuid,
|
|
child.ServerGuid,
|
|
(ParentLocation)0,
|
|
(Placement)0,
|
|
_setup,
|
|
_setup,
|
|
Array.Empty<MeshRef>(),
|
|
Array.Empty<bool>(),
|
|
Array.Empty<Matrix4x4>(),
|
|
Array.Empty<MeshRef>(),
|
|
1f,
|
|
child.WorldEntity!,
|
|
],
|
|
culture: null)!;
|
|
FieldInfo mapField = typeof(EquippedChildRenderController).GetField(
|
|
"_attachedByChild",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
var map = (IDictionary)mapField.GetValue(Controller)!;
|
|
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);
|
|
Assert.True(Live.ParentAttachments.CommitProjection(relation));
|
|
Live.ParentAttachments.MarkProjected(
|
|
relation,
|
|
ParentProjectionCandidateKind.Recovery);
|
|
}
|
|
|
|
internal void InvokeOrdinaryRemoval(uint guid)
|
|
{
|
|
MethodInfo method = typeof(EquippedChildRenderController).GetMethod(
|
|
"TearDownCurrentObjectProjections",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
method.Invoke(Controller, [guid]);
|
|
}
|
|
|
|
internal void InvokeDetachedRemoval(uint guid)
|
|
{
|
|
MethodInfo method = typeof(EquippedChildRenderController).GetMethod(
|
|
"BeginDetachedRemoval",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
method.Invoke(Controller, [guid]);
|
|
}
|
|
|
|
internal void InvokeOrphanRemoval(LiveEntityRecord record)
|
|
{
|
|
FieldInfo mapField = typeof(EquippedChildRenderController).GetField(
|
|
"_pendingOrphanRemovalByChild",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
object map = mapField.GetValue(Controller)!;
|
|
MethodInfo method = typeof(EquippedChildRenderController).GetMethod(
|
|
"BeginProjectionSubtreeWithdrawal",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
method.Invoke(Controller, [map, record, false, false]);
|
|
}
|
|
|
|
public void Dispose() => Controller.Dispose();
|
|
}
|
|
|
|
private class NullDatProxy : DispatchProxy
|
|
{
|
|
internal Setup? Setup { get; set; }
|
|
|
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
|
{
|
|
if (targetMethod?.Name == "Get"
|
|
&& targetMethod.ReturnType == typeof(Setup))
|
|
{
|
|
return Setup;
|
|
}
|
|
if (targetMethod?.ReturnType == typeof(void))
|
|
return null;
|
|
if (targetMethod?.ReturnType.IsValueType == true)
|
|
return Activator.CreateInstance(targetMethod.ReturnType);
|
|
return null;
|
|
}
|
|
}
|
|
}
|