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); } AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 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; } private void AddActiveScript( DatPhysicsScript script, uint scriptId, uint entityId, Vector3 anchorWorldPos, float delaySeconds) { _active.Add(new ActiveScript { Script = script, ScriptId = scriptId, EntityId = entityId, AnchorWorld = anchorWorldPos, StartTimeAbs = _now + Math.Max(0f, delaySeconds), NextHookIndex = 0, }); } /// /// 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 links it into the active script // list with StartTime = now + Pause; our flat list preserves // that timing without replacing the currently running script. var subScript = ResolveScript(call.PES); if (subScript is null || subScript.ScriptData.Count == 0) { if (DiagEnabled) Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty"); return; } AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause); 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; } }