docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-11 13:36:18 +02:00
parent 06d7fbd5ef
commit ed5335b81e
2 changed files with 694 additions and 20 deletions

View file

@ -0,0 +1,651 @@
# Phase C.1.5a — Portal PES wiring implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fire `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` when a server-spawned `WorldEntity` enters the world, so portals emit their retail-faithful persistent particle effects automatically.
**Architecture:** One new ~50-line class `EntityScriptActivator` under `src/AcDream.App/Rendering/Vfx/`. Wired into `GpuWorldState`'s `AppendLiveEntity` (calls `OnCreate`) and `RemoveEntityByServerGuid` (calls `OnRemove`), immediately after the matching `_wbEntitySpawnAdapter` calls. Activator is constructed in `GameWindow` (alongside the existing entity-spawn adapter) and passed into `GpuWorldState` as a new optional ctor parameter.
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../specs/2026-05-12-phase-c1.5a-portals-design.md). Read it first.
---
## File structure
**Created:**
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — the new orchestrator class.
- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — three xUnit tests covering OnCreate-fires, OnCreate-no-op, OnRemove-cleanup.
**Modified:**
- `src/AcDream.App/Streaming/GpuWorldState.cs` — new optional ctor parameter; two `?.` call sites added.
- `src/AcDream.App/Rendering/GameWindow.cs` — construct the activator alongside `_wbEntitySpawnAdapter` (~line 1614) and pass it into the `GpuWorldState` ctor (~line 1619). One field declaration added.
- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5a SHIPPED" entry on verification pass (Task 4 only).
Each file has one clear responsibility:
- `EntityScriptActivator` — orchestrates DefaultScript fire-on-spawn / stop-on-despawn. Knows nothing about dats or GL.
- `GpuWorldState` — owns spawn lifecycle. The activator is one more `?.` collaborator alongside the existing adapter.
- `GameWindow` — wiring root. Constructs the resolver lambda where `_dats` is in scope; everything else is plumbing.
---
## Task 1: Build `EntityScriptActivator` with tests (TDD)
**Files:**
- Create: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`
- Create: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`
- [ ] **Step 1.1 — Write the test file with three failing tests + helpers**
Create `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Rendering.Vfx;
public sealed class EntityScriptActivatorTests
{
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
}
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
{
var script = new DatPhysicsScript();
foreach (var (t, h) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
return script;
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
new()
{
Id = serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
private record Pipeline(
ParticleSystem System,
ParticleHookSink Sink,
PhysicsScriptRunner Runner,
RecordingSink Recording);
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
var recording = new RecordingSink(); // for runner's hook dispatch
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
recording);
return new Pipeline(system, hookSink, runner, recording);
}
[Fact]
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
}
[Fact]
public void OnCreate_WithoutDefaultScript_DoesNothing()
{
var p = BuildPipeline(); // no scripts registered
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Empty(p.Recording.Calls);
}
[Fact]
public void OnRemove_StopsScriptsAndEmitters()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
activator.OnRemove(0xCAFEu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
// Tick after Remove must not surface any further hook fires.
p.Runner.Tick(1.0f);
Assert.Empty(p.Recording.Calls);
}
}
```
- [ ] **Step 1.2 — Run the tests, confirm they fail with "type not found"**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
Expected: compile error — `AcDream.App.Rendering.Vfx.EntityScriptActivator` does not exist. (This is the failing red-bar that drives the next step.)
- [ ] **Step 1.3 — Create the `Vfx/` directory and the activator file**
Create `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
/// objects (portals, chimneys, fireplaces, building details) emit their
/// retail-faithful persistent particle effects automatically. Stops the
/// scripts and live emitters when the entity despawns.
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts +
/// particles. Both are render-thread-only.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
/// this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
/// to drop any per-entity emitter handles on despawn.</param>
/// <param name="defaultScriptResolver">Returns
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
/// stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_defaultScriptResolver = defaultScriptResolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. No-op if the entity has no DefaultScript
/// (resolver returns 0) or if the entity has no server guid
/// (atlas-tier entities are out of scope for this activator).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (entity.ServerGuid == 0) return;
uint scriptId = _defaultScriptResolver(entity);
if (scriptId == 0) return;
_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this entity, and
/// kill every live emitter the sink has attributed to it. Idempotent for
/// unknown guids (both calls no-op).
/// </summary>
public void OnRemove(uint serverGuid)
{
if (serverGuid == 0) return;
_scriptRunner.StopAllForEntity(serverGuid);
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
}
}
```
- [ ] **Step 1.4 — Run the tests, confirm all three pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
Expected: 3 passed, 0 failed.
If a test fails: re-read the assertion against the implementation. The most likely failure is `RecordingSink.Calls` empty after `Runner.Tick` — that means the `Play` call didn't queue the script. Check that `entity.ServerGuid != 0` in `MakeEntity`.
- [ ] **Step 1.5 — Run the full test suite for the test project**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
Expected: all existing tests still pass plus the new 3.
- [ ] **Step 1.6 — Commit Task 1**
```bash
git add src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet)
New ~50-line orchestrator that fires Setup.DefaultScript through the
already-shipped PhysicsScriptRunner on entity spawn and stops scripts +
live emitters on despawn. Resolver delegate avoids DatCollection coupling
so the class is fully unit-testable with stubs.
Three xUnit tests cover the three branches: fire-with-script,
no-op-without-script, stop-on-remove. No wiring into the live spawn path
yet — that lands in the next commit.
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Wire activator into `GpuWorldState`
**Files:**
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:42-65` (field + constructor)
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:285` (OnRemove call site)
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:345` (OnCreate call site)
- [ ] **Step 2.1 — Add `using` for the new namespace**
Open `src/AcDream.App/Streaming/GpuWorldState.cs`. The existing `using` block at the top (line ~4) imports `AcDream.App.Rendering.Wb;`. Add a second line below it:
```csharp
using AcDream.App.Rendering.Vfx;
```
- [ ] **Step 2.2 — Add the field**
Around line 43 there is:
```csharp
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
```
Add immediately below:
```csharp
private readonly EntityScriptActivator? _entityScriptActivator;
```
- [ ] **Step 2.3 — Extend the constructor**
Replace the existing constructor (lines 5765) with:
```csharp
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
```
The new parameter is optional and last — existing callers (production and tests) compile unchanged.
- [ ] **Step 2.4 — Add the `OnCreate` call in `AppendLiveEntity`**
At line 345 the existing call is:
```csharp
_wbEntitySpawnAdapter?.OnCreate(entity);
```
Add immediately below:
```csharp
_entityScriptActivator?.OnCreate(entity);
```
- [ ] **Step 2.5 — Add the `OnRemove` call in `RemoveEntityByServerGuid`**
At line 285 the existing call is:
```csharp
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
```
Add immediately below:
```csharp
_entityScriptActivator?.OnRemove(serverGuid);
```
- [ ] **Step 2.6 — Run the build to confirm GpuWorldState compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: build succeeds. `GameWindow.cs` still calls the old 3-arg constructor; the new parameter is optional so this compiles fine.
- [ ] **Step 2.7 — Run the test suite to confirm GpuWorldStateTests still pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~GpuWorldStateTests"`
Expected: all pass. Existing tests construct `GpuWorldState` with positional args; they don't pass the new optional parameter so behavior is unchanged.
If a test fails because it asserts something about per-entity-lifecycle ordering: read the assertion. The new `?.OnCreate(entity)` after `_wbEntitySpawnAdapter?.OnCreate(entity)` is a no-op when no activator is injected, so tests that don't inject one should not see new behavior.
- [ ] **Step 2.8 — Commit Task 2**
```bash
git add src/AcDream.App/Streaming/GpuWorldState.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle
GpuWorldState grows a fourth optional ctor parameter for the activator,
paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls
OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate;
RemoveEntityByServerGuid calls OnRemove after the existing OnRemove.
Symmetric, same order, null-safe.
GameWindow still passes the old 3-arg ctor — activator construction +
wire-through lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Construct activator in `GameWindow` and pass through to `GpuWorldState`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:35` (field declaration block)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:1612-1622` (activator construction + GpuWorldState ctor call)
- [ ] **Step 3.1 — Add the field declaration**
Around line 35 in `GameWindow.cs` there is:
```csharp
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
```
Add immediately below:
```csharp
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
```
- [ ] **Step 3.2 — Build the resolver lambda and construct the activator**
In the block starting at line 1612 (where `wbEntitySpawnAdapter` is constructed and assigned), the current code is:
```csharp
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
```
Replace with:
```csharp
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a: construct EntityScriptActivator so server-spawned static
// entities (portals first) fire Setup.DefaultScript through the
// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink
// are initialised earlier in OnLoad (line ~1083); both are non-null
// here. The resolver lambda captures _dats and swallows dat-lookup
// throws — see C.1.5a spec §6 (error handling) for rationale.
var capturedDatsForActivator = _dats;
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e)
{
try
{
var setup = capturedDatsForActivator?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
return setup?.DefaultScript.DataId ?? 0u;
}
catch
{
return 0u;
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _particleSink!, ResolveDefaultScript);
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
```
Two changes: (1) inline construction of activator + resolver between `_wbEntitySpawnAdapter` assignment and the `_worldState =` line, (2) add the `entityScriptActivator: entityScriptActivator` named argument to the `GpuWorldState` constructor call.
- [ ] **Step 3.3 — Build the project**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: build succeeds. If you get an "_scriptRunner is null here" warning at the activator construction site, it's a nullable-flow false positive — the runner is built at line 1083 inside the same `OnLoad` method which executes before this block. Use `_scriptRunner!` and `_particleSink!` (already shown above).
- [ ] **Step 3.4 — Run the full test suite**
Run: `dotnet test`
Expected: all tests pass. No new tests added in this task — verification of the wiring is the visual step in Task 4.
- [ ] **Step 3.5 — Commit Task 3**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow
Wires the activator into the production lifecycle:
- Construct alongside _wbEntitySpawnAdapter using _scriptRunner +
_particleSink (both built earlier in OnLoad).
- Production resolver lambda hits _dats.Get<Setup>(...) wrapped in
try/catch returning 0 on miss/throw — matches ParticleRenderer's
defensive read pattern.
- Pass into GpuWorldState's new optional ctor parameter.
Closes the wiring half of C.1.5a. Visual verification at the Holtburg
Town network portal is the acceptance gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Visual verification + roadmap update
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md` (append a "Phase C.1.5a SHIPPED" entry)
This is a manual verification task with a user-in-the-loop step. Do not mark the slice "done" until the user confirms the portal swirl visually matches retail.
- [ ] **Step 4.1 — Build green, tests green**
Run sequentially:
```powershell
dotnet build
dotnet test
```
Expected: both green. If either fails: stop and fix before launching.
- [ ] **Step 4.2 — Kill any stale acdream process from a prior session**
Run:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
```
Per [CLAUDE.md](../../../CLAUDE.md) "Logout-before-reconnect" — ACE keeps a session alive briefly after disconnect; relaunching within ~3 s causes a handshake failure that looks like a code bug but isn't.
- [ ] **Step 4.3 — Launch the live client with PES diagnostics**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DUMP_PLAYSCRIPT = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "c1.5a-verify.log"
```
(Use the Bash tool's `run_in_background: true` parameter so the launch doesn't block the agent on the user's testing.)
- [ ] **Step 4.4 — Hand off to the user for visual verification**
Once the client reaches in-world state (~8 s after launch), tell the user:
> "Client launched with PES diagnostics. Walk `+Acdream` to the Holtburg Town network portal and compare side-by-side with retail. Confirm the portal swirl matches in color, density, motion, and persistence. Reply 'pass' if it matches or describe what differs."
Wait for the user's response. If they reply with anything other than confirmation, stop and investigate; do NOT proceed to Step 4.5.
- [ ] **Step 4.5 — On user confirmation: update the roadmap**
Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (look for "Phase N.6 slice 1" or similar recent entries). Add a new entry above the earlier shipped phases:
```markdown
**Phase C.1.5a (Portal PES wiring) shipped 2026-05-12.** Server-spawned
`WorldEntity` entities now fire their `Setup.DefaultScript` through the
shipped `PhysicsScriptRunner` on enter-world. New
[`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)
class wires into `GpuWorldState`'s spawn lifecycle. Visual verification
passed at the Holtburg Town network portal. Slice 2 (C.1.5b — EnvCell
static objects + animation-hook verification) is the natural next step.
Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md).
Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
```
If the roadmap has a "Currently in flight" line that mentions C.1.5 or
similar, update it: change "in flight" to "Phase C.1.5b (EnvCell statics
+ verification) — see [C.1.5a spec §10 slice 2 preview](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)".
- [ ] **Step 4.6 — Commit the roadmap update**
```bash
git add docs/plans/2026-04-11-roadmap.md
git commit -m "$(cat <<'EOF'
docs(roadmap #C.1.5a): mark Phase C.1.5a shipped
Portal PES wiring landed and visually verified at the Holtburg Town
network portal. EntityScriptActivator fires Setup.DefaultScript through
the shipped PhysicsScriptRunner on entity spawn. C.1.5b (EnvCell static
objects + animation-hook verification) is the next slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4.7 — Close the loop**
Report to the user:
- C.1.5a shipped, three commits: activator, wiring, roadmap.
- Tests green; visual verification passed.
- Suggest brainstorming C.1.5b as the next step (or take a break / pick something else).
---
## Self-review against the spec
Run through the spec's section list and confirm each requirement maps to a plan task:
- **§2 Scope "in":**
- New `EntityScriptActivator` class → Task 1.3 ✓
- Wiring in `GpuWorldState` → Task 2.4, 2.5 ✓
- Activator constructed in `GameWindow`, passed into `GpuWorldState` → Task 3.2 ✓
- Three unit tests → Task 1.1 ✓
- Visual verification at Holtburg Town network portal → Task 4.3, 4.4 ✓
- **§4 Architecture — file placement under `Rendering/Vfx/`** → Task 1.3 (creates the directory implicitly via the file path) ✓
- **§4 Architecture — resolver delegate pattern** → Tests use stubs (Task 1.1); production uses the lambda in `GameWindow` (Task 3.2) ✓
- **§4 Trigger condition "has DefaultScript, not is portal"** → Resolver returns `Setup.DefaultScript.DataId ?? 0`; activator gates `if (scriptId == 0) return` (Task 1.3) ✓
- **§5 Lifecycle ordering: spawnAdapter → activator** → Task 2.4, 2.5 add the activator call immediately after the existing adapter call ✓
- **§6 Error handling — resolver swallows exceptions** → Task 3.2 wraps `_dats.Get<Setup>(...)` in try/catch returning 0 ✓
- **§7 Thread safety** → All calls on render thread; no new synchronization needed (covered by inheriting `GpuWorldState`'s existing single-thread contract) ✓
- **§8 Three named tests** → Task 1.1 ✓
- **§8 Visual verification procedure** → Task 4.34.5 ✓
No gaps.
Type / name consistency check:
- `EntityScriptActivator` is the class name in tests (Task 1.1), the file (Task 1.3), the field in `GpuWorldState` (Task 2.2), the parameter (Task 2.3), and the field + construction in `GameWindow` (Task 3.1, 3.2). Consistent.
- `OnCreate(WorldEntity)` / `OnRemove(uint)` signatures match across tests and implementation. ✓
- Constructor signature `(PhysicsScriptRunner, ParticleHookSink, Func<WorldEntity, uint>)` matches between tests, implementation, and production wiring. ✓
- `ResolveDefaultScript` lambda (Task 3.2) returns `uint` — matches the `Func<WorldEntity, uint>` declared on the activator. ✓

View file

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