acdream/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
Erik 8d63e5c28a test(vfx): harden live missile and effect lifetimes
Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 15:47:00 +02:00

815 lines
30 KiB
C#

using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectControllerTests
{
private const uint Guid = 0x70000001u;
private const uint DirectDid = 0x33000011u;
private const uint TypedDid = 0x33000022u;
private const uint TableDid = 0x34000033u;
private const uint RawType = 0x16u;
private sealed class NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) =>
Calls.Add((entityId, worldPosition, hook));
}
private sealed class Fixture
{
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
private readonly Dictionary<uint, PhysicsScriptTable> _tables = new();
private EntityEffectController? _controller;
public Fixture(
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Func<uint, PhysicsScriptTable?>? tableLoader = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
{
Spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
Spatial,
new NoopResources(),
record => _controller?.OnLiveEntityUnregistered(record));
Runner = new PhysicsScriptRunner(
id => _scripts.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
Router,
randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _controller?.CanAdvanceOwner(ownerId) ?? true);
Controller = new EntityEffectController(
Runtime,
Runner,
new PhysicsScriptTableResolver(
tableLoader
?? (id => _tables.TryGetValue(id, out PhysicsScriptTable? table) ? table : null)),
Poses,
childAtPart,
parentOfAttachedChild,
ownerUnregistered,
ownerSoundTableChanged);
_controller = Controller;
Router.Register(Sink);
Router.Register(Controller);
AddScript(DirectDid, 1u);
AddScript(TypedDid, 2u);
_tables.Add(TableDid, BuildTable(TableDid, RawType, (1f, TypedDid)));
}
public GpuWorldState Spatial { get; } = new();
public LiveEntityRuntime Runtime { get; }
public RecordingSink Sink { get; } = new();
public AnimationHookRouter Router { get; } = new();
public PhysicsScriptRunner Runner { get; }
public EntityEffectController Controller { get; }
public EntityEffectPoseRegistry Poses { get; } = new();
public void AddScript(uint did, uint emitterId, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = new CreateParticleHook { EmitterInfoId = emitterId },
});
_scripts[did] = script;
}
public void AddScript(uint did, AnimationHook hook, double startTime = 0.0)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = startTime,
Hook = hook,
});
_scripts[did] = script;
}
public WorldEntity ReadyLive(
uint guid = Guid,
ushort generation = 1,
EntityEffectProfile? profile = null)
{
WorldSession.EntitySpawn spawn = Spawn(guid, generation);
Runtime.RegisterLiveEntity(spawn);
Runtime.SetEffectProfile(guid, profile ?? LiveProfile());
WorldEntity entity = Runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid))!;
Assert.True(Controller.OnLiveEntityReady(guid));
return entity;
}
public static EntityEffectProfile LiveProfile(
uint tableDid = TableDid,
uint rawDefaultType = RawType,
float defaultIntensity = 0.5f,
uint? soundTableDid = null) =>
EntityEffectProfile.CreateLive(
new Setup(),
default(PhysicsSpawnData) with
{
PhysicsScriptTableId = tableDid,
SoundTableId = soundTableDid,
DefaultScriptType = rawDefaultType,
DefaultScriptIntensity = defaultIntensity,
});
}
[Fact]
public void PreMaterializationDirectAndTypedPacketsReplayOnceInMixedOrder()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(2, fixture.Controller.PendingPacketCount);
WorldEntity entity = fixture.ReadyLive();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
// A duplicate readiness notification is idempotent and cannot replay.
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
}
[Fact]
public void PreparedOwner_DoesNotReplayPendingPacketsUntilConstructionBarrierOpens()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.PrepareLiveEntityOwner(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Controller.ReplayPendingForLiveEntity(Guid));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
}
[Fact]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{
var fixture = new Fixture();
fixture.ReadyLive();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void CelllessOwnerRejectsNewPacketAndPausesExistingQueueUntilReentry()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Guid,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
1,
2),
out _));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(2, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.ReportCollisions, 1, 3),
out _));
fixture.Runner.Tick(2.0);
Assert.Equal([1u, 1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PreCreatePacketWaitsForProjectionButPostCreateCelllessPacketIsDropped()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
WorldEntity entity = fixture.Runtime.MaterializeLiveEntity(
Guid,
0x02020001u,
id => Entity(id, Guid))!;
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(0.0);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.All(fixture.Sink.Calls, call => Assert.Equal(entity.Id, call.EntityId));
}
[Fact]
public void LoadedToPendingMovePausesActiveQueueUntilLandblockLoads()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.5);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x02020001u));
fixture.Runner.Tick(2.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
fixture.Runner.Tick(2.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void PendingInvalidTypedPacketDoesNotCorruptFollowingDirectPacket()
{
var fixture = new Fixture();
fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, float.NaN));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.ReadyLive();
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void PacketNeverUsesServerGuidOrFallbackAnchorBeforeOwnerIsReady()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(10.0);
Assert.Empty(fixture.Sink.Calls);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(1, fixture.Controller.PendingPacketCount);
}
[Fact]
public void AcceptedDeleteClearsPendingGenerationBeforeGuidReuse()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn first = Spawn(Guid, generation: 1);
fixture.Runtime.RegisterLiveEntity(first);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
fixture.ReadyLive(Guid, generation: 2);
fixture.Runner.Tick(0.0);
Assert.Empty(fixture.Sink.Calls);
}
[Fact]
public void StaleDeleteCannotClearCurrentGenerationQueue()
{
var fixture = new Fixture();
WorldSession.EntitySpawn current = Spawn(Guid, generation: 2);
fixture.Runtime.RegisterLiveEntity(current);
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.False(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
current.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.OnLiveEntityReady(Guid));
fixture.Runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void UnknownDeleteClearsQueueThatNeverReceivedCreateObject()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ForgetUnknownOwner(Guid);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownStopsQueuedScriptsAndClearsReadyIdentity()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 60.0);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(0, fixture.Controller.ReadyOwnerCount);
Assert.Equal(0, fixture.Controller.PendingPacketCount);
}
[Fact]
public void LogicalTeardownReleasesOwnerScopedResourcesBeforeGuidReuse()
{
var removedOwners = new List<uint>();
var soundTables = new DictionaryEntitySoundTable();
var fixture = new Fixture(
ownerUnregistered: ownerId =>
{
removedOwners.Add(ownerId);
soundTables.Remove(ownerId);
},
ownerSoundTableChanged: (ownerId, soundTableDid) =>
{
soundTables.Remove(ownerId);
if (soundTableDid is { } did)
soundTables.Set(ownerId, did);
});
uint firstLocalId = fixture.ReadyLive(
Guid,
generation: 1,
Fixture.LiveProfile(soundTableDid: 0x200000EEu)).Id;
Assert.Equal(0x200000EEu, soundTables.GetSoundTableId(firstLocalId));
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
uint replacementLocalId = fixture.ReadyLive(Guid, generation: 2).Id;
Assert.Equal([firstLocalId], removedOwners);
Assert.Equal(0u, soundTables.GetSoundTableId(firstLocalId));
Assert.Equal(0u, soundTables.GetSoundTableId(replacementLocalId));
Assert.NotEqual(firstLocalId, replacementLocalId);
}
[Fact]
public void DelayedOldEffectTeardown_DoesNotClearReadyReplacementGeneration()
{
var fixture = new Fixture();
WorldEntity old = fixture.ReadyLive(Guid, generation: 1);
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord oldRecord));
WorldEntity replacement = fixture.ReadyLive(Guid, generation: 2);
fixture.Controller.OnLiveEntityUnregistered(oldRecord);
Assert.NotEqual(old.Id, replacement.Id);
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal(replacement.Id, Assert.Single(fixture.Sink.Calls).EntityId);
}
[Fact]
public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup { DefaultSoundTable = 0x200000DDu };
EntityEffectProfile networkOverride = EntityEffectProfile.CreateLive(
setup,
default(PhysicsSpawnData) with { SoundTableId = 0x200000EEu });
WorldEntity first = fixture.ReadyLive(profile: networkOverride);
Assert.False(fixture.Runtime.TryGetAnimationRuntime(first.Id, out _));
Assert.Equal([(first.Id, (uint?)0x200000EEu)], changed);
changed.Clear();
EntityEffectProfile networkAbsent = EntityEffectProfile.CreateLive(
setup,
default);
WorldEntity second = fixture.ReadyLive(
guid: 0x70000002u,
profile: networkAbsent);
Assert.Equal([(second.Id, (uint?)null)], changed);
}
[Fact]
public void SameGenerationDescriptionRefreshReplacesAndClearsLiveSoundTable()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
EntityEffectProfile profile = Fixture.LiveProfile(
soundTableDid: 0x200000EEu);
WorldEntity entity = fixture.ReadyLive(profile: profile);
changed.Clear();
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0x200000EFu,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = TableDid,
SoundTableId = 0u,
});
Assert.True(fixture.Controller.OnLiveEntityDescriptionChanged(Guid));
Assert.Equal(
[
(entity.Id, (uint?)0x200000EFu),
(entity.Id, (uint?)null),
],
changed);
}
[Fact]
public void CallPesAndDefaultHooksEnqueueThroughOwningProfiles()
{
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(nestedDid, 4u);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new DefaultScriptHook());
fixture.Controller.OnHook(
entity.Id,
entity.Position,
new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.Runner.Tick(0.0);
Assert.Equal([2u, 4u], EmitterIds(fixture.Sink));
}
[Fact]
public void RunnerRouterControllerPathExecutesNestedCallPes()
{
const uint outerDid = 0x33000043u;
const uint nestedDid = 0x33000044u;
var fixture = new Fixture();
fixture.AddScript(outerDid, new CallPESHook { PES = nestedDid, Pause = 0f });
fixture.AddScript(nestedDid, 4u);
fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, outerDid));
fixture.Runner.Tick(0.0);
Assert.Collection(
fixture.Sink.Calls,
call => Assert.IsType<CallPESHook>(call.Hook),
call => Assert.Equal(4u, Assert.IsType<CreateParticleHook>(call.Hook).EmitterInfoId.DataId));
}
[Fact]
public void DefaultScriptPartResolvesAttachedChildAndUsesChildProfile()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture((parent, part) =>
parent == parentLocalId && part == 7u ? childLocalId : null);
parentLocalId = fixture.ReadyLive(Guid, generation: 1).Id;
childLocalId = fixture.ReadyLive(0x70000002u, generation: 1).Id;
fixture.Controller.OnHook(
parentLocalId,
Vector3.Zero,
new DefaultScriptPartHook { PartIndex = 7u });
fixture.Runner.Tick(0.0);
var call = Assert.Single(fixture.Sink.Calls);
Assert.Equal(childLocalId, call.EntityId);
Assert.Equal(2u, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId);
}
[Fact]
public void AttachedChildAdvancesThroughEligibleParentWhileCellless()
{
uint parentLocalId = 0;
uint childLocalId = 0;
var fixture = new Fixture(
parentOfAttachedChild: child => child == childLocalId ? parentLocalId : null);
parentLocalId = fixture.ReadyLive(Guid).Id;
childLocalId = fixture.ReadyLive(0x70000002u).Id;
fixture.Runtime.WithdrawLiveEntityProjection(0x70000002u);
Assert.True(fixture.Controller.PlayDefault(childLocalId));
fixture.Runner.Tick(0.0);
Assert.Equal(childLocalId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Runtime.WithdrawLiveEntityProjection(Guid);
Assert.False(fixture.Controller.PlayDefault(childLocalId));
}
[Fact]
public void RefreshLiveOwnerPoses_PreservesComposedAttachedRootAndUsesItAsScriptAnchor()
{
const uint childGuid = 0x70000002u;
var fixture = new Fixture();
WorldEntity child = fixture.ReadyLive(childGuid);
WorldSession.EntitySpawn spawn = Spawn(childGuid, generation: 1);
fixture.Runtime.MaterializeLiveEntity(
childGuid,
spawn.Position!.Value.LandblockId,
_ => throw new InvalidOperationException("existing projection must be reused"),
LiveEntityProjectionKind.Attached);
Matrix4x4 attachedRoot = Matrix4x4.CreateTranslation(7, 8, 9);
fixture.Poses.Publish(
child.Id,
attachedRoot,
Array.Empty<Matrix4x4>(),
spawn.Position.Value.LandblockId);
fixture.Controller.RefreshLiveOwnerPoses();
fixture.Controller.HandleDirect(new PlayPhysicsScript(childGuid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.True(fixture.Poses.TryGetRootPose(child.Id, out Matrix4x4 retained));
Assert.Equal(new Vector3(7, 8, 9), retained.Translation);
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(fixture.Sink.Calls).Position);
}
[Fact]
public void MissingDirectScriptReportsOwnerAndDid()
{
const uint missingDid = 0x3300DEADu;
var fixture = new Fixture();
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, missingDid));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{missingDid:X8}", message, StringComparison.Ordinal);
}
[Fact]
public void TypedTableLoadFailureReportsOwnerTableAndException()
{
var fixture = new Fixture(
tableLoader: _ => throw new InvalidDataException("fixture corrupt table"));
WorldEntity entity = fixture.ReadyLive();
var diagnostics = new List<string>();
fixture.Controller.DiagnosticSink = diagnostics.Add;
fixture.Controller.HandleTyped(
new PlayPhysicsScriptType(Guid, RawType, 0.5f));
string message = Assert.Single(diagnostics);
Assert.Contains($"0x{entity.Id:X8}", message, StringComparison.Ordinal);
Assert.Contains($"0x{TableDid:X8}", message, StringComparison.Ordinal);
Assert.Contains(nameof(InvalidDataException), message, StringComparison.Ordinal);
Assert.Contains("fixture corrupt table", message, StringComparison.Ordinal);
}
[Fact]
public void StaticOwnerRetainsSetupTableAndIsRemovedSymmetrically()
{
var changed = new List<(uint OwnerId, uint? SoundTableDid)>();
var fixture = new Fixture(
ownerSoundTableChanged: (ownerId, soundTableDid) =>
changed.Add((ownerId, soundTableDid)));
var setup = new Setup
{
DefaultScriptTable = TableDid,
DefaultSoundTable = 0x200000DDu,
};
WorldEntity entity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
entity.Id,
entity,
EntityEffectProfile.CreateDatStatic(setup));
Assert.Equal([(entity.Id, (uint?)0x200000DDu)], changed);
Assert.True(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
fixture.Runner.Tick(0.0);
Assert.Equal([2u], EmitterIds(fixture.Sink));
fixture.Controller.OnDatStaticEntityRemoved(entity.Id);
Assert.False(fixture.Controller.PlayTyped(entity.Id, RawType, 0.5f));
}
[Fact]
public void RegisteredSyntheticOwnerAdvancesThroughGlobalRunnerEligibility()
{
const uint skyOwnerId = 0xF0000042u;
var fixture = new Fixture();
fixture.Controller.RegisterSyntheticOwner(skyOwnerId);
Assert.True(fixture.Runner.PlayDirect(skyOwnerId, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal(skyOwnerId, Assert.Single(fixture.Sink.Calls).EntityId);
fixture.Controller.UnregisterSyntheticOwner(skyOwnerId);
Assert.False(fixture.Controller.CanAdvanceOwner(skyOwnerId));
}
[Fact]
public void RefreshLiveOwnerPosesUsesCurrentWorldPositionAtDispatch()
{
var fixture = new Fixture();
fixture.AddScript(DirectDid, 1u, startTime: 1.0);
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30));
fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(1.0);
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
}
[Fact]
public void ClearNetworkStatePreservesDatStaticProfiles()
{
var fixture = new Fixture();
fixture.ReadyLive();
WorldEntity staticEntity = Entity(55u, 0u);
fixture.Controller.OnDatStaticEntityReady(
staticEntity.Id,
staticEntity,
EntityEffectProfile.CreateDatStatic(
new Setup { DefaultScriptTable = TableDid }));
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Controller.ClearNetworkState();
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
Assert.True(fixture.Controller.PlayTyped(staticEntity.Id, RawType, 0.5f));
}
private static PhysicsScriptTable BuildTable(
uint tableDid,
uint rawType,
params (float Mod, uint ScriptDid)[] entries)
{
var data = new PhysicsScriptTableData();
foreach ((float mod, uint scriptDid) in entries)
{
data.Scripts.Add(new ScriptAndModData
{
Mod = mod,
ScriptId = scriptDid,
});
}
var table = new PhysicsScriptTable { Id = tableDid };
table.ScriptTable.Add(unchecked((PlayScript)rawType), data);
return table;
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid, ushort generation)
{
var position = new CreateObject.ServerPosition(
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
1, 1, 1, 1, 0, 1, 0, 1, generation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: TableDid,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: RawType,
DefaultScriptIntensity: 0.5f,
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,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: generation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static uint[] EmitterIds(RecordingSink sink) =>
sink.Calls
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
.ToArray();
}