feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue