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:
parent
845d70248c
commit
2e9a836f08
2 changed files with 96 additions and 0 deletions
|
|
@ -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})");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue