diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e6d52f4..c0446da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,37 @@ Copy this block when adding a new issue: # Active issues +## #56 — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root + +**Status:** OPEN +**Severity:** MEDIUM (every multi-emitter PES on a multi-part entity is visually wrong — portals, chimneys, fireplaces, animation-hook spell effects, anything where the dat author distributed emitters across mesh parts) +**Filed:** 2026-05-12 +**Component:** vfx / `ParticleHookSink` (call-site contract gap with the renderer) + +**Description:** When `EntityScriptActivator` (Phase C.1.5a) fires a multi-emitter `PhysicsScript` such as a portal's `DefaultScript`, every `CreateParticleHook` in the script spawns at `entity.Position + rotated(hook.Offset.Origin)` — ignoring the hook's `PartIndex`. The Holtburg Town network portal's script `0x3300126D` has 10 hooks (8 `CreateParticle` + sounds + sub-script calls) intended to attach to different mesh parts of the Setup (arch base, columns, apex). All 10 collapse to one point, producing a compressed, ground-buried swirl instead of the multi-tier shape retail renders. + +Captured during C.1.5a visual verification 2026-05-12: +- Portal A: entity `0x7A9B405B`, script `0x3300126D`, anchor `(27.33, 137.49, 66.30)`, 10 hooks +- Portal B: entity `0x7A9B4080`, script `0x3300067A`, anchor `(14.39, 55.61, 78.20)`, 4 hooks +User report: "It's less flat [than pre-rotation-fix] but in retail it seems to expand more in all directions. … still buried in the ground." + +**Root cause / status:** Documented in [ParticleHookSink.cs:18-24](../src/AcDream.Core/Vfx/ParticleHookSink.cs#L18) as a known C.1 limitation: "Retail attaches to a specific mesh part; we attach to the entity's root and will refine per-part when the renderer exposes per-part world transforms." The renderer (`WbDrawDispatcher`) does compute per-part transforms each frame for the modern bindless path, but they're not surfaced to the sink. The activator passes only `entity.Position` + `entity.Rotation`; the part-relative offsets the dat author chose are lost. + +**Files:** +- [src/AcDream.Core/Vfx/ParticleHookSink.cs:176-217](../src/AcDream.Core/Vfx/ParticleHookSink.cs) (`SpawnFromHook` — currently `anchor = worldPos + Vector3.Transform(offset, rotation)`; missing the `part[PartIndex].Transform` multiplication). +- [src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs](../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) (would need to pass per-part transforms, or arrange for the sink to query them). +- Renderer-side: per-part transforms live in `WbDrawDispatcher` / `AnimatedEntityState`. + +**Research:** For static entities (portals, chimneys, fireplaces), per-part offsets can be precomputed from `Setup.PlacementFrames[Resting]` at spawn time — no animation tick needed. For animated entities, the per-part transform varies per frame and the sink would need a per-tick refresh similar to how `UpdateEntityAnchor` works for AttachLocal emitters today. + +**Acceptance:** The Holtburg Town network portal's swirl matches retail in vertical extent (no ground-burial) and lateral spread (multiple emitters at distinct part positions, not collapsed to one point). Side-by-side dual-client visual check, same procedure as the C.1.5a acceptance gate. + +**Blocks / unblocks:** +- Phase C.1.5b (EnvCell static chimneys + fireplaces) will visually disappoint without this fix — chimneys are multi-part dat objects with smoke emitters attached to the chimney-top part. +- Phase C.1.5a (portal wiring) shipped without it because the mechanism is correct end-to-end and the gap is a separate concern that benefits every multi-part PES path. + +--- + ## #55 — Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill **Status:** OPEN diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 91b674a..e5f317e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -64,6 +64,7 @@ | N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 — modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ | | N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ | | N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 30–50× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ | +| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity` → `OnCreate`, `RemoveEntityByServerGuid` → `OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost diff --git a/docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md b/docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md new file mode 100644 index 0000000..7de4a3e --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md @@ -0,0 +1,651 @@ +# Phase C.1.5a — Portal PES wiring implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fire `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` when a server-spawned `WorldEntity` enters the world, so portals emit their retail-faithful persistent particle effects automatically. + +**Architecture:** One new ~50-line class `EntityScriptActivator` under `src/AcDream.App/Rendering/Vfx/`. Wired into `GpuWorldState`'s `AppendLiveEntity` (calls `OnCreate`) and `RemoveEntityByServerGuid` (calls `OnRemove`), immediately after the matching `_wbEntitySpawnAdapter` calls. Activator is constructed in `GameWindow` (alongside the existing entity-spawn adapter) and passed into `GpuWorldState` as a new optional ctor parameter. + +**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies. + +**Spec:** [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../specs/2026-05-12-phase-c1.5a-portals-design.md). Read it first. + +--- + +## File structure + +**Created:** + +- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — the new orchestrator class. +- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — three xUnit tests covering OnCreate-fires, OnCreate-no-op, OnRemove-cleanup. + +**Modified:** + +- `src/AcDream.App/Streaming/GpuWorldState.cs` — new optional ctor parameter; two `?.` call sites added. +- `src/AcDream.App/Rendering/GameWindow.cs` — construct the activator alongside `_wbEntitySpawnAdapter` (~line 1614) and pass it into the `GpuWorldState` ctor (~line 1619). One field declaration added. +- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5a SHIPPED" entry on verification pass (Task 4 only). + +Each file has one clear responsibility: +- `EntityScriptActivator` — orchestrates DefaultScript fire-on-spawn / stop-on-despawn. Knows nothing about dats or GL. +- `GpuWorldState` — owns spawn lifecycle. The activator is one more `?.` collaborator alongside the existing adapter. +- `GameWindow` — wiring root. Constructs the resolver lambda where `_dats` is in scope; everything else is plumbing. + +--- + +## Task 1: Build `EntityScriptActivator` with tests (TDD) + +**Files:** +- Create: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` +- Create: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` + +- [ ] **Step 1.1 — Write the test file with three failing tests + helpers** + +Create `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Rendering.Vfx; + +public sealed class EntityScriptActivatorTests +{ + /// Recording sink so we can assert which hooks the runner fires. + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items) + { + var script = new DatPhysicsScript(); + foreach (var (t, h) in items) + script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h }); + return script; + } + + private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => + new() + { + Id = serverGuid, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + private record Pipeline( + ParticleSystem System, + ParticleHookSink Sink, + PhysicsScriptRunner Runner, + RecordingSink Recording); + + private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts) + { + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity + var recording = new RecordingSink(); // for runner's hook dispatch + var table = new Dictionary(); + foreach (var (id, s) in scripts) table[id] = s; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + recording); + return new Pipeline(system, hookSink, runner, recording); + } + + [Fact] + public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition() + { + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3)); + + activator.OnCreate(entity); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); + } + + [Fact] + public void OnCreate_WithoutDefaultScript_DoesNothing() + { + var p = BuildPipeline(); // no scripts registered + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.Empty(p.Recording.Calls); + } + + [Fact] + public void OnRemove_StopsScriptsAndEmitters() + { + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + Assert.Equal(1, p.Runner.ActiveScriptCount); + + activator.OnRemove(0xCAFEu); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + // Tick after Remove must not surface any further hook fires. + p.Runner.Tick(1.0f); + Assert.Empty(p.Recording.Calls); + } +} +``` + +- [ ] **Step 1.2 — Run the tests, confirm they fail with "type not found"** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"` + +Expected: compile error — `AcDream.App.Rendering.Vfx.EntityScriptActivator` does not exist. (This is the failing red-bar that drives the next step.) + +- [ ] **Step 1.3 — Create the `Vfx/` directory and the activator file** + +Create `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`: + +```csharp +using System; +using System.Numerics; +using AcDream.Core.Vfx; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Vfx; + +/// +/// Fires Setup.DefaultScript through +/// when a server-spawned enters the world, so static +/// objects (portals, chimneys, fireplaces, building details) emit their +/// retail-faithful persistent particle effects automatically. Stops the +/// scripts and live emitters when the entity despawns. +/// +/// +/// Wires alongside EntitySpawnAdapter in GpuWorldState: the +/// adapter handles meshes + animation state, the activator handles scripts + +/// particles. Both are render-thread-only. +/// +/// +/// +/// Retail oracle: play_script_internal(setup.DefaultScript) is what +/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan §C.1 +/// and memory/project_sky_pes_port.md). C.1 already shipped the runner; +/// this class adds the missing fire-on-spawn call site. +/// +/// +public sealed class EntityScriptActivator +{ + private readonly PhysicsScriptRunner _scriptRunner; + private readonly ParticleHookSink _particleSink; + private readonly Func _defaultScriptResolver; + + /// Already-shipped runner from C.1. Owns the + /// (scriptId, entityId) instance table and schedules hooks at their + /// StartTime offsets. + /// Already-shipped hook sink from C.1. The + /// activator only calls its + /// to drop any per-entity emitter handles on despawn. + /// Returns + /// entity.SourceGfxObjOrSetupId's Setup.DefaultScript.DataId, + /// or 0 on miss / dat throw / missing field. Production lambda hits + /// ; tests pass a hand-rolled + /// stub. + public EntityScriptActivator( + PhysicsScriptRunner scriptRunner, + ParticleHookSink particleSink, + Func defaultScriptResolver) + { + ArgumentNullException.ThrowIfNull(scriptRunner); + ArgumentNullException.ThrowIfNull(particleSink); + ArgumentNullException.ThrowIfNull(defaultScriptResolver); + _scriptRunner = scriptRunner; + _particleSink = particleSink; + _defaultScriptResolver = defaultScriptResolver; + } + + /// + /// Resolve the entity's Setup.DefaultScript and fire it through + /// the script runner. No-op if the entity has no DefaultScript + /// (resolver returns 0) or if the entity has no server guid + /// (atlas-tier entities are out of scope for this activator). + /// + public void OnCreate(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) return; + + uint scriptId = _defaultScriptResolver(entity); + if (scriptId == 0) return; + + _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); + } + + /// + /// Stop every script instance the runner is tracking for this entity, and + /// kill every live emitter the sink has attributed to it. Idempotent for + /// unknown guids (both calls no-op). + /// + public void OnRemove(uint serverGuid) + { + if (serverGuid == 0) return; + _scriptRunner.StopAllForEntity(serverGuid); + _particleSink.StopAllForEntity(serverGuid, fadeOut: false); + } +} +``` + +- [ ] **Step 1.4 — Run the tests, confirm all three pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"` + +Expected: 3 passed, 0 failed. + +If a test fails: re-read the assertion against the implementation. The most likely failure is `RecordingSink.Calls` empty after `Runner.Tick` — that means the `Play` call didn't queue the script. Check that `entity.ServerGuid != 0` in `MakeEntity`. + +- [ ] **Step 1.5 — Run the full test suite for the test project** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` + +Expected: all existing tests still pass plus the new 3. + +- [ ] **Step 1.6 — Commit Task 1** + +```bash +git add src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +git commit -m "$(cat <<'EOF' +feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet) + +New ~50-line orchestrator that fires Setup.DefaultScript through the +already-shipped PhysicsScriptRunner on entity spawn and stops scripts + +live emitters on despawn. Resolver delegate avoids DatCollection coupling +so the class is fully unit-testable with stubs. + +Three xUnit tests cover the three branches: fire-with-script, +no-op-without-script, stop-on-remove. No wiring into the live spawn path +yet — that lands in the next commit. + +Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Wire activator into `GpuWorldState` + +**Files:** +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:42-65` (field + constructor) +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:285` (OnRemove call site) +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:345` (OnCreate call site) + +- [ ] **Step 2.1 — Add `using` for the new namespace** + +Open `src/AcDream.App/Streaming/GpuWorldState.cs`. The existing `using` block at the top (line ~4) imports `AcDream.App.Rendering.Wb;`. Add a second line below it: + +```csharp +using AcDream.App.Rendering.Vfx; +``` + +- [ ] **Step 2.2 — Add the field** + +Around line 43 there is: + +```csharp +private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; +``` + +Add immediately below: + +```csharp +private readonly EntityScriptActivator? _entityScriptActivator; +``` + +- [ ] **Step 2.3 — Extend the constructor** + +Replace the existing constructor (lines 57–65) with: + +```csharp +public GpuWorldState( + LandblockSpawnAdapter? wbSpawnAdapter = null, + EntitySpawnAdapter? wbEntitySpawnAdapter = null, + System.Action? onLandblockUnloaded = null, + EntityScriptActivator? entityScriptActivator = null) +{ + _wbSpawnAdapter = wbSpawnAdapter; + _wbEntitySpawnAdapter = wbEntitySpawnAdapter; + _onLandblockUnloaded = onLandblockUnloaded; + _entityScriptActivator = entityScriptActivator; +} +``` + +The new parameter is optional and last — existing callers (production and tests) compile unchanged. + +- [ ] **Step 2.4 — Add the `OnCreate` call in `AppendLiveEntity`** + +At line 345 the existing call is: + +```csharp +_wbEntitySpawnAdapter?.OnCreate(entity); +``` + +Add immediately below: + +```csharp +_entityScriptActivator?.OnCreate(entity); +``` + +- [ ] **Step 2.5 — Add the `OnRemove` call in `RemoveEntityByServerGuid`** + +At line 285 the existing call is: + +```csharp +_wbEntitySpawnAdapter?.OnRemove(serverGuid); +``` + +Add immediately below: + +```csharp +_entityScriptActivator?.OnRemove(serverGuid); +``` + +- [ ] **Step 2.6 — Run the build to confirm GpuWorldState compiles** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` + +Expected: build succeeds. `GameWindow.cs` still calls the old 3-arg constructor; the new parameter is optional so this compiles fine. + +- [ ] **Step 2.7 — Run the test suite to confirm GpuWorldStateTests still pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~GpuWorldStateTests"` + +Expected: all pass. Existing tests construct `GpuWorldState` with positional args; they don't pass the new optional parameter so behavior is unchanged. + +If a test fails because it asserts something about per-entity-lifecycle ordering: read the assertion. The new `?.OnCreate(entity)` after `_wbEntitySpawnAdapter?.OnCreate(entity)` is a no-op when no activator is injected, so tests that don't inject one should not see new behavior. + +- [ ] **Step 2.8 — Commit Task 2** + +```bash +git add src/AcDream.App/Streaming/GpuWorldState.cs +git commit -m "$(cat <<'EOF' +feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle + +GpuWorldState grows a fourth optional ctor parameter for the activator, +paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls +OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate; +RemoveEntityByServerGuid calls OnRemove after the existing OnRemove. +Symmetric, same order, null-safe. + +GameWindow still passes the old 3-arg ctor — activator construction + +wire-through lands in the next commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Construct activator in `GameWindow` and pass through to `GpuWorldState` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:35` (field declaration block) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:1612-1622` (activator construction + GpuWorldState ctor call) + +- [ ] **Step 3.1 — Add the field declaration** + +Around line 35 in `GameWindow.cs` there is: + +```csharp +private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; +``` + +Add immediately below: + +```csharp +private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator; +``` + +- [ ] **Step 3.2 — Build the resolver lambda and construct the activator** + +In the block starting at line 1612 (where `wbEntitySpawnAdapter` is constructed and assigned), the current code is: + +```csharp +var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( + _textureCache!, SequencerFactory, _wbMeshAdapter!); +_wbEntitySpawnAdapter = wbEntitySpawnAdapter; +// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock +// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload. +// Per spec §5.3 W3b. The callback receives the canonical landblock id +// matching the LandblockHint stored at Populate time. +_worldState = new AcDream.App.Streaming.GpuWorldState( + wbSpawnAdapter, + wbEntitySpawnAdapter, + onLandblockUnloaded: _classificationCache.InvalidateLandblock); +``` + +Replace with: + +```csharp +var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( + _textureCache!, SequencerFactory, _wbMeshAdapter!); +_wbEntitySpawnAdapter = wbEntitySpawnAdapter; + +// Phase C.1.5a: construct EntityScriptActivator so server-spawned static +// entities (portals first) fire Setup.DefaultScript through the +// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink +// are initialised earlier in OnLoad (line ~1083); both are non-null +// here. The resolver lambda captures _dats and swallows dat-lookup +// throws — see C.1.5a spec §6 (error handling) for rationale. +var capturedDatsForActivator = _dats; +uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e) +{ + try + { + var setup = capturedDatsForActivator?.Get(e.SourceGfxObjOrSetupId); + return setup?.DefaultScript.DataId ?? 0u; + } + catch + { + return 0u; + } +} +var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( + _scriptRunner!, _particleSink!, ResolveDefaultScript); +_entityScriptActivator = entityScriptActivator; + +// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock +// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload. +// Per spec §5.3 W3b. The callback receives the canonical landblock id +// matching the LandblockHint stored at Populate time. +_worldState = new AcDream.App.Streaming.GpuWorldState( + wbSpawnAdapter, + wbEntitySpawnAdapter, + onLandblockUnloaded: _classificationCache.InvalidateLandblock, + entityScriptActivator: entityScriptActivator); +``` + +Two changes: (1) inline construction of activator + resolver between `_wbEntitySpawnAdapter` assignment and the `_worldState =` line, (2) add the `entityScriptActivator: entityScriptActivator` named argument to the `GpuWorldState` constructor call. + +- [ ] **Step 3.3 — Build the project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` + +Expected: build succeeds. If you get an "_scriptRunner is null here" warning at the activator construction site, it's a nullable-flow false positive — the runner is built at line 1083 inside the same `OnLoad` method which executes before this block. Use `_scriptRunner!` and `_particleSink!` (already shown above). + +- [ ] **Step 3.4 — Run the full test suite** + +Run: `dotnet test` + +Expected: all tests pass. No new tests added in this task — verification of the wiring is the visual step in Task 4. + +- [ ] **Step 3.5 — Commit Task 3** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow + +Wires the activator into the production lifecycle: +- Construct alongside _wbEntitySpawnAdapter using _scriptRunner + + _particleSink (both built earlier in OnLoad). +- Production resolver lambda hits _dats.Get(...) wrapped in + try/catch returning 0 on miss/throw — matches ParticleRenderer's + defensive read pattern. +- Pass into GpuWorldState's new optional ctor parameter. + +Closes the wiring half of C.1.5a. Visual verification at the Holtburg +Town network portal is the acceptance gate. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Visual verification + roadmap update + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` (append a "Phase C.1.5a SHIPPED" entry) + +This is a manual verification task with a user-in-the-loop step. Do not mark the slice "done" until the user confirms the portal swirl visually matches retail. + +- [ ] **Step 4.1 — Build green, tests green** + +Run sequentially: + +```powershell +dotnet build +dotnet test +``` + +Expected: both green. If either fails: stop and fix before launching. + +- [ ] **Step 4.2 — Kill any stale acdream process from a prior session** + +Run: + +```powershell +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 3 +``` + +Per [CLAUDE.md](../../../CLAUDE.md) "Logout-before-reconnect" — ACE keeps a session alive briefly after disconnect; relaunching within ~3 s causes a handshake failure that looks like a code bug but isn't. + +- [ ] **Step 4.3 — Launch the live client with PES diagnostics** + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_DUMP_PLAYSCRIPT = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "c1.5a-verify.log" +``` + +(Use the Bash tool's `run_in_background: true` parameter so the launch doesn't block the agent on the user's testing.) + +- [ ] **Step 4.4 — Hand off to the user for visual verification** + +Once the client reaches in-world state (~8 s after launch), tell the user: + +> "Client launched with PES diagnostics. Walk `+Acdream` to the Holtburg Town network portal and compare side-by-side with retail. Confirm the portal swirl matches in color, density, motion, and persistence. Reply 'pass' if it matches or describe what differs." + +Wait for the user's response. If they reply with anything other than confirmation, stop and investigate; do NOT proceed to Step 4.5. + +- [ ] **Step 4.5 — On user confirmation: update the roadmap** + +Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (look for "Phase N.6 slice 1" or similar recent entries). Add a new entry above the earlier shipped phases: + +```markdown +**Phase C.1.5a (Portal PES wiring) shipped 2026-05-12.** Server-spawned +`WorldEntity` entities now fire their `Setup.DefaultScript` through the +shipped `PhysicsScriptRunner` on enter-world. New +[`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) +class wires into `GpuWorldState`'s spawn lifecycle. Visual verification +passed at the Holtburg Town network portal. Slice 2 (C.1.5b — EnvCell +static objects + animation-hook verification) is the natural next step. +Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). +Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). +``` + +If the roadmap has a "Currently in flight" line that mentions C.1.5 or +similar, update it: change "in flight" to "Phase C.1.5b (EnvCell statics ++ verification) — see [C.1.5a spec §10 slice 2 preview](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)". + +- [ ] **Step 4.6 — Commit the roadmap update** + +```bash +git add docs/plans/2026-04-11-roadmap.md +git commit -m "$(cat <<'EOF' +docs(roadmap #C.1.5a): mark Phase C.1.5a shipped + +Portal PES wiring landed and visually verified at the Holtburg Town +network portal. EntityScriptActivator fires Setup.DefaultScript through +the shipped PhysicsScriptRunner on entity spawn. C.1.5b (EnvCell static +objects + animation-hook verification) is the next slice. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 4.7 — Close the loop** + +Report to the user: +- C.1.5a shipped, three commits: activator, wiring, roadmap. +- Tests green; visual verification passed. +- Suggest brainstorming C.1.5b as the next step (or take a break / pick something else). + +--- + +## Self-review against the spec + +Run through the spec's section list and confirm each requirement maps to a plan task: + +- **§2 Scope "in":** + - New `EntityScriptActivator` class → Task 1.3 ✓ + - Wiring in `GpuWorldState` → Task 2.4, 2.5 ✓ + - Activator constructed in `GameWindow`, passed into `GpuWorldState` → Task 3.2 ✓ + - Three unit tests → Task 1.1 ✓ + - Visual verification at Holtburg Town network portal → Task 4.3, 4.4 ✓ +- **§4 Architecture — file placement under `Rendering/Vfx/`** → Task 1.3 (creates the directory implicitly via the file path) ✓ +- **§4 Architecture — resolver delegate pattern** → Tests use stubs (Task 1.1); production uses the lambda in `GameWindow` (Task 3.2) ✓ +- **§4 Trigger condition "has DefaultScript, not is portal"** → Resolver returns `Setup.DefaultScript.DataId ?? 0`; activator gates `if (scriptId == 0) return` (Task 1.3) ✓ +- **§5 Lifecycle ordering: spawnAdapter → activator** → Task 2.4, 2.5 add the activator call immediately after the existing adapter call ✓ +- **§6 Error handling — resolver swallows exceptions** → Task 3.2 wraps `_dats.Get(...)` in try/catch returning 0 ✓ +- **§7 Thread safety** → All calls on render thread; no new synchronization needed (covered by inheriting `GpuWorldState`'s existing single-thread contract) ✓ +- **§8 Three named tests** → Task 1.1 ✓ +- **§8 Visual verification procedure** → Task 4.3–4.5 ✓ + +No gaps. + +Type / name consistency check: +- `EntityScriptActivator` is the class name in tests (Task 1.1), the file (Task 1.3), the field in `GpuWorldState` (Task 2.2), the parameter (Task 2.3), and the field + construction in `GameWindow` (Task 3.1, 3.2). Consistent. +- `OnCreate(WorldEntity)` / `OnRemove(uint)` signatures match across tests and implementation. ✓ +- Constructor signature `(PhysicsScriptRunner, ParticleHookSink, Func)` matches between tests, implementation, and production wiring. ✓ +- `ResolveDefaultScript` lambda (Task 3.2) returns `uint` — matches the `Func` declared on the activator. ✓ diff --git a/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md new file mode 100644 index 0000000..b92f3b2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md @@ -0,0 +1,385 @@ +# Phase C.1.5a — Portal PES wiring (Setup.DefaultScript on entity spawn) + +**Created:** 2026-05-12. +**Author:** Claude (lead engineer/architect). +**Phase:** C.1.5a (first of two slices; C.1.5b covers EnvCell statics + animation-hook verification). +**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5. +**Baseline justification:** [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](../../plans/2026-05-11-phase-n6-perf-baseline.md) §4 — C.1.5 is the right next phase; production preset is comfortable, no perf escalation pressure. + +--- + +## §1 Goal + +Make server-spawned `WorldEntity` portals emit their retail-faithful particle +effects (portal swirls) at spawn time. Implement by **firing `Setup.DefaultScript` +through the already-shipped `PhysicsScriptRunner`** at the moment the entity +enters the world, mirroring retail's `play_script_internal` dispatch on object +spawn. + +Acceptance: the user walks `+Acdream` up to the **Holtburg Town network portal**, +opens a side-by-side comparison with a retail AC client, and confirms the portal +swirl matches retail in color, density, motion, and persistence. + +## §2 Scope + +**In:** + +- New class `EntityScriptActivator` (one file, ~50 lines). +- Wiring of activator's `OnCreate` / `OnRemove` calls into `GpuWorldState`'s + spawn-lifecycle methods (`AppendLiveEntity` / `RemoveEntityByServerGuid`), + immediately after the matching `EntitySpawnAdapter` calls. The activator is + constructed in `GameWindow` (where `_dats`, `_scriptRunner`, and + `_particleSink` are in scope) and passed into `GpuWorldState`'s constructor + as a new optional parameter, paralleling how `EntitySpawnAdapter` is wired. +- Three unit tests covering the activator's three branches + (fire / no-op-on-zero / cleanup-on-remove). +- Visual verification at the Holtburg Town network portal. + +**Out (deferred to C.1.5b):** + +- `EnvCell.StaticObjects` walker for interior chimneys / fireplaces. +- Animation-hook particle path verification (already wired in C.1; needs + a confirming check, deferred so this slice stays small). +- The WB-style "re-fire after 1 second" loop logic for non-persistent emitters + ([`ParticleEmitterRenderer.cs:119-130`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs) in WB). + Portal swirls are persistent (`TotalParticles=0 && TotalSeconds=0`) and don't + need it. If C.1.5b discovers EnvCell static objects need it, that slice adds it. + +**Out (out of phase entirely):** + +- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6 + slice 2 territory. +- Performance work. Per [baseline §4](../../plans/2026-05-11-phase-n6-perf-baseline.md), + CPU at the production preset is comfortable and there is no GPU pressure. +- Adding `WeenieClassId` to `WorldEntity`. Trigger is "has DefaultScript", + not "is portal" (see §4 Architecture for rationale). + +## §3 Background + +### Why this works today for *some* particles, not portals + +C.1 shipped a complete particle pipeline: +[`EmitterDescRegistry`](../../../src/AcDream.Core/Vfx/EmitterDescRegistry.cs) +(data) → [`ParticleSystem`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) (sim) +→ [`ParticleHookSink`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs) +(dispatch) → [`PhysicsScriptRunner`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs) +(script scheduler) → [`ParticleRenderer`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs) +(draw). + +The chain is end-to-end, but `PhysicsScriptRunner.Play` is only called from +**two places today**: + +1. The server-driven `PlayScript (0xF754)` opcode handler in `GameWindow` — + spell casts, combat hits, emote effects. +2. The animation-hook path inside `MotionInterpreter` — feet sparks, weapon + trails (via `ParticleHookSink` directly, not through the runner). + +**Nothing fires `Setup.DefaultScript` when a static entity spawns.** Retail +does this (per the named decomp's `play_script_internal` analysis), and +`WorldBuilder` does the equivalent at mesh-prep time +([`ObjectMeshManager.cs:797`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs)). +Acdream skips it — every portal lacks its swirl, every chimney lacks its smoke. + +### Why not consume WB's staged emitters + +WB's `ObjectMeshManager.PrepareSetupMeshData` (line 771–795) collects +`StagedEmitter` entries from `setup.DefaultScript` and attaches them to +`ObjectMeshData.ParticleEmitters`. Three reasons we don't consume them: + +1. `WbMeshAdapter` calls `PrepareMeshDataAsync(id, isSetup: false)` — we go + through the per-part GfxObj path, not the Setup path + ([`WbMeshAdapter.cs:136`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs)). + Flipping that breaks shipped N.4/N.5 dispatcher assumptions. +2. WB's `CollectEmittersFromScript` drops the script's per-hook `StartTime` + offsets — it spawns every `CreateParticleHook` immediately. Our + `PhysicsScriptRunner` honors `StartTime` and is more retail-faithful. +3. C.1 already shipped a runner that *is* the equivalent of retail's + `play_script_internal`. Adding the missing call sites is cheaper and + structurally cleaner than building a parallel emitter-staging path. + +## §4 Architecture + +### New class + +`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`. New `Vfx/` +subdirectory under `Rendering/` — sits next to `ParticleRenderer.cs` and is +**not** under `Wb/` because the activator drives our own `PhysicsScriptRunner` +and has no WB dependency. + +Constructor — mirrors `EntitySpawnAdapter`'s factory-delegate pattern so the +activator has no `DatCollection` coupling and is fully unit-testable with +stubs: + +```csharp +public EntityScriptActivator( + PhysicsScriptRunner scriptRunner, + ParticleHookSink particleSink, + Func defaultScriptResolver) +``` + +The resolver returns the entity's `Setup.DefaultScript.DataId`, or `0` if the +Setup is missing / the dat throws / the field is zero. **The resolver swallows +exceptions; the activator stays a thin orchestrator.** + +Public surface — two methods only: + +```csharp +public void OnCreate(WorldEntity entity); +public void OnRemove(uint serverGuid); +``` + +No state on the activator. `PhysicsScriptRunner` already tracks per-entity +script instances by `(scriptId, entityId)`; `ParticleHookSink` already tracks +per-entity emitter handles. The activator doesn't duplicate that bookkeeping. + +### Trigger condition: "has DefaultScript", not "is portal" + +`WorldEntity` carries no `WeenieClassId` / `ObjectType` field +([`WorldEntity.cs`](../../../src/AcDream.Core/World/WorldEntity.cs)). We +*could* add one, but the WB-faithful trigger is "this entity's Setup has a +non-zero `DefaultScript`," which is also what retail's +`play_script_internal(setup.DefaultScript)` does at object load. + +Side effect of this choice: **the activator will fire DefaultScript for any +server-spawned entity whose Setup has one**, not just portals. This is +correct retail behavior. If a non-portal entity spawns visible unwanted +particles in slice 1, that means our resolver is reading retail's intended +data faithfully and the visual is what retail shows. If retail does NOT show +those particles and we do, that's evidence of a different gate retail +applies — to be investigated when seen. + +### Wiring point: GpuWorldState + +Live entity spawn / despawn already flows through +[`GpuWorldState`](../../../src/AcDream.App/Streaming/GpuWorldState.cs) on the +render thread — the network layer pushes spawns into +`AppendLiveEntity(landblockId, entity)`, the server's `RemoveObject` opcode +routes through `RemoveEntityByServerGuid(serverGuid)`. The existing +`EntitySpawnAdapter` lifecycle hooks live at those two call sites +(line 345 `OnCreate`, line 285 `OnRemove`). The activator hooks fire +immediately after, in the same order: + +```csharp +// GpuWorldState.AppendLiveEntity (line ~345): +_wbEntitySpawnAdapter?.OnCreate(entity); +_entityScriptActivator?.OnCreate(entity); // NEW — fires DefaultScript + +// GpuWorldState.RemoveEntityByServerGuid (line ~285): +_wbEntitySpawnAdapter?.OnRemove(serverGuid); +_entityScriptActivator?.OnRemove(serverGuid); // NEW — stops scripts + emitters +``` + +`GpuWorldState`'s constructor grows a fifth (optional) parameter for the +activator, paralleling how `EntitySpawnAdapter` is plumbed today. `GameWindow` +constructs the activator alongside `_wbEntitySpawnAdapter` and passes it +through. + +Production resolver lambda, constructed in `GameWindow` where `_dats` is in +scope: + +```csharp +entity => +{ + try + { + return _dats.Get(entity.SourceGfxObjOrSetupId)?.DefaultScript.DataId ?? 0; + } + catch + { + return 0; + } +} +``` + +The try/catch matches the pattern in +[`ParticleRenderer.cs:296-318`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs) +(`ReadParticleGfxInfo`). + +## §5 Data flow + lifecycle + +### On spawn + +``` +GpuWorldState.AppendLiveEntity(landblockId, entity) +├─ _wbEntitySpawnAdapter?.OnCreate(entity) // meshes ref-counted, animation state built +└─ _entityScriptActivator?.OnCreate(entity) + ├─ scriptId = resolver(entity) // Setup.DefaultScript.DataId, or 0 on miss/throw + ├─ if (scriptId == 0) return // no DefaultScript → no-op + └─ _scriptRunner.Play(scriptId, + entity.ServerGuid, + entity.Position) + └─ PhysicsScriptRunner schedules hooks at their StartTime offsets; + each CreateParticleHook → ParticleHookSink → ParticleSystem + spawns the ParticleEmitter dat at the entity's anchor. +``` + +### On despawn + +``` +GpuWorldState.RemoveEntityByServerGuid(serverGuid) +├─ _wbEntitySpawnAdapter?.OnRemove(serverGuid) // meshes ref-decremented, state cleared +└─ _entityScriptActivator?.OnRemove(serverGuid) + ├─ _scriptRunner.StopAllForEntity(serverGuid) // drop pending hooks + └─ _particleSink.StopAllForEntity(serverGuid, false) // kill live emitters (no fade) +``` + +Order on both is `spawnAdapter → activator`. Symmetric. + +### Persistence (no re-fire logic needed) + +Portal swirls are persistent emitters: their `ParticleEmitter` dat has +`TotalParticles=0` AND `TotalSeconds=0`. +[`ParticleSystem.Tick`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) +only flips `Finished` when `TotalDuration > 0` or `TotalParticles > 0`, so +both-zero emitters never finish. They keep emitting until +`StopAllForEntity` kills them on despawn. + +WB's `_deadTimer` re-fire-after-1s (line 119–130 of +[`ParticleEmitterRenderer.cs`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs)) +is for non-persistent emitters that should loop (`TotalSeconds > 0`, finishes, +1s gap, re-emit). Portals don't use it. Defer to C.1.5b if EnvCell static +objects need it. + +### Idempotency + +- Duplicate `OnCreate` for same `serverGuid` — `PhysicsScriptRunner.Play` + dedupes by `(scriptId, entityId)` and replaces the prior instance + ([`PhysicsScriptRunner.cs:136-140`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)). + ✓ +- Duplicate `OnRemove` — both `StopAllForEntity` calls no-op on unknown guid. ✓ +- `OnRemove` for never-spawned guid — same no-op behavior. ✓ + +### Position handling + +Portals are stationary. `entity.Position` captured at spawn time is the anchor +for all of the script's hooks. We do not refresh per-frame. + +**Known limitation (documented, not fixed in slice 1):** if a portal is ever +relocated via server `SetPosition`, emitters stay at the old anchor. If this +case appears in practice we add a position-update handler — but no current +evidence retail's portals move. + +## §6 Error handling + +Failure modes and behavior: + +| Failure | Behavior | Notes | +|---|---|---| +| `entity.SourceGfxObjOrSetupId` references a missing Setup | resolver returns `0` | activator no-ops; standard streaming flicker handling | +| `_dats.Get(...)` throws | resolver returns `0` | try/catch in the resolver lambda | +| `Setup.DefaultScript.DataId == 0` | resolver returns `0` | activator no-ops; entity has no persistent script | +| `PhysicsScript` dat lookup misses inside `Play` | `Play` returns `false` | runner already handles; activator does nothing | +| `EmitterDescRegistry` miss for a `CreateParticleHook.EmitterInfoId` | exception propagates through `PhysicsScriptRunner.DispatchHook` (currently uncaught) | pre-existing C.1 behavior; out of scope for this slice. File an issue if observed in verification. | + +All failure paths are silent (no exceptions surface to the caller). Diagnostic +visibility comes from `ACDREAM_DUMP_PLAYSCRIPT=1` — every successful `Play` +and every fired hook prints. A missing portal swirl in verification is +diagnosed by checking the log for the missing entity's guid. + +## §7 Thread safety + +All calls execute on the render thread (where `EntitySpawnAdapter` already +runs). `PhysicsScriptRunner` is single-threaded by design. +`ParticleHookSink` uses `ConcurrentDictionary` and is safe regardless. No +new threading concerns introduced. + +## §8 Testing + +### Unit tests (slice 1's gating tests) + +`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` (test +project convention: production code lives under `src/AcDream.App/...` but tests +go in `AcDream.Core.Tests` — mirrors the existing +[`EntitySpawnAdapterTests`](../../../tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs) +location). Uses +hand-built `PhysicsScriptRunner` + capturing `ParticleHookSink` (or a thin +test double). No dats, no GL. + +1. **`OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition`** — + stub resolver returns `0x33000001`; assert `runner.Play(0x33000001, + entity.ServerGuid, entity.Position)` was called exactly once. +2. **`OnCreate_WithoutDefaultScript_DoesNothing`** — stub resolver returns + `0`; assert no `Play` call. +3. **`OnRemove_StopsScriptsAndEmitters`** — sequence an `OnCreate(entity)` + then `OnRemove(entity.ServerGuid)`; assert `runner.StopAllForEntity` and + `sink.StopAllForEntity` were each called once with the matching guid, and + `sink.StopAllForEntity` was passed `fadeOut: false`. + +### Integration tests — none for slice 1 + +The `GpuWorldState` wiring is two added lines (one in `AppendLiveEntity`, one +in `RemoveEntityByServerGuid`) plus a constructor parameter. An integration +test would require booting GL + dats + network. Coverage is the visual +verification gate instead. Existing `GpuWorldStateTests` will need a minor +update if they assert constructor arity; we extend them if so. + +### Visual verification — the acceptance criterion + +Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"): + +1. `dotnet build` green. +2. `dotnet test` green (the three new unit tests plus the existing suite). +3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1` exported. +4. Walk `+Acdream` from spawn to the **Holtburg Town network portal**. +5. In parallel, a retail AC client viewing the same portal. +6. **User confirms**: the portal-swirl effect in acdream matches retail in + color, density, motion, and persistence. + +If verification fails (e.g. portal Setup has `DefaultScript=0` in the dat), +the diagnostic log shows whether `Play` fired and with what scriptId. We +investigate the actual data path in retail's named decomp before iterating — +do not blindly retry. + +## §9 Limitations + known gaps (post-slice-1) + +These are intentionally not fixed in slice 1; tracked here so the next slice +or a future phase picks them up: + +1. **`PartIndex` collapse on multi-part entities** (NEW — verified 2026-05-12 + at the Holtburg Town network portal). `ParticleHookSink.SpawnFromHook` + ignores `CreateParticleHook.PartIndex`, so every emitter in a multi-emitter + script collapses to `entity.Position + rotated(hook.Offset.Origin)`. Retail + distributes the script's emitters across the entity's mesh parts (arch base, + columns, apex). Visual symptom for the Holtburg portal: the 10-hook script + produces a compressed swirl partially buried in the ground instead of the + multi-tier shape retail renders. Filed as `docs/ISSUES.md` #56 with the + captured entity guids + script ids; affects slice 2 (EnvCell chimneys / + fireplaces are multi-part) and any future multi-emitter PES path. +2. **Moving entities** don't re-anchor their DefaultScript emitters per + frame. No evidence retail's portals or chimneys move; revisit if visual + verification surfaces a regression. +3. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters + work today; looping non-persistent emitters (if EnvCell static objects + use them) would need it in C.1.5b. +4. **Animation-hook particle path** (`MotionInterpreter` → + `ParticleHookSink`) is shipped in C.1 but **not verified** by a recent + visual test in this codebase state. Confirming this path is the second + half of C.1.5b. + +## §10 Slice 2 preview (C.1.5b) + +For context, not part of this slice's work: + +- **Walker for `EnvCell.StaticObjects`.** Each static object has a Setup + reference; same `DefaultScript` dispatch applies. Needs a synthetic + entity-id scheme because static objects have no `ServerGuid`. Likely: + hash of `(landblockId, cellIndex, staticIndex)` → 32-bit synthetic id with + a marker high bit so it doesn't collide with server guids. +- **Verification step for animation hooks.** Cast a spell or trigger an + emote on `+Acdream`, observe the particle effect, compare to retail. +- **Possible: WB re-fire-after-1s logic** in `ParticleSystem` if EnvCell + static-object PES data needs it. + +C.1.5b spec lands after C.1.5a verification passes. + +## §11 Implementation notes + +- The new directory `src/AcDream.App/Rendering/Vfx/` is created by this + slice. `ParticleRenderer.cs` stays where it is (under `Rendering/`); the + new `Vfx/` is for spawn-time orchestration classes only. +- Estimated effort: ~1 day. Activator is small, wiring is two lines, tests + are three cases. +- No CLAUDE.md updates required by this slice — the C.1.5a / C.1.5b split is + internal to the C.1 phase plan. +- Roadmap update: on ship, add a "Phase C.1.5a SHIPPED 2026-05-12" entry to + [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b81d484..e679b92 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -33,6 +33,7 @@ public sealed class GameWindow : IDisposable /// after OnLoad completes (modern path is mandatory as of N.5). private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter; private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; + private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws @@ -1612,6 +1613,29 @@ public sealed class GameWindow : IDisposable var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( _textureCache!, SequencerFactory, _wbMeshAdapter!); _wbEntitySpawnAdapter = wbEntitySpawnAdapter; + + // Phase C.1.5a: construct EntityScriptActivator so server-spawned static + // entities (portals first) fire Setup.DefaultScript through the + // PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink + // are initialised earlier in OnLoad (line ~1083); both are non-null + // here. The resolver lambda captures _dats and swallows dat-lookup + // throws — see C.1.5a spec §6 (error handling) for rationale. + uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e) + { + try + { + var setup = capturedDats?.Get(e.SourceGfxObjOrSetupId); + return setup?.DefaultScript.DataId ?? 0u; + } + catch + { + return 0u; + } + } + var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( + _scriptRunner!, _particleSink!, ResolveDefaultScript); + _entityScriptActivator = entityScriptActivator; + // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock // so Tier 1 cache entries get swept on LB demote (Near to Far) and unload. // Per spec §5.3 W3b. The callback receives the canonical landblock id @@ -1619,7 +1643,8 @@ public sealed class GameWindow : IDisposable _worldState = new AcDream.App.Streaming.GpuWorldState( wbSpawnAdapter, wbEntitySpawnAdapter, - onLandblockUnloaded: _classificationCache.InvalidateLandblock); + onLandblockUnloaded: _classificationCache.InvalidateLandblock, + entityScriptActivator: entityScriptActivator); _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs new file mode 100644 index 0000000..ad14615 --- /dev/null +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -0,0 +1,93 @@ +using System; +using AcDream.Core.Vfx; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Vfx; + +/// +/// Fires Setup.DefaultScript through +/// when a server-spawned enters the world, so static +/// objects (portals, chimneys, fireplaces, building details) emit their +/// retail-faithful persistent particle effects automatically. Stops the +/// scripts and live emitters when the entity despawns. +/// +/// +/// Wires alongside EntitySpawnAdapter in GpuWorldState: the +/// adapter handles meshes + animation state, the activator handles scripts + +/// particles. Both are render-thread-only. +/// +/// +/// +/// Retail oracle: play_script_internal(setup.DefaultScript) is what +/// retail's CPhysicsObj invokes at object load (see Phase C.1 plan §C.1 +/// and memory/project_sky_pes_port.md). C.1 already shipped the runner; +/// this class adds the missing fire-on-spawn call site. +/// +/// +public sealed class EntityScriptActivator +{ + private readonly PhysicsScriptRunner _scriptRunner; + private readonly ParticleHookSink _particleSink; + private readonly Func _defaultScriptResolver; + + /// Already-shipped runner from C.1. Owns the + /// (scriptId, entityId) instance table and schedules hooks at their + /// StartTime offsets. + /// Already-shipped hook sink from C.1. The + /// activator only calls its + /// to drop any per-entity emitter handles on despawn. + /// Returns + /// entity.SourceGfxObjOrSetupId's Setup.DefaultScript.DataId, + /// or 0 on miss / dat throw / missing field. Production lambda hits + /// ; tests pass a hand-rolled + /// stub. + public EntityScriptActivator( + PhysicsScriptRunner scriptRunner, + ParticleHookSink particleSink, + Func defaultScriptResolver) + { + ArgumentNullException.ThrowIfNull(scriptRunner); + ArgumentNullException.ThrowIfNull(particleSink); + ArgumentNullException.ThrowIfNull(defaultScriptResolver); + _scriptRunner = scriptRunner; + _particleSink = particleSink; + _defaultScriptResolver = defaultScriptResolver; + } + + /// + /// Resolve the entity's Setup.DefaultScript and fire it through + /// the script runner. No-op if the entity has no DefaultScript + /// (resolver returns 0) or if the entity has no server guid + /// (atlas-tier entities are out of scope for this activator). + /// + public void OnCreate(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) return; + + uint scriptId = _defaultScriptResolver(entity); + if (scriptId == 0) return; + + // Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin + // (in entity-local frame) transforms correctly to world space when the + // hook fires. Without this, the sink falls through to Quaternion.Identity + // and the offset gets applied in world axes — visual symptom for portals: + // swirl oriented along world XYZ instead of the portal's facing, partially + // buried because the local-Z lift becomes a world-axis offset. + _particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation); + + _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position); + } + + /// + /// Stop every script instance the runner is tracking for this entity, and + /// kill every live emitter the sink has attributed to it. Idempotent for + /// unknown guids (both calls no-op). + /// + public void OnRemove(uint serverGuid) + { + if (serverGuid == 0) return; + _scriptRunner.StopAllForEntity(serverGuid); + _particleSink.StopAllForEntity(serverGuid, fadeOut: false); + } +} diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 2965b24..b0524ed 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.Core.World; @@ -41,6 +42,7 @@ public sealed class GpuWorldState { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; + private readonly EntityScriptActivator? _entityScriptActivator; /// /// Phase Post-A.5 #53 (Task 12): optional callback fired before @@ -57,11 +59,13 @@ public sealed class GpuWorldState public GpuWorldState( LandblockSpawnAdapter? wbSpawnAdapter = null, EntitySpawnAdapter? wbEntitySpawnAdapter = null, - System.Action? onLandblockUnloaded = null) + System.Action? onLandblockUnloaded = null, + EntityScriptActivator? entityScriptActivator = null) { _wbSpawnAdapter = wbSpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter; _onLandblockUnloaded = onLandblockUnloaded; + _entityScriptActivator = entityScriptActivator; } private readonly Dictionary _loaded = new(); @@ -283,6 +287,7 @@ public sealed class GpuWorldState // Phase N.4 Task 17: release per-instance state for server-spawned // entities. No-op for atlas-tier entities (never registered). _wbEntitySpawnAdapter?.OnRemove(serverGuid); + _entityScriptActivator?.OnRemove(serverGuid); bool rebuiltLoaded = false; @@ -343,6 +348,7 @@ public sealed class GpuWorldState // per-instance adapter. Atlas-tier entities (ServerGuid == 0) are // skipped by OnCreate — it returns null immediately for those. _wbEntitySpawnAdapter?.OnCreate(entity); + _entityScriptActivator?.OnCreate(entity); uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs new file mode 100644 index 0000000..e1e75f9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Rendering.Vfx; + +public sealed class EntityScriptActivatorTests +{ + /// Recording sink so we can assert which hooks the runner fires. + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items) + { + var script = new DatPhysicsScript(); + foreach (var (t, h) in items) + script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h }); + return script; + } + + private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => + new() + { + Id = serverGuid, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + private record Pipeline( + ParticleSystem System, + ParticleHookSink Sink, + PhysicsScriptRunner Runner, + RecordingSink Recording); + + private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts) + { + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity + var recording = new RecordingSink(); // for runner's hook dispatch + var table = new Dictionary(); + foreach (var (id, s) in scripts) table[id] = s; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + recording); + return new Pipeline(system, hookSink, runner, recording); + } + + [Fact] + public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition() + { + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3)); + + activator.OnCreate(entity); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); + } + + [Fact] + public void OnCreate_WithoutDefaultScript_DoesNothing() + { + var p = BuildPipeline(); // no scripts registered + var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.Empty(p.Recording.Calls); + } + + /// + /// Persistent emitter: TotalDuration=0 and TotalParticles=0 prevent + /// auto-finish; InitialParticles=1 ensures a particle spawns at t=0 + /// without waiting for the Birthrate timer; Lifespan=999f keeps that + /// particle alive far past the test horizon. + /// + private static EmitterDesc BuildPersistentEmitterDesc() => new() + { + DatId = 100u, + Type = ParticleType.Still, + EmitterKind = ParticleEmitterKind.BirthratePerSec, + MaxParticles = 4, + InitialParticles = 1, + TotalParticles = 0, // 0 = no particle-count cap + TotalDuration = 0f, // 0 = no time-based finish + Lifespan = 999f, + LifetimeMin = 999f, + LifetimeMax = 999f, + Birthrate = 0.5f, + StartSize = 0.5f, + EndSize = 0.5f, + StartAlpha = 1f, + EndAlpha = 1f, + }; + + [Fact] + public void OnCreate_SetsEntityRotationForHookOffsetTransform() + { + // The CreateParticleHook's Offset is in entity-local frame; the sink + // needs the entity's rotation to transform it to world space. If the + // activator forgets SetEntityRotation, the offset goes off in world + // axes — visual symptom: portal swirls misaligned to the portal stone. + // This test verifies the seed happens by checking the spawned particle's + // world position matches the rotated offset, not the unrotated offset. + + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + // Hook offset = (1, 0, 0) in entity-local frame. + var hookOffset = new Frame + { + Origin = new Vector3(1f, 0f, 0f), + Orientation = Quaternion.Identity, + }; + var script = BuildScript( + (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); + + var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); + + // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y. + var entityRotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, MathF.PI / 2f); + var entity = new WorldEntity + { + Id = 0xCAFEu, + ServerGuid = 0xCAFEu, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = entityRotation, + MeshRefs = System.Array.Empty(), + }; + + activator.OnCreate(entity); + runner.Tick(0.001f); + system.Tick(0.001f); + + // Find the live particle. With the rotation applied, world position of + // the local-(1,0,0) offset should be approximately world-(0,1,0). Without + // the rotation seed (the bug), it would be world-(1,0,0). + var live = system.EnumerateLive().FirstOrDefault(); + Assert.NotNull(live.Emitter); + var worldPos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(worldPos.X, -0.01f, 0.01f); + Assert.InRange(worldPos.Y, 0.99f, 1.01f); + } + + [Fact] + public void OnRemove_StopsScriptsAndEmitters() + { + // For this test we need the runner to dispatch into the REAL + // ParticleHookSink so OnRemove's sink.StopAllForEntity has a live + // emitter to kill. This is the only observable way to verify the + // call had effect without subclassing the sealed sink. + var registry = new EmitterDescRegistry(); + registry.Register(BuildPersistentEmitterDesc()); + + var system = new ParticleSystem(registry); + var hookSink = new ParticleHookSink(system); + + var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() })); + var table = new Dictionary { [0xAAu] = script }; + var runner = new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + hookSink); // runner dispatches into real sink, not RecordingSink + + var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); + var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); + + activator.OnCreate(entity); + runner.Tick(0.001f); // fires the CreateParticleHook → spawns emitter + + Assert.True(system.ActiveEmitterCount > 0, + "Setup precondition failed: emitter should be alive after the hook fires."); + + activator.OnRemove(0xCAFEu); + + Assert.Equal(0, runner.ActiveScriptCount); + // sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it. + system.Tick(0.01f); + Assert.Equal(0, system.ActiveEmitterCount); + } +}