Self-contained handoff doc for the C.1.5b session. §1 is a copy-paste startup prompt for a fresh Claude Code session; §2+ is detailed context (commits shipped in C.1.5a, decision space for the #56 fix including precompute-per-spawn vs render-thread-side-table options, EnvCell synthetic-id scheme, walker-class placement options, file pointers, verification plan). Slice will resolve issue #56 first (per-part transform handling for static entities) before adding the EnvCell static-object DefaultScript walker, per the C.1.5a final reviewer's recommendation that resolving #56 unblocks slice 2's visual delight gate (the multi-emitter collapse symptom affects portals, chimneys, and fireplaces alike). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Phase C.1.5b handoff — issue #56 + EnvCell statics + animation-hook verification
Created: 2026-05-12, immediately after Phase C.1.5a merged to main (commit 88bda12).
Audience: the fresh-session Claude (or human) picking up C.1.5b.
Predecessor: C.1.5a portal PES wiring — slice 1, shipped.
§1 Startup prompt (copy this into a fresh session)
Everything below this fence is the prompt to paste into a new Claude Code session. The detailed context the session needs lives in §2+ of this same file.
Pick up Phase C.1.5b — issue #56 (multi-emitter per-part collapse) first,
then EnvCell static-object DefaultScript dispatch + animation-hook
particle path verification.
## Context
Phase C.1.5a (portal PES wiring) merged to main 2026-05-12 (merge commit
88bda12). The PhysicsScriptRunner now fires Setup.DefaultScript on every
server-spawned WorldEntity via the new EntityScriptActivator. Visual
verification at the Holtburg Town network portal confirmed the mechanism
works end-to-end (10-hook portal script fires correctly, color +
persistence + orientation match retail), but exposed a pre-existing C.1
limitation now tracked as ISSUE #56: ParticleHookSink ignores
CreateParticleHook.PartIndex, so all 10 of the portal's emitters
collapse to one root position → compressed, partly-ground-buried swirl.
The C.1.5a final cross-task reviewer recommended #56 be resolved FIRST
in this slice, before the EnvCell static-object walker, because slice
2's natural visual gate (Holtburg inn interior fireplace, cottage
chimney) uses the same multi-emitter pattern — without #56 fixed,
slice 2 ships with the same visual gap.
## Read first (in order)
1. docs/plans/2026-05-12-phase-c1.5b-handoff.md (this file's §2+)
2. docs/ISSUES.md #56 (the per-part collapse problem with reproducible
identifiers from the C.1.5a verification session)
3. docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md §10
(slice 2 preview written during C.1.5a brainstorming)
4. docs/plans/2026-04-27-phase-c1-pes-particles.md lines 285–295 (the
original C.1.5 scope source)
## Two slices in this session
### Slice A — issue #56 fix (per-part transform handling for static entities)
For static entities (portals, EnvCell statics, building decorations —
no animation), precompute the per-part offset from
Setup.PlacementFrames[Resting] at spawn time and surface those offsets
to the ParticleHookSink so SpawnFromHook can apply them. The handoff
doc §3 has the suggested architecture + decision space.
Acceptance: relaunch + walk to the Holtburg Town network portal. The
10 emitters should distribute across the portal Setup's parts instead
of collapsing — swirl extends vertically through the arch with
retail-like shape, not buried in the ground.
### Slice B — EnvCell static-object DefaultScript dispatch + animation-hook verification
Walk EnvCell.StaticObjects for newly-loaded landblocks; for each
StaticObject whose Setup has a non-zero DefaultScript, fire the
activator with a synthetic entity ID (suggested scheme: hash of
(landblockId, cellIndex, staticIndex) with a high-bit marker so it
doesn't collide with server guids — see handoff §4). Then verify the
animation-hook particle path (already shipped in C.1; just needs
visual confirmation): cast a spell on +Acdream and compare to retail.
Acceptance: Holtburg inn fireplace flames, cottage chimney smoke, and
a spell-cast particle effect on +Acdream all match retail.
## What this is NOT
- Not a renderer change. particle.frag stays as-is; bindless migration
waits for N.6 slice 2 after this slice lands.
- Not a perf phase. The N.6 baseline at radius=4 still holds; the
per-part precompute cost is bounded by N parts × M emitters per
spawned entity (small).
- Not adding new emitter types. Use the existing PES emitter data.
- Not touching the animated-entity path. For animated entities (NPCs,
monsters), per-part transforms vary per frame and would need a
per-tick refresh similar to UpdateEntityAnchor. Defer to a future
phase; C.1.5b stays scoped to static entities only.
## Suggested workflow
1. Read the handoff doc + the four referenced docs above.
2. Invoke superpowers:brainstorming to settle:
- For slice A: precompute-per-part-at-spawn vs render-thread-side-table
approach (handoff §3 has the tradeoff analysis).
- For slice B: the synthetic-entity-id scheme; whether the EnvCell
walker piggybacks LandblockSpawnAdapter or gets its own class.
- Visual verification locations.
3. After brainstorm: spec at
docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md (one spec
for both slices since they share the activator and tests), then plan
at docs/superpowers/plans/2026-05-13-phase-c1.5b.md, then execute
via superpowers:subagent-driven-development.
## Open issues from C.1.5a worth knowing
- #56 — multi-emitter per-part collapse. This slice's headline.
- #55 — meshMissing diagnostic spam at radius=4 standstill. LOW
severity, not blocking; only touch if you're already in the
dispatcher for unrelated reasons.
- Cold-path timing observation (C.1.5a Task 2 review): the activator
fires DefaultScript before pending-bucket entities are merged into
a loaded landblock. Mirrors existing _wbEntitySpawnAdapter pattern;
not a regression; defer.
## Three doc-drift items from C.1.5a (trivial — fold into the new spec)
1. C.1.5a spec §4 says "fifth (optional) parameter" — actually fourth.
2. C.1.5a spec §4 says "~50 lines" — file ships at 93 lines.
3. GpuWorldState.AddEntitiesToExistingLandblock (A.5 Far→Near
promotion path) does not fire the activator. No-op today because
promotion-tier entities are atlas-tier and the activator's
ServerGuid==0 guard would skip them anyway, but worth a code
comment explaining why the call is intentionally omitted there
(parallel to existing comments at the RemoveEntitiesFromLandblock
block in the same file).
Start by reading the handoff doc, then ask me what slice-A/slice-B
boundary feels right and what visual verification locations I want
to target.
§2 What shipped in C.1.5a (so you don't re-do it)
Commits on main (oldest to newest under merge 88bda12)
| SHA | Title |
|---|---|
06d7fbd |
docs(vfx): Phase C.1.5a — portal PES wiring design spec |
ed5335b |
docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes |
003c502 |
feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet) |
e0529b0 |
test(vfx #C.1.5a): real-emitter verification in OnRemove test + unused using |
44d8502 |
feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle |
65d833d |
feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow |
849690c |
refactor(vfx #C.1.5a): reuse SequencerFactory's capturedDats in resolver |
334f0c6 |
fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates |
9009318 |
docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse |
88bda12 |
Merge branch 'claude/lucid-burnell-aab524' — Phase C.1.5a |
New files
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs— 93 lines including doc comments. Constructor(PhysicsScriptRunner, ParticleHookSink, Func<WorldEntity, uint>);OnCreate(WorldEntity)resolves the entity'sSetup.DefaultScript.DataId, seeds_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation), and calls_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);OnRemove(uint serverGuid)calls_scriptRunner.StopAllForEntity(serverGuid)+_particleSink.StopAllForEntity(serverGuid, fadeOut: false).tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs— 4 xUnit[Fact]tests with mutation-check teeth verified during the C.1.5a code-quality reviews.
Modified files
src/AcDream.App/Streaming/GpuWorldState.cs— fourth optional ctor parameterEntityScriptActivator? entityScriptActivator = null, field_entityScriptActivator, and two?.OnCreate(entity)/?.OnRemove(serverGuid)calls immediately after the matching_wbEntitySpawnAdapter?.OnCreate/?.OnRemovecalls inAppendLiveEntityandRemoveEntityByServerGuid.src/AcDream.App/Rendering/GameWindow.cs— new field declaration alongside_wbEntitySpawnAdapterand inline construction of the activator + resolver lambda inside the existingOnLoadblock (~line 1620), passed toGpuWorldStateas a named argument.
What's working
- Server-spawned entities (
ServerGuid != 0) withSetup.DefaultScript.DataId != 0fire that script throughPhysicsScriptRunner.Playon enter-world. - Multi-hook scripts dispatch all their hooks in order (timed by
StartTimeoffsets — more retail-faithful than WB's "all at once" collection). CreateParticleHook.Offset.Originrotates correctly from entity-local to world frame via the activator'sSetEntityRotationseed.- Despawn cleanly stops all scripts + emitters for the entity.
- 4 unit tests cover all three branches plus the rotation-seed correctness.
- Visual verification at the Holtburg Town network portal passed for the mechanism: 10-hook portal script fires correctly with matching color, persistence, orientation, multi-emitter dispatch.
§3 Issue #56 decision space (slice A)
The problem
ParticleHookSink.SpawnFromHook computes:
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
…where worldPos is entity.Position and offset is cph.Offset.Origin. The CreateParticleHook.PartIndex field is recorded into the per-handle tracking dict but never applied to the anchor. Retail's intended geometry is:
anchor = entityWorldPose × partLocalTransform[partIndex] × hookOffsetInPartLocal
Without the part transform multiplication, every emitter in a multi-emitter script lands at the same root position. Visible symptom: the Holtburg portal's 10 emitters compress to one point and the swirl appears partially buried because the offset's local-up direction goes off in world axes instead of the part's local axes.
Where part transforms come from
For STATIC entities (no animation), per-part transforms come from Setup.PlacementFrames[Resting].Frames[partIndex] — see how ObjectMeshManager.CollectParts walks them in references/WorldBuilder (worktree-relative path; submodule must be initialized to read):
- For each
iin0..setup.Parts.Count, the per-part transform isMatrix4x4.CreateScale(setup.DefaultScale[i]) * Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation) * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin). DefaultScaleonly applies whenSetupFlags.HasDefaultScaleis set.- Fall back to
PlacementFrames[Default]ifRestingisn't present.
For ANIMATED entities (NPCs, monsters, the player), per-part transforms vary per animation frame and live in AnimatedEntityState / the animation tick. Out of scope for C.1.5b.
Approach options
Option A — precompute per-spawn, pass at activator-call time.
EntityScriptActivator reads the Setup's PlacementFrames[Resting] once per spawn, builds a Matrix4x4[] partTransforms array, and passes it to a new sink method _particleSink.SetEntityPartTransforms(entityId, partTransforms) before calling _scriptRunner.Play(...). ParticleHookSink.SpawnFromHook then reads _partTransformsByEntity to apply per-hook:
var partXf = _partTransformsByEntity.TryGetValue(entityId, out var pts) && partIndex < pts.Length
? pts[partIndex] : Matrix4x4.Identity;
var anchor = worldPos + Vector3.Transform(Vector3.Transform(offset, partXf), rotation);
Pros: clean ownership (activator owns the lifecycle of part transforms keyed by entityId), matches existing sink-state patterns (_rotationByEntity, _renderPassByEntity), small code surface, fully testable.
Cons: stores per-entity array (matrix per part) — bounded but allocates. Doesn't compose with the animated-entity case (which would need per-tick refresh).
Option B — render-thread side-table populated by the dispatcher.
The WbDrawDispatcher already computes per-part world transforms each frame. Surface them via a side-table the sink queries. Per-frame.
Pros: free composition with animated entities (the dispatcher transforms whether the entity is animated or not).
Cons: render-thread / sink-thread coordination concern, bigger architectural surface, the dispatcher would need a new responsibility (publish part transforms) outside its draw-loop hot path. Risk of touching the modern bindless dispatcher's perf budget that N.5/N.5b worked to lock in.
Option C — sink-side dat lookup on demand.
ParticleHookSink calls _dats.Get<Setup>(...) on the hook fire to look up the part transform. Pros: zero state on activator. Cons: introduces dat coupling into the sink (currently dat-free), per-hook-fire dat lookup is a hidden allocation, doesn't compose with animated entities either, and we'd be reading the same Setup multiple times for the same entity.
Recommended approach
Option A. It's the smallest surface, matches the existing sink-state pattern, doesn't expand any other layer's responsibilities, and the "doesn't compose with animated entities" downside is intentional — animated entities are explicitly out of scope and will get their own treatment later, possibly via Option B at that time.
Test approach
Mirror the C.1.5a OnCreate_SetsEntityRotationForHookOffsetTransform test: construct an entity whose Setup has 2 parts (root at origin + part 1 lifted at (0, 0, 1)), fire a CreateParticleHook with PartIndex=1 and Offset.Origin=(0, 0, 0), assert the spawned particle's world position is (0, 0, 1) (the part's offset, not the root). Add a mutation check: delete the SetEntityPartTransforms line and confirm the test fails.
§4 EnvCell static-object dispatch decision space (slice B)
The problem
EnvCell.StaticObjects are interior decoration objects inside dungeon / building cells. Each StaticObject has a Setup reference and a placement frame. They have NO ServerGuid — they're dat-hydrated, not server-spawned.
Our EntityScriptActivator.OnCreate early-returns when entity.ServerGuid == 0 (atlas-tier guard). So as-is, the activator won't fire DefaultScript for EnvCell statics.
Two architectural questions
Q1 — synthetic entity ID for tracking + cleanup.
PhysicsScriptRunner keys active scripts by (scriptId, entityId). ParticleHookSink keys per-entity emitter handles by entityId. EnvCell statics need a stable, unique 32-bit ID for these tables that won't collide with server guids (and won't collide between two EnvCell statics in different cells).
Suggested scheme:
uint syntheticId = 0xC0000000u
| ((landblockId & 0x0000FF00u) << 16) // landblock X byte → bits 24-31 minus high marker
| ((landblockId & 0xFF000000u) >> 8) // landblock Y byte → bits 16-23
| ((cellIndex & 0x0000FFFFu) << 0); // bits 0-15: cell index within landblock
…leaving 4 bits for the static-object index within the cell. Adjust bit layout for the actual (LandblockId, CellIndex, StaticIndex) distribution. The 0xC0_______u marker is above server guid range and above the anonymous-emitter range (0x80_______u) used by ParticleHookSink._anonymousEmitterSerial, so no collision.
Sanity check: WorldEntity.ServerGuid is uint; the (scriptId, entityId) dedupe key in the runner only needs uniqueness, not semantic meaning. Either scheme works as long as it's collision-free.
Q2 — which adapter walks EnvCell.StaticObjects?
Three options:
-
Option α — piggyback
LandblockSpawnAdapter. That adapter already walkslandblock.Entitiesfor atlas-tier mesh-ref counting. Extending it to also walkEnvCell.StaticObjectsand fire DefaultScript via the activator keeps the per-landblock-load flow in one place. Cons: blurs the adapter's single responsibility. -
Option β — new
EnvCellStaticActivatorclass. MirrorEntityScriptActivator's shape but key by synthetic-id, walking each loaded landblock's EnvCells on load and firing per-static-object. Cons: more code; slight duplication of the activator pattern. -
Option γ —
EntityScriptActivatorlearns a "static-object" entry point. AddOnEnvCellStaticCreate(LoadedLandblock landblock, int cellIndex, int staticIndex, Setup setup, Vector3 worldPos, Quaternion worldRot)to the existing activator. Compute the synthetic ID inside. Cons: signature creep on the activator.
Recommended: Option β. Keeps the existing activator's WorldEntity-shaped contract pure; the new class has a clean per-static-object contract; both share _scriptRunner and _particleSink instances so no architectural duplication, just two thin orchestrators.
Lifecycle
EnvCell statics live as long as their parent landblock is loaded. On landblock unload, the new activator should stop all scripts for all its synthetic IDs from that landblock. Mirror LandblockSpawnAdapter's OnLandblockLoaded / OnLandblockUnloaded lifecycle.
§5 Animation-hook verification (slice B's quick half)
Already shipped in C.1: MotionInterpreter fires per-keyframe hooks through IAnimationHookSink → ParticleHookSink. We just haven't verified visually in the current codebase state.
Procedure:
- Cast a spell on
+Acdream(the test character likely has at least one spell + components configured — check or grant if needed). - Watch the cast-anim particle effect (sparkles, glyphs, etc.) — does it match retail's casting animation?
- Optional: trigger an emote with a particle hook (the
\dance/\drinkemotes are good candidates if they have particle data).
If broken, file an issue with the symptom. If working, mark slice B complete on verification.
§6 Verification locations
All in or near Holtburg, within ~30s of +Acdream's spawn:
- #56 fix re-verify — the Town network portal used in C.1.5a. Same procedure as C.1.5a's Task 4 (see the C.1.5a spec §8).
- EnvCell chimney — any cottage / inn within the Holtburg outer perimeter with a smoking chimney in retail. Confirm via dual-client.
- EnvCell fireplace — Holtburg Inn interior. Walk inside and stand near the fireplace. Confirm flame particles match retail.
- Animation-hook verify — cast a spell standing somewhere safe (outside any aggro range). Compare to retail.
§7 File pointers for slice 2
- Particle pipeline (Core):
src/AcDream.Core/Vfx/ParticleSystem.cs,ParticleHookSink.cs,PhysicsScriptRunner.cs. - Activator (App):
src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs. - Streaming bridge (App):
src/AcDream.App/Streaming/GpuWorldState.cs,LandblockSpawnAdapter.cs. - Renderer:
src/AcDream.App/Rendering/ParticleRenderer.cs— don't touch in C.1.5b; bindless migration is N.6 slice 2. - EnvCell loader: search for
LoadedCell/EnvCell.StaticObjectsinsrc/AcDream.App/Streaming/andsrc/AcDream.Core/World/. - C.1.5a tests as a reference:
tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs.
§8 Open questions to surface during brainstorming
- Slice A: does the C.1.5a final reviewer's "static-only fix is self-contained" claim hold up? (Section §3 Option A says yes; brainstorming should verify by checking
EntityScriptActivator's spawn path doesn't depend on animation state.) - Slice B: which Setup field actually lives on
EnvCell.StaticObjects— is it aSetupIdreference or an inline Setup? Different shape changes the synthetic-ID hash input. - Slice B: are EnvCell statics ALSO subject to the cold-path timing observation from C.1.5a Task 2 review (firing before the cell is rendered)?
§9 Worktree cleanup reminder (one-time, from outside the worktree)
The C.1.5a worktree directory at C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524 was not auto-removed because the controller session held a file lock. After this session ends, from any other directory:
git -C "C:/Users/erikn/source/repos/acdream" worktree remove --force `
"C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524"
The branch claude/lucid-burnell-aab524 was successfully deleted; only the worktree directory needs manual cleanup.