acdream/docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
Erik 4abb838729 docs(post-A.5 #53): Tier 1 retry — mutation audit + cache design spec
The audit at docs/research/2026-05-10-tier1-mutation-audit.md enumerates
every entity.MeshRefs write site (5 STATIC at hydration, 1 DYNAMIC at
GameWindow.cs:7580 inside TickAnimations) and verifies that all 7
Position/Rotation write sites only touch entities in _animatedEntities.
Establishes the load-bearing invariant: an entity's renderer state is
stable from spawn to despawn iff entity.Id is NOT in _animatedEntities.

The spec at docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
locks in the design from brainstorming on 2026-05-10:
- Static-only cache + DEBUG cross-check (option c) — catches future
  regressions of the prior bug class without paying perf cost in Release
- Separate EntityClassificationCache class injected into WbDrawDispatcher
- Cache the rest pose, not the full model matrix (Position/Rotation read
  live each frame so Release stays correct even if the invariant breaks)
- Pre-flatten Setup multi-parts at populate time (the bulk of the win)
- 15 new tests covering all invalidation paths + DEBUG cross-check +
  Setup pre-flatten + lifecycle pin

Closes the audit + design steps of the post-A.5 polish Priority 3 work.
Implementation plan owned by superpowers:writing-plans next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:50:26 +02:00

451 lines
27 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.

# 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).