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