feat(vfx): port retail effect scheduling and delivery

This commit is contained in:
Erik 2026-07-14 09:25:44 +02:00
parent 363e046112
commit 96ddfdf175
28 changed files with 2473 additions and 691 deletions

View file

@ -0,0 +1,746 @@
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)),
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 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 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.RefreshLiveOwnerAnchors();
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 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 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 RefreshLiveOwnerAnchorsUsesCurrentWorldPositionAtDispatch()
{
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.RefreshLiveOwnerAnchors();
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();
}

View file

@ -15,6 +15,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000DDu, profile.CurrentSoundTableDid);
Assert.False(profile.HasNetworkDescription);
}
@ -27,6 +28,7 @@ public sealed class EntityEffectProfileTests
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
Assert.True(profile.HasNetworkDescription);
@ -38,6 +40,7 @@ public sealed class EntityEffectProfileTests
PhysicsSpawnData physics = default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
SoundTableId = 0x200000EEu,
DefaultScriptType = 0xA5A5A5A5u,
DefaultScriptIntensity = 0.75f,
};
@ -47,6 +50,7 @@ public sealed class EntityEffectProfileTests
physics);
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0x200000EEu, profile.CurrentSoundTableDid);
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
Assert.Equal(0.75f, profile.DefaultScriptIntensity);
}
@ -66,11 +70,13 @@ public sealed class EntityEffectProfileTests
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0u,
SoundTableId = 0u,
DefaultScriptType = 0u,
DefaultScriptIntensity = 0f,
});
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Null(profile.CurrentSoundTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
}
@ -79,5 +85,6 @@ public sealed class EntityEffectProfileTests
{
DefaultScript = 0x330000AAu,
DefaultScriptTable = 0x340000BBu,
DefaultSoundTable = 0x200000DDu,
};
}

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
@ -8,7 +9,7 @@ using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Rendering.Vfx;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityScriptActivatorTests
{
@ -209,7 +210,7 @@ public sealed class EntityScriptActivatorTests
Assert.True(system.ActiveEmitterCount > 0,
"Setup precondition failed: emitter should be alive after the hook fires.");
activator.OnRemove(0xCAFEu);
activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount);
// sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it.
@ -218,7 +219,7 @@ public sealed class EntityScriptActivatorTests
}
[Fact]
public void OnCreate_KeysByEntityId_WhenServerGuidZero()
public void OnCreate_UsesCanonicalStaticIdentity_WhenServerGuidZero()
{
// C.1.5b: dat-hydrated EnvCell statics + exterior stabs have
// ServerGuid == 0 but a stable entity.Id in the 0x40xxxxxx range.
@ -241,10 +242,49 @@ public sealed class EntityScriptActivatorTests
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
}
[Fact]
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
{
var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
activator.OnCreate(first);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => activator.OnCreate(second));
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
Assert.Equal(1, p.Runner.ActiveOwnerCount);
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
}
[Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var entity = new WorldEntity
{
Id = 1_000_123u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
}
[Fact]
public void OnCreate_PassesPartTransformsToSink()
{
@ -288,7 +328,7 @@ public sealed class EntityScriptActivatorTests
}
[Fact]
public void OnRemove_StopsByGivenKey_ForDatHydratedEntity()
public void OnRemove_StopsReservedOwner_ForDatHydratedEntity()
{
// C.1.5b: caller passes the entity.Id as the key for dat-hydrated
// entities (not ServerGuid). OnRemove must clean up correctly.
@ -318,48 +358,21 @@ public sealed class EntityScriptActivatorTests
runner.Tick(0.001f);
Assert.True(system.ActiveEmitterCount > 0);
activator.OnRemove(0x40A9B402u); // caller passes the entity.Id key
activator.OnRemove(entity);
Assert.Equal(0, runner.ActiveScriptCount);
system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount);
}
[Fact]
public void OnRemoveLegacyOwner_StopsPreMaterializationF754ServerGuidAlias()
private static WorldEntity MakeDatStatic(uint id, Vector3 position) => new()
{
const uint serverGuid = 0x7000CAFEu;
const uint localId = 0x00100001u;
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
var p = BuildPipeline((0xAAu, script));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
Id = id,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
// This is the current AD-32 pre-materialization F754 path: no local ID
// exists yet, so the direct PES is temporarily owned by server GUID.
Assert.True(activator.PlayLegacyPending(serverGuid, 0xAAu, Vector3.Zero));
Assert.Equal(1, p.Runner.ActiveScriptCount);
Assert.Equal(1, activator.LegacyPendingOwnerCount);
activator.OnRemoveLegacyOwner(serverGuid, localId);
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
[Fact]
public void ClearLegacyPendingOwners_StopsF754AliasesWithoutLiveRecords()
{
var script = BuildScript((60.0, new CreateParticleHook { EmitterInfoId = 100u }));
var p = BuildPipeline((0xAAu, script));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
Assert.True(activator.PlayLegacyPending(0x70000001u, 0xAAu, Vector3.Zero));
Assert.True(activator.PlayLegacyPending(0x70000002u, 0xAAu, Vector3.Zero));
Assert.Equal(2, p.Runner.ActiveScriptCount);
Assert.Equal(2, activator.LegacyPendingOwnerCount);
activator.ClearLegacyPendingOwners();
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
}

View file

@ -11,7 +11,7 @@ using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Streaming;
namespace AcDream.App.Tests.Streaming;
/// <summary>
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
@ -94,7 +94,7 @@ public sealed class GpuWorldStateActivatorTests
// Tick fires the CreateParticleHook into RecordingSink.
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
}

