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>
289 lines
11 KiB
C#
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 <= 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>
|
|
/// </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<PhysicsScript></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;
|
|
}
|
|
}
|