weather(phase-6a): port retail PhysicsScript runtime
The central runtime for every client-visible scripted effect server
triggers via PlayScript (opcode 0xF754) — spell casts, emote
gestures, combat flinches, AND lightning flashes during storms.
Previously acdream parsed PhysicsScript from the dat (via DRW) but
had no runner; the PlayScript stub in ParticleSystem.cs was a
no-op.
Decompile provenance (`docs/research/2026-04-23-physicsscript.md`,
`docs/research/2026-04-23-lightning-real.md`):
FUN_0051bed0 — play_script(scriptId) public API — resolves the
dat id, queues into the owner's ScriptManager list.
FUN_0051be40 — ScriptManager::Start — alloc 16-byte node
{startTime, script*, next}.
FUN_0051bf20 — advance one hook, schedule next fire by next
hook's StartTime.
FUN_0051bfb0 — per-frame tick: while head.NextHookAbsTime ≤
globalClock, fire via vtable dispatch.
Port choices:
- Flat List<ActiveScript> vs retail linked list — iteration is
simpler, N is small.
- Scripts keyed by (scriptId, entityId) — replay replaces instead
of stacking, matches retail's "play_script on the same obj
doesn't double-schedule".
- Anchor world pos cached at Play() time — good enough for
short-lived effects (lightning, spell casts). Callers that
need fresh positions for long emote animations can Play()
again each frame (idempotent).
- Constructor takes Func<uint, PhysicsScript?> resolver so tests
don't need DatCollection; production uses the DatCollection
overload that wraps Get<PhysicsScript> with null-on-fail.
- CallPESHook recurses Play() with Pause baked into the
sub-script's StartTimeAbs. Matches retail semantics where
nested scripts fire on the NEXT tick (list iteration order).
Diag: ACDREAM_DUMP_PLAYSCRIPT=1 logs every Play() and every fire as
[pes] lines. Use this to identify the actual script IDs your ACE
server is sending so we can confirm the lightning pipeline when the
server sends a strike.
Test coverage (9 new tests, all passing):
- unknown script returns false, zero id silent-ignore
- hooks fire in order at their scheduled times
- entityId + anchor pass through to sink
- replay same (scriptId, entityId) replaces, doesn't stack
- different entities run independently
- StopAllForEntity cancels that entity's scripts only
- CallPES nested spawn semantics (fires next tick)
- CallPES with Pause delays correctly
No GameWindow wiring yet — Phase 6b handles the 0xF754 packet
handler and Phase 6c plugs the runner into the frame loop.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a42750459
commit
845d70248c
2 changed files with 489 additions and 0 deletions
279
src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
Normal file
279
src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Types;
|
||||
// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in
|
||||
// VfxModel.cs; alias the dat-reader type to avoid name collision.
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime —
|
||||
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by
|
||||
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script
|
||||
/// start). Every visible effect the server triggers via the
|
||||
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
|
||||
/// spell casts, emote gestures, combat flinches, AND — per the
|
||||
/// 2026-04-23 lightning research — weather lightning flashes.
|
||||
///
|
||||
/// <para>
|
||||
/// Decompile provenance (see
|
||||
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
|
||||
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
|
||||
/// public API: resolves the dat id, allocates a script node, inserts
|
||||
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
|
||||
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
|
||||
/// schedules the next fire time based on the next hook's
|
||||
/// <c>StartTime</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
|
||||
/// <c>head.NextHookAbsTime <= globalClock</c>, fire hooks via
|
||||
/// vtable dispatch on the owner <c>PhysicsObj</c>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Design choices vs retail:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Flat list, not a linked list — iteration is
|
||||
/// simpler and N is small (< 100 active scripts in practice).
|
||||
/// </description></item>
|
||||
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
|
||||
/// — same pair re-played replaces the old instance so we don't
|
||||
/// stack duplicates when the server retriggers.
|
||||
/// </description></item>
|
||||
/// <item><description>The anchor world position is cached at spawn
|
||||
/// time. For long-running scripts on moving entities, the caller
|
||||
/// can <see cref="Play"/> again with a fresh position each
|
||||
/// frame — idempotent.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PhysicsScriptRunner
|
||||
{
|
||||
private readonly Func<uint, DatPhysicsScript?> _resolver;
|
||||
private readonly IAnimationHookSink _sink;
|
||||
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
|
||||
|
||||
// One active node per (scriptId, entityId) pair. Replaying replaces.
|
||||
private readonly List<ActiveScript> _active = new();
|
||||
private double _now; // absolute runtime in seconds
|
||||
|
||||
/// <summary>
|
||||
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment,
|
||||
/// every <see cref="Play"/> call and every hook fire prints a line
|
||||
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
|
||||
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
|
||||
/// and which script IDs those are. Off by default.
|
||||
/// </summary>
|
||||
public bool DiagEnabled { get; set; } =
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Preferred ctor — resolver delegate lets this class stay
|
||||
/// DatCollection-free for testing. Production code will pass
|
||||
/// a lambda that hits <c>DatCollection.Get<PhysicsScript></c>.
|
||||
/// </summary>
|
||||
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
|
||||
{
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience ctor — builds a resolver around a <see cref="DatCollection"/>.
|
||||
/// </summary>
|
||||
public PhysicsScriptRunner(DatCollection dats, IAnimationHookSink sink)
|
||||
: this(id => SafeGet(dats, id), sink)
|
||||
{
|
||||
}
|
||||
|
||||
private static DatPhysicsScript? SafeGet(DatCollection dats, uint id)
|
||||
{
|
||||
if (dats is null) return null;
|
||||
try { return dats.Get<DatPhysicsScript>(id); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Number of scripts currently active (for telemetry).</summary>
|
||||
public int ActiveScriptCount => _active.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Start (or restart) a PhysicsScript on the given entity.
|
||||
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
|
||||
/// <c>true</c> if the script was found and queued, <c>false</c>
|
||||
/// if the dat lookup failed. Replaying the same
|
||||
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
|
||||
/// instead of stacking.
|
||||
/// </summary>
|
||||
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
|
||||
{
|
||||
if (scriptId == 0) return false;
|
||||
|
||||
var script = ResolveScript(scriptId);
|
||||
if (script is null || script.ScriptData.Count == 0)
|
||||
{
|
||||
if (DiagEnabled)
|
||||
Console.WriteLine($"[pes] Play: script 0x{scriptId:X8} not found / empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dedupe: if this (scriptId, entityId) already has an active
|
||||
// instance, replace it — retail's ScriptManager doesn't
|
||||
// double-schedule the same script on the same object in the
|
||||
// common path.
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
}
|
||||
|
||||
_active.Add(new ActiveScript
|
||||
{
|
||||
Script = script,
|
||||
ScriptId = scriptId,
|
||||
EntityId = entityId,
|
||||
AnchorWorld = anchorWorldPos,
|
||||
StartTimeAbs = _now,
|
||||
NextHookIndex = 0,
|
||||
});
|
||||
|
||||
if (DiagEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pes] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " +
|
||||
$"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " +
|
||||
$"hooks={script.ScriptData.Count}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every active script by <paramref name="dtSeconds"/>.
|
||||
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
|
||||
/// (measured from the script's <see cref="Play"/> moment) has been
|
||||
/// reached. Removes scripts that have finished all their hooks.
|
||||
/// </summary>
|
||||
public void Tick(float dtSeconds)
|
||||
{
|
||||
if (dtSeconds < 0) dtSeconds = 0;
|
||||
_now += dtSeconds;
|
||||
|
||||
// Back-to-front so RemoveAt() is cheap and safe mid-iteration.
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var a = _active[i];
|
||||
double elapsed = _now - a.StartTimeAbs;
|
||||
|
||||
// Fire every hook whose scheduled time has arrived.
|
||||
while (a.NextHookIndex < a.Script.ScriptData.Count
|
||||
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed)
|
||||
{
|
||||
var entry = a.Script.ScriptData[a.NextHookIndex];
|
||||
DispatchHook(a, entry.Hook);
|
||||
a.NextHookIndex++;
|
||||
}
|
||||
|
||||
if (a.NextHookIndex >= a.Script.ScriptData.Count)
|
||||
_active.RemoveAt(i);
|
||||
else
|
||||
_active[i] = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop an active script instance by
|
||||
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
|
||||
/// despawns. Not necessary to call on normal script completion —
|
||||
/// scripts self-remove via <see cref="Tick"/>.
|
||||
/// </summary>
|
||||
public void Stop(uint scriptId, uint entityId)
|
||||
{
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
|
||||
public void StopAllForEntity(uint entityId)
|
||||
{
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchHook(ActiveScript a, AnimationHook hook)
|
||||
{
|
||||
if (DiagEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
|
||||
$"hook={hook.HookType}");
|
||||
}
|
||||
|
||||
// Handle the nested-script hook inline — it needs our runner.
|
||||
// Everything else delegates to the sink (ParticleHookSink
|
||||
// handles CreateParticle, DestroyParticle, StopParticle,
|
||||
// CreateBlockingParticle, etc).
|
||||
if (hook is CallPESHook call)
|
||||
{
|
||||
// CallPESHook.PES = sub-script id; Pause = delay before the
|
||||
// sub-script starts (retail's ScriptManager links it into
|
||||
// the list with StartTime = now + Pause). For our flat-list
|
||||
// design we just recurse Play() — the sub-script schedules
|
||||
// its own hooks from its own time zero. If Pause > 0 we
|
||||
// delay by baking it into the sub-script's StartTimeAbs.
|
||||
Play(call.PES, a.EntityId, a.AnchorWorld);
|
||||
if (call.Pause > 0f && _active.Count > 0)
|
||||
{
|
||||
var sub = _active[^1];
|
||||
sub.StartTimeAbs = _now + call.Pause;
|
||||
_active[^1] = sub;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_sink.OnHook(a.EntityId, a.AnchorWorld, hook);
|
||||
}
|
||||
|
||||
private DatPhysicsScript? ResolveScript(uint id)
|
||||
{
|
||||
if (_scriptCache.TryGetValue(id, out var cached)) return cached;
|
||||
var script = _resolver(id);
|
||||
_scriptCache[id] = script;
|
||||
return script;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam: pre-seed the resolver cache with a hand-built
|
||||
/// script so unit tests can exercise the scheduler without loading
|
||||
/// dats. Production code never calls this (name carries the warning).
|
||||
/// </summary>
|
||||
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
|
||||
=> _scriptCache[id] = script;
|
||||
|
||||
private struct ActiveScript
|
||||
{
|
||||
public DatPhysicsScript Script;
|
||||
public uint ScriptId;
|
||||
public uint EntityId;
|
||||
public Vector3 AnchorWorld;
|
||||
public double StartTimeAbs;
|
||||
public int NextHookIndex;
|
||||
}
|
||||
}
|
||||
210
tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
Normal file
210
tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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));
|
||||
}
|
||||
|
||||
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 });
|
||||
return script;
|
||||
}
|
||||
|
||||
private static CreateParticleHook CreateHook(uint emitterInfoId)
|
||||
=> new CreateParticleHook { EmitterInfoId = emitterInfoId };
|
||||
|
||||
private static PhysicsScriptRunner MakeRunner(RecordingSink sink, 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;
|
||||
return new PhysicsScriptRunner(
|
||||
id => table.TryGetValue(id, out var s) ? s : null,
|
||||
sink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_UnknownScript_ReturnsFalse()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink); // no scripts registered
|
||||
Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
Assert.Empty(sink.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_ZeroScriptId_IgnoredSilently()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink);
|
||||
Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero));
|
||||
Assert.Equal(0, runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HooksFire_InOrder_AtScheduledTimes()
|
||||
{
|
||||
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(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, 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.Equal(2, runner.ActiveScriptCount);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopAllForEntity_CancelsEntityScripts_LeavesOthers()
|
||||
{
|
||||
var script = BuildScript(
|
||||
(0.0, CreateHook(1)),
|
||||
(1.0, CreateHook(2)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, 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.StopAllForEntity(1);
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_NestedScript_SpawnsOnSameEntity()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3));
|
||||
|
||||
// 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
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallPES_WithPause_DelaysSubScript()
|
||||
{
|
||||
var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f }));
|
||||
var inner = BuildScript((0.0, CreateHook(99)));
|
||||
|
||||
var sink = new RecordingSink();
|
||||
var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner));
|
||||
runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue