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_00452060 → FUN_00511800 → FUN_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