# ISSUE #53 — Tier 1 entity-classification cache (design)

**Created:** 2026-05-10.
**Status:** approved design, ready for implementation plan.
**Audit foundation:** [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).
**Originating issue:** [docs/ISSUES.md](../../ISSUES.md) §#53.
**Phase context:** Phase Post-A.5 polish, Priority 3 (only remaining priority after #52 + #54 closed).

---

## §1. Problem

`WbDrawDispatcher.Draw` runs full per-frame entity classification at radius=12: walk every visible entity → resolve textures (palette + override) → bucket into groups by `(IBO, FirstIndex, BaseVertex, IndexCount, textureHandle, layer, translucency)`. At ~10K visible entities × ~3 batches average = ~30K classification ops/frame, this dominates the dispatcher's CPU at ~3.5 ms median (post-#52/#54 baseline) — 75% over the Phase A.5 spec's 2.0 ms entity dispatcher budget.

For ~99.5% of entities (stabs, scenery, cell-mesh, interior fixtures, lifestone), the classification result is *identical* every frame from spawn to despawn. The classification work for those entities is pure waste.

A first attempt to cache this state — commit `3639a6f`, reverted at `9b49009` — froze NPC animation by caching `meshRef.PartTransform`, which is mutated every frame for entities in `_animatedEntities`. ([memory entry on the failure mode](../../../../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_a5_state.md))

This spec is the audit-driven retry.

---

## §2. Goals and non-goals

### Goals

1. Drop entity dispatcher CPU median from ~3.5 ms to ≤ 2.0 ms (matches A.5 spec budget) at the horizon-safe preset (radius=4/12).
2. Hold p95 at ≤ 2.5 ms.
3. Hold animation correctness — NPCs animate, the lifestone crystal animates, the player animates, no frozen poses.
4. Hold N.5b conformance sentinel: 94/94 passing (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`) throughout.
5. Hold full test baseline: 1688 passing, 8 pre-existing physics/input failures unchanged.
6. Surface a defensive guard against the prior bug class so the next regression of "static entity gets per-frame mutation snuck in" fails fast instead of silently freezing visuals.

### Non-goals

- Tier 2 (static/dynamic split with persistent groups) — separate multi-week phase per [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md). DO NOT bundle.
- Tier 3 (GPU compute culling) — same roadmap; depends on Tier 2 first.
- Caching for animated entities. Animated entities use today's per-frame classification path, unchanged.
- Persistent-mapped indirect buffer or any other rendering perf work outside the entity classification path.

---

## §3. Design decisions (from brainstorming, 2026-05-10)

| # | Decision | Rationale |
|---|---|---|
| Q1 | **Static-only cache + DEBUG cross-check** (option `c`) | The prior failure mode was "we silently cached mutable state." DEBUG cross-check converts that class of regression from "user notices a frozen NPC" to "Debug.Assert fires in any dev/test run." Zero Release cost. |
| Q2 | **Separate `EntityClassificationCache` class** (option `B`) at `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`, injected into `WbDrawDispatcher` via ctor | Pure-CPU testable in isolation. The single invariant ("static entity = `entity.Id ∉ _animatedEntities`") lives at the top of one ~200-line file rather than scattered through the 940-line dispatcher. |
| Q3 | **Cache the rest pose, not the full model matrix** (option `P`) | Full-matrix would save ~50 µs/frame of mat4 mults at the cost of baking `Position`/`Rotation` into the cache. With rest pose, `Position`/`Rotation` are read live every frame; if a future regression introduces a static-entity Position write, Release builds still produce correct visuals (just with unused cache entries). DEBUG cross-check catches the regression either way. Marginal perf delta dominated by safety. |
| Q4 | **Pre-flatten Setup multi-parts at populate time** (option `F`) | The bulk of the visible CPU win lives here. Today the dispatcher walks `renderData.SetupParts` per frame even though that list is per-GfxObj-immutable. Pre-flattening makes the per-frame hot path branchless: walk one flat list per entity regardless of Setup-vs-non-Setup. Populate cost: one extra mat4 mult per subPart, run once per entity per session. |
| Q5 | **Thorough test coverage** (option `T`): ~10 tests in a new `EntityClassificationCacheTests.cs`, +2 integration tests in `WbDrawDispatcherBucketingTests.cs` | The prior bug would have been caught by the DEBUG cross-check test. The "ObjDescEvent treated as despawn-respawn" test pins a contract from the audit so it can't quietly change. Setup pre-flattening test verifies the per-batch product math without the GL stack. ~150-200 lines of test code. |

---

## §4. The invariant

The cache rests on this single rule, verified in the audit:

> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**

Six write sites in `src/`, five static (one-shot at hydration), one dynamic (per-frame in `TickAnimations`, only for entities in `_animatedEntities`). All seven `Position`/`Rotation` write sites operate on entities in `_animatedEntities`. `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, `Scale` are `init`-only on `WorldEntity`. `MeshRef` is a `readonly record struct` — no in-place mutation possible. See [audit §1, §3, §4](../../research/2026-05-10-tier1-mutation-audit.md#1-entitymeshrefs---write-sites-the-core-question).

The DEBUG cross-check (§6.5) is the safety net for any future regression that violates this rule.

---

## §5. Architecture

```
                     ┌─────────────────────────────────┐
                     │  GameWindow                     │
                     │  └─ _animatedEntities (dict)    │   ← gating predicate
                     │  └─ _classificationCache (NEW) ─┼──┐
                     │  └─ _wbDrawDispatcher           │  │
                     └──────────────────┬──────────────┘  │
                                        │                 │
                                        ▼                 │
                     ┌─────────────────────────────────┐  │
                     │  WbDrawDispatcher (MODIFIED)    │  │
                     │  └─ Draw(...)                   │  │
                     │     └─ per (entity, partIdx):   │  │
                     │        ├─ animated? → slow path │  │
                     │        ├─ cache hit? → fast path┼──┤
                     │        └─ cache miss? → slow    │  │
                     │           path + populate ──────┼──┘
                     └─────────────────────────────────┘
                                        ▲
                                        │ ctor injection
                                        │
                     ┌─────────────────────────────────┐
                     │  EntityClassificationCache (NEW)│
                     │  └─ Dictionary<uint, Entry>     │
                     │  └─ TryGet(id, out CachedBatch[])│
                     │  └─ Populate(id, partIdx, ...)  │
                     │  └─ InvalidateEntity(id)        │
                     │  └─ InvalidateLandblock(lbId)   │
                     │  └─ [DEBUG] CrossCheck(...)     │
                     └─────────────────────────────────┘
                                        ▲
                                        │ invalidation calls
                                        │
                     ┌─────────────────────────────────┐
                     │  GameWindow.RemoveLiveEntity… ──┘
                     │  GpuWorldState.RemoveEntities…  │   (or wired via callback)
                     └─────────────────────────────────┘
```

### §5.1 Cache shape

```csharp
namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result. Stored flat in
/// EntityCacheEntry.Batches — one entry per (logical-part, batch), where
/// for a Setup MeshRef each subPart contributes its own entries.
/// </summary>
public readonly record struct CachedBatch(
    GroupKey Key,                  // bucket identity (matches the dispatcher's private GroupKey)
    ulong    BindlessTextureHandle, // resolved texture (post-palette + override)
    Matrix4x4 RestPose);            // meshRef.PartTransform (or subPart.PartTransform * meshRef.PartTransform for Setup)

internal sealed class EntityCacheEntry
{
    public required uint EntityId;
    public required uint LandblockHint;     // for InvalidateLandblock sweep
    public required CachedBatch[] Batches;  // flat across (partIdx, batchIdx); ordered as classification produced them
}

public sealed class EntityClassificationCache
{
    private readonly Dictionary<uint, EntityCacheEntry> _entries = new();

    public bool TryGet(uint entityId, out EntityCacheEntry entry);
    public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
    public void InvalidateEntity(uint entityId);
    public void InvalidateLandblock(uint landblockId);
    public int Count => _entries.Count;  // diag

#if DEBUG
    public void DebugCrossCheck(
        uint entityId,
        Matrix4x4 entityWorld,
        IReadOnlyList<MeshRef> liveMeshRefs,
        // …enough live state to recompute model matrices and assert match
    );
#endif
}
```

`GroupKey` is defined privately inside `WbDrawDispatcher` today (lines 923-930); promote to internal or pass an opaque payload through. Implementation detail; settle in writing-plans.

### §5.2 Dispatcher integration (the per-entity branch)

```csharp
// Inside WbDrawDispatcher.Draw, replacing today's per-(entity, partIdx) body
// at lines 367-423.

foreach (var (entity, partIdx) in _walkScratch)
{
    if (diag) _entitiesSeen++;

    var entityWorld =
        Matrix4x4.CreateFromQuaternion(entity.Rotation) *
        Matrix4x4.CreateTranslation(entity.Position);

    bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
    if (!isAnimated && _cache.TryGet(entity.Id, out var entry))
    {
        // Fast path: cache hit on a static entity.
        foreach (var cached in entry.Batches)
        {
            if (!_groups.TryGetValue(cached.Key, out var grp))
            {
                grp = new InstanceGroup { /* …materialize from key… */ };
                _groups[cached.Key] = grp;
            }
            grp.Matrices.Add(cached.RestPose * entityWorld);
        }

#if DEBUG
        _cache.DebugCrossCheck(entity.Id, entityWorld, entity.MeshRefs, /*…*/);
#endif

        if (diag) _entitiesDrawn++;
        continue;
    }

    // Slow path: animated entity, OR cache miss.
    // Run today's classification, optionally collecting into a populate buffer
    // when !isAnimated.
    var collector = isAnimated ? null : _populateScratch;
    collector?.Clear();

    // …today's TryGetRenderData / SetupParts walk / ClassifyBatches …
    //    ClassifyBatches now also writes (key, texHandle, restPose) into
    //    `collector` when collector is non-null.

    if (collector is not null && collector.Count > 0)
    {
        _cache.Populate(entity.Id, /*landblockHint*/ ResolveLandblockHint(entity),
                        collector.ToArray());
    }
}
```

`ClassifyBatches` is extended to optionally append into a caller-supplied `List<CachedBatch>`. When the collector is null (animated path), behavior is unchanged from today. When non-null (cache-miss path on static entities), each emitted batch also produces a `CachedBatch` record.

### §5.3 Invalidation wiring

Two invalidation events:

1. **Per-entity despawn** at [GameWindow.cs:2933-2935](../../../src/AcDream.App/Rendering/GameWindow.cs#L2933) — add `_classificationCache.InvalidateEntity(existingEntity.Id);` next to `_animatedEntities.Remove(...)`.
2. **Landblock demote / unload** — `GpuWorldState.RemoveEntitiesFromLandblock` is the choke point. Wire one of:
   - **(W1)** Add an `Action<uint /*entityId*/>?` callback parameter; `GameWindow` wires it to `_classificationCache.InvalidateEntity`. Cleaner separation.
   - **(W2)** Pass the cache directly into `GpuWorldState`. Less ceremony.
   - **(W3)** Call `_classificationCache.InvalidateLandblock(landblockId)` from `StreamingController.Tick` before invoking `RemoveEntitiesFromLandblock`. Requires the cache to maintain `LandblockHint` correctly per entry.

   Implementation plan picks one. My lean: **(W3)** — the cache already needs `LandblockHint` for the sweep, and `StreamingController` is the natural lifecycle owner.

### §5.4 Failure modes and recovery

| Failure mode | Detection | Recovery |
|---|---|---|
| Future regression adds `MeshRefs` write site for static entity | DEBUG cross-check `Debug.Assert` fires in dev runs | Audit + fix source. Cross-check stays as guard. |
| Future regression adds `Position`/`Rotation` write site for static entity | DEBUG cross-check (compares `RestPose * liveEntityWorld` against live `meshRef.PartTransform * liveEntityWorld`) | Same. |
| Despawn fires but invalidation not wired | Despawn test asserts `cache.TryGet(id, …) == false` post-call | TDD test catches in CI. |
| Landblock unload misses cache invalidation | `RemoveEntitiesFromLandblock` test asserts every entry with matching `LandblockHint` is gone | TDD test catches in CI. |
| Animated→static membership flip leaves stale entry | No-op (membership predicate skips cache for animated entries; if entity later flips static, cache miss → populate fresh) | None needed. |
| Static→animated membership flip leaves stale entry | No-op (predicate now skips cache; entry sits unused until despawn) | None needed. |
| Cache memory growth | At radius=12: ~10K static entities × ~3-10 batches × ~64 bytes = ~2-6 MB total | None needed. |
| Cache hit on a `_meshAdapter.TryGetRenderData` mesh that subsequently becomes unavailable (theoretical — adapter is session-stable) | N/A — adapter doesn't evict during play | N/A |

---

## §6. Components and their contracts

### §6.1 `EntityClassificationCache` (NEW)

**File:** `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`

**Public surface:**

```csharp
public sealed class EntityClassificationCache
{
    public bool TryGet(uint entityId, out EntityCacheEntry entry);
    public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
    public void InvalidateEntity(uint entityId);
    public void InvalidateLandblock(uint landblockId);
    public int Count { get; }  // for diag
}
```

**Invariants:**

- `Populate` overwrites any existing entry for `entityId` (defensive: handles a populate that races with a partial despawn).
- `InvalidateEntity` is idempotent (no-throw on missing id).
- `InvalidateLandblock` walks all entries; entries whose `LandblockHint == landblockId` are removed.
- `TryGet` is read-only; never mutates.

**Threading:** dispatcher runs on the render thread. All cache operations are render-thread only. No locking needed.

### §6.2 `WbDrawDispatcher` (MODIFIED)

**File:** `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`

**Constructor change:** add `EntityClassificationCache classificationCache` parameter; assign to a private `readonly` field.

**`Draw` change:** the per-entity body at lines ~367-423 is restructured per §5.2. The `WalkEntitiesInto` walk and the GL state setup phases (sort, upload, two `glMultiDrawElementsIndirect` calls) are unchanged.

**`ClassifyBatches` change:** add optional `List<CachedBatch>? collector` parameter. When non-null, every classified `(key, texHandle, restPose)` triple is also appended to the collector. Today's behavior preserved for animated entities (collector is null).

**`ResolveLandblockHint(entity)`:** small helper that returns the landblock id the cache should associate with the entity, for `InvalidateLandblock` sweeps. For dat-loaded entities, this is the landblock the entity was hydrated into. For live-spawned entities, it's the entity's current `Position`-implied landblock at spawn time (or `0` if landblock-invalidation isn't expected to fire — live entities are invalidated by `InvalidateEntity` on despawn).

### §6.3 `GameWindow` (MODIFIED)

**File:** `src/AcDream.App/Rendering/GameWindow.cs`

**Construction:** instantiate `EntityClassificationCache`, pass to dispatcher ctor.

**Despawn hook:** at line 2935 (inside `RemoveLiveEntityByServerGuid`), add `_classificationCache.InvalidateEntity(existingEntity.Id);` adjacent to `_animatedEntities.Remove(...)`.

### §6.4 `GpuWorldState` and/or `StreamingController` (MODIFIED, exact split per W1/W2/W3)

Implementation plan picks one of W1/W2/W3 from §5.3. The wiring lands invalidation calls at the LB demote / unload boundary.

### §6.5 DEBUG cross-check

```csharp
#if DEBUG
public void DebugCrossCheck(
    uint entityId,
    Matrix4x4 entityWorld,
    IReadOnlyList<MeshRef> liveMeshRefs,
    Func<uint, ObjectRenderData?> tryGetRenderData,
    AcSurfaceMetadataTable metaTable,
    Func<WorldEntity, MeshRef, ObjectRenderBatch, ulong, ulong> resolveTexture,
    WorldEntity entity,
    ulong palHash)
{
    if (!_entries.TryGetValue(entityId, out var entry)) return;

    // Re-classify from live state and compare against cached batches one-by-one.
    int idx = 0;
    foreach (var meshRef in liveMeshRefs)
    {
        var renderData = tryGetRenderData(meshRef.GfxObjId);
        if (renderData is null) continue;
        var setupParts = renderData.IsSetup ? renderData.SetupParts : OnePart(meshRef);
        foreach (var (subGfxId, subTransform) in setupParts)
        {
            var subData = tryGetRenderData(subGfxId);
            if (subData is null) continue;
            var liveRestPose = renderData.IsSetup
                ? subTransform * meshRef.PartTransform
                : meshRef.PartTransform;
            for (int b = 0; b < subData.Batches.Count; b++)
            {
                var batch = subData.Batches[b];
                var liveTex = resolveTexture(entity, meshRef, batch, palHash);
                Debug.Assert(idx < entry.Batches.Length,
                    $"cache size mismatch for entity {entityId}");
                var cached = entry.Batches[idx];
                Debug.Assert(MatrixApproxEqual(cached.RestPose, liveRestPose, 1e-5f),
                    $"RestPose drift for entity {entityId} batch {idx}");
                Debug.Assert(cached.BindlessTextureHandle == liveTex,
                    $"texture drift for entity {entityId} batch {idx}");
                idx++;
            }
        }
    }
    Debug.Assert(idx == entry.Batches.Length,
        $"cache batch count mismatch for entity {entityId}");
}
#endif
```

The cross-check duplicates the slow-path classification against live state and compares to cached. If any drift is detected, the assert fires in dev runs with an actionable message. Zero cost in Release.

---

## §7. Test plan

### §7.1 New tests — `EntityClassificationCacheTests.cs`

**File:** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

| # | Test | What it verifies |
|---|---|---|
| 1 | `TryGet_EmptyCache_ReturnsFalse` | Baseline. |
| 2 | `Populate_ThenTryGet_ReturnsBatchesInOrder` | Round-trip. |
| 3 | `Populate_OverridesExistingEntry` | Defensive overwrite. |
| 4 | `InvalidateEntity_RemovesEntry` | Entity despawn invalidation. |
| 5 | `InvalidateEntity_OnMissingId_NoThrow` | Idempotent. |
| 6 | `InvalidateLandblock_RemovesAllMatchingEntries` | LB demote invalidation, single LB. |
| 7 | `InvalidateLandblock_LeavesNonMatchingEntries` | LB sweep is precise. |
| 8 | `InvalidateLandblock_OnMissingLb_NoThrow` | Idempotent. |
| 9 | `Count_TracksLiveEntries` | Diag accuracy. |
| 10 | `Populate_WithEmptyBatches_StoresEmptyEntry` | Edge case (entity with zero classifiable batches). |

### §7.2 Extended tests — `WbDrawDispatcherBucketingTests.cs`

| # | Test | What it verifies |
|---|---|---|
| 11 | `Draw_StaticEntity_RoutesThroughCache` | Spawn one static entity; first frame populates the cache; second frame's draw call doesn't invoke `ClassifyBatches` (verify via spy / counter on a mock `WbMeshAdapter`). |
| 12 | `Draw_AnimatedEntity_BypassesCache` | Spawn one entity in `animatedEntityIds`; verify cache is never populated for it; `ClassifyBatches` runs every frame. |

### §7.3 (DEBUG-only) Cross-check test

| # | Test | What it verifies |
|---|---|---|
| 13 | `DebugCrossCheck_DetectsMutatedRestPose` | Populate with synthetic data, mutate the live `MeshRef` list, invoke `DebugCrossCheck`, assert fires. Wrapped in `#if DEBUG`. |

### §7.4 Setup pre-flatten lock-in

| # | Test | What it verifies |
|---|---|---|
| 14 | `Populate_SetupMultiPart_StoresFlatBatchPerSubPart` | Synthetic Setup with N subParts × M batches each → cache stores N × M entries with the expected `RestPose` products. |

### §7.5 Lifecycle integration

| # | Test | What it verifies |
|---|---|---|
| 15 | `DespawnRespawn_UnderReusedId_RepopulatesFresh` | Populate, invalidate, populate again under same id with different batches → final state matches second populate. (Pins the audit's ObjDescEvent contract — ObjDescEvent is despawn+respawn, not in-place mutation. Audit §1 cites this.) |

Total new tests: 15. Some can collapse if overlap is identified during implementation; baseline is "≥ 10 in `EntityClassificationCacheTests` + ≥ 2 in dispatcher integration + ≥ 1 DEBUG cross-check".

### §7.6 Sentinel and baseline (existing tests, must stay green)

- N.5b conformance sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` → 94 passing.
- Full suite: 1688 passing, 8 pre-existing failures unchanged.

---

## §8. Sequencing for implementation

(The implementation plan from `superpowers:writing-plans` will refine this into per-task increments. Sketch:)

1. **Skeleton + tests 1-10.** Add `CachedBatch`, `EntityCacheEntry`, `EntityClassificationCache`. Tests 1-10 in the new file. Cache exists but isn't wired to anything yet.
2. **Setup pre-flatten test (test 14) + populate path.** Synthetic `CachedBatch[]` populate; verify `Count` and `TryGet` round-trip on multi-part data shapes.
3. **Wire dispatcher: cache miss + populate.** Modify `WbDrawDispatcher.Draw` and `ClassifyBatches`. First-frame static entity populates; subsequent frames still go through slow path (cache hit branch not yet in). Build green.
4. **Wire dispatcher: cache hit + DEBUG cross-check.** Cache-hit fast path. Tests 11, 12, 13 added.
5. **Wire invalidation hooks.** `InvalidateEntity` from `RemoveLiveEntityByServerGuid`; `InvalidateLandblock` per chosen W1/W2/W3 from §5.3. Test 15.
6. **Visual gate.** Launch + walk Holtburg → North Yanshi at horizon-safe preset. Verify NPC animates, lifestone renders, buildings at correct positions.
7. **Perf gate.** `ACDREAM_WB_DIAG=1`; capture entity dispatcher cpu_us median + p95 over a ≥ 30s standstill at center of Holtburg. Confirm median ≤ 2.0 ms, p95 ≤ 2.5 ms.
8. **Ship.** Commit chain. Close #53 in `docs/ISSUES.md` Recently closed. Update `CLAUDE.md` "Currently in flight" (closes the post-A.5 polish phase). Update memory if any new gotchas surfaced.

---

## §9. Acceptance criteria (whole spec)

- [ ] `EntityClassificationCache.cs` exists with the public surface in §6.1.
- [ ] `WbDrawDispatcher` accepts the cache via ctor and routes static entities through the cache; animated entities bypass.
- [ ] `RemoveLiveEntityByServerGuid` invokes `InvalidateEntity`.
- [ ] LB demote / unload path invokes `InvalidateLandblock` (or per-entity invalidation, per chosen W1/W2/W3).
- [ ] All 15 new tests pass; no existing test regresses; 8 pre-existing failures unchanged.
- [ ] N.5b sentinel: 94/94 passing on every commit.
- [ ] Build green throughout.
- [ ] Visual gate: animation works on a moving NPC, the lifestone renders, buildings are at correct positions, no new artifacts.
- [ ] Perf gate at horizon-safe preset: entity dispatcher cpu_us median ≤ 2.0 ms; p95 ≤ 2.5 ms.
- [ ] ISSUE #53 moved to "Recently closed" with the closing commit SHA.
- [ ] `CLAUDE.md` "Currently in flight" updated to reflect post-A.5 polish phase complete.
- [ ] Memory updated (`project_phase_a5_state.md` or new entry) if any new gotchas surface during implementation.

---

## §10. What this design explicitly does NOT do

- Touch the animated path. Animated entities use today's `ClassifyBatches` flow unchanged.
- Touch the GPU upload pipeline (`_instanceSsbo`, `_batchSsbo`, `_indirectBuffer`). Same upload shape; just less CPU work to produce the inputs.
- Touch terrain. `TerrainModernRenderer` already runs at ~21 µs median; not in scope.
- Touch sky / particles / EnvCell rendering. All unchanged.
- Add new shader variants. The `mesh_modern.vert` / `mesh_modern.frag` pair is unchanged.
- Add new bindless texture handles. `TextureCache` is read-only from this work; it returns the same handle for the same surface id whether we ask once at populate or every frame.

---

## §11. Open implementation choices for writing-plans

These survive into the implementation plan because they're tactical (mechanical), not strategic:

- **W1 vs W2 vs W3 for the LB invalidation wiring** (§5.3). Pick one; stick with it.
- **`GroupKey` visibility.** Today `private` inside the dispatcher. Either promote to `internal` (within `AcDream.App`) or pass an opaque payload through the cache. Either works. Lean: promote to `internal`.
- **`ResolveLandblockHint` placement.** On the dispatcher (uses dispatcher state for live-spawn entities) or on the cache (passed in by caller)? Lean: dispatcher computes it, passes to `Populate`.
- **`_populateScratch` reuse.** Per-frame field on the dispatcher (matches `_walkScratch` pattern) or per-call allocation? Lean: field, matching `_walkScratch`.
- **Test fixtures.** Synthetic `WorldEntity` / `MeshRef` instances may need helper builders. Lean: add a small `EntityClassificationCacheTestFixtures.cs` if the helpers grow past ~30 lines.

---

**End of spec.** Implementation plan owned by `superpowers:writing-plans`. Audit foundation lives at [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).
