diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
new file mode 100644
index 0000000..f50f740
--- /dev/null
+++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
@@ -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;
+
+///
+/// Retail-verbatim port of the AC PhysicsScript runtime —
+/// a time-ordered list of s scheduled by
+/// (seconds from script
+/// start). Every visible effect the server triggers via the
+/// PlayScript opcode (0xF754) flows through this runner:
+/// spell casts, emote gestures, combat flinches, AND — per the
+/// 2026-04-23 lightning research — weather lightning flashes.
+///
+///
+/// Decompile provenance (see
+/// docs/research/2026-04-23-physicsscript.md and
+/// docs/research/2026-04-23-lightning-real.md):
+///
+/// - FUN_0051bed0 — play_script(scriptId)
+/// public API: resolves the dat id, allocates a script node, inserts
+/// into the owner PhysicsObj's linked list at +0x30.
+///
+/// - FUN_0051be40 — ScriptManager::Start:
+/// allocates the {startTime, script*, next} 16-byte node.
+///
+/// - FUN_0051bf20 — advances one hook,
+/// schedules the next fire time based on the next hook's
+/// StartTime.
+///
+/// - FUN_0051bfb0 — per-frame tick: while
+/// head.NextHookAbsTime <= globalClock, fire hooks via
+/// vtable dispatch on the owner PhysicsObj.
+///
+///
+///
+///
+///
+/// Design choices vs retail:
+///
+/// - Flat list, not a linked list — iteration is
+/// simpler and N is small (< 100 active scripts in practice).
+///
+/// - Scripts are keyed by (scriptId, entityId)
+/// — same pair re-played replaces the old instance so we don't
+/// stack duplicates when the server retriggers.
+///
+/// - The anchor world position is cached at spawn
+/// time. For long-running scripts on moving entities, the caller
+/// can again with a fresh position each
+/// frame — idempotent.
+///
+///
+///
+///
+public sealed class PhysicsScriptRunner
+{
+ private readonly Func _resolver;
+ private readonly IAnimationHookSink _sink;
+ private readonly Dictionary _scriptCache = new();
+
+ // One active node per (scriptId, entityId) pair. Replaying replaces.
+ private readonly List _active = new();
+ private double _now; // absolute runtime in seconds
+
+ ///
+ /// When ACDREAM_DUMP_PLAYSCRIPT=1 is set in the environment,
+ /// every call and every hook fire prints a line
+ /// prefixed with [pes]. Use this to confirm the server is
+ /// delivering PlayScript opcodes (lightning, spell casts, emotes)
+ /// and which script IDs those are. Off by default.
+ ///
+ public bool DiagEnabled { get; set; } =
+ System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
+
+ ///
+ /// Preferred ctor — resolver delegate lets this class stay
+ /// DatCollection-free for testing. Production code will pass
+ /// a lambda that hits DatCollection.Get<PhysicsScript>.
+ ///
+ public PhysicsScriptRunner(Func resolver, IAnimationHookSink sink)
+ {
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+ _sink = sink ?? throw new ArgumentNullException(nameof(sink));
+ }
+
+ ///
+ /// Convenience ctor — builds a resolver around a .
+ ///
+ 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(id); }
+ catch { return null; }
+ }
+
+ /// Number of scripts currently active (for telemetry).
+ public int ActiveScriptCount => _active.Count;
+
+ ///
+ /// Start (or restart) a PhysicsScript on the given entity.
+ /// Retail-equivalent of PhysicsObj::play_script. Returns
+ /// true if the script was found and queued, false
+ /// if the dat lookup failed. Replaying the same
+ /// (scriptId, entityId) pair replaces the prior instance
+ /// instead of stacking.
+ ///
+ 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;
+ }
+
+ ///
+ /// Advance every active script by .
+ /// Fires each hook whose
+ /// (measured from the script's moment) has been
+ /// reached. Removes scripts that have finished all their hooks.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Stop an active script instance by
+ /// (scriptId, entityId). Used for cleanup when an entity
+ /// despawns. Not necessary to call on normal script completion —
+ /// scripts self-remove via .
+ ///
+ 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);
+ }
+ }
+
+ /// Stop all scripts on an entity (e.g. on despawn).
+ 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
new file mode 100644
index 0000000..0eafa2e
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs
@@ -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
+{
+ ///
+ /// Recording sink so tests can assert each hook dispatch.
+ ///
+ 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();
+ 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);
+ }
+}