acdream/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs
Erik ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).

Post-review fixes folded into this commit:

H1: AttachLocal (is_parent_local=1) follows live parent each frame.
    ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
    let the owning subsystem refresh AnchorPos every tick — matches
    ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
    parent frame when is_parent_local != 0. Drops the renderer-side
    cameraOffset hack that only worked when the parent was the camera.

H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
    retail-faithful (1 - translucency) opacity formula. The code was
    right; the comment was a leftover from an earlier hypothesis and
    would have invited a wrong "fix".

M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
    and restores them to Repeat at end-of-pass, so non-sky renderers
    that share the GL handle can't silently inherit clamped wrap state.

M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
    weather-flagged AND bit 0x08 is clear, matching retail
    GameSky::UpdatePosition 0x00506dd0. The old code applied it to
    every post-scene object — a no-op today (every Dereth post-scene
    entry happens to be weather-flagged) but a future post-scene-only
    sun rim would have been pushed below the camera.

M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
    handles from the per-entity tracking dictionaries, fixing a slow
    leak where naturally-expired emitters' handles stayed in the
    ConcurrentBag forever during long sessions.

M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
    can't ever overlap the object-index range. Synthetic IDs stay in
    the reserved 0xFxxxxxxx space.

New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
  single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:11 +02:00

289 lines
11 KiB
C#

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;
/// <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 &lt;= 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 (&lt; 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>
/// </summary>
public sealed class PhysicsScriptRunner
{
private readonly Func<uint, DatPhysicsScript?> _resolver;
private readonly IAnimationHookSink _sink;
private readonly Dictionary<uint, DatPhysicsScript?> _scriptCache = new();
// 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
/// DatCollection-free for testing. Production code will pass
/// a lambda that hits <c>DatCollection.Get&lt;PhysicsScript&gt;</c>.
/// </summary>
public PhysicsScriptRunner(Func<uint, DatPhysicsScript?> resolver, IAnimationHookSink sink)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
}
/// <summary>
/// Convenience ctor — builds a resolver around a <see cref="DatCollection"/>.
/// </summary>
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<DatPhysicsScript>(id); }
catch { return null; }
}
/// <summary>Number of scripts currently active (for telemetry).</summary>
public int ActiveScriptCount => _active.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)
{
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,
});
}
/// <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.
/// </summary>
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;
}
}
/// <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)
{
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;
}
/// <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
{
public DatPhysicsScript Script;
public uint ScriptId;
public uint EntityId;
public Vector3 AnchorWorld;
public double StartTimeAbs;
public int NextHookIndex;
}
}