feat(vfx): port retail effect scheduling and delivery
This commit is contained in:
parent
363e046112
commit
96ddfdf175
28 changed files with 2473 additions and 691 deletions
|
|
@ -1,273 +1,400 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-verbatim port of the AC <c>PhysicsScript</c> runtime —
|
||||
/// a time-ordered list of <see cref="AnimationHook"/>s scheduled by
|
||||
/// <see cref="PhysicsScriptData.StartTime"/> (seconds from script
|
||||
/// start). Every visible effect the server triggers via the
|
||||
/// <c>PlayScript</c> opcode (0xF754) flows through this runner:
|
||||
/// spell casts, emote gestures, combat flinches, AND — per the
|
||||
/// 2026-04-23 lightning research — weather lightning flashes.
|
||||
///
|
||||
/// <para>
|
||||
/// Decompile provenance (see
|
||||
/// <c>docs/research/2026-04-23-physicsscript.md</c> and
|
||||
/// <c>docs/research/2026-04-23-lightning-real.md</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>FUN_0051bed0</c> — <c>play_script(scriptId)</c>
|
||||
/// public API: resolves the dat id, allocates a script node, inserts
|
||||
/// into the owner <c>PhysicsObj</c>'s linked list at <c>+0x30</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051be40</c> — <c>ScriptManager::Start</c>:
|
||||
/// allocates the <c>{startTime, script*, next}</c> 16-byte node.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bf20</c> — advances one hook,
|
||||
/// schedules the next fire time based on the next hook's
|
||||
/// <c>StartTime</c>.
|
||||
/// </description></item>
|
||||
/// <item><description><c>FUN_0051bfb0</c> — per-frame tick: while
|
||||
/// <c>head.NextHookAbsTime <= globalClock</c>, fire hooks via
|
||||
/// vtable dispatch on the owner <c>PhysicsObj</c>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Design choices vs retail:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Flat list, not a linked list — iteration is
|
||||
/// simpler and N is small (< 100 active scripts in practice).
|
||||
/// </description></item>
|
||||
/// <item><description>Scripts are keyed by <c>(scriptId, entityId)</c>
|
||||
/// — same pair re-played replaces the old instance so we don't
|
||||
/// stack duplicates when the server retriggers.
|
||||
/// </description></item>
|
||||
/// <item><description>The anchor world position is cached at spawn
|
||||
/// time. For long-running scripts on moving entities, the caller
|
||||
/// can <see cref="Play"/> again with a fresh position each
|
||||
/// frame — idempotent.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port of <c>ScriptManager::AddScriptInternal</c> (<c>0x0051B310</c>),
|
||||
/// <c>ScriptManager::NextHook</c> (<c>0x0051B3F0</c>), and
|
||||
/// <c>ScriptManager::UpdateScripts</c> (<c>0x0051B480</c>).
|
||||
/// Delayed nested calls follow <c>CPhysicsObj::CallPES</c>
|
||||
/// (<c>0x00511AF0</c>).
|
||||
/// </remarks>
|
||||
public sealed class PhysicsScriptRunner
|
||||
{
|
||||
public const float ImmediateCallPesThresholdSeconds = 0.0002f;
|
||||
|
||||
private readonly Func<uint, DatPhysicsScript?> _resolver;
|
||||
private readonly IAnimationHookSink _sink;
|
||||
private readonly Func<double> _clock;
|
||||
private readonly Func<double> _randomUnit;
|
||||
private readonly Func<uint, bool> _canAdvanceOwner;
|
||||
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
|
||||
private readonly Dictionary<uint, OwnerQueue> _owners = new();
|
||||
private readonly Dictionary<uint, Vector3> _ownerAnchors = new();
|
||||
private readonly List<DelayedCallPes> _delayedCalls = new();
|
||||
private double _gameTime;
|
||||
private long _tickGeneration;
|
||||
private bool _frameTimePublished;
|
||||
private bool _processingTimedHooks;
|
||||
private DispatchContext? _dispatchContext;
|
||||
|
||||
// One active node per (scriptId, entityId) pair. Replaying replaces.
|
||||
private readonly List<ActiveScript> _active = new();
|
||||
private double _now; // absolute runtime in seconds
|
||||
|
||||
/// <summary>
|
||||
/// When <c>ACDREAM_DUMP_PLAYSCRIPT=1</c> is set in the environment,
|
||||
/// every <see cref="Play"/> call and every hook fire prints a line
|
||||
/// prefixed with <c>[pes]</c>. Use this to confirm the server is
|
||||
/// delivering PlayScript opcodes (lightning, spell casts, emotes)
|
||||
/// and which script IDs those are. Off by default.
|
||||
/// </summary>
|
||||
public bool DiagEnabled { get; set; } =
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Preferred ctor — resolver delegate lets this class stay
|
||||
/// DAT-reader-free for testing. Production passes the centralized retail
|
||||
/// compatibility loader so CreateBlockingParticle cannot misalign hooks.
|
||||
/// </summary>
|
||||
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
|
||||
public PhysicsScriptRunner(
|
||||
Func<uint, DatPhysicsScript?> resolver,
|
||||
IAnimationHookSink sink,
|
||||
Func<double>? clock = null,
|
||||
Func<double>? randomUnit = null,
|
||||
Func<uint, bool>? canAdvanceOwner = null)
|
||||
{
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_clock = clock ?? (() => _gameTime);
|
||||
_randomUnit = randomUnit ?? Random.Shared.NextDouble;
|
||||
_canAdvanceOwner = canAdvanceOwner ?? (_ => true);
|
||||
}
|
||||
|
||||
/// <summary>Number of scripts currently active (for telemetry).</summary>
|
||||
public int ActiveScriptCount => _active.Count;
|
||||
/// <summary>Queued PhysicsScripts across every owner, including tails.</summary>
|
||||
public int ActiveScriptCount => _owners.Values.Sum(owner => owner.Scripts.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Start (or restart) a PhysicsScript on the given entity.
|
||||
/// Retail-equivalent of <c>PhysicsObj::play_script</c>. Returns
|
||||
/// <c>true</c> if the script was found and queued, <c>false</c>
|
||||
/// if the dat lookup failed. Replaying the same
|
||||
/// <c>(scriptId, entityId)</c> pair replaces the prior instance
|
||||
/// instead of stacking.
|
||||
/// </summary>
|
||||
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
|
||||
public int ActiveOwnerCount => _owners.Count;
|
||||
public int ScheduledCallPesCount => _delayedCalls.Count;
|
||||
|
||||
public bool DiagEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1";
|
||||
|
||||
/// <summary>Always-on structural/load diagnostics; production supplies a logger.</summary>
|
||||
public Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
/// <summary>Updates the root anchor used when this owner's hooks dispatch.</summary>
|
||||
public void SetOwnerAnchor(uint ownerLocalId, Vector3 worldPosition)
|
||||
{
|
||||
if (scriptId == 0) return false;
|
||||
if (ownerLocalId != 0)
|
||||
_ownerAnchors[ownerLocalId] = worldPosition;
|
||||
}
|
||||
|
||||
var script = ResolveScript(scriptId);
|
||||
/// <summary>Enqueues a direct F754/Setup PhysicsScript DID.</summary>
|
||||
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] Play: script 0x{scriptId:X8} not found / empty");
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 (!_owners.TryGetValue(ownerLocalId, out OwnerQueue? owner))
|
||||
{
|
||||
if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId)
|
||||
_active.RemoveAt(i);
|
||||
owner = new OwnerQueue();
|
||||
_owners.Add(ownerLocalId, owner);
|
||||
}
|
||||
|
||||
AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0);
|
||||
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<uint>? immediateAncestors = null;
|
||||
if (_dispatchContext is { } dispatch
|
||||
&& dispatch.OwnerLocalId == ownerLocalId
|
||||
&& start <= dispatch.Script.StartTime)
|
||||
{
|
||||
immediateAncestors = dispatch.Script.ImmediateAncestors is null
|
||||
? new HashSet<uint> { dispatch.Script.ScriptDid }
|
||||
: new HashSet<uint>(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] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " +
|
||||
$"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " +
|
||||
$"hooks={script.ScriptData.Count}");
|
||||
}
|
||||
Console.WriteLine($"[pes] enqueue script=0x{scriptDid:X8} owner=0x{ownerLocalId:X8} start={start:R} depth={owner.Scripts.Count}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddActiveScript(
|
||||
DatPhysicsScript script,
|
||||
uint scriptId,
|
||||
uint entityId,
|
||||
Vector3 anchorWorldPos,
|
||||
float delaySeconds)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos)
|
||||
{
|
||||
_active.Add(new ActiveScript
|
||||
{
|
||||
Script = script,
|
||||
ScriptId = scriptId,
|
||||
EntityId = entityId,
|
||||
AnchorWorld = anchorWorldPos,
|
||||
StartTimeAbs = _now + Math.Max(0f, delaySeconds),
|
||||
NextHookIndex = 0,
|
||||
});
|
||||
SetOwnerAnchor(entityId, anchorWorldPos);
|
||||
return PlayDirect(entityId, scriptId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every active script by <paramref name="dtSeconds"/>.
|
||||
/// Fires each hook whose <see cref="PhysicsScriptData.StartTime"/>
|
||||
/// (measured from the script's <see cref="Play"/> moment) has been
|
||||
/// reached. Removes scripts that have finished all their hooks.
|
||||
/// Implements retail CallPES. Near-zero pauses append immediately; a
|
||||
/// positive pause samples a deterministic injectable uniform delay in
|
||||
/// <c>[0, maximumPause]</c> and retains the call until due.
|
||||
/// </summary>
|
||||
public void Tick(float dtSeconds)
|
||||
public bool ScheduleCallPes(uint ownerLocalId, uint scriptDid, float maximumPause)
|
||||
{
|
||||
if (dtSeconds < 0) dtSeconds = 0;
|
||||
_now += dtSeconds;
|
||||
if (ownerLocalId == 0 || scriptDid == 0 || !float.IsFinite(maximumPause))
|
||||
return false;
|
||||
if (maximumPause < ImmediateCallPesThresholdSeconds)
|
||||
return PlayDirect(ownerLocalId, scriptDid);
|
||||
|
||||
// Back-to-front so RemoveAt() is cheap and safe mid-iteration.
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the current frame clock before animation hooks are delivered,
|
||||
/// without running retail's timed-hook/script update pass.
|
||||
/// </summary>
|
||||
public void PublishTime(double gameTime)
|
||||
{
|
||||
ValidateMonotonicTime(gameTime);
|
||||
_gameTime = gameTime;
|
||||
_frameTimePublished = true;
|
||||
}
|
||||
|
||||
/// <summary>Advances to an absolute game-clock timestamp.</summary>
|
||||
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++)
|
||||
{
|
||||
var a = _active[i];
|
||||
double elapsed = _now - a.StartTimeAbs;
|
||||
uint ownerId = ownerIds[ownerIndex];
|
||||
if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner))
|
||||
continue;
|
||||
if (!_canAdvanceOwner(ownerId))
|
||||
continue;
|
||||
DrainOwner(ownerId, owner, gameTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire every hook whose scheduled time has arrived.
|
||||
while (a.NextHookIndex < a.Script.ScriptData.Count
|
||||
&& a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed)
|
||||
/// <summary>Compatibility delta-time overload used by existing callers.</summary>
|
||||
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--)
|
||||
{
|
||||
var entry = a.Script.ScriptData[a.NextHookIndex];
|
||||
DispatchHook(a, entry.Hook);
|
||||
a.NextHookIndex++;
|
||||
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 (a.NextHookIndex >= a.Script.ScriptData.Count)
|
||||
_active.RemoveAt(i);
|
||||
else
|
||||
_active[i] = a;
|
||||
if (current.NextHookIndex < current.Script.ScriptData.Count)
|
||||
return;
|
||||
|
||||
owner.Scripts.RemoveAt(0);
|
||||
}
|
||||
|
||||
_owners.Remove(ownerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop an active script instance by
|
||||
/// <c>(scriptId, entityId)</c>. Used for cleanup when an entity
|
||||
/// despawns. Not necessary to call on normal script completion —
|
||||
/// scripts self-remove via <see cref="Tick"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stop all scripts on an entity (e.g. on despawn).</summary>
|
||||
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)
|
||||
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
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " +
|
||||
$"hook={hook.HookType}");
|
||||
_sink.OnHook(ownerId, anchor, hook);
|
||||
}
|
||||
|
||||
// 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)
|
||||
finally
|
||||
{
|
||||
// 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;
|
||||
_dispatchContext = previous;
|
||||
}
|
||||
|
||||
_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);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public void RegisterScriptForTest(uint id, DatPhysicsScript script)
|
||||
=> _scriptCache[id] = script;
|
||||
|
||||
private struct ActiveScript
|
||||
private long EarliestTickGenerationForNewTimedHook()
|
||||
{
|
||||
public DatPhysicsScript Script;
|
||||
public uint ScriptId;
|
||||
public uint EntityId;
|
||||
public Vector3 AnchorWorld;
|
||||
public double StartTimeAbs;
|
||||
public int NextHookIndex;
|
||||
// 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<ScheduledScript> Scripts { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class ScheduledScript
|
||||
{
|
||||
public ScheduledScript(
|
||||
uint scriptDid,
|
||||
DatPhysicsScript script,
|
||||
double startTime,
|
||||
HashSet<uint>? 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<uint>? 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue