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

@ -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)
{