docs(vfx): Phase C.1.5a — portal PES wiring design spec
Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already- shipped PhysicsScriptRunner on server-spawned WorldEntity create, so portals (and any other entity with a DefaultScript) emit their retail- faithful persistent particle effects at spawn time. Reuses the C.1 runner-sink-system-renderer chain end-to-end; one new ~50-line class (EntityScriptActivator) plus a two-line wiring in GameWindow. Slice 2 (C.1.5b) will cover EnvCell.StaticObjects + animation-hook verification; spec landed separately after slice 1 verification passes. Acceptance: visual confirmation at the Holtburg Town network portal, side-by-side with retail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b447d4ca8
commit
06d7fbd5ef
1 changed files with 352 additions and 0 deletions
352
docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Normal file
352
docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Normal file
|
|
@ -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<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: 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<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
|
||||||
|
|
||||||
|
```
|
||||||
|
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<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.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).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue