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