View file

@ -460,6 +460,46 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0, resources.UnregisterCount);
}
[Fact]
public void RootRuntimePausesWhilePendingOrFrozenAndResumesInVisibleCell()
{
const uint guid = 0x70000027u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
Assert.True(runtime.TryApplyState(
new SetState.Parsed(
guid,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Frozen),
1,
2),
out _));
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
Assert.True(runtime.TryApplyState(
new SetState.Parsed(
guid,
(uint)PhysicsStateFlags.ReportCollisions,
1,
3),
out _));
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
}
[Fact]
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
{
@ -701,109 +741,6 @@ public sealed class LiveEntityRuntimeTests
firstLocalEntityId: uint.MaxValue));
}
[Fact]
public void Delete_StopsScriptsOwnedByCanonicalLocalId_NotServerGuid()
{
const uint serverGuid = 0x70000013u;
const uint scriptId = 0x33000001u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 60d,
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
});
var particleSystem = new ParticleSystem(new EmitterDescRegistry());
var particleSink = new ParticleHookSink(particleSystem);
var runner = new PhysicsScriptRunner(
id => id == scriptId ? script : null,
particleSink);
var activator = new EntityScriptActivator(
runner,
particleSink,
_ => new ScriptActivationInfo(scriptId, Array.Empty<System.Numerics.Matrix4x4>()));
var runtime = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(
activator.OnCreate,
entity => activator.OnRemove(entity.Id)),
record => activator.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u));
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 7, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
// Current AD-32 behavior before Step 4's pending FIFO: an F754 that
// precedes materialization temporarily starts under server GUID.
Assert.True(activator.PlayLegacyPending(
serverGuid,
scriptId,
System.Numerics.Vector3.Zero));
Assert.Equal(1, runner.ActiveScriptCount);
WorldEntity entity = runtime.MaterializeLiveEntity(
serverGuid,
spawn.Position!.Value.LandblockId,
id => Entity(id, serverGuid))!;
Assert.NotEqual(serverGuid, entity.Id);
Assert.Equal(2, runner.ActiveScriptCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, spawn.InstanceSequence),
isLocalPlayer: false));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void StaleDelete_DoesNotStopCurrentGenerationPreMaterializationF754Alias()
{
const uint serverGuid = 0x70000027u;
const uint scriptId = 0x33000001u;
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 60d,
Hook = new CreateParticleHook { EmitterInfoId = 0x32000001u },
});
var particleSink = new ParticleHookSink(
new ParticleSystem(new EmitterDescRegistry()));
var runner = new PhysicsScriptRunner(
id => id == scriptId ? script : null,
particleSink);
var activator = new EntityScriptActivator(
runner,
particleSink,
_ => null);
var runtime = new LiveEntityRuntime(
new GpuWorldState(),
new RecordingResources(),
record => activator.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u));
WorldSession.EntitySpawn spawn = Spawn(serverGuid, 10, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
Assert.True(activator.PlayLegacyPending(
serverGuid,
scriptId,
System.Numerics.Vector3.Zero));
Assert.False(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, 9),
isLocalPlayer: false));
Assert.Equal(1, runner.ActiveScriptCount);
Assert.Equal(1, activator.LegacyPendingOwnerCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(serverGuid, 10),
isLocalPlayer: false));
Assert.Equal(0, runner.ActiveScriptCount);
Assert.Equal(0, activator.LegacyPendingOwnerCount);
}
[Fact]
public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails()
{

View file

@ -18,9 +18,15 @@ public sealed class RetailDatLoaderTests
private sealed class RawDatabase : IDatDatabase
{
private readonly Dictionary<uint, byte[]> _entries = new();
private int _activeReads;
private int _maxConcurrentReads;
private int _totalReads;
public DatDatabase Db => null!;
public int Iteration => 0;
public int ReadDelayMilliseconds { get; set; }
public int MaxConcurrentReads => Volatile.Read(ref _maxConcurrentReads);
public int TotalReads => Volatile.Read(ref _totalReads);
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
@ -28,8 +34,28 @@ public sealed class RetailDatLoaderTests
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
_entries.TryGetValue(fileId, out value);
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
{
Interlocked.Increment(ref _totalReads);
int active = Interlocked.Increment(ref _activeReads);
int observed;
do
{
observed = Volatile.Read(ref _maxConcurrentReads);
}
while (active > observed
&& Interlocked.CompareExchange(ref _maxConcurrentReads, active, observed) != observed);
try
{
if (ReadDelayMilliseconds > 0)
Thread.Sleep(ReadDelayMilliseconds);
return _entries.TryGetValue(fileId, out value);
}
finally
{
Interlocked.Decrement(ref _activeReads);
}
}
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
{
if (!_entries.TryGetValue(fileId, out byte[]? value))
@ -176,6 +202,47 @@ public sealed class RetailDatLoaderTests
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
}
[Fact]
public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads()
{
const uint firstDid = 0x33010010u;
const uint secondDid = 0x33010011u;
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
byte[] first = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
byte[] second = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(first, firstDid);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(second, secondDid);
portal.Add(firstDid, first);
portal.Add(secondDid, second);
var loader = new RetailPhysicsScriptLoader(portal);
await Task.WhenAll(
Task.Run(() => loader.LoadPhysicsScript(firstDid)),
Task.Run(() => loader.LoadPhysicsScript(secondDid)));
Assert.Equal(2, portal.MaxConcurrentReads);
Assert.Equal(2, portal.TotalReads);
}
[Fact]
public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid()
{
const uint scriptDid = 0x33010012u;
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(bytes, scriptDid);
portal.Add(scriptDid, bytes);
var loader = new RetailPhysicsScriptLoader(portal);
DatPhysicsScript?[] loaded = await Task.WhenAll(
Task.Run(() => loader.LoadPhysicsScript(scriptDid)),
Task.Run(() => loader.LoadPhysicsScript(scriptDid)));
Assert.All(loaded, Assert.NotNull);
Assert.Same(loaded[0], loaded[1]);
Assert.Equal(1, portal.TotalReads);
}
[Fact]
public void Parsers_RejectTrailingOrTruncatedEntries()
{
@ -192,6 +259,17 @@ public sealed class RetailDatLoaderTests
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
}
[Fact]
public void PhysicsScriptParserRejectsNonFiniteHookTime()
{
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(
script.AsSpan(8, 8),
BitConverter.DoubleToInt64Bits(double.NaN));
Assert.Throws<InvalidDataException>(() => RetailPhysicsScriptLoader.Parse(script));
}
[Fact]
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
{

View file

@ -1,234 +1,517 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptRunnerTests
{
/// <summary>
/// Recording sink so tests can assert each hook dispatch.
/// </summary>
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = new();
public Action<uint, Vector3, AnimationHook>? Callback { get; init; }
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
{
Calls.Add((entityId, worldPosition, hook));
Callback?.Invoke(entityId, worldPosition, hook);
}
}
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
private static DatPhysicsScript BuildScript(params (double Time, AnimationHook Hook)[] items)
{
var script = new DatPhysicsScript();
foreach (var (t, h) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
foreach ((double time, AnimationHook hook) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = time, Hook = hook });
return script;
}
private static CreateParticleHook CreateHook(uint emitterInfoId)
=> new CreateParticleHook { EmitterInfoId = emitterInfoId };
private static CreateParticleHook CreateHook(uint emitterInfoId) =>
new() { EmitterInfoId = emitterInfoId };
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts)
private static PhysicsScriptRunner MakeRunner(
IAnimationHookSink sink,
Func<double>? clock = null,
Func<double>? randomUnit = null,
Func<uint, bool>? canAdvanceOwner = null,
params (uint Id, DatPhysicsScript Script)[] scripts)
{
// Build an in-memory resolver from the script table — no DatCollection needed.
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
var table = scripts.ToDictionary(entry => entry.Id, entry => entry.Script);
return new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
sink);
id => table.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
sink,
clock,
randomUnit,
canAdvanceOwner);
}
[Fact]
public void Play_UnknownScript_ReturnsFalse()
public void PlayDirect_MissingOrZeroInputReturnsFalseWithoutQueueCorruption()
{
var sink = new RecordingSink();
var runner = MakeRunner(sink); // no scripts registered
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
var runner = MakeRunner(sink);
Assert.False(runner.PlayDirect(1u, 0u));
Assert.False(runner.PlayDirect(0u, 0x33000001u));
Assert.False(runner.PlayDirect(1u, 0x33000001u));
Assert.Equal(0, runner.ActiveScriptCount);
Assert.Empty(sink.Calls);
}
[Fact]
public void Play_ZeroScriptId_IgnoredSilently()
public void HooksFireInOrderAtAbsoluteScheduledTimes()
{
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(100)),
(0.5, CreateHook(101)),
(1.0, CreateHook(102)));
var sink = new RecordingSink();
var runner = MakeRunner(sink);
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero));
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.SetOwnerAnchor(7u, new Vector3(1, 2, 3));
Assert.True(runner.PlayDirect(7u, 0x330000AAu));
runner.Tick(0.25);
runner.Tick(0.60);
runner.Tick(1.50);
Assert.Equal([100u, 101u, 102u],
sink.Calls.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId));
Assert.All(sink.Calls, call => Assert.Equal(new Vector3(1, 2, 3), call.Position));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void HooksFire_InOrder_AtScheduledTimes()
public void DuplicatePlaysAppendSeriallyToOneOwner()
{
var script = BuildScript(
(0.0, CreateHook(100)),
(0.5, CreateHook(101)),
(1.0, CreateHook(102)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
runner.Tick(0.25f);
Assert.Single(sink.Calls);
Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
runner.Tick(0.35f); // total 0.6
Assert.Equal(2, sink.Calls.Count);
Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
runner.Tick(0.9f); // total 1.5
Assert.Equal(3, sink.Calls.Count);
Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId);
Assert.Equal(0, runner.ActiveScriptCount); // fully consumed
}
[Fact]
public void EntityIdAndAnchor_ArePassedThrough()
{
var script = BuildScript((0.0, CreateHook(1)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
var anchor = new Vector3(123, 45, 67);
runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor);
runner.Tick(0.1f);
Assert.Single(sink.Calls);
Assert.Equal(0xCAFEu, sink.Calls[0].EntityId);
Assert.Equal(anchor, sink.Calls[0].Pos);
}
[Fact]
public void Replay_SameScriptSameEntity_Replaces_DoesNotStack()
{
var script = BuildScript(
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
runner.Tick(0.1f);
Assert.Single(sink.Calls);
// Re-play — the old instance should be replaced, not stacked.
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(0.1f);
Assert.Equal(2, sink.Calls.Count);
// Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1.
Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId);
}
[Fact]
public void Replay_DifferentEntities_BothActiveConcurrently()
{
var script = BuildScript((0.0, CreateHook(42)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0));
runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0));
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
Assert.Equal(2, runner.ActiveScriptCount);
Assert.Equal(1, runner.ActiveOwnerCount);
runner.Tick(0.1f);
Assert.Equal(2, sink.Calls.Count);
Assert.Contains(sink.Calls, c => c.EntityId == 1u);
Assert.Contains(sink.Calls, c => c.EntityId == 2u);
runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(sink));
// At exactly one second the first tail and the second head are both due.
runner.Tick(1.0);
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
runner.Tick(2.0);
Assert.Equal([1u, 2u, 1u, 2u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers()
public void DifferentOwnersProgressIndependently()
{
var script = BuildScript(
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero);
runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero);
runner.Tick(0.1f); // both fire hook 0
Assert.Equal(2, sink.Calls.Count);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(0.5);
runner.PlayDirect(2u, 0x330000AAu);
runner.Tick(1.0);
runner.StopAllForEntity(1);
Assert.Equal([(1u, 1u), (1u, 2u), (2u, 1u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(2.0f); // only entity 2's script should fire hook 1
Assert.Equal(3, sink.Calls.Count);
Assert.Equal(2u, sink.Calls[^1].EntityId);
Assert.Equal(1, runner.ActiveOwnerCount);
}
[Fact]
public void CallPES_NestedScript_SpawnsOnSameEntity()
public void CatchUpTickDrainsEveryDueHookAndQueuedScript()
{
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f }));
var inner = BuildScript((0.0, CreateHook(99)));
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(0.25, CreateHook(2)),
(0.5, CreateHook(3)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.PlayDirect(1u, 0x330000AAu);
// First tick fires the CallPES hook. Inner script gets queued to
// _active but does NOT fire this tick (we iterate _active
// backwards, and the inner is appended AFTER the current index) —
// matches retail's linked-list insertion semantics. Inner fires
// on the NEXT tick instead.
runner.Tick(0.1f);
Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit
Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done
runner.Tick(5.0);
// Second tick — inner's hook at t=0 fires now.
runner.Tick(0.1f);
Assert.Single(sink.Calls);
Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
Assert.Equal(0x7u, sink.Calls[0].EntityId);
Assert.Equal([1u, 2u, 3u, 1u, 2u, 3u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void CallPES_WithPause_DelaysSubScript()
public void HookMayAppendToCurrentOwnerWithoutInvalidatingCatchUp()
{
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f }));
var inner = BuildScript((0.0, CreateHook(99)));
DatPhysicsScript outer = BuildScript((0.0, CreateHook(1)));
DatPhysicsScript nested = BuildScript((0.0, CreateHook(2)));
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
runner!.PlayDirect(owner, 0x330000BBu);
},
};
runner = MakeRunner(
sink,
scripts: [(0x330000AAu, outer), (0x330000BBu, nested)]);
runner.PlayDirect(1u, 0x330000AAu);
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
runner.Tick(0.0);
// CallPES fires immediately, but inner script's hook is gated by Pause.
runner.Tick(0.1f);
Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s
runner.Tick(0.5f); // total 0.6 > 0.5 pause
Assert.Single(sink.Calls);
Assert.Equal([1u, 2u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance()
public void HookMayDeleteItsOwnerWithoutDispatchingDetachedTail()
{
var script = BuildScript(
(0.0, new CallPESHook { PES = 0xAA, Pause = 30f }),
(0.0, CreateHook(123)));
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(0.0, CreateHook(2)));
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
runner!.StopAllForEntity(owner);
},
};
runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(0.0);
Assert.Equal([1u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void ScheduleCallPesUsesInjectedUniformDelayAndNearZeroAppendsImmediately()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, (0xAAu, script));
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
var runner = MakeRunner(
sink,
randomUnit: () => 0.5,
scripts: [(0x330000AAu, script)]);
runner.Tick(0.1f);
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
Assert.Equal(1, runner.ScheduledCallPesCount);
runner.Tick(0.999);
Assert.Empty(sink.Calls);
runner.Tick(1.0);
Assert.Equal([9u], EmitterIds(sink));
Assert.Single(sink.Calls);
Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId);
Assert.True(runner.ScheduleCallPes(
1u,
0x330000AAu,
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
Assert.Equal(1, runner.ActiveScriptCount);
runner.Tick(29.8f);
Assert.Single(sink.Calls);
runner.Tick(0.3f);
Assert.Equal(2, sink.Calls.Count);
runner.Tick(1.0);
Assert.Equal([9u, 9u], EmitterIds(sink));
}
[Fact]
public void PublishedAnimationPhaseRunsImmediateScriptButDefersTimedCallOnePass()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.0,
scripts: [(0x330000AAu, script)]);
runner.PublishTime(1.0);
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 0.5f));
Assert.True(runner.ScheduleCallPes(
2u,
0x330000AAu,
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
runner.Tick(1.0);
Assert.Equal([(2u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
runner.PublishTime(1.1);
runner.Tick(1.1);
Assert.Equal([(2u, 9u), (1u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
}
[Fact]
public void UpdatePhasePlayUsesPublishedCurrentClockForPositiveHookOffset()
{
DatPhysicsScript script = BuildScript((0.1, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PublishTime(1.0);
Assert.True(runner.PlayDirect(1u, 0x330000AAu));
runner.Tick(1.0);
Assert.Empty(sink.Calls);
runner.PublishTime(1.1);
runner.Tick(1.1);
Assert.Equal([9u], EmitterIds(sink));
}
[Fact]
public void IneligibleOwnerRetainsScriptsAndTimedCallsUntilReentry()
{
bool eligible = true;
DatPhysicsScript script = BuildScript((1.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.5,
canAdvanceOwner: _ => eligible,
scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.ScheduleCallPes(2u, 0x330000AAu, 2f);
eligible = false;
runner.Tick(2.0);
Assert.Empty(sink.Calls);
Assert.Equal(1, runner.ActiveScriptCount);
Assert.Equal(1, runner.ScheduledCallPesCount);
eligible = true;
runner.Tick(2.0);
Assert.Equal([(1u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
runner.Tick(3.0);
Assert.Equal([(1u, 9u), (2u, 9u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
}
[Fact]
public void ZeroTimeRecursiveCallChainIsRejectedWithoutHanging()
{
const uint firstDid = 0x330000AAu;
const uint secondDid = 0x330000BBu;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
? secondDid
: firstDid;
runner!.PlayDirect(owner, target);
},
};
runner = MakeRunner(
sink,
scripts:
[
(firstDid, BuildScript((0.0, CreateHook(1)))),
(secondDid, BuildScript((0.0, CreateHook(2)))),
]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
runner.PlayDirect(1u, firstDid);
runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(sink));
Assert.Single(diagnostics, message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void PositiveDurationRecursiveChainDrainsCatchUpWithoutFalseCycleRejection()
{
const uint firstDid = 0x33000428u;
const uint secondDid = 0x33000429u;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (owner, _, hook) =>
{
uint target = ((CreateParticleHook)hook).EmitterInfoId.DataId == 1u
? secondDid
: firstDid;
runner!.PlayDirect(owner, target);
},
};
runner = MakeRunner(
sink,
scripts:
[
(firstDid, BuildScript((2.8, CreateHook(1)))),
(secondDid, BuildScript((2.8, CreateHook(2)))),
]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
runner.PlayDirect(1u, firstDid);
runner.Tick(8.4);
Assert.Equal([1u, 2u, 1u], EmitterIds(sink));
Assert.DoesNotContain(
diagnostics,
message => message.Contains("recursive", StringComparison.OrdinalIgnoreCase));
Assert.Equal(1, runner.ActiveScriptCount);
}
[Fact]
public void CrossOwnerReentrantPlayWaitsForNextGlobalScriptPass()
{
const uint parentDid = 0x330000AAu;
const uint childCurrentDid = 0x330000BBu;
const uint childDefaultDid = 0x330000CCu;
PhysicsScriptRunner? runner = null;
var sink = new RecordingSink
{
Callback = (_, _, hook) =>
{
if (((CreateParticleHook)hook).EmitterInfoId.DataId == 1u)
runner!.PlayDirect(2u, childDefaultDid);
},
};
runner = MakeRunner(
sink,
scripts:
[
(parentDid, BuildScript((0.0, CreateHook(1)))),
(childCurrentDid, BuildScript((0.0, CreateHook(2)))),
(childDefaultDid, BuildScript((0.0, CreateHook(3)))),
]);
runner.PlayDirect(1u, parentDid);
runner.PlayDirect(2u, childCurrentDid);
runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(sink));
runner.Tick(0.0);
Assert.Equal([1u, 2u, 3u], EmitterIds(sink));
}
[Fact]
public void NonFiniteTimelineIsRejectedBeforeItCanBypassRecursionGuard()
{
const uint scriptDid = 0x330000AAu;
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
scripts: [(scriptDid, BuildScript((double.NaN, CreateHook(1))))]);
var diagnostics = new List<string>();
runner.DiagnosticSink = diagnostics.Add;
Assert.False(runner.PlayDirect(1u, scriptDid));
runner.Tick(0.0);
Assert.Empty(sink.Calls);
Assert.Contains(diagnostics, message => message.Contains("non-finite", StringComparison.Ordinal));
}
[Fact]
public void ScheduleCallPesRejectsNonFiniteInputsAndStopClearsDelayedCalls()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(9)));
var sink = new RecordingSink();
var runner = MakeRunner(
sink,
randomUnit: () => 0.5,
scripts: [(0x330000AAu, script)]);
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.NaN));
Assert.False(runner.ScheduleCallPes(1u, 0x330000AAu, float.PositiveInfinity));
Assert.True(runner.ScheduleCallPes(1u, 0x330000AAu, 2f));
runner.StopAllForEntity(1u);
runner.Tick(10.0);
Assert.Equal(0, runner.ScheduledCallPesCount);
Assert.Empty(sink.Calls);
}
[Fact]
public void CurrentAnchorIsReadWhenHookFires()
{
DatPhysicsScript script = BuildScript((1.0, CreateHook(1)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.SetOwnerAnchor(1u, new Vector3(1, 0, 0));
runner.PlayDirect(1u, 0x330000AAu);
runner.SetOwnerAnchor(1u, new Vector3(7, 8, 9));
runner.Tick(1.0);
Assert.Equal(new Vector3(7, 8, 9), Assert.Single(sink.Calls).Position);
}
[Fact]
public void EveryHookFansOutThroughCompleteRouter()
{
DatPhysicsScript script = BuildScript((0.0, CreateHook(1)));
var first = new RecordingSink();
var second = new RecordingSink();
var router = new AnimationHookRouter();
router.Register(first);
router.Register(second);
var runner = MakeRunner(router, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(0.0);
Assert.Single(first.Calls);
Assert.Single(second.Calls);
}
[Fact]
public void LoaderFailureIsCachedAndDoesNotCorruptOtherOwners()
{
DatPhysicsScript good = BuildScript((0.0, CreateHook(1)));
int failingLoads = 0;
var sink = new RecordingSink();
var runner = new PhysicsScriptRunner(
id =>
{
if (id == 0x330000AAu)
return good;
failingLoads++;
throw new InvalidDataException("fixture DAT failure");
},
sink);
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
Assert.False(runner.PlayDirect(1u, 0x330000BBu));
Assert.True(runner.PlayDirect(2u, 0x330000AAu));
runner.Tick(0.0);
Assert.Equal(1, failingLoads);
Assert.Equal([1u], EmitterIds(sink));
}
[Fact]
public void TickRejectsNonFiniteOrBackwardsTime()
{
var runner = MakeRunner(new RecordingSink());
runner.Tick(1.0);
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(0.5));
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.NaN));
Assert.Throws<ArgumentOutOfRangeException>(() => runner.Tick(double.PositiveInfinity));
}
private static uint[] EmitterIds(RecordingSink sink) =>
sink.Calls
.Select(call => ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)
.ToArray();
}

View file

@ -97,6 +97,17 @@ public sealed class PhysicsScriptTableResolverTests
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
}
[Fact]
public void Resolve_LoaderFailureReturnsNoPlayAndPreservesDiagnosticException()
{
var resolver = new PhysicsScriptTableResolver(
_ => throw new InvalidDataException("fixture DAT failure"));
Assert.Null(resolver.Resolve(TableDid, RawType, 0f, out Exception? failure));
var invalid = Assert.IsType<InvalidDataException>(failure);
Assert.Equal("fixture DAT failure", invalid.Message);
}
private static PhysicsScriptTable BuildTable(
params (float Mod, uint ScriptDid)[] entries)
{

View file

@ -87,4 +87,20 @@ public class InteriorEntityIdAllocatorTests
// that accidentally narrows it again gets caught here first.
Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter);
}
[Fact]
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
{
uint counter = InteriorEntityIdAllocator.MaxCounter;
uint last = InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter);
Assert.Equal(
InteriorEntityIdAllocator.Base(0xA9u, 0xB4u)
+ InteriorEntityIdAllocator.MaxCounter,
last);
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => InteriorEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter));
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
}
}

View file

@ -163,6 +163,19 @@ public class LandblockLoaderTests
Assert.Equal(1u, entities[0].Id);
}
[Fact]
public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias()
{
var info = new LandBlockInfo();
for (uint i = 0; i < 256u; i++)
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u));
Assert.Contains("255-entry", error.Message, StringComparison.Ordinal);
}
[Fact]
public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue()
{