# 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 ~100–110 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.
