diff --git a/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md new file mode 100644 index 0000000..0e9a75d --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md @@ -0,0 +1,352 @@ +# Phase C.1.5a — Portal PES wiring (Setup.DefaultScript on entity spawn) + +**Created:** 2026-05-12. +**Author:** Claude (lead engineer/architect). +**Phase:** C.1.5a (first of two slices; C.1.5b covers EnvCell statics + animation-hook verification). +**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5. +**Baseline justification:** [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](../../plans/2026-05-11-phase-n6-perf-baseline.md) §4 — C.1.5 is the right next phase; production preset is comfortable, no perf escalation pressure. + +--- + +## §1 Goal + +Make server-spawned `WorldEntity` portals emit their retail-faithful particle +effects (portal swirls) at spawn time. Implement by **firing `Setup.DefaultScript` +through the already-shipped `PhysicsScriptRunner`** at the moment the entity +enters the world, mirroring retail's `play_script_internal` dispatch on object +spawn. + +Acceptance: the user walks `+Acdream` up to the **Holtburg Town network portal**, +opens a side-by-side comparison with a retail AC client, and confirms the portal +swirl matches retail in color, density, motion, and persistence. + +## §2 Scope + +**In:** + +- New class `EntityScriptActivator` (one file, ~50 lines). +- Wiring of activator's `OnCreate` / `OnRemove` calls into `GameWindow`'s + spawn-lifecycle handlers, immediately after the matching `EntitySpawnAdapter` + calls. +- Three unit tests covering the activator's three branches + (fire / no-op-on-zero / cleanup-on-remove). +- Visual verification at the Holtburg Town network portal. + +**Out (deferred to C.1.5b):** + +- `EnvCell.StaticObjects` walker for interior chimneys / fireplaces. +- Animation-hook particle path verification (already wired in C.1; needs + a confirming check, deferred so this slice stays small). +- The WB-style "re-fire after 1 second" loop logic for non-persistent emitters + ([`ParticleEmitterRenderer.cs:119-130`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs) in WB). + Portal swirls are persistent (`TotalParticles=0 && TotalSeconds=0`) and don't + need it. If C.1.5b discovers EnvCell static objects need it, that slice adds it. + +**Out (out of phase entirely):** + +- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6 + slice 2 territory. +- Performance work. Per [baseline §4](../../plans/2026-05-11-phase-n6-perf-baseline.md), + CPU at the production preset is comfortable and there is no GPU pressure. +- Adding `WeenieClassId` to `WorldEntity`. Trigger is "has DefaultScript", + not "is portal" (see §4 Architecture for rationale). + +## §3 Background + +### Why this works today for *some* particles, not portals + +C.1 shipped a complete particle pipeline: +[`EmitterDescRegistry`](../../../src/AcDream.Core/Vfx/EmitterDescRegistry.cs) +(data) → [`ParticleSystem`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) (sim) +→ [`ParticleHookSink`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs) +(dispatch) → [`PhysicsScriptRunner`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs) +(script scheduler) → [`ParticleRenderer`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs) +(draw). + +The chain is end-to-end, but `PhysicsScriptRunner.Play` is only called from +**two places today**: + +1. The server-driven `PlayScript (0xF754)` opcode handler in `GameWindow` — + spell casts, combat hits, emote effects. +2. The animation-hook path inside `MotionInterpreter` — feet sparks, weapon + trails (via `ParticleHookSink` directly, not through the runner). + +**Nothing fires `Setup.DefaultScript` when a static entity spawns.** Retail +does this (per the named decomp's `play_script_internal` analysis), and +`WorldBuilder` does the equivalent at mesh-prep time +([`ObjectMeshManager.cs:797`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs)). +Acdream skips it — every portal lacks its swirl, every chimney lacks its smoke. + +### Why not consume WB's staged emitters + +WB's `ObjectMeshManager.PrepareSetupMeshData` (line 771–795) collects +`StagedEmitter` entries from `setup.DefaultScript` and attaches them to +`ObjectMeshData.ParticleEmitters`. Three reasons we don't consume them: + +1. `WbMeshAdapter` calls `PrepareMeshDataAsync(id, isSetup: false)` — we go + through the per-part GfxObj path, not the Setup path + ([`WbMeshAdapter.cs:136`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs)). + Flipping that breaks shipped N.4/N.5 dispatcher assumptions. +2. WB's `CollectEmittersFromScript` drops the script's per-hook `StartTime` + offsets — it spawns every `CreateParticleHook` immediately. Our + `PhysicsScriptRunner` honors `StartTime` and is more retail-faithful. +3. C.1 already shipped a runner that *is* the equivalent of retail's + `play_script_internal`. Adding the missing call sites is cheaper and + structurally cleaner than building a parallel emitter-staging path. + +## §4 Architecture + +### New class + +`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`. New `Vfx/` +subdirectory under `Rendering/` — sits next to `ParticleRenderer.cs` and is +**not** under `Wb/` because the activator drives our own `PhysicsScriptRunner` +and has no WB dependency. + +Constructor — mirrors `EntitySpawnAdapter`'s factory-delegate pattern so the +activator has no `DatCollection` coupling and is fully unit-testable with +stubs: + +```csharp +public EntityScriptActivator( + PhysicsScriptRunner scriptRunner, + ParticleHookSink particleSink, + Func defaultScriptResolver) +``` + +The resolver returns the entity's `Setup.DefaultScript.DataId`, or `0` if the +Setup is missing / the dat throws / the field is zero. **The resolver swallows +exceptions; the activator stays a thin orchestrator.** + +Public surface — two methods only: + +```csharp +public void OnCreate(WorldEntity entity); +public void OnRemove(uint serverGuid); +``` + +No state on the activator. `PhysicsScriptRunner` already tracks per-entity +script instances by `(scriptId, entityId)`; `ParticleHookSink` already tracks +per-entity emitter handles. The activator doesn't duplicate that bookkeeping. + +### Trigger condition: "has DefaultScript", not "is portal" + +`WorldEntity` carries no `WeenieClassId` / `ObjectType` field +([`WorldEntity.cs`](../../../src/AcDream.Core/World/WorldEntity.cs)). We +*could* add one, but the WB-faithful trigger is "this entity's Setup has a +non-zero `DefaultScript`," which is also what retail's +`play_script_internal(setup.DefaultScript)` does at object load. + +Side effect of this choice: **the activator will fire DefaultScript for any +server-spawned entity whose Setup has one**, not just portals. This is +correct retail behavior. If a non-portal entity spawns visible unwanted +particles in slice 1, that means our resolver is reading retail's intended +data faithfully and the visual is what retail shows. If retail does NOT show +those particles and we do, that's evidence of a different gate retail +applies — to be investigated when seen. + +### Wiring point: GameWindow + +```csharp +// In GameWindow.OnCreateObject(WorldEntity entity): +_entitySpawnAdapter.OnCreate(entity); +_entityScriptActivator.OnCreate(entity); // NEW — runs after meshes are registered + +// In GameWindow.OnRemoveObject(uint serverGuid): +_entitySpawnAdapter.OnRemove(serverGuid); +_entityScriptActivator.OnRemove(serverGuid); // NEW — runs in same order as create +``` + +Production resolver lambda, constructed in `GameWindow` where `_dats` is in +scope: + +```csharp +entity => +{ + try + { + return _dats.Get(entity.SourceGfxObjOrSetupId)?.DefaultScript.DataId ?? 0; + } + catch + { + return 0; + } +} +``` + +The try/catch matches the pattern in +[`ParticleRenderer.cs:296-318`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs) +(`ReadParticleGfxInfo`). + +## §5 Data flow + lifecycle + +### On spawn + +``` +GameWindow.OnCreateObject(entity) +├─ _entitySpawnAdapter.OnCreate(entity) // meshes ref-counted, animation state built +└─ _entityScriptActivator.OnCreate(entity) + ├─ scriptId = resolver(entity) // Setup.DefaultScript.DataId, or 0 on miss/throw + ├─ if (scriptId == 0) return // no DefaultScript → no-op + └─ _scriptRunner.Play(scriptId, + entity.ServerGuid, + entity.Position) + └─ PhysicsScriptRunner schedules hooks at their StartTime offsets; + each CreateParticleHook → ParticleHookSink → ParticleSystem + spawns the ParticleEmitter dat at the entity's anchor. +``` + +### On despawn + +``` +GameWindow.OnRemoveObject(serverGuid) +├─ _entitySpawnAdapter.OnRemove(serverGuid) // meshes ref-decremented, state cleared +└─ _entityScriptActivator.OnRemove(serverGuid) + ├─ _scriptRunner.StopAllForEntity(serverGuid) // drop pending hooks + └─ _particleSink.StopAllForEntity(serverGuid, false) // kill live emitters (no fade) +``` + +Order on both is `spawnAdapter → activator`. Symmetric. + +### Persistence (no re-fire logic needed) + +Portal swirls are persistent emitters: their `ParticleEmitter` dat has +`TotalParticles=0` AND `TotalSeconds=0`. +[`ParticleSystem.Tick`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) +only flips `Finished` when `TotalDuration > 0` or `TotalParticles > 0`, so +both-zero emitters never finish. They keep emitting until +`StopAllForEntity` kills them on despawn. + +WB's `_deadTimer` re-fire-after-1s (line 119–130 of +[`ParticleEmitterRenderer.cs`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs)) +is for non-persistent emitters that should loop (`TotalSeconds > 0`, finishes, +1s gap, re-emit). Portals don't use it. Defer to C.1.5b if EnvCell static +objects need it. + +### Idempotency + +- Duplicate `OnCreate` for same `serverGuid` — `PhysicsScriptRunner.Play` + dedupes by `(scriptId, entityId)` and replaces the prior instance + ([`PhysicsScriptRunner.cs:136-140`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)). + ✓ +- Duplicate `OnRemove` — both `StopAllForEntity` calls no-op on unknown guid. ✓ +- `OnRemove` for never-spawned guid — same no-op behavior. ✓ + +### Position handling + +Portals are stationary. `entity.Position` captured at spawn time is the anchor +for all of the script's hooks. We do not refresh per-frame. + +**Known limitation (documented, not fixed in slice 1):** if a portal is ever +relocated via server `SetPosition`, emitters stay at the old anchor. If this +case appears in practice we add a position-update handler — but no current +evidence retail's portals move. + +## §6 Error handling + +Failure modes and behavior: + +| Failure | Behavior | Notes | +|---|---|---| +| `entity.SourceGfxObjOrSetupId` references a missing Setup | resolver returns `0` | activator no-ops; standard streaming flicker handling | +| `_dats.Get(...)` throws | resolver returns `0` | try/catch in the resolver lambda | +| `Setup.DefaultScript.DataId == 0` | resolver returns `0` | activator no-ops; entity has no persistent script | +| `PhysicsScript` dat lookup misses inside `Play` | `Play` returns `false` | runner already handles; activator does nothing | +| `EmitterDescRegistry` miss for a `CreateParticleHook.EmitterInfoId` | exception propagates through `PhysicsScriptRunner.DispatchHook` (currently uncaught) | pre-existing C.1 behavior; out of scope for this slice. File an issue if observed in verification. | + +All failure paths are silent (no exceptions surface to the caller). Diagnostic +visibility comes from `ACDREAM_DUMP_PLAYSCRIPT=1` — every successful `Play` +and every fired hook prints. A missing portal swirl in verification is +diagnosed by checking the log for the missing entity's guid. + +## §7 Thread safety + +All calls execute on the render thread (where `EntitySpawnAdapter` already +runs). `PhysicsScriptRunner` is single-threaded by design. +`ParticleHookSink` uses `ConcurrentDictionary` and is safe regardless. No +new threading concerns introduced. + +## §8 Testing + +### Unit tests (slice 1's gating tests) + +`tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`. Uses +hand-built `PhysicsScriptRunner` + capturing `ParticleHookSink` (or a thin +test double). No dats, no GL. + +1. **`OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition`** — + stub resolver returns `0x33000001`; assert `runner.Play(0x33000001, + entity.ServerGuid, entity.Position)` was called exactly once. +2. **`OnCreate_WithoutDefaultScript_DoesNothing`** — stub resolver returns + `0`; assert no `Play` call. +3. **`OnRemove_StopsScriptsAndEmitters`** — sequence an `OnCreate(entity)` + then `OnRemove(entity.ServerGuid)`; assert `runner.StopAllForEntity` and + `sink.StopAllForEntity` were each called once with the matching guid, and + `sink.StopAllForEntity` was passed `fadeOut: false`. + +### Integration tests — none for slice 1 + +The `GameWindow` wiring is two added lines (one in `OnCreateObject`, one in +`OnRemoveObject`). An integration test would require booting GL + dats + +network. Coverage is the visual verification gate instead. + +### Visual verification — the acceptance criterion + +Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"): + +1. `dotnet build` green. +2. `dotnet test` green (the three new unit tests plus the existing suite). +3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1` exported. +4. Walk `+Acdream` from spawn to the **Holtburg Town network portal**. +5. In parallel, a retail AC client viewing the same portal. +6. **User confirms**: the portal-swirl effect in acdream matches retail in + color, density, motion, and persistence. + +If verification fails (e.g. portal Setup has `DefaultScript=0` in the dat), +the diagnostic log shows whether `Play` fired and with what scriptId. We +investigate the actual data path in retail's named decomp before iterating — +do not blindly retry. + +## §9 Limitations + known gaps (post-slice-1) + +These are intentionally not fixed in slice 1; tracked here so the next slice +or a future phase picks them up: + +1. **Moving entities** don't re-anchor their DefaultScript emitters per + frame. No evidence retail's portals or chimneys move; revisit if visual + verification surfaces a regression. +2. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters + work today; looping non-persistent emitters (if EnvCell static objects + use them) would need it in C.1.5b. +3. **Animation-hook particle path** (`MotionInterpreter` → + `ParticleHookSink`) is shipped in C.1 but **not verified** by a recent + visual test in this codebase state. Confirming this path is the second + half of C.1.5b. + +## §10 Slice 2 preview (C.1.5b) + +For context, not part of this slice's work: + +- **Walker for `EnvCell.StaticObjects`.** Each static object has a Setup + reference; same `DefaultScript` dispatch applies. Needs a synthetic + entity-id scheme because static objects have no `ServerGuid`. Likely: + hash of `(landblockId, cellIndex, staticIndex)` → 32-bit synthetic id with + a marker high bit so it doesn't collide with server guids. +- **Verification step for animation hooks.** Cast a spell or trigger an + emote on `+Acdream`, observe the particle effect, compare to retail. +- **Possible: WB re-fire-after-1s logic** in `ParticleSystem` if EnvCell + static-object PES data needs it. + +C.1.5b spec lands after C.1.5a verification passes. + +## §11 Implementation notes + +- The new directory `src/AcDream.App/Rendering/Vfx/` is created by this + slice. `ParticleRenderer.cs` stays where it is (under `Rendering/`); the + new `Vfx/` is for spawn-time orchestration classes only. +- Estimated effort: ~1 day. Activator is small, wiring is two lines, tests + are three cases. +- No CLAUDE.md updates required by this slice — the C.1.5a / C.1.5b split is + internal to the C.1 phase plan. +- Roadmap update: on ship, add a "Phase C.1.5a SHIPPED 2026-05-12" entry to + [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md).