acdream/docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Erik 9009318656 docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse
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>
2026-05-11 16:13:12 +02:00

18 KiB
Raw Blame History

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 §C.1.5. Baseline justification: docs/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 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, 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 (data) → ParticleSystem (sim) → ParticleHookSink (dispatch) → PhysicsScriptRunner (script scheduler) → ParticleRenderer (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). 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 771795) 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). 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:

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:

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). 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 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:

// 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:

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 (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 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 119130 of 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 serverGuidPhysicsScriptRunner.Play dedupes by (scriptId, entityId) and replaces the prior instance (PhysicsScriptRunner.cs:136-140). ✓
  • 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 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 "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 (MotionInterpreterParticleHookSink) 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.