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);
}
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;
}
}