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>
809 lines
32 KiB
C#
809 lines
32 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Content.Vfx;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime.Entities;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Options;
|
|
using Xunit.Sdk;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
public sealed class LiveEntityPresentationControllerTests
|
|
{
|
|
[Fact]
|
|
public void InitialVisibleObject_DoesNotPlayUnHide()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.Empty(fixture.TypedPlays);
|
|
Assert.True(fixture.Entity.IsDrawVisible);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void HiddenThenVisible_PlaysTypedEffectsMutatesChildrenAndRestoresShadow()
|
|
{
|
|
Fixture fixture = new(
|
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
|
|
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.Equal(
|
|
[(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f)],
|
|
fixture.TypedPlays);
|
|
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
|
|
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
|
|
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
|
Assert.Equal([0], fixture.PartArrayShadowCounts);
|
|
Assert.Equal(
|
|
["effect:76", "children:True", "part-array", "target"],
|
|
fixture.PresentationOrder);
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
Assert.False(fixture.Entity.IsDrawVisible);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
|
|
out _,
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
|
|
Assert.Equal(
|
|
[
|
|
(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f),
|
|
(fixture.Entity.Id, LiveEntityPresentationController.UnHideScriptType, 1f),
|
|
],
|
|
fixture.TypedPlays);
|
|
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
|
|
Assert.Equal(
|
|
[fixture.Entity.Id, fixture.Entity.Id],
|
|
fixture.PartArrayEnterWorld);
|
|
Assert.Equal([0, 0], fixture.PartArrayShadowCounts);
|
|
Assert.Equal(
|
|
[
|
|
"effect:76", "children:True", "part-array", "target",
|
|
"effect:75", "children:False", "part-array",
|
|
],
|
|
fixture.PresentationOrder);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
Assert.True(fixture.Entity.IsDrawVisible);
|
|
Assert.Same(
|
|
fixture.Entity,
|
|
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #319 F2 (contract §3.2). Dual-parent-class matrix: a player-range
|
|
/// parent with a nonzero incarnation and a creature/static-range parent
|
|
/// at sequence 0. Neither <c>IsSpatiallyProjected</c> nor
|
|
/// <c>IsSpatiallyVisible</c> excludes a committed child (its
|
|
/// presentation-only rebucket sets <c>IsSpatiallyProjected</c> true
|
|
/// every frame), so the Hidden -> Visible edge must not install a
|
|
/// shadow row for it - route 7's P4 record: a committed child never
|
|
/// owns an independent broadphase row.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(0x50000456u, (ushort)3)]
|
|
[InlineData(0x70000199u, (ushort)0)]
|
|
public void CommittedChild_HiddenThenVisible_NeverInstallsShadowRow(
|
|
uint parentGuid,
|
|
ushort parentInstanceSequence)
|
|
{
|
|
Fixture fixture = new(
|
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
|
|
|
|
// Simulate the post-#319-fix state: a committed parent relation
|
|
// exists for this child, giving it a nonzero canonical FullCellId
|
|
// via D1/D2 (exercised elsewhere). Pre-fix the relation DID commit
|
|
// for a player-class parent too (HasCommittedParent is child-keyed;
|
|
// retail review R3).
|
|
//
|
|
// Per-row pre-fix reachability (retail review round 2, D2):
|
|
// - PLAYER row (0x50000456u): the relation committed under the
|
|
// WRONG key, D1 never matched, FullCellId stayed 0 forever
|
|
// pre-fix - RestoreShadow's `FullCellId == 0` clause already
|
|
// refused this row's population at HEAD.
|
|
// - CREATURE row (0x70000199u): the hardcoded 0 matches a
|
|
// creature-class parent's real incarnation, so D1 already
|
|
// passed pre-fix and this child ALREADY carried a nonzero
|
|
// FullCellId at HEAD - RestoreShadow's `FullCellId == 0` clause
|
|
// did NOT refuse this row pre-fix (architecture review A4); the
|
|
// `HasCommittedParent` clause added by this fix is what now
|
|
// refuses it, a live behavior change for this class.
|
|
var relation = new ParentAttachmentRelation(
|
|
parentGuid,
|
|
Fixture.Guid,
|
|
ParentLocation: 0,
|
|
PlacementId: 0,
|
|
parentInstanceSequence,
|
|
ChildPositionSequence: 1);
|
|
fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation);
|
|
Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation));
|
|
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
|
|
out _,
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
|
|
// #319 F2: sabotage - deleting the `HasCommittedParent` gate in
|
|
// LiveEntityPresentationController.RestoreShadow makes this fail
|
|
// for BOTH rows (TotalRegistered becomes 1, the #184 shape: an
|
|
// invisible-but-solid weapon row).
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
Assert.True(fixture.Entity.IsDrawVisible);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController()
|
|
{
|
|
const uint humanSetup = 0x02000001u;
|
|
const uint humanMotionTable = 0x09000001u;
|
|
const uint magic = 0x80000049u;
|
|
const uint ready = 0x41000003u;
|
|
const uint magicPortal = 0x40000038u;
|
|
string datDir = @"C:\Turbine\Asheron's Call";
|
|
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
|
|
throw SkipException.ForSkip("Installed retail DATs are required.");
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(humanSetup));
|
|
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(humanMotionTable));
|
|
var sequencer = new AnimationSequencer(
|
|
setup,
|
|
table,
|
|
new RetailAnimationLoader(dats));
|
|
var fixture = new Fixture(
|
|
PhysicsStateFlags.ReportCollisions,
|
|
_ => sequencer.Manager.HandleEnterWorld());
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
sequencer.SetCycle(magic, ready);
|
|
sequencer.PlayAction(magicPortal);
|
|
Assert.False(sequencer.CurrentNodeDiag.IsLooping);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2),
|
|
out _,
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
Assert.True(sequencer.CurrentNodeDiag.IsLooping);
|
|
Assert.Empty(sequencer.Manager.PendingAnimations);
|
|
|
|
// A motion accepted while the mesh is hidden is retired again at
|
|
// retail's matching UnHide PartArray boundary before cell visibility
|
|
// returns.
|
|
var unhideSequencer = new AnimationSequencer(
|
|
setup,
|
|
table,
|
|
new RetailAnimationLoader(dats));
|
|
var unhideFixture = new Fixture(
|
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions,
|
|
_ => unhideSequencer.Manager.HandleEnterWorld());
|
|
Assert.True(unhideFixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
unhideSequencer.SetCycle(magic, ready);
|
|
unhideSequencer.PlayAction(magicPortal);
|
|
Assert.False(unhideSequencer.CurrentNodeDiag.IsLooping);
|
|
Assert.True(unhideFixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
|
|
out _,
|
|
out _));
|
|
Assert.True(unhideFixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
Assert.True(unhideSequencer.CurrentNodeDiag.IsLooping);
|
|
Assert.Empty(unhideSequencer.Manager.PendingAnimations);
|
|
Assert.Equal([0], fixture.PartArrayShadowCounts);
|
|
Assert.Equal([0, 0], unhideFixture.PartArrayShadowCounts);
|
|
Assert.Equal(1, unhideFixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
|
var hidden = new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.False(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
|
|
Assert.Single(fixture.TypedPlays);
|
|
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
|
|
fixture.TypedPlays[0].Type);
|
|
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
|
}
|
|
|
|
[Fact]
|
|
public void StateAcceptedDuringHiddenEffect_DrainsAfterCompleteHiddenTail()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
bool nestedAccepted = false;
|
|
fixture.OnTypedPlay = type =>
|
|
{
|
|
if (type != LiveEntityPresentationController.HiddenScriptType
|
|
|| nestedAccepted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
nestedAccepted = true;
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)PhysicsStateFlags.ReportCollisions,
|
|
1,
|
|
3),
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
};
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden
|
|
| PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2),
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
|
|
Assert.True(nestedAccepted);
|
|
Assert.Equal(
|
|
[
|
|
"effect:76", "children:True", "part-array", "target",
|
|
"effect:75", "children:False", "part-array",
|
|
],
|
|
fixture.PresentationOrder);
|
|
Assert.True(fixture.Entity.IsDrawVisible);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteAndGuidReuseDuringHiddenEffect_StopsOldTransitionTail()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.True(fixture.Runtime.TryGetRecord(
|
|
Fixture.Guid,
|
|
out LiveEntityRecord oldRecord));
|
|
WorldEntity? replacement = null;
|
|
fixture.OnTypedPlay = type =>
|
|
{
|
|
if (type != LiveEntityPresentationController.HiddenScriptType
|
|
|| replacement is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
fixture.Controller.Forget(oldRecord);
|
|
fixture.Shadows.Deregister(fixture.Entity.Id);
|
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Fixture.Guid, 1),
|
|
isLocalPlayer: false));
|
|
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions,
|
|
instanceSequence: 2));
|
|
replacement = fixture.Runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
0x01010001u,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(20f, 20f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
});
|
|
Assert.NotNull(replacement);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
};
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden
|
|
| PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2),
|
|
out _));
|
|
Assert.False(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
|
|
Assert.NotNull(replacement);
|
|
Assert.Equal(["effect:76"], fixture.PresentationOrder);
|
|
Assert.Empty(fixture.ChildNoDraw);
|
|
Assert.Empty(fixture.PartArrayEnterWorld);
|
|
Assert.Empty(fixture.InvalidTargets);
|
|
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current));
|
|
Assert.Same(replacement, current.WorldEntity);
|
|
Assert.NotSame(oldRecord, current);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2),
|
|
out _,
|
|
out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
|
|
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
|
|
Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity(
|
|
Fixture.Guid,
|
|
out _));
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
|
|
out _,
|
|
out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
|
|
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
|
0x0202FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
|
|
Assert.True(fixture.Runtime.TryGetInteractionEligibleEntity(
|
|
Fixture.Guid,
|
|
out _));
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void VisibleOrdinaryProjectionPending_SuspendsAndHydrationRestoresShadow()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
|
|
Assert.True(fixture.Runtime.RebucketLiveEntity(
|
|
Fixture.Guid,
|
|
0x02020001u));
|
|
|
|
Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity(
|
|
Fixture.Guid,
|
|
out _));
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
|
|
Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount);
|
|
Assert.True(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
|
|
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
|
0x0202FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
|
|
Assert.Same(
|
|
fixture.Entity,
|
|
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount);
|
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingVisibilityCallback_GuidReuseCannotRestoreOldShadowOwner()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.True(fixture.Runtime.TryGetRecord(
|
|
Fixture.Guid,
|
|
out LiveEntityRecord oldRecord));
|
|
WorldEntity oldEntity = fixture.Entity;
|
|
WorldEntity? replacement = null;
|
|
|
|
fixture.Runtime.ProjectionVisibilityChanged += (record, visible) =>
|
|
{
|
|
if (visible
|
|
|| replacement is not null
|
|
|| !ReferenceEquals(record, oldRecord))
|
|
{
|
|
return;
|
|
}
|
|
|
|
fixture.Controller.Forget(oldRecord);
|
|
fixture.Shadows.Deregister(oldEntity.Id);
|
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Fixture.Guid, 1),
|
|
isLocalPlayer: false));
|
|
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions,
|
|
instanceSequence: 2));
|
|
replacement = fixture.Runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
0x01010001u,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(20f, 20f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
});
|
|
Assert.NotNull(replacement);
|
|
fixture.Shadows.Register(
|
|
replacement.Id,
|
|
0x01000001u,
|
|
replacement.Position,
|
|
replacement.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x01010000u,
|
|
state: (uint)PhysicsStateFlags.ReportCollisions,
|
|
seedCellId: 0x01010001u);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
};
|
|
|
|
Assert.False(fixture.Runtime.RebucketLiveEntity(
|
|
Fixture.Guid,
|
|
0x02020001u));
|
|
|
|
Assert.NotNull(replacement);
|
|
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current));
|
|
Assert.NotSame(oldRecord, current);
|
|
Assert.Same(replacement, current.WorldEntity);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
|
|
Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount);
|
|
Assert.Contains(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == replacement.Id);
|
|
Assert.DoesNotContain(
|
|
fixture.Shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == oldEntity.Id);
|
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void InitialPendingProjection_ReadyBarrierSuspendsAndHydrationRestoresShadow()
|
|
{
|
|
const uint pendingCell = 0x02020001u;
|
|
var spatial = new GpuWorldState();
|
|
var shadows = new ShadowObjectRegistry();
|
|
var runtime = LiveEntityRuntimeFixture.Create(
|
|
spatial,
|
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
|
runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions));
|
|
WorldEntity entity = Assert.IsType<WorldEntity>(
|
|
runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
pendingCell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(10f, 10f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = pendingCell,
|
|
}));
|
|
shadows.Register(
|
|
entity.Id,
|
|
0x01000001u,
|
|
entity.Position,
|
|
entity.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x02020000u,
|
|
state: (uint)PhysicsStateFlags.ReportCollisions,
|
|
seedCellId: pendingCell);
|
|
using var controller = new LiveEntityPresentationController(
|
|
runtime,
|
|
shadows,
|
|
(_, _, _) => true,
|
|
new LiveEntityPartArrayEnterWorldPort(_ => { }),
|
|
liveCenter: () => (2, 2));
|
|
|
|
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.Equal(0, shadows.TotalRegistered);
|
|
Assert.Equal(1, shadows.RetainedRegistrationCount);
|
|
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
|
Assert.True(controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
0x0202FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
|
|
Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity);
|
|
Assert.Equal(1, shadows.TotalRegistered);
|
|
Assert.Equal(0, shadows.SuspendedRegistrationCount);
|
|
Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void InitialPendingHydrationCallback_GuidReuseCannotRestoreOldShadow()
|
|
{
|
|
const uint pendingCell = 0x02020001u;
|
|
var spatial = new GpuWorldState();
|
|
var shadows = new ShadowObjectRegistry();
|
|
var runtime = LiveEntityRuntimeFixture.Create(
|
|
spatial,
|
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
|
runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions));
|
|
WorldEntity oldEntity = Assert.IsType<WorldEntity>(
|
|
runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
pendingCell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(10f, 10f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = pendingCell,
|
|
}));
|
|
Assert.True(runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord));
|
|
shadows.Register(
|
|
oldEntity.Id,
|
|
0x01000001u,
|
|
oldEntity.Position,
|
|
oldEntity.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x02020000u,
|
|
state: (uint)PhysicsStateFlags.ReportCollisions,
|
|
seedCellId: pendingCell);
|
|
|
|
LiveEntityPresentationController? controller = null;
|
|
WorldEntity? replacement = null;
|
|
runtime.ProjectionVisibilityChanged += (record, visible) =>
|
|
{
|
|
if (!visible
|
|
|| replacement is not null
|
|
|| !ReferenceEquals(record, oldRecord))
|
|
{
|
|
return;
|
|
}
|
|
|
|
controller!.Forget(oldRecord);
|
|
shadows.Deregister(oldEntity.Id);
|
|
Assert.True(runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Fixture.Guid, 1),
|
|
isLocalPlayer: false));
|
|
runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions,
|
|
instanceSequence: 2));
|
|
replacement = runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
pendingCell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(20f, 20f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = pendingCell,
|
|
});
|
|
Assert.NotNull(replacement);
|
|
shadows.Register(
|
|
replacement.Id,
|
|
0x01000001u,
|
|
replacement.Position,
|
|
replacement.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x02020000u,
|
|
state: (uint)PhysicsStateFlags.ReportCollisions,
|
|
seedCellId: pendingCell);
|
|
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
|
};
|
|
controller = new LiveEntityPresentationController(
|
|
runtime,
|
|
shadows,
|
|
(_, _, _) => true,
|
|
new LiveEntityPartArrayEnterWorldPort(_ => { }),
|
|
liveCenter: () => (2, 2));
|
|
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
|
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
0x0202FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
|
|
Assert.NotNull(replacement);
|
|
Assert.True(runtime.TryGetRecord(Fixture.Guid, out var current));
|
|
Assert.NotSame(oldRecord, current);
|
|
Assert.Same(replacement, current.WorldEntity);
|
|
Assert.Equal(1, shadows.TotalRegistered);
|
|
Assert.Equal(1, shadows.RetainedRegistrationCount);
|
|
Assert.Equal(0, shadows.SuspendedRegistrationCount);
|
|
Assert.Contains(
|
|
shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == replacement.Id);
|
|
Assert.DoesNotContain(
|
|
shadows.AllEntriesForDebug(),
|
|
entry => entry.EntityId == oldEntity.Id);
|
|
Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
controller.Dispose();
|
|
}
|
|
|
|
// C4 route 4b-3: ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset
|
|
// deleted — BeginAuthoritativePlacement/CompleteAuthoritativePlacement/
|
|
// HasActivePlacement/DeferShadowRestore and the backing
|
|
// _activePlacementOwners set are dead (their only production caller was
|
|
// the deleted RemoteTeleportPlacementPresentation); see
|
|
// docs/research/2026-08-04-c4-route-4b-3-contract.md's deletion inventory.
|
|
|
|
private sealed class Fixture
|
|
{
|
|
public const uint Guid = 0x70000051u;
|
|
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
|
|
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
|
|
public List<uint> InvalidTargets { get; } = [];
|
|
public List<uint> PartArrayEnterWorld { get; } = [];
|
|
public List<int> PartArrayShadowCounts { get; } = [];
|
|
public List<string> PresentationOrder { get; } = [];
|
|
public GpuWorldState Spatial { get; }
|
|
public LiveEntityRuntime Runtime { get; }
|
|
public ShadowObjectRegistry Shadows { get; } = new();
|
|
public WorldEntity Entity { get; }
|
|
public LiveEntityPresentationController Controller { get; }
|
|
public Action<uint>? OnTypedPlay { get; set; }
|
|
|
|
public Fixture(
|
|
PhysicsStateFlags initialState,
|
|
Action<uint>? onPartArrayEnterWorld = null)
|
|
{
|
|
Spatial = new GpuWorldState();
|
|
Spatial.AddLandblock(new LoadedLandblock(
|
|
0x0101FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
Runtime = LiveEntityRuntimeFixture.Create(
|
|
Spatial,
|
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
|
WorldSession.EntitySpawn spawn = Spawn(initialState);
|
|
Runtime.RegisterLiveEntity(spawn);
|
|
Entity = Runtime.MaterializeLiveEntity(
|
|
Guid,
|
|
0x01010001u,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(10f, 10f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
})!;
|
|
Shadows.Register(
|
|
Entity.Id,
|
|
0x01000001u,
|
|
Entity.Position,
|
|
Entity.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x01010000u,
|
|
state: (uint)initialState,
|
|
seedCellId: 0x01010001u);
|
|
|
|
Controller = new LiveEntityPresentationController(
|
|
Runtime,
|
|
Shadows,
|
|
(owner, type, intensity) =>
|
|
{
|
|
TypedPlays.Add((owner, type, intensity));
|
|
PresentationOrder.Add($"effect:{type:X2}");
|
|
OnTypedPlay?.Invoke(type);
|
|
return true;
|
|
},
|
|
new LiveEntityPartArrayEnterWorldPort(localEntityId =>
|
|
{
|
|
PartArrayEnterWorld.Add(localEntityId);
|
|
PartArrayShadowCounts.Add(Shadows.TotalRegistered);
|
|
PresentationOrder.Add("part-array");
|
|
onPartArrayEnterWorld?.Invoke(localEntityId);
|
|
}),
|
|
(parent, noDraw) =>
|
|
{
|
|
ChildNoDraw.Add((parent, noDraw));
|
|
PresentationOrder.Add($"children:{noDraw}");
|
|
},
|
|
guid =>
|
|
{
|
|
InvalidTargets.Add(guid);
|
|
PresentationOrder.Add("target");
|
|
},
|
|
() => (1, 1),
|
|
onShadowRestored: null);
|
|
}
|
|
|
|
internal static WorldSession.EntitySpawn Spawn(
|
|
PhysicsStateFlags state,
|
|
ushort instanceSequence = 1)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
|
// C3c: residence admission requires the nested block's Instance
|
|
// timestamp to agree with the flattened InstanceSequence.
|
|
var timestamps = new PhysicsTimestamps(
|
|
1, 1, 1, 1, 0, 1, 0, 1, instanceSequence);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)state,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
Guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
MotionTableId: 0x09000001u,
|
|
PhysicsState: (uint)state,
|
|
InstanceSequence: instanceSequence,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
}
|
|
}
|