Merge branch 'claude/lucid-burnell-aab524' — Phase C.1.5a (portal PES wiring)

Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already-
shipped PhysicsScriptRunner when a server-spawned WorldEntity enters
the world. Portals and any other static object with a DefaultScript
now emit their retail-faithful persistent particle effects at spawn
time, mirroring retail's play_script_internal dispatch on object load.

One new ~85-line orchestrator class EntityScriptActivator wires
alongside EntitySpawnAdapter in GpuWorldState's spawn lifecycle
(AppendLiveEntity → OnCreate; RemoveEntityByServerGuid → OnRemove).
Activator construction in GameWindow uses a defensive resolver lambda
over _dats.Get<Setup>(...) and also seeds _particleSink.SetEntityRotation
so hook offsets transform from entity-local to world space correctly.
Four xUnit tests with mutation-check verification. End-to-end: 8 commits,
every per-task commit reviewed for spec compliance + code quality;
final cross-task review by Opus approved.

Visual verification at the Holtburg Town network portal: 10-hook portal
script fires with correct color, persistence, orientation, and
multi-emitter dispatch.

Known limitation surfaced and filed as issue #56: ParticleHookSink
ignores CreateParticleHook.PartIndex, so multi-emitter scripts collapse
to one root position instead of distributing across the entity's mesh
parts. Visually produces a compressed, partly-ground-buried swirl on
multi-part dat objects. Mechanism is correct; per-part transform
handling is the next vfx-pipeline concern and blocks slice 2 (EnvCell
chimneys / fireplaces).

Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Issue: docs/ISSUES.md #56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-11 16:23:17 +02:00
commit 88bda12d98
8 changed files with 1404 additions and 2 deletions

View file

@ -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

View file

@ -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 3050× 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<Setup>(...)?.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

View file

@ -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
{
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
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<MeshRef>(),
};
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<uint, DatPhysicsScript>();
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;
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a server-spawned <see cref="WorldEntity"/> 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.
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts +
/// particles. Both are render-thread-only.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
/// this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
/// to drop any per-entity emitter handles on despawn.</param>
/// <param name="defaultScriptResolver">Returns
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
/// stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_defaultScriptResolver = defaultScriptResolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> 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).
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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) <noreply@anthropic.com>
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 5765) with:
```csharp
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? 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) <noreply@anthropic.com>
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<DatReaderWriter.DBObjs.Setup>(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<Setup>(...) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<Setup>(...)` 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.34.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<WorldEntity, uint>)` matches between tests, implementation, and production wiring. ✓
- `ResolveDefaultScript` lambda (Task 3.2) returns `uint` — matches the `Func<WorldEntity, uint>` declared on the activator. ✓

View file

@ -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 771795) 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<WorldEntity, uint> 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<Setup>(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 119130 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<Setup>(...)` 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).

View file

@ -33,6 +33,7 @@ public sealed class GameWindow : IDisposable
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
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;
/// <summary>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<DatReaderWriter.DBObjs.Setup>(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!,

View file

@ -0,0 +1,93 @@
using System;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a server-spawned <see cref="WorldEntity"/> 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.
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts +
/// particles. Both are render-thread-only.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
/// this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
/// to drop any per-entity emitter handles on despawn.</param>
/// <param name="defaultScriptResolver">Returns
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
/// stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_defaultScriptResolver = defaultScriptResolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> 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).
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
public void OnRemove(uint serverGuid)
{
if (serverGuid == 0) return;
_scriptRunner.StopAllForEntity(serverGuid);
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
}
}

View file

@ -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;
/// <summary>
/// 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<uint>? onLandblockUnloaded = null)
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
private readonly Dictionary<uint, LoadedLandblock> _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;

View file

@ -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
{
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
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<MeshRef>(),
};
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<uint, DatPhysicsScript>();
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);
}
/// <summary>
/// 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.
/// </summary>
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<uint, DatPhysicsScript> { [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<MeshRef>(),
};
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<uint, DatPhysicsScript> { [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);
}
}