acdream/docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md
Erik 1e3c33b4db docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
Two-slice phase:
- Slice A: ParticleHookSink applies CreateParticleHook.PartIndex via
  SetupPartTransforms.Compute(setup.PlacementFrames). Closes #56.
- Slice B: drop EntityScriptActivator's ServerGuid==0 guard so
  dat-hydrated EnvCell statics + exterior stabs fire DefaultScript.

Key reality discovery folded into the spec §3: EnvCell.StaticObjects
are already WorldEntities (via GameWindow.BuildInteriorEntitiesForStreaming),
so no synthetic-ID scheme + no new walker class needed — the handoff's
§4 Q1/Q2 options are mooted by entity.Id being collision-free.

Doc-drift fixes from C.1.5a folded into §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:51:44 +02:00

522 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch
**Created:** 2026-05-13.
**Author:** Claude (lead engineer/architect).
**Phase:** C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge `88bda12`).
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
**Handoff doc:** [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](../../plans/2026-05-12-phase-c1.5b-handoff.md).
---
## §1 Goals
Two coupled slices in one phase, in this order:
**Slice A — `ParticleHookSink` honors `CreateParticleHook.PartIndex` for static
entities.** Closes [issue #56](../../ISSUES.md). The Holtburg Town network
portal's 10-emitter script currently collapses every emitter to the entity
root, producing a compressed, partially-ground-buried swirl. The fix is to
precompute each Setup part's resting transform at spawn time and apply it to
the hook offset before spawning the particle.
**Slice B — `EntityScriptActivator` fires `Setup.DefaultScript` for
dat-hydrated entities too.** Right now the activator gates on
`entity.ServerGuid != 0`, which means EnvCell static objects (interior
fireplaces, inn decorations, exterior stabs like cottage chimneys) — which
have no server guid because they come from the dat file, not the network —
never get their DefaultScript fired. Drop the guard, key by `entity.Id` when
`ServerGuid == 0`, and wire `OnCreate` / `OnRemove` calls into GpuWorldState's
dat-hydration paths.
Plus a **visual confirmation pass** for the animation-hook particle path
(already shipped in C.1; just needs a sanity check by casting a spell on
`+Acdream`).
### Acceptance
Visual verification at three retail-side-by-side locations in/near Holtburg:
1. **Town network portal** (the C.1.5a verification site): swirl extends
vertically through the portal arch with retail-like shape; no
ground-burial; emitters distributed across the portal Setup's parts.
2. **Holtburg Inn fireplace** (interior, EnvCell static): flame particles
match retail's pattern and position over the firebox.
3. **Cottage chimney** (exterior stab — TBD which cottage): smoke
particles match retail.
4. **Animation-hook spell cast** on `+Acdream`: cast-anim particle effect
matches retail.
## §2 Scope
**In:**
- New helper `AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)` that
walks `PlacementFrames[Resting]` → fallback `[Default]` → first available
and returns `IReadOnlyList<Matrix4x4>` (one transform per part).
- `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)`
+ a backing `_partTransformsByEntity` map cleared by `StopAllForEntity`.
- `ParticleHookSink.SpawnFromHook` applies `partTransforms[partIndex]` to the
hook offset before rotating to world space.
- `EntityScriptActivator` resolver signature changes from
`Func<WorldEntity, uint>` to `Func<WorldEntity, ScriptActivationInfo?>` so
both `ScriptId` and `PartTransforms` come from one dat lookup.
- `EntityScriptActivator.OnCreate` keys by `entity.ServerGuid != 0 ?
entity.ServerGuid : entity.Id`. Same activator handles both server-spawned
and dat-hydrated entities — no new class.
- `EntityScriptActivator.OnRemove(uint key)` — caller picks the key.
- `GpuWorldState` wires the activator into four more places:
`AddLandblock` (dat-hydrated entities only — filter by `ServerGuid==0` to
avoid double-firing pending live entities), `AddEntitiesToExistingLandblock`
(the just-promoted entities — all dat-hydrated by construction),
`RemoveLandblock` (dat-hydrated entities only), and
`RemoveEntitiesFromLandblock` (dat-hydrated entities only).
- Visual verification at the four sites in §1 Acceptance.
**Out:**
- Animated entities (NPCs, monsters, the player). Per-part transforms vary
per animation frame and would need a per-tick refresh similar to
`UpdateEntityAnchor`. Deferred to a future phase. The new
`SetEntityPartTransforms` is keyed by entity, so an animated-entity path
can later push fresh transforms each tick without changing the contract.
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
slice 2.
- WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are
persistent (`TotalParticles=0 && TotalSeconds=0`), no re-fire needed.
- New emitter types. Reuse existing PES data.
## §3 Background
### What shipped in C.1.5a (the part we keep)
The mechanism is correct: `EntityScriptActivator.OnCreate` runs on every
server-spawned `WorldEntity`, resolves `Setup.DefaultScript`, seeds
`_particleSink.SetEntityRotation`, calls `_scriptRunner.Play(scriptId,
entity.ServerGuid, entity.Position)`. Multi-hook scripts dispatch at their
correct `StartTime` offsets. Despawn cleanup works.
### What's broken (issue #56)
[`ParticleHookSink.SpawnFromHook`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
at lines 176-217:
```csharp
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
```
The hook author intended `offset` to be in **part-local** space — i.e.,
relative to the mesh part identified by `cph.PartIndex` — so the geometry
retail computes is:
```
anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset)
```
Our sink drops the part transform multiplication. For the Holtburg portal
(entity `0x7A9B405B`, script `0x3300126D`, 10 hooks distributed across the
portal Setup's parts), every emitter lands at the entity root. Visible
symptom: swirl partially buried, lateral spread compressed.
### Where part transforms come from (static entities)
For static entities, per-part transforms live in
`setup.PlacementFrames[Placement.Resting]` (fallback `[Default]`, fallback
first available — same priority chain
[`SetupMesh.Flatten`](../../../src/AcDream.Core/Meshing/SetupMesh.cs) at
lines 36-50 already uses). Per part `i`:
```csharp
Matrix4x4.CreateScale(setup.DefaultScale[i])
* Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation)
* Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)
```
`DefaultScale` defaults to `Vector3.One` when the list is shorter than
`Parts.Count`.
### Where EnvCell statics come from (slice B)
**Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted:**
[`GameWindow.BuildInteriorEntitiesForStreaming`](../../../src/AcDream.App/Rendering/GameWindow.cs)
at lines 5030-5135 already hydrates EnvCell `StaticObjects` as `WorldEntity`
instances with stable `entity.Id` in the `0x40xxxxxx` range:
```csharp
uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
// ... for each EnvCell, for each stab in envCell.StaticObjects ...
var hydrated = new WorldEntity {
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = stab.Id, // 0x02000000 → Setup-based
Position = stab.Frame.Origin + lbOffset,
Rotation = stab.Frame.Orientation,
MeshRefs = meshRefs,
ParentCellId = envCellId,
};
```
These flow into `GpuWorldState.AddLandblock` as part of `landblock.Entities`,
sit there with `ServerGuid == 0`, and currently never have their
`Setup.DefaultScript` fired. The activator's existing
`ServerGuid == 0 → return;` guard intentionally skips them (atlas-tier
exemption inherited from `EntitySpawnAdapter`).
Three architectural consequences:
1. **No synthetic ID scheme needed.** `entity.Id` is already collision-free
with server guids (live spawns use `0x500000xx``0x7Fxxxxxx`), anonymous
emitter IDs (`0x80000000u+`), and the four entity-id ranges
(`0x40xxxxxx` interior / `0x80xxxxxx` scenery / etc) all live in disjoint
high-byte slices.
2. **No new `EnvCellStaticActivator` class.** The existing
`EntityScriptActivator` handles both server-spawned and dat-hydrated
entities once the guard is keyed-by-id-when-zero.
3. **No new walker.** `BuildInteriorEntitiesForStreaming` is the walker —
it already happens. We just need the OnCreate fire-site in
GpuWorldState's `AddLandblock` / `AddEntitiesToExistingLandblock`.
The handoff §4 wrote three options (α piggyback / β new class / γ extend
activator) under the assumption that EnvCell statics were NOT WorldEntities.
This reality discovery collapses all three to a simpler answer that none of
them anticipated.
## §4 Architecture
### Slice A: part-transform pipeline
```
EntityScriptActivator.OnCreate(entity)
├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id
├─ info = resolver(entity) // ScriptActivationInfo? {ScriptId, PartTransforms}
├─ if (info is null || info.ScriptId == 0) return
├─ _particleSink.SetEntityRotation(key, entity.Rotation)
├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms) // NEW
└─ _scriptRunner.Play(info.ScriptId, key, entity.Position)
ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...)
├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity
├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count)
│ ? partTransforms[partIndex] : Matrix4x4.Identity
├─ partLocal = Vector3.Transform(offset, partTransform)
├─ anchor = worldPos + Vector3.Transform(partLocal, rotation)
└─ _system.SpawnEmitterById(...)
```
### Slice B: dat-hydration fire-sites
```
GpuWorldState.AddLandblock(landblock)
├─ merge pending live entities (existing)
├─ _loaded[id] = landblock (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in landblock.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnCreate(entity)
└─ RebuildFlatView() (existing)
GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities)
├─ canonicalize + merge (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in entities: // NEW
│ _entityScriptActivator?.OnCreate(entity) // all dat-hydrated
└─ RebuildFlatView() (existing)
GpuWorldState.RemoveLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ rescue persistent (existing)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ remove from _loaded, RebuildFlatView (existing)
GpuWorldState.RemoveEntitiesFromLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ replace lb.Entities with empty list, RebuildFlatView (existing)
```
The `ServerGuid == 0` filter avoids double-firing OnCreate on live entities
that came via `AppendLiveEntity` and got pending-bucket-merged in
`AddLandblock`. Their OnCreate already fired at AppendLiveEntity time.
### Resolver evolution
C.1.5a resolver:
```csharp
Func<WorldEntity, uint> defaultScriptResolver // returns scriptId or 0
```
C.1.5b resolver:
```csharp
Func<WorldEntity, ScriptActivationInfo?> activationResolver // returns null on miss
```
Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`:
```csharp
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
```
Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one):
```csharp
entity =>
{
try
{
var setup = _dats.Get<Setup>(entity.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
return new ScriptActivationInfo(scriptId, parts);
}
catch
{
return null;
}
}
```
One dat lookup → both pieces of info. The Setup is cached by DatCollection,
so even hot-path scenery firing with no DefaultScript stays O(1).
### Helper: `SetupPartTransforms.Compute`
New static helper in `AcDream.Core.Meshing` (next to `SetupMesh`):
```csharp
public static class SetupPartTransforms
{
/// <summary>
/// Compute the per-part static transforms for a Setup using its
/// PlacementFrames. For each part i, the returned matrix is the
/// transform from part-local to setup-local space at the Setup's
/// resting pose. Mirrors SetupMesh.Flatten's pose-source priority:
/// PlacementFrames[Resting] → [Default] → first available.
/// Returns an empty list when the Setup has no PlacementFrames
/// (caller falls back to "no part transforms applied").
/// </summary>
public static IReadOnlyList<Matrix4x4> Compute(Setup setup);
}
```
This deliberately mirrors the pose-source priority in
`SetupMesh.Flatten` so a part's particle anchor matches its visible rest
position. (If the renderer's pose source ever diverges from this resolver,
particles will visibly drift — keep them in lockstep.)
For animated entities, the renderer's `AnimatedEntityState` computes
per-frame part transforms; a future "animated DefaultScript" path would
publish those each tick via the same `SetEntityPartTransforms` seam. Out
of scope for C.1.5b.
## §5 Data + lifecycle invariants
| Concern | Behavior |
|---|---|
| Server-spawned entity spawn | `AppendLiveEntity` → `OnCreate` (existing). Keys by `ServerGuid`. |
| Server-spawned entity despawn | `RemoveEntityByServerGuid` → `OnRemove(serverGuid)` (existing). |
| Dat-hydrated entity load (initial) | `AddLandblock` → `OnCreate` for each `ServerGuid==0` entity. Keys by `entity.Id`. |
| Dat-hydrated entity load (promotion) | `AddEntitiesToExistingLandblock` → `OnCreate` for each entity in the new batch. Keys by `entity.Id`. |
| Dat-hydrated entity unload (full LB) | `RemoveLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Dat-hydrated entity unload (Near→Far demotion) | `RemoveEntitiesFromLandblock` → `OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Pending live entity merged into AddLandblock | `OnCreate` already fired at `AppendLiveEntity`; filtered out by `ServerGuid != 0`. |
| Persistent live entity rescued from RemoveLandblock | Not unloaded; its script continues. Filtered out by `ServerGuid != 0`. |
| PartIndex out of bounds | Sink falls back to `Matrix4x4.Identity` for that part (no part transform applied, offset stays in entity-local frame as before). |
| Setup with empty PlacementFrames | Resolver returns empty `PartTransforms` list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior. |
| Resolver throws | Lambda's try/catch returns null; activator no-ops. |
| Same script re-fired on dedupe | `PhysicsScriptRunner.Play` replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by `ServerGuid==0` — they're not double-fired. |
### Idempotency
- Duplicate `OnCreate` for same key → script restarts (existing dedupe).
- Duplicate `OnRemove` for same key → no-op.
- `OnRemove` for never-spawned key → no-op.
- LB unload immediately followed by LB load → entities get fresh `entity.Id`
(localCounter resets per-call) but the keys are computed deterministically
from landblockId + iteration order so a re-entered LB gets identical keys.
Script restarts cleanly because OnRemove fired during the unload.
## §6 Testing
### Unit tests — new
1. **`SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable`** —
Setup with `PlacementFrames[Resting]` containing 2 parts; assert returned
list has 2 matrices matching the resting frames.
2. **`SetupPartTransforms_FallsBackToDefault_WhenRestingMissing`** —
Setup with only `PlacementFrames[Default]`; assert it's used.
3. **`SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames`** —
Setup with empty `PlacementFrames` dict; assert empty list.
4. **`SetupPartTransforms_AppliesDefaultScale_WhenPresent`** —
Setup with `DefaultScale[0] = (2, 2, 2)`; assert the matrix scales by 2.
5. **`ParticleHookSink_AppliesPartTransform_WhenRegistered`** —
register part transforms `[Identity, Translation(0,0,1)]`; fire a
CreateParticleHook with `PartIndex=1, Offset=(1,0,0)`; assert spawned
particle world position is `(1, 0, 1)`.
6. **`ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds`** —
register 2 part transforms; fire hook with `PartIndex=99`; assert
spawned at root + offset (no buried-by-bad-matrix).
7. **`EntityScriptActivator_KeysByEntityId_WhenServerGuidZero`** —
dat-hydrated entity with `ServerGuid=0, Id=0x40A9B401`; fire OnCreate;
assert script runner saw `entityId=0x40A9B401`.
8. **`EntityScriptActivator_PassesPartTransformsToSink`** —
resolver returns non-empty PartTransforms; assert sink's
`SetEntityPartTransforms` was called with the matching list.
9. **`EntityScriptActivator_OnRemove_StopsByGivenKey`** —
call `OnRemove(0x40A9B401)`; assert runner + sink both got that key.
### Unit tests — updated
The 4 existing `EntityScriptActivatorTests` are updated for the new
resolver signature (`_ => 0xAAu` → `_ => new ScriptActivationInfo(0xAAu,
Array.Empty<Matrix4x4>())`). Test names and assertions stay the same.
### Integration tests — GpuWorldState wiring
10. **`GpuWorldState_AddLandblock_FiresActivatorForDatHydrated`** —
construct GpuWorldState with a fake activator (recording mock); add
a landblock with one `ServerGuid==0` entity; assert OnCreate fired
exactly once.
11. **`GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge`** —
AppendLiveEntity with `ServerGuid=0xCAFE` (one OnCreate); then
AddLandblock for the same canonical id; assert OnCreate fired only
once total for the live entity.
12. **`GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated`** —
AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert
OnRemove fired with `entity.Id`.
13. **`GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator`** —
promotion path; assert OnCreate fires for each promoted entity.
14. **`GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove`** —
demotion path; assert OnRemove fires for each removed dat-hydrated
entity.
Existing `GpuWorldStateTests` may need a minor update if any assert on
constructor arity (the resolver doesn't change shape — same 4 ctor params).
### Visual verification (acceptance gate)
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
1. `dotnet build` green.
2. `dotnet test` green.
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1`.
4. **Site 1 — Holtburg Town network portal** (same site as C.1.5a):
user walks `+Acdream` to the portal arch. Compare swirl vertical
extent + lateral spread to retail. Pass: no ground-burial, distinct
columns of emission visible across the arch.
5. **Site 2 — Holtburg Inn fireplace** (interior, EnvCell static):
user walks into the inn, stands near the fireplace. Pass: flame
particles emit from the firebox at retail-matching height/density.
6. **Site 3 — Cottage chimney** (exterior stab): user finds a Holtburg
cottage with smoke in retail; same cottage in acdream should now
show smoke. Pass: smoke column matches retail.
7. **Site 4 — Spell cast** on `+Acdream`: user casts a spell, optionally
in a safe spot. Pass: cast-anim particles match retail.
Diagnostic: `ACDREAM_DUMP_PLAYSCRIPT=1` prints every `[pes] Play:` line —
if a site doesn't show particles, check the log to see whether the script
fired and with what scriptId.
## §7 Risk + rollback
**Slice A risks:**
- `SetupPartTransforms.Compute` returns a list whose length doesn't match
`setup.Parts.Count`. **Mitigation:** sink's per-index bounds check falls
back to Identity; no buried particles, just reverts to C.1.5a behavior
for the over-indexed hook.
- Wrong pose source chosen (Resting vs Default). **Mitigation:** mirror
`SetupMesh.Flatten`'s priority chain exactly so renderer + particle
anchor stay in lockstep. If they ever diverge, particles drift visibly;
user spot-checks at the portal.
**Slice B risks:**
- Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands
per landblock at radius=4) becomes a perf hit. **Mitigation:** resolver
is one cached `DatCollection.Get<Setup>` per entity — already amortized.
Most entities have `DefaultScript.DataId == 0`, resolver returns null,
OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed
by mesh upload + RebuildFlatView. Measured if `[pes] Play:` line
spam appears in launch.log.
- Filter `ServerGuid==0` is too aggressive — misses some valid case.
**Mitigation:** every entity with `ServerGuid != 0` came through
`AppendLiveEntity` (verified by `RelocateEntity`'s
`if (entity.ServerGuid == 0) return;` guard at GpuWorldState.cs:204),
so they already had OnCreate fired. No miss.
- Idempotency edge case: rapid LB load/unload cycles produce repeated
Play → Stop → Play. **Mitigation:** existing PhysicsScriptRunner
dedupe handles re-Play; this is the same as a server retriggering a
PlayScript opcode.
**Rollback path:** revert the spec's commits; the C.1.5a `EntityScriptActivator`
keeps working for live entities exactly as before. No data migrations.
## §8 Doc-drift fixes from C.1.5a (folded in)
The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded
here for the record:
1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator
is actually GpuWorldState's **fourth** optional parameter (verified at
[GpuWorldState.cs:63](../../../src/AcDream.App/Streaming/GpuWorldState.cs):
`wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded,
entityScriptActivator`).
2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at
**93 lines** including doc comments. Slice A adds the part-transform
call + slice B drops the `ServerGuid == 0` guard, so the file will
land at ~100110 lines after this phase.
3. `GpuWorldState.AddEntitiesToExistingLandblock` *will* fire the activator
in slice B (the handoff said it currently doesn't and noted "no-op
today because promotion-tier entities are atlas-tier"). With slice B,
atlas-tier entities WITH `DefaultScript` set will now activate. Per
the architecture comment at GpuWorldState.cs:384-391, this path
handles dat-static stabs/buildings — exactly the case slice B targets.
## §9 Implementation notes
- **File touches:** `ParticleHookSink.cs` (+~30 lines), `EntityScriptActivator.cs`
(+~10 lines, -~5 lines), `GpuWorldState.cs` (+~12 lines, 4 fire-sites),
`GameWindow.cs` (resolver lambda update, ~10 lines), new
`SetupPartTransforms.cs` (~50 lines), updated `EntityScriptActivatorTests.cs`
(4 ctor-signature updates + new tests), new `SetupPartTransformsTests.cs`
(~80 lines, 4 tests), new `ParticleHookSinkTests.cs` additions or new file
(~60 lines, 2 tests), new `GpuWorldStateActivatorTests.cs` (~120 lines,
5 integration tests).
- **Estimated effort:** ~1 day.
- **Commit cadence:** four commits land this phase cleanly —
(1) `SetupPartTransforms` helper + tests, (2) `ParticleHookSink` part-transform
support + tests, (3) `EntityScriptActivator` resolver refactor + ServerGuid
guard relaxation + tests, (4) `GpuWorldState` fire-site wiring + tests +
production lambda update + the C.1.5a doc-drift comment for
`AddEntitiesToExistingLandblock`. Each commit `dotnet test` green.
Visual verification after all four land.
- **Roadmap update:** on ship, add a "Phase C.1.5b SHIPPED 2026-05-13"
entry to [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md);
move #56 to "Recently closed" in `docs/ISSUES.md`.
- **CLAUDE.md update:** the "Currently in flight" line at the top of the
project-instructions block changes from C.1.5b to the next phase, with
the handoff doc reference dropped. Decide the next-phase pointer at
verification time.
## §10 What's next (post-C.1.5b)
Pending user direction. The roadmap candidate list from
[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md):
- Triage the chronic open-issue list — #2 (lightning), #4 (sky horizon-glow),
#28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree),
#41 (remote-motion blips) — link each to a future phase or downgrade.
- More Phase C visual-fidelity work (C.2 dynamic point lights, C.3 palette
tuning, C.4 double-sided translucent polys).
- N.6 slice 2 at reduced scope (atlas opportunities only).
- Perf tiers 2/3 only if sustained 500+ FPS becomes a requirement.
Verification will surface which option the user picks.