From 845d70248cb271b9c0011a2bd941756cd8859c74 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:20:39 +0200 Subject: [PATCH] weather(phase-6a): port retail PhysicsScript runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 resolver so tests don't need DatCollection; production uses the DatCollection overload that wraps Get 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) --- src/AcDream.Core/Vfx/PhysicsScriptRunner.cs | 279 ++++++++++++++++++ .../Vfx/PhysicsScriptRunnerTests.cs | 210 +++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 src/AcDream.Core/Vfx/PhysicsScriptRunner.cs create mode 100644 tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs 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_0051bed0play_script(scriptId) +/// public API: resolves the dat id, allocates a script node, inserts +/// into the owner PhysicsObj's linked list at +0x30. +/// +/// FUN_0051be40ScriptManager::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); + } +}