# 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` (one transform per part). - `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList 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` to `Func` 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 defaultScriptResolver // returns scriptId or 0 ``` C.1.5b resolver: ```csharp Func activationResolver // returns null on miss ``` Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`: ```csharp public sealed record ScriptActivationInfo( uint ScriptId, IReadOnlyList PartTransforms); ``` Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one): ```csharp entity => { try { var setup = _dats.Get(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 { /// /// 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"). /// public static IReadOnlyList 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())`). 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` 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.