# Phase C.1.5a — Portal PES wiring (Setup.DefaultScript on entity spawn)

**Created:** 2026-05-12.
**Author:** Claude (lead engineer/architect).
**Phase:** C.1.5a (first of two slices; C.1.5b covers EnvCell statics + animation-hook verification).
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
**Baseline justification:** [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](../../plans/2026-05-11-phase-n6-perf-baseline.md) §4 — C.1.5 is the right next phase; production preset is comfortable, no perf escalation pressure.

---

## §1 Goal

Make server-spawned `WorldEntity` portals emit their retail-faithful particle
effects (portal swirls) at spawn time. Implement by **firing `Setup.DefaultScript`
through the already-shipped `PhysicsScriptRunner`** at the moment the entity
enters the world, mirroring retail's `play_script_internal` dispatch on object
spawn.

Acceptance: the user walks `+Acdream` up to the **Holtburg Town network portal**,
opens a side-by-side comparison with a retail AC client, and confirms the portal
swirl matches retail in color, density, motion, and persistence.

## §2 Scope

**In:**

- New class `EntityScriptActivator` (one file, ~50 lines).
- Wiring of activator's `OnCreate` / `OnRemove` calls into `GpuWorldState`'s
  spawn-lifecycle methods (`AppendLiveEntity` / `RemoveEntityByServerGuid`),
  immediately after the matching `EntitySpawnAdapter` calls. The activator is
  constructed in `GameWindow` (where `_dats`, `_scriptRunner`, and
  `_particleSink` are in scope) and passed into `GpuWorldState`'s constructor
  as a new optional parameter, paralleling how `EntitySpawnAdapter` is wired.
- Three unit tests covering the activator's three branches
  (fire / no-op-on-zero / cleanup-on-remove).
- Visual verification at the Holtburg Town network portal.

**Out (deferred to C.1.5b):**

- `EnvCell.StaticObjects` walker for interior chimneys / fireplaces.
- Animation-hook particle path verification (already wired in C.1; needs
  a confirming check, deferred so this slice stays small).
- The WB-style "re-fire after 1 second" loop logic for non-persistent emitters
  ([`ParticleEmitterRenderer.cs:119-130`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs) in WB).
  Portal swirls are persistent (`TotalParticles=0 && TotalSeconds=0`) and don't
  need it. If C.1.5b discovers EnvCell static objects need it, that slice adds it.

**Out (out of phase entirely):**

- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
  slice 2 territory.
- Performance work. Per [baseline §4](../../plans/2026-05-11-phase-n6-perf-baseline.md),
  CPU at the production preset is comfortable and there is no GPU pressure.
- Adding `WeenieClassId` to `WorldEntity`. Trigger is "has DefaultScript",
  not "is portal" (see §4 Architecture for rationale).

## §3 Background

### Why this works today for *some* particles, not portals

