weather(phase-6bc): wire PlayScript packet + script runner into frame loop

Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754)
that retail uses for all server-triggered client-side visual effects.
Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336):

    [u32 opcode=0xF754][u32 targetGuid][u32 scriptId]

New event `PlayScriptReceived(uint guid, uint scriptId)` fires on
every matching fragment. Unknown payloads (body < 12 bytes) are
silently ignored.

Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup,
subscribes to PlayScriptReceived, and ticks the runner every frame
BEFORE the ParticleSystem tick so a CreateParticleHook fired this
frame gets its emitter integrated in the same frame.

Anchor policy: use the camera's world position for the script anchor.
For Dereth-wide storm effects (lightning flashes) the camera is the
right reference frame — the flash is "around the player." Per-entity
effects (spell casts, emotes) dedupe by (scriptId, entityId) so
multiple simultaneous plays on different guids work; a follow-up will
look up the guid's last-known world pos from _worldState for accurate
per-entity anchoring.

The full pipeline now for a lightning flash:
  1. ACE (or other retail-emulating server) sends
     GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx).
  2. WorldSession parses: PlayScriptReceived event fires.
  3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play.
  4. Runner loads the PhysicsScript from the dat, schedules every
     (StartTime, AnimationHook) entry.
  5. Per-frame Tick fires each hook at its scheduled time via
     ParticleHookSink — CreateParticleHook spawns a particle emitter
     (the flash), SoundHook plays thunder audio (Phase 5d), etc.

Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each
hook fire as `[pes]` log lines — useful for identifying which script
IDs your ACE server actually sends.

Build + 742 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 11:24:30 +02:00
parent 845d70248c
commit 2e9a836f08
2 changed files with 96 additions and 0 deletions

View file

@ -140,6 +140,10 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink; private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// from the server and schedules the dat-defined hooks (particle spawns,
// sounds, light toggles) at their StartTime offsets.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Remote-entity motion inference: tracks when each remote entity last // Remote-entity motion inference: tracks when each remote entity last
@ -827,6 +831,12 @@ public sealed class GameWindow : IDisposable
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_hookRouter.Register(_particleSink); _hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
// resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline.
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(_dats, _particleSink);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on // Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up, // owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark. // extinguish-torch animations go dark.
@ -1064,6 +1074,16 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted; _liveSession.TeleportStarted += OnTeleportStarted;
// Phase 6c — PlayScript (0xF754) arrives from the server as
// a (guid, scriptId) pair. Resolve the guid's current world
// position and feed the PhysicsScript runner; it schedules
// the script's hooks (particle spawns, sound cues, light
// toggles) at their StartTime offsets. This is the channel
// retail uses for spell casts, combat flinches, emote
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
// Phase G.1: keep the client's day/night clock in sync with // Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed) // server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets. // and repeatedly on TimeSync-flagged packets.
@ -2222,6 +2242,38 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: teleport started (seq={sequence})"); Console.WriteLine($"live: teleport started (seq={sequence})");
} }
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// with the CAMERA's current world position as the anchor. For
/// scene-wide storm effects (lightning) the camera is the right
/// reference frame since the flash is meant to be "around the
/// player." For per-entity effects the runner's dedupe by
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays
/// working on different guids.
///
/// <para>
/// Improvements for follow-up: look up the guid's actual last-
/// known world position from <c>_worldState</c> so per-entity
/// spell casts and emote gestures anchor correctly. For Phase 6
/// scope (lightning, which is Dereth-wide) the camera anchor is
/// sufficient.
/// </para>
/// </summary>
private void OnPlayScriptReceived(uint guid, uint scriptId)
{
if (_scriptRunner is null) return;
var camWorldPos = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
_scriptRunner.Play(scriptId, guid, camWorldPos);
}
/// <summary> /// <summary>
/// Phase A.1: streaming load delegate, runs on the worker thread. /// Phase A.1: streaming load delegate, runs on the worker thread.
/// Reads the landblock from the dats, hydrates its stab entities (same /// Reads the landblock from the dats, hydrates its stab entities (same
@ -3602,6 +3654,11 @@ public sealed class GameWindow : IDisposable
// Phase E.3: advance live particle emitters AFTER animation tick // Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated. // so emitters spawned by hooks fired this frame get integrated.
// Tick the PhysicsScript runner BEFORE the particle system so any
// CreateParticleHook fired this frame has its emitter alive when
// the particle system advances.
_scriptRunner?.Tick((float)deltaSeconds);
_particleSystem?.Tick((float)deltaSeconds); _particleSystem?.Tick((float)deltaSeconds);
int visibleLandblocks = 0; int visibleLandblocks = 0;

View file

@ -109,6 +109,29 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard; public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
/// wire format <c>[u32 opcode][u32 guid][u32 scriptId]</c>.
///
/// <para>
/// This is retail's ONLY general-purpose "make a visual thing
/// happen" channel: spell casts, emote gestures, combat flinches,
/// portal storms, and lightning flashes during stormy weather all
/// flow through this opcode. Subscribers (typically
/// <c>GameWindow</c>) resolve the guid to the appropriate entity
/// position and dispatch to a <c>PhysicsScriptRunner</c>.
/// </para>
///
/// <para>
/// Trail: <c>chunk_006A0000.c:12320-12336</c> opcode dispatch →
/// <c>FUN_00452060</c> → <c>FUN_00511800</c> → <c>FUN_005117a0</c>
/// (PhysicsObj::RunScript) → <c>FUN_0051bed0</c> (PhysicsScript
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
/// </para>
/// </summary>
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
/// <summary> /// <summary>
/// Phase G.1: latest server Portal Year tick count. Seeded from the /// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game /// ConnectRequest handshake (r12 §1.3 — server sends absolute game
@ -548,6 +571,22 @@ public sealed class WorldSession : IDisposable
var env = GameEventEnvelope.TryParse(body); var env = GameEventEnvelope.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value); if (env is not null) GameEvents.Dispatch(env.Value);
} }
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
{
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
// per chunk_006A0000.c:12320 disassembly. Dispatch the
// event; GameWindow subscribes and feeds its
// PhysicsScriptRunner. This is the channel retail uses for
// lightning flashes, spell casts, emotes, combat FX, etc.
if (body.Length >= 12)
{
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
uint scriptId = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
PlayScriptReceived?.Invoke(targetGuid, scriptId);
}
}
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{ {
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the // Phase B.3: holtburger opcodes.rs confirms 0xF751 is the