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