C.1 shipped a complete particle pipeline:
[`EmitterDescRegistry`](../../../src/AcDream.Core/Vfx/EmitterDescRegistry.cs)
(data) → [`ParticleSystem`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) (sim)
→ [`ParticleHookSink`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
(dispatch) → [`PhysicsScriptRunner`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)
(script scheduler) → [`ParticleRenderer`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
(draw).

The chain is end-to-end, but `PhysicsScriptRunner.Play` is only called from
**two places today**:

1. The server-driven `PlayScript (0xF754)` opcode handler in `GameWindow` —
   spell casts, combat hits, emote effects.
2. The animation-hook path inside `MotionInterpreter` — feet sparks, weapon
   trails (via `ParticleHookSink` directly, not through the runner).

**Nothing fires `Setup.DefaultScript` when a static entity spawns.** Retail
does this (per the named decomp's `play_script_internal` analysis), and
`WorldBuilder` does the equivalent at mesh-prep time
([`ObjectMeshManager.cs:797`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs)).
Acdream skips it — every portal lacks its swirl, every chimney lacks its smoke.

### Why not consume WB's staged emitters

WB's `ObjectMeshManager.PrepareSetupMeshData` (line 771–795) collects
`StagedEmitter` entries from `setup.DefaultScript` and attaches them to
`ObjectMeshData.ParticleEmitters`. Three reasons we don't consume them:

1. `WbMeshAdapter` calls `PrepareMeshDataAsync(id, isSetup: false)` — we go
   through the per-part GfxObj path, not the Setup path
   ([`WbMeshAdapter.cs:136`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs)).
   Flipping that breaks shipped N.4/N.5 dispatcher assumptions.
2. WB's `CollectEmittersFromScript` drops the script's per-hook `StartTime`
   offsets — it spawns every `CreateParticleHook` immediately. Our
   `PhysicsScriptRunner` honors `StartTime` and is more retail-faithful.
3. C.1 already shipped a runner that *is* the equivalent of retail's
   `play_script_internal`. Adding the missing call sites is cheaper and
   structurally cleaner than building a parallel emitter-staging path.

## §4 Architecture

### New class

`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`. New `Vfx/`
subdirectory under `Rendering/` — sits next to `ParticleRenderer.cs` and is
**not** under `Wb/` because the activator drives our own `PhysicsScriptRunner`
and has no WB dependency.

Constructor — mirrors `EntitySpawnAdapter`'s factory-delegate pattern so the
activator has no `DatCollection` coupling and is fully unit-testable with
stubs:

```csharp
public EntityScriptActivator(
    PhysicsScriptRunner scriptRunner,
    ParticleHookSink particleSink,
    Func<WorldEntity, uint> defaultScriptResolver)
```

The resolver returns the entity's `Setup.DefaultScript.DataId`, or `0` if the
Setup is missing / the dat throws / the field is zero. **The resolver swallows
exceptions; the activator stays a thin orchestrator.**

Public surface — two methods only:

```csharp
public void OnCreate(WorldEntity entity);
public void OnRemove(uint serverGuid);
```

No state on the activator. `PhysicsScriptRunner` already tracks per-entity
script instances by `(scriptId, entityId)`; `ParticleHookSink` already tracks
per-entity emitter handles. The activator doesn't duplicate that bookkeeping.

### Trigger condition: "has DefaultScript", not "is portal"

`WorldEntity` carries no `WeenieClassId` / `ObjectType` field
([`WorldEntity.cs`](../../../src/AcDream.Core/World/WorldEntity.cs)). We
*could* add one, but the WB-faithful trigger is "this entity's Setup has a
non-zero `DefaultScript`," which is also what retail's
`play_script_internal(setup.DefaultScript)` does at object load.

Side effect of this choice: **the activator will fire DefaultScript for any
server-spawned entity whose Setup has one**, not just portals. This is
correct retail behavior. If a non-portal entity spawns visible unwanted
particles in slice 1, that means our resolver is reading retail's intended
data faithfully and the visual is what retail shows. If retail does NOT show
those particles and we do, that's evidence of a different gate retail
applies — to be investigated when seen.

### Wiring point: GpuWorldState

Live entity spawn / despawn already flows through
[`GpuWorldState`](../../../src/AcDream.App/Streaming/GpuWorldState.cs) on the
render thread — the network layer pushes spawns into
`AppendLiveEntity(landblockId, entity)`, the server's `RemoveObject` opcode
routes through `RemoveEntityByServerGuid(serverGuid)`. The existing
`EntitySpawnAdapter` lifecycle hooks live at those two call sites
(line 345 `OnCreate`, line 285 `OnRemove`). The activator hooks fire
immediately after, in the same order:

```csharp
// GpuWorldState.AppendLiveEntity (line ~345):
_wbEntitySpawnAdapter?.OnCreate(entity);
_entityScriptActivator?.OnCreate(entity);   // NEW — fires DefaultScript

// GpuWorldState.RemoveEntityByServerGuid (line ~285):
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
_entityScriptActivator?.OnRemove(serverGuid);   // NEW — stops scripts + emitters
```

`GpuWorldState`'s constructor grows a fifth (optional) parameter for the
activator, paralleling how `EntitySpawnAdapter` is plumbed today. `GameWindow`
constructs the activator alongside `_wbEntitySpawnAdapter` and passes it
through.

Production resolver lambda, constructed in `GameWindow` where `_dats` is in
scope:

```csharp
entity =>
{
    try
    {
        return _dats.Get<Setup>(entity.SourceGfxObjOrSetupId)?.DefaultScript.DataId ?? 0;
    }
    catch
    {
        return 0;
    }
}
```

The try/catch matches the pattern in
[`ParticleRenderer.cs:296-318`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
(`ReadParticleGfxInfo`).

## §5 Data flow + lifecycle

### On spawn

```
GpuWorldState.AppendLiveEntity(landblockId, entity)
├─ _wbEntitySpawnAdapter?.OnCreate(entity)     // meshes ref-counted, animation state built
└─ _entityScriptActivator?.OnCreate(entity)
   ├─ scriptId = resolver(entity)              // Setup.DefaultScript.DataId, or 0 on miss/throw
   ├─ if (scriptId == 0) return                // no DefaultScript → no-op
   └─ _scriptRunner.Play(scriptId,
                          entity.ServerGuid,
                          entity.Position)
      └─ PhysicsScriptRunner schedules hooks at their StartTime offsets;
         each CreateParticleHook → ParticleHookSink → ParticleSystem
         spawns the ParticleEmitter dat at the entity's anchor.
```

### On despawn

```
GpuWorldState.RemoveEntityByServerGuid(serverGuid)
├─ _wbEntitySpawnAdapter?.OnRemove(serverGuid)         // meshes ref-decremented, state cleared
└─ _entityScriptActivator?.OnRemove(serverGuid)
   ├─ _scriptRunner.StopAllForEntity(serverGuid)       // drop pending hooks
   └─ _particleSink.StopAllForEntity(serverGuid, false) // kill live emitters (no fade)
```

Order on both is `spawnAdapter → activator`. Symmetric.

### Persistence (no re-fire logic needed)

Portal swirls are persistent emitters: their `ParticleEmitter` dat has
`TotalParticles=0` AND `TotalSeconds=0`.
[`ParticleSystem.Tick`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs)
only flips `Finished` when `TotalDuration > 0` or `TotalParticles > 0`, so
both-zero emitters never finish. They keep emitting until
`StopAllForEntity` kills them on despawn.

WB's `_deadTimer` re-fire-after-1s (line 119–130 of
[`ParticleEmitterRenderer.cs`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs))
is for non-persistent emitters that should loop (`TotalSeconds > 0`, finishes,
1s gap, re-emit). Portals don't use it. Defer to C.1.5b if EnvCell static
objects need it.

### Idempotency

- Duplicate `OnCreate` for same `serverGuid` — `PhysicsScriptRunner.Play`
  dedupes by `(scriptId, entityId)` and replaces the prior instance
  ([`PhysicsScriptRunner.cs:136-140`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)).
  ✓
- Duplicate `OnRemove` — both `StopAllForEntity` calls no-op on unknown guid. ✓
- `OnRemove` for never-spawned guid — same no-op behavior. ✓

### Position handling

Portals are stationary. `entity.Position` captured at spawn time is the anchor
for all of the script's hooks. We do not refresh per-frame.

**Known limitation (documented, not fixed in slice 1):** if a portal is ever
relocated via server `SetPosition`, emitters stay at the old anchor. If this
case appears in practice we add a position-update handler — but no current
evidence retail's portals move.

## §6 Error handling

Failure modes and behavior:

| Failure | Behavior | Notes |
|---|---|---|
| `entity.SourceGfxObjOrSetupId` references a missing Setup | resolver returns `0` | activator no-ops; standard streaming flicker handling |
| `_dats.Get<Setup>(...)` throws | resolver returns `0` | try/catch in the resolver lambda |
| `Setup.DefaultScript.DataId == 0` | resolver returns `0` | activator no-ops; entity has no persistent script |
| `PhysicsScript` dat lookup misses inside `Play` | `Play` returns `false` | runner already handles; activator does nothing |
| `EmitterDescRegistry` miss for a `CreateParticleHook.EmitterInfoId` | exception propagates through `PhysicsScriptRunner.DispatchHook` (currently uncaught) | pre-existing C.1 behavior; out of scope for this slice. File an issue if observed in verification. |

All failure paths are silent (no exceptions surface to the caller). Diagnostic
visibility comes from `ACDREAM_DUMP_PLAYSCRIPT=1` — every successful `Play`
and every fired hook prints. A missing portal swirl in verification is
diagnosed by checking the log for the missing entity's guid.

## §7 Thread safety

All calls execute on the render thread (where `EntitySpawnAdapter` already
runs). `PhysicsScriptRunner` is single-threaded by design.
`ParticleHookSink` uses `ConcurrentDictionary` and is safe regardless. No
new threading concerns introduced.

## §8 Testing

### Unit tests (slice 1's gating tests)

`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` (test
project convention: production code lives under `src/AcDream.App/...` but tests
go in `AcDream.Core.Tests` — mirrors the existing
[`EntitySpawnAdapterTests`](../../../tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs)
location). Uses
hand-built `PhysicsScriptRunner` + capturing `ParticleHookSink` (or a thin
test double). No dats, no GL.

1. **`OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition`** —
   stub resolver returns `0x33000001`; assert `runner.Play(0x33000001,
   entity.ServerGuid, entity.Position)` was called exactly once.
2. **`OnCreate_WithoutDefaultScript_DoesNothing`** — stub resolver returns
   `0`; assert no `Play` call.
3. **`OnRemove_StopsScriptsAndEmitters`** — sequence an `OnCreate(entity)`
   then `OnRemove(entity.ServerGuid)`; assert `runner.StopAllForEntity` and
   `sink.StopAllForEntity` were each called once with the matching guid, and
   `sink.StopAllForEntity` was passed `fadeOut: false`.

### Integration tests — none for slice 1

The `GpuWorldState` wiring is two added lines (one in `AppendLiveEntity`, one
in `RemoveEntityByServerGuid`) plus a constructor parameter. An integration
test would require booting GL + dats + network. Coverage is the visual
verification gate instead. Existing `GpuWorldStateTests` will need a minor
update if they assert constructor arity; we extend them if so.

### Visual verification — the acceptance criterion

Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):

1. `dotnet build` green.
2. `dotnet test` green (the three new unit tests plus the existing suite).
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1` exported.
4. Walk `+Acdream` from spawn to the **Holtburg Town network portal**.
5. In parallel, a retail AC client viewing the same portal.
6. **User confirms**: the portal-swirl effect in acdream matches retail in
   color, density, motion, and persistence.

If verification fails (e.g. portal Setup has `DefaultScript=0` in the dat),
the diagnostic log shows whether `Play` fired and with what scriptId. We
investigate the actual data path in retail's named decomp before iterating —
do not blindly retry.

## §9 Limitations + known gaps (post-slice-1)

These are intentionally not fixed in slice 1; tracked here so the next slice
or a future phase picks them up:

1. **`PartIndex` collapse on multi-part entities** (NEW — verified 2026-05-12
   at the Holtburg Town network portal). `ParticleHookSink.SpawnFromHook`
   ignores `CreateParticleHook.PartIndex`, so every emitter in a multi-emitter
   script collapses to `entity.Position + rotated(hook.Offset.Origin)`. Retail
   distributes the script's emitters across the entity's mesh parts (arch base,
   columns, apex). Visual symptom for the Holtburg portal: the 10-hook script
   produces a compressed swirl partially buried in the ground instead of the
   multi-tier shape retail renders. Filed as `docs/ISSUES.md` #56 with the
   captured entity guids + script ids; affects slice 2 (EnvCell chimneys /
   fireplaces are multi-part) and any future multi-emitter PES path.
2. **Moving entities** don't re-anchor their DefaultScript emitters per
   frame. No evidence retail's portals or chimneys move; revisit if visual
   verification surfaces a regression.
3. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters
   work today; looping non-persistent emitters (if EnvCell static objects
   use them) would need it in C.1.5b.
4. **Animation-hook particle path** (`MotionInterpreter` →
   `ParticleHookSink`) is shipped in C.1 but **not verified** by a recent
   visual test in this codebase state. Confirming this path is the second
   half of C.1.5b.

## §10 Slice 2 preview (C.1.5b)

For context, not part of this slice's work:

- **Walker for `EnvCell.StaticObjects`.** Each static object has a Setup
  reference; same `DefaultScript` dispatch applies. Needs a synthetic
  entity-id scheme because static objects have no `ServerGuid`. Likely:
  hash of `(landblockId, cellIndex, staticIndex)` → 32-bit synthetic id with
  a marker high bit so it doesn't collide with server guids.
- **Verification step for animation hooks.** Cast a spell or trigger an
  emote on `+Acdream`, observe the particle effect, compare to retail.
- **Possible: WB re-fire-after-1s logic** in `ParticleSystem` if EnvCell
  static-object PES data needs it.

C.1.5b spec lands after C.1.5a verification passes.

## §11 Implementation notes

- The new directory `src/AcDream.App/Rendering/Vfx/` is created by this
  slice. `ParticleRenderer.cs` stays where it is (under `Rendering/`); the
  new `Vfx/` is for spawn-time orchestration classes only.
- Estimated effort: ~1 day. Activator is small, wiring is two lines, tests
  are three cases.
- No CLAUDE.md updates required by this slice — the C.1.5a / C.1.5b split is
  internal to the C.1 phase plan.
- Roadmap update: on ship, add a "Phase C.1.5a SHIPPED 2026-05-12" entry to
  [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md).
