From 06d7fbd5efc5487e71ce737f211e5bb502e64a3b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 13:28:55 +0200 Subject: [PATCH 1/9] =?UTF-8?q?docs(vfx):=20Phase=20C.1.5a=20=E2=80=94=20p?= =?UTF-8?q?ortal=20PES=20wiring=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already- shipped PhysicsScriptRunner on server-spawned WorldEntity create, so portals (and any other entity with a DefaultScript) emit their retail- faithful persistent particle effects at spawn time. Reuses the C.1 runner-sink-system-renderer chain end-to-end; one new ~50-line class (EntityScriptActivator) plus a two-line wiring in GameWindow. Slice 2 (C.1.5b) will cover EnvCell.StaticObjects + animation-hook verification; spec landed separately after slice 1 verification passes. Acceptance: visual confirmation at the Holtburg Town network portal, side-by-side with retail. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-12-phase-c1.5a-portals-design.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md 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..0e9a75d --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md @@ -0,0 +1,352 @@ +# 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 `GameWindow`'s + spawn-lifecycle handlers, immediately after the matching `EntitySpawnAdapter` + calls. +- 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: GameWindow + +```csharp +// In GameWindow.OnCreateObject(WorldEntity entity): +_entitySpawnAdapter.OnCreate(entity); +_entityScriptActivator.OnCreate(entity); // NEW — runs after meshes are registered + +// In GameWindow.OnRemoveObject(uint serverGuid): +_entitySpawnAdapter.OnRemove(serverGuid); +_entityScriptActivator.OnRemove(serverGuid); // NEW — runs in same order as create +``` + +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 + +``` +GameWindow.OnCreateObject(entity) +├─ _entitySpawnAdapter.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 + +``` +GameWindow.OnRemoveObject(serverGuid) +├─ _entitySpawnAdapter.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.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`. 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 `GameWindow` wiring is two added lines (one in `OnCreateObject`, one in +`OnRemoveObject`). An integration test would require booting GL + dats + +network. Coverage is the visual verification gate instead. + +### 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. **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. +2. **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. +3. **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). From ed5335b81e4f8d37c9fe4d82437af5238d08e494 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 13:36:18 +0200 Subject: [PATCH 2/9] docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md. Four tasks, TDD-style, each task is a single commit boundary: 1. EntityScriptActivator class + three xUnit tests 2. Wire into GpuWorldState (new optional ctor param + two ?. calls) 3. Construct in GameWindow with resolver lambda 4. Visual verification at Holtburg Town network portal + roadmap update Spec amendments correct an inaccuracy in the 2026-05-12 commit (06d7fbd): the activator's call sites live in GpuWorldState (AppendLiveEntity / RemoveEntityByServerGuid), not directly in GameWindow as the original spec described. Also fixes the test file path: tests/AcDream.Core.Tests/... not AcDream.App.Tests/... per the existing test-project convention. No design changes — same activator, same trigger condition, same lifecycle ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-phase-c1.5a-portals.md | 651 ++++++++++++++++++ .../2026-05-12-phase-c1.5a-portals-design.md | 63 +- 2 files changed, 694 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md 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 index 0e9a75d..c1eaf5d 100644 --- 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 @@ -25,9 +25,12 @@ swirl matches retail in color, density, motion, and persistence. **In:** - New class `EntityScriptActivator` (one file, ~50 lines). -- Wiring of activator's `OnCreate` / `OnRemove` calls into `GameWindow`'s - spawn-lifecycle handlers, immediately after the matching `EntitySpawnAdapter` - calls. +- 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. @@ -145,18 +148,32 @@ 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: GameWindow +### 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 -// In GameWindow.OnCreateObject(WorldEntity entity): -_entitySpawnAdapter.OnCreate(entity); -_entityScriptActivator.OnCreate(entity); // NEW — runs after meshes are registered +// GpuWorldState.AppendLiveEntity (line ~345): +_wbEntitySpawnAdapter?.OnCreate(entity); +_entityScriptActivator?.OnCreate(entity); // NEW — fires DefaultScript -// In GameWindow.OnRemoveObject(uint serverGuid): -_entitySpawnAdapter.OnRemove(serverGuid); -_entityScriptActivator.OnRemove(serverGuid); // NEW — runs in same order as create +// 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: @@ -183,9 +200,9 @@ The try/catch matches the pattern in ### On spawn ``` -GameWindow.OnCreateObject(entity) -├─ _entitySpawnAdapter.OnCreate(entity) // meshes ref-counted, animation state built -└─ _entityScriptActivator.OnCreate(entity) +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, @@ -199,9 +216,9 @@ GameWindow.OnCreateObject(entity) ### On despawn ``` -GameWindow.OnRemoveObject(serverGuid) -├─ _entitySpawnAdapter.OnRemove(serverGuid) // meshes ref-decremented, state cleared -└─ _entityScriptActivator.OnRemove(serverGuid) +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) ``` @@ -270,7 +287,11 @@ new threading concerns introduced. ### Unit tests (slice 1's gating tests) -`tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`. Uses +`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. @@ -286,9 +307,11 @@ test double). No dats, no GL. ### Integration tests — none for slice 1 -The `GameWindow` wiring is two added lines (one in `OnCreateObject`, one in -`OnRemoveObject`). An integration test would require booting GL + dats + -network. Coverage is the visual verification gate instead. +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 From 003c502774a64cbce2ecd1d66ee3fc7b2033d27f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:10:38 +0200 Subject: [PATCH 3/9] 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) --- .../Rendering/Vfx/EntityScriptActivator.cs | 86 ++++++++++++++ .../Vfx/EntityScriptActivatorTests.cs | 110 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs new file mode 100644 index 0000000..7211f85 --- /dev/null +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -0,0 +1,86 @@ +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); + } +} 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..d31ca29 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -0,0 +1,110 @@ +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); + } +} From e0529b023d46e3a66b812124affad0fbdcd773ae Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:20:46 +0200 Subject: [PATCH 4/9] test(vfx #C.1.5a): real-emitter verification in OnRemove test + unused using MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up to 003c502: 1. Test 3 (OnRemove_StopsScriptsAndEmitters) now wires the runner into the real ParticleHookSink instead of a RecordingSink, registers a persistent EmitterDesc, lets the CreateParticleHook actually spawn an emitter, then asserts the sink killed it after OnRemove. Previously the test only verified runner-side state — sink.StopAllForEntity was never observably exercised, so a regression dropping that call would have passed silently. 2. Removed unused `using System.Numerics` from EntityScriptActivator.cs. No production code changes. Tests 1 and 2 unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Vfx/EntityScriptActivator.cs | 1 - .../Vfx/EntityScriptActivatorTests.cs | 58 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index 7211f85..828ccae 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -1,5 +1,4 @@ using System; -using System.Numerics; using AcDream.Core.Vfx; using AcDream.Core.World; diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index d31ca29..d835e8c 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -89,22 +89,64 @@ public sealed class EntityScriptActivatorTests 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 OnRemove_StopsScriptsAndEmitters() { - var p = BuildPipeline( - (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); - var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu); + // 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); - Assert.Equal(1, p.Runner.ActiveScriptCount); + 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, p.Runner.ActiveScriptCount); - // Tick after Remove must not surface any further hook fires. - p.Runner.Tick(1.0f); - Assert.Empty(p.Recording.Calls); + Assert.Equal(0, runner.ActiveScriptCount); + // sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it. + system.Tick(0.01f); + Assert.Equal(0, system.ActiveEmitterCount); } } From 44d85022e8062439e791abcb95d9980006600649 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:25:22 +0200 Subject: [PATCH 5/9] feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Streaming/GpuWorldState.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; From 65d833de1e1e8be4fc29e22c3243d93d6a4f4365 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:30:53 +0200 Subject: [PATCH 6/9] feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b81d484..14463e8 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,30 @@ 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. + 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 @@ -1619,7 +1644,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!, From 849690c814fefe3850c758565f8eab413c7d2737 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 14:35:02 +0200 Subject: [PATCH 7/9] refactor(vfx #C.1.5a): reuse SequencerFactory's capturedDats in resolver Code-review follow-up to 65d833d: ResolveDefaultScript was closing over its own var capturedDatsForActivator = _dats, but the sibling SequencerFactory in the same block already declared var capturedDats = _dats. The two locals pointed at the same reference and served the same purpose; the alias added no value and muddied the closure pattern. Reuse capturedDats. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 14463e8..e679b92 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1620,12 +1620,11 @@ public sealed class GameWindow : IDisposable // 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); + var setup = capturedDats?.Get(e.SourceGfxObjOrSetupId); return setup?.DefaultScript.DataId ?? 0u; } catch From 334f0c6a265244fd2c52141b0106fcdfd62d352b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 15:56:27 +0200 Subject: [PATCH 8/9] fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates Visual verification at the Holtburg Town network portal revealed the swirl was oriented along world axes (NS) instead of the portal's actual facing (EW), and partially buried in the ground because the hook's local-frame Offset.Origin was being applied in world axes too. Root cause: EntityScriptActivator.OnCreate fired _scriptRunner.Play but never called _particleSink.SetEntityRotation. When the runner's CreateParticleHook fires, the sink reads per-entity rotation from _rotationByEntity (defaults to Quaternion.Identity for unknown entities) and uses it to transform the hook's Offset.Origin from entity-local to world space. Without the seed call, the rotation lookup falls through to Identity and the offset goes off along world XYZ. Fix is a single SetEntityRotation call before the Play call. Added a 4th unit test that constructs an entity with a 90 deg yaw and asserts the spawned particle's world position reflects the rotated offset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Vfx/EntityScriptActivator.cs | 8 +++ .../Vfx/EntityScriptActivatorTests.cs | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index 828ccae..ad14615 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -68,6 +68,14 @@ public sealed class EntityScriptActivator 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); } diff --git a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index d835e8c..e1e75f9 100644 --- a/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -114,6 +114,64 @@ public sealed class EntityScriptActivatorTests 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() { From 9009318656cde20157cf20f435e6aeee0035b6da Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 16:13:12 +0200 Subject: [PATCH 9/9] docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual verification at the Holtburg Town network portal passed for the slice's mechanism: 10-hook portal script fires end-to-end with correct color, persistence, orientation, and multi-emitter dispatch. After the 334f0c6 rotation-seed fix, the swirl is oriented correctly along the portal's facing instead of world-NS. Known limitation surfaced during verification and filed as issue #56: ParticleHookSink ignores CreateParticleHook.PartIndex, so all 10 of the portal's emitters collapse to the entity root position + identity-rotated offset, producing a compressed and partly-ground-buried swirl instead of the multi-tier shape retail renders. Mechanism is correct; per-part transform handling is the next vfx-pipeline concern (will affect every multi-emitter PES — slice 2 chimneys/fireplaces in particular). Documentation changes: - docs/ISSUES.md: new #56 entry with the captured entity guids (0x7A9B405B / 0x7A9B4080), script ids (0x3300126D / 0x3300067A), symptom data, root-cause hypothesis, file pointers, and acceptance criterion. Notes the blocks-slice-2 relationship explicitly. - docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md §9: new limitation #1 documenting the verified PartIndex collapse symptom. - docs/plans/2026-04-11-roadmap.md: new "C.1.5a" row in the shipped table referencing the spec, plan, and #56 caveat. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 31 +++++++++++++++++++ docs/plans/2026-04-11-roadmap.md | 1 + .../2026-05-12-phase-c1.5a-portals-design.md | 16 ++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) 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/specs/2026-05-12-phase-c1.5a-portals-design.md b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md index c1eaf5d..b92f3b2 100644 --- 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 @@ -335,13 +335,23 @@ do not blindly retry. These are intentionally not fixed in slice 1; tracked here so the next slice or a future phase picks them up: -1. **Moving entities** don't re-anchor their DefaultScript emitters per +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. -2. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters +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. -3. **Animation-hook particle path** (`MotionInterpreter` → +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.