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