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

@ -109,6 +109,29 @@ public sealed class WorldSession : IDisposable
/// </summary>
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>
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
@ -548,6 +571,22 @@ public sealed class WorldSession : IDisposable
var env = GameEventEnvelope.TryParse(body);
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
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the