Visual verification at the Holtburg Town network portal passed for the
slice's mechanism: 10-hook portal script fires end-to-end with correct
color, persistence, orientation, and multi-emitter dispatch. After the
334f0c6 rotation-seed fix, the swirl is oriented correctly along the
portal's facing instead of world-NS.
Known limitation surfaced during verification and filed as issue #56:
ParticleHookSink ignores CreateParticleHook.PartIndex, so all 10 of the
portal's emitters collapse to the entity root position + identity-rotated
offset, producing a compressed and partly-ground-buried swirl instead of
the multi-tier shape retail renders. Mechanism is correct; per-part
transform handling is the next vfx-pipeline concern (will affect every
multi-emitter PES — slice 2 chimneys/fireplaces in particular).
Documentation changes:
- docs/ISSUES.md: new #56 entry with the captured entity guids
(0x7A9B405B / 0x7A9B4080), script ids (0x3300126D / 0x3300067A),
symptom data, root-cause hypothesis, file pointers, and acceptance
criterion. Notes the blocks-slice-2 relationship explicitly.
- docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md §9:
new limitation #1 documenting the verified PartIndex collapse symptom.
- docs/plans/2026-04-11-roadmap.md: new "C.1.5a" row in the shipped
table referencing the spec, plan, and #56 caveat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
18 KiB
Markdown
385 lines
18 KiB
Markdown
# 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).
|