From 2e9a836f08a6d06bdc8b9925124b2ccc4e812d60 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:24:30 +0200 Subject: [PATCH] weather(phase-6bc): wire PlayScript packet + script runner into frame loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 57 +++++++++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 39 +++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 99dd2bd..ceb3c5a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -140,6 +140,10 @@ public sealed class GameWindow : IDisposable private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); private AcDream.Core.Vfx.ParticleSystem? _particleSystem; 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; // 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); _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 // owner-tagged lights so ignite-torch animations light up, // extinguish-torch animations go dark. @@ -1064,6 +1074,16 @@ public sealed class GameWindow : IDisposable _liveSession.PositionUpdated += OnLivePositionUpdated; _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 // server time. Fires once from ConnectRequest (initial seed) // and repeatedly on TimeSync-flagged packets. @@ -2222,6 +2242,38 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"live: teleport started (seq={sequence})"); } + /// + /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the + /// (guid, scriptId) pair into + /// 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 + /// (scriptId, entityId) keeps multiple simultaneous plays + /// working on different guids. + /// + /// + /// Improvements for follow-up: look up the guid's actual last- + /// known world position from _worldState so per-entity + /// spell casts and emote gestures anchor correctly. For Phase 6 + /// scope (lightning, which is Dereth-wide) the camera anchor is + /// sufficient. + /// + /// + 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); + } + /// /// Phase A.1: streaming load delegate, runs on the worker thread. /// 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 // 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); int visibleLandblocks = 0; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index e4d9649..1a9fd87 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -109,6 +109,29 @@ public sealed class WorldSession : IDisposable /// public event Action? SpeechHeard; + /// + /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the + /// server sends a PlayScriptId (opcode 0xF754) packet — + /// wire format [u32 opcode][u32 guid][u32 scriptId]. + /// + /// + /// 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 + /// GameWindow) resolve the guid to the appropriate entity + /// position and dispatch to a PhysicsScriptRunner. + /// + /// + /// + /// Trail: chunk_006A0000.c:12320-12336 opcode dispatch → + /// FUN_00452060FUN_00511800FUN_005117a0 + /// (PhysicsObj::RunScript) → FUN_0051bed0 (PhysicsScript + /// runtime). See docs/research/2026-04-23-lightning-real.md. + /// + /// + public event Action? PlayScriptReceived; + /// /// 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