acdream/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs

517 lines
17 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptRunnerTests
{
private sealed class RecordingSink : IAnimationHookSink
{
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)
{
var script = new DatPhysicsScript();
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() { EmitterInfoId = emitterInfoId };
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)
{
var table = scripts.ToDictionary(entry => entry.Id, entry => entry.Script);
return new PhysicsScriptRunner(
id => table.TryGetValue(id, out DatPhysicsScript? script) ? script : null,
sink,
clock,
randomUnit,
canAdvanceOwner);
}
[Fact]
public void PlayDirect_MissingOrZeroInputReturnsFalseWithoutQueueCorruption()
{
var sink = new RecordingSink();
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 HooksFireInOrderAtAbsoluteScheduledTimes()
{
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(100)),
(0.5, CreateHook(101)),
(1.0, CreateHook(102)));
var sink = new RecordingSink();
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 DuplicatePlaysAppendSeriallyToOneOwner()
{
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
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.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 DifferentOwnersProgressIndependently()
{
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(1.0, CreateHook(2)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(0.5);
runner.PlayDirect(2u, 0x330000AAu);
runner.Tick(1.0);
Assert.Equal([(1u, 1u), (1u, 2u), (2u, 1u)],
sink.Calls.Select(call =>
(call.EntityId, ((CreateParticleHook)call.Hook).EmitterInfoId.DataId)));
Assert.Equal(1, runner.ActiveScriptCount);
Assert.Equal(1, runner.ActiveOwnerCount);
}
[Fact]
public void CatchUpTickDrainsEveryDueHookAndQueuedScript()
{
DatPhysicsScript script = BuildScript(
(0.0, CreateHook(1)),
(0.25, CreateHook(2)),
(0.5, CreateHook(3)));
var sink = new RecordingSink();
var runner = MakeRunner(sink, scripts: [(0x330000AAu, script)]);
runner.PlayDirect(1u, 0x330000AAu);
runner.PlayDirect(1u, 0x330000AAu);
runner.Tick(5.0);
Assert.Equal([1u, 2u, 3u, 1u, 2u, 3u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void HookMayAppendToCurrentOwnerWithoutInvalidatingCatchUp()
{
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);
runner.Tick(0.0);
Assert.Equal([1u, 2u], EmitterIds(sink));
Assert.Equal(0, runner.ActiveScriptCount);
}
[Fact]
public void HookMayDeleteItsOwnerWithoutDispatchingDetachedTail()
{
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,
randomUnit: () => 0.5,
scripts: [(0x330000AAu, script)]);
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.True(runner.ScheduleCallPes(
1u,
0x330000AAu,
PhysicsScriptRunner.ImmediateCallPesThresholdSeconds / 2f));
Assert.Equal(1, runner.ActiveScriptCount);
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();
}