From ed5335b81e4f8d37c9fe4d82437af5238d08e494 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 13:36:18 +0200 Subject: [PATCH] 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