using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.Types; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; namespace AcDream.Core.Vfx; /// /// Retail PhysicsScript scheduler: one serial FIFO per owning physics object. /// Duplicate plays append, different owners progress independently, and a /// catch-up tick drains every due hook in order. /// /// /// Port of ScriptManager::AddScriptInternal (0x0051B310), /// ScriptManager::NextHook (0x0051B3F0), and /// ScriptManager::UpdateScripts (0x0051B480). /// Delayed nested calls follow CPhysicsObj::CallPES /// (0x00511AF0). /// public sealed class PhysicsScriptRunner { public const float ImmediateCallPesThresholdSeconds = 0.0002f; private readonly Func _resolver; private readonly IAnimationHookSink _sink; private readonly Func _clock; private readonly Func _randomUnit; private readonly Func _canAdvanceOwner; private readonly Dictionary _scriptCache = new(); private readonly Dictionary _owners = new(); private readonly Dictionary _ownerAnchors = new(); private readonly List _delayedCalls = new(); private double _gameTime; private long _tickGeneration; private bool _frameTimePublished; private bool _processingTimedHooks; private DispatchContext? _dispatchContext; public PhysicsScriptRunner( Func resolver, IAnimationHookSink sink, Func? clock = null, Func? randomUnit = null, Func? canAdvanceOwner = null) { _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _sink = sink ?? throw new ArgumentNullException(nameof(sink)); _clock = clock ?? (() => _gameTime); _randomUnit = randomUnit ?? Random.Shared.NextDouble; _canAdvanceOwner = canAdvanceOwner ?? (_ => true); } /// Queued PhysicsScripts across every owner, including tails. public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count); public int ActiveOwnerCount => _owners.Count; public int ScheduledCallPesCount => _delayedCalls.Count; public bool DiagEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1"; /// Always-on structural/load diagnostics; production supplies a logger. public Action? DiagnosticSink { get; set; } /// Updates the root anchor used when this owner's hooks dispatch. public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition) { if (ownerLocalId != 0) _ownerAnchors[ownerLocalId] = worldPosition; } /// Enqueues a direct F754/Setup PhysicsScript DID. public bool PlayDirect(uint ownerLocalId, uint scriptDid) { if (ownerLocalId == 0 || scriptDid == 0) return false; DatPhysicsScript? script = ResolveScript(scriptDid); if (script is null || script.ScriptData.Count == 0) { ReportDiagnostic( $"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} is missing or empty."); if (DiagEnabled) Console.WriteLine($"[pes] missing/empty script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8}"); return false; } if (script.ScriptData.Any(entry => !double.IsFinite(entry.StartTime))) { ReportDiagnostic( $"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} has a non-finite hook time."); return false; } if (!_owners.TryGetValue(ownerLocalId, out OwnerQueue? owner)) { owner = new OwnerQueue(); _owners.Add(ownerLocalId, owner); } double start = owner.Scripts.Count == 0 ? _clock() : owner.Scripts[^1].StartTime + owner.Scripts[^1].Duration; if (!double.IsFinite(start)) { ReportDiagnostic( $"PhysicsScript 0x{scriptDid:X8} for owner 0x{ownerLocalId:X8} produced a non-finite start time."); return false; } HashSet? immediateAncestors = null; if (_dispatchContext is { } dispatch && dispatch.OwnerLocalId == ownerLocalId && start <= dispatch.Script.StartTime) { immediateAncestors = dispatch.Script.ImmediateAncestors is null ? new HashSet { dispatch.Script.ScriptDid } : new HashSet(dispatch.Script.ImmediateAncestors) { dispatch.Script.ScriptDid }; if (immediateAncestors.Contains(scriptDid)) { ReportDiagnostic( $"Rejected zero-time recursive PhysicsScript 0x{scriptDid:X8} for owner " + $"0x{ownerLocalId:X8}; DAT CallPES chain would never yield."); return false; } } owner.Scripts.Add(new ScheduledScript( scriptDid, script, start, immediateAncestors, EarliestScriptTickGeneration(ownerLocalId))); if (DiagEnabled) Console.WriteLine($"[pes] enqueue script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8} start={start:R} depth={owner.Scripts.Count}"); return true; } /// /// Compatibility seam for existing static/sky callers. The scheduler is /// still keyed by canonical owner ID; the position merely refreshes its /// hook anchor before enqueue. /// public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos) { SetOwnerAnchor(entityId, anchorWorldPos); return PlayDirect(entityId, scriptId); } /// /// Implements retail CallPES. Near-zero pauses append immediately; a /// positive pause samples a deterministic injectable uniform delay in /// [0, maximumPause] and retains the call until due. /// public bool ScheduleCallPes(uint ownerLocalId, uint scriptDid, float maximumPause) { if (ownerLocalId == 0 || scriptDid == 0 || !float.IsFinite(maximumPause)) return false; if (maximumPause < ImmediateCallPesThresholdSeconds) return PlayDirect(ownerLocalId, scriptDid); double sample = _randomUnit(); if (!double.IsFinite(sample)) return false; sample = Math.Clamp(sample, 0.0, 1.0); double due = _clock() + sample * maximumPause; _delayedCalls.Add(new DelayedCallPes( ownerLocalId, scriptDid, due, EarliestTickGenerationForNewTimedHook())); return true; } /// /// Publishes the current frame clock before animation hooks are delivered, /// without running retail's timed-hook/script update pass. /// public void PublishTime(double gameTime) { ValidateMonotonicTime(gameTime); _gameTime = gameTime; _frameTimePublished = true; } /// Advances to an absolute game-clock timestamp. public void Tick(double gameTime) { ValidateMonotonicTime(gameTime); _gameTime = gameTime; _frameTimePublished = false; _tickGeneration++; DrainDueCallPes(gameTime); // Snapshot owner IDs. Hooks may append to their current owner, create a // different owner, or delete an owner without invalidating iteration. uint[] ownerIds = _owners.Keys.ToArray(); for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++) { uint ownerId = ownerIds[ownerIndex]; if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner)) continue; if (!_canAdvanceOwner(ownerId)) continue; DrainOwner(ownerId, owner, gameTime); } } /// Compatibility delta-time overload used by existing callers. public void Tick(float deltaSeconds) { if (deltaSeconds < 0f) deltaSeconds = 0f; Tick(_gameTime + deltaSeconds); } public void StopAllForEntity(uint ownerLocalId) { _owners.Remove(ownerLocalId); _ownerAnchors.Remove(ownerLocalId); _delayedCalls.RemoveAll(call => call.OwnerLocalId == ownerLocalId); } public void Clear() { _owners.Clear(); _ownerAnchors.Clear(); _delayedCalls.Clear(); } public void RegisterScriptForTest(uint id, DatPhysicsScript script) { ArgumentNullException.ThrowIfNull(script); _scriptCache[id] = script; } private void DrainDueCallPes(double gameTime) { // CPhysicsObj inserts timed object hooks at the head. Traversing in // reverse insertion order reproduces newest-first observation. _processingTimedHooks = true; try { for (int i = _delayedCalls.Count - 1; i >= 0; i--) { DelayedCallPes call = _delayedCalls[i]; if (call.DueTime > gameTime || call.EarliestTickGeneration > _tickGeneration || !_canAdvanceOwner(call.OwnerLocalId)) continue; _delayedCalls.RemoveAt(i); PlayDirect(call.OwnerLocalId, call.ScriptDid); } } finally { _processingTimedHooks = false; } } private void DrainOwner(uint ownerId, OwnerQueue owner, double gameTime) { while (owner.Scripts.Count > 0) { ScheduledScript current = owner.Scripts[0]; if (current.EarliestTickGeneration > _tickGeneration) return; while (current.NextHookIndex < current.Script.ScriptData.Count) { PhysicsScriptData entry = current.Script.ScriptData[current.NextHookIndex]; if (current.StartTime + entry.StartTime > gameTime) break; current.NextHookIndex++; DispatchHook(ownerId, current, entry.Hook); // A hook callback may delete the owner. Never continue through // the detached queue object after logical teardown. if (!_owners.TryGetValue(ownerId, out OwnerQueue? currentOwner) || !ReferenceEquals(currentOwner, owner)) return; } if (current.NextHookIndex < current.Script.ScriptData.Count) return; owner.Scripts.RemoveAt(0); } _owners.Remove(ownerId); } private void DispatchHook(uint ownerId, ScheduledScript script, AnimationHook hook) { if (DiagEnabled) Console.WriteLine($"[pes] fire script=0x{script.ScriptDid:X8} owner=0x{ownerId:X8} hook={hook.HookType}"); _ownerAnchors.TryGetValue(ownerId, out Vector3 anchor); DispatchContext? previous = _dispatchContext; _dispatchContext = new DispatchContext(ownerId, script); try { _sink.OnHook(ownerId, anchor, hook); } finally { _dispatchContext = previous; } } private DatPhysicsScript? ResolveScript(uint id) { if (_scriptCache.TryGetValue(id, out DatPhysicsScript? cached)) return cached; DatPhysicsScript? script; try { script = _resolver(id); } catch (Exception error) { ReportDiagnostic($"Failed to load PhysicsScript 0x{id:X8}: {error.Message}"); if (DiagEnabled) Console.WriteLine($"[pes] load failed script=0x{id:X8}: {error.Message}"); script = null; } _scriptCache[id] = script; return script; } private long EarliestTickGenerationForNewTimedHook() { // A positive-pause CallPES adds a timed object hook. Retail does not // revisit the active process_hooks traversal for a hook inserted by // the animation phase, even when its sampled delay is zero. Near-zero // CallPES bypasses this list and may reach the upcoming script pass. return _frameTimePublished ? _tickGeneration + 2 : _tickGeneration + 1; } private long EarliestScriptTickGeneration(uint ownerLocalId) { if (_processingTimedHooks || _dispatchContext is { OwnerLocalId: var dispatchOwner } && dispatchOwner == ownerLocalId) return _tickGeneration; return _tickGeneration + 1; } private void ValidateMonotonicTime(double gameTime) { if (!double.IsFinite(gameTime)) throw new ArgumentOutOfRangeException(nameof(gameTime)); if (gameTime < _gameTime) throw new ArgumentOutOfRangeException(nameof(gameTime), "PhysicsScript time must be monotonic."); } private void ReportDiagnostic(string message) => DiagnosticSink?.Invoke(message); private sealed class OwnerQueue { public List Scripts { get; } = new(); } private sealed class ScheduledScript { public ScheduledScript( uint scriptDid, DatPhysicsScript script, double startTime, HashSet? immediateAncestors, long earliestTickGeneration) { ScriptDid = scriptDid; Script = script; StartTime = startTime; Duration = script.ScriptData[^1].StartTime; ImmediateAncestors = immediateAncestors; EarliestTickGeneration = earliestTickGeneration; } public uint ScriptDid { get; } public DatPhysicsScript Script { get; } public double StartTime { get; } public double Duration { get; } public HashSet? ImmediateAncestors { get; } public long EarliestTickGeneration { get; } public int NextHookIndex { get; set; } } private readonly record struct DelayedCallPes( uint OwnerLocalId, uint ScriptDid, double DueTime, long EarliestTickGeneration); private sealed record DispatchContext(uint OwnerLocalId, ScheduledScript Script); }