# Tier 1 Entity-Classification Cache Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Drop `WbDrawDispatcher` entity dispatcher CPU median from ~3.5 ms to ≤ 2.0 ms by caching per-entity classification results for static entities, while holding animation correctness via a `_animatedEntities` membership predicate and a DEBUG cross-check guard.

**Architecture:** New pure-CPU class `EntityClassificationCache` (separate file, ctor-injected into the dispatcher) holds `Dictionary<entityId, CachedBatch[]>`. Dispatcher checks `_animatedEntities` membership at the top of the per-entity loop; static entities go through the cache (miss → populate; hit → fast path that walks the cached flat batch list and appends `RestPose * entityWorld` matrices). Two invalidation hooks: `InvalidateEntity` from `RemoveLiveEntityByServerGuid` (live despawn) and `InvalidateLandblock` from `GpuWorldState.RemoveEntitiesFromLandblock` (LB demote/unload, wired via callback at `GameWindow` construction). DEBUG-only cross-check recomputes live state and asserts it matches cached, catching the prior Tier 1 bug class.

**Tech Stack:** C# / .NET 10 preview / Silk.NET / xUnit / FluentAssertions. Repository at `C:\Users\erikn\source\repos\acdream`. Worktree branch `claude/friendly-varahamihira-7b8664`.

**Spec foundation:** [docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md](../specs/2026-05-10-issue-53-tier1-cache-design.md).
**Audit foundation:** [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).

---

## File Structure

| File | Status | Responsibility |
|---|---|---|
| `src/AcDream.App/Rendering/Wb/GroupKey.cs` | NEW | Top-level `internal record struct GroupKey` — extracted from `WbDrawDispatcher` so the cache can reference it without touching dispatcher internals |
| `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs` | NEW | Pure-CPU cache class; `Dictionary<uint, EntityCacheEntry>`; `TryGet` / `Populate` / `InvalidateEntity` / `InvalidateLandblock` + DEBUG cross-check |
| `src/AcDream.App/Rendering/Wb/CachedBatch.cs` | NEW | Top-level `public readonly record struct CachedBatch` + `public sealed class EntityCacheEntry` |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFIED | Add cache ctor param; restructure `Draw` per-entity branch; extend `ClassifyBatches` with optional collector |
| `src/AcDream.App/Rendering/GameWindow.cs` | MODIFIED | Construct `EntityClassificationCache`; pass to dispatcher; wire `InvalidateEntity` at the despawn site (line ~2935); wire `InvalidateLandblock` callback into `GpuWorldState` ctor |
| `src/AcDream.App/Streaming/GpuWorldState.cs` | MODIFIED | Optional `Action<uint>?` invalidation callback parameter on the constructor; invoked from `RemoveEntitiesFromLandblock` |
| `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs` | NEW | 12+ pure-CPU tests covering TryGet / Populate / Invalidate paths + Setup pre-flatten + DEBUG cross-check |
| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` | MODIFIED | +2 integration tests for cache routing; existing tests adapted for new ctor param |

**Out of scope (do NOT touch):** `mesh_modern.vert`, `mesh_modern.frag`, `TerrainModernRenderer`, `WbMeshAdapter`, `TextureCache`, sky/particles/EnvCell renderers, GPU upload pipeline.

---

## Pre-flight (do these before Task 1)

- [ ] **Confirm working tree clean and on the worktree branch.**

```bash
git status
git branch --show-current
```

Expected: `working tree clean`, current branch `claude/friendly-varahamihira-7b8664`.

- [ ] **Confirm baseline: build green + 1688/8 tests + 94/94 N.5b sentinel.**

```powershell
dotnet build
```

Expected: `Build succeeded. 0 Error(s)`.

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: `Passed!  - Failed: 0, Passed: 94, Skipped: 0, Total: 94`.

If submodules missing: `git submodule update --init --recursive references/WorldBuilder`.

---

## Phase 1: Cache foundation (Tasks 1-5)

### Task 1: Extract `GroupKey` to its own file

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/GroupKey.cs`
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:923-930` (remove the nested type)

This is a mechanical refactor so the cache can reference `GroupKey` without depending on `WbDrawDispatcher`'s private members.

- [ ] **Step 1: Create the new file.**

`src/AcDream.App/Rendering/Wb/GroupKey.cs`:

```csharp
using AcDream.Core.Meshing;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Bucket identity for <see cref="WbDrawDispatcher"/>'s per-frame group dictionary.
/// Two (entity, batch) pairs that share the same <see cref="GroupKey"/> render
/// in a single <c>glMultiDrawElementsIndirect</c> draw command. Promoted to
/// <c>internal</c> at file scope (was a private nested type) so
/// <see cref="EntityClassificationCache"/> can store it inside <see cref="CachedBatch"/>
/// without depending on dispatcher internals.
/// </summary>
internal readonly record struct GroupKey(
    uint Ibo,
    uint FirstIndex,
    int BaseVertex,
    int IndexCount,
    ulong BindlessTextureHandle,
    uint TextureLayer,
    TranslucencyKind Translucency);
```

- [ ] **Step 2: Remove the nested `GroupKey` from the dispatcher.**

In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, delete lines 923-930 (the `private readonly record struct GroupKey(...)` block). Leave the surrounding code unchanged.

- [ ] **Step 3: Build to verify the refactor compiled.**

```powershell
dotnet build
```

Expected: `Build succeeded. 0 Error(s)`. If it fails because some test or code referenced `WbDrawDispatcher.GroupKey`, change those references to use the bare `GroupKey` (now `internal` at namespace scope).

- [ ] **Step 4: Run the full test suite to verify no behavior change.**

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1688` (baseline preserved).

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/GroupKey.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "refactor(render): extract WbDrawDispatcher.GroupKey to internal type at namespace scope

Mechanical refactor: GroupKey was a private nested record struct on
WbDrawDispatcher. The upcoming EntityClassificationCache (ISSUE #53) needs
to store GroupKey inside CachedBatch records, so it must be visible to
both the dispatcher and the cache. Promoting to internal at file scope is
the smallest change that achieves this.

No behavior change. 1688 tests pass; 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: Skeleton — `EntityClassificationCache` + `CachedBatch` + first test

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/CachedBatch.cs`
- Create: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
- Create: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

The test file references the `internal` `GroupKey`; if `AcDream.App` doesn't already grant `InternalsVisibleTo("AcDream.Core.Tests")`, add it as part of this task.

- [ ] **Step 1: Write the first failing test.**

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

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using FluentAssertions;
using Xunit;

namespace AcDream.Core.Tests.Rendering.Wb;

public class EntityClassificationCacheTests
{
    [Fact]
    public void TryGet_EmptyCache_ReturnsFalse()
    {
        var cache = new EntityClassificationCache();
        bool found = cache.TryGet(entityId: 42, out var entry);
        found.Should().BeFalse();
        entry.Should().BeNull();
    }

    private static CachedBatch MakeCachedBatch(
        uint ibo, uint firstIndex, int indexCount, ulong texHandle)
    {
        var key = new GroupKey(
            Ibo: ibo,
            FirstIndex: firstIndex,
            BaseVertex: 0,
            IndexCount: indexCount,
            BindlessTextureHandle: texHandle,
            TextureLayer: 0,
            Translucency: TranslucencyKind.Opaque);
        return new CachedBatch(key, texHandle, Matrix4x4.Identity);
    }
}
```

- [ ] **Step 2: Add `InternalsVisibleTo` if needed.**

Check if `AcDream.App` already exposes internals to `AcDream.Core.Tests`:

```powershell
Select-String -Path src/AcDream.App/**/*.cs, src/AcDream.App/AcDream.App.csproj -Pattern "InternalsVisibleTo"
```

If no hit, add a new file `src/AcDream.App/Properties/AssemblyInfo.cs`:

```csharp
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("AcDream.Core.Tests")]
```

(Place it under `Properties/` to follow the conventional .NET assembly-info pattern; if `AcDream.App` already has another conventional location, use that instead.)

- [ ] **Step 3: Run the test to verify it fails to compile.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
```

Expected: build error — `EntityClassificationCache`, `CachedBatch` don't exist yet.

- [ ] **Step 4: Create `CachedBatch.cs`.**

`src/AcDream.App/Rendering/Wb/CachedBatch.cs`:

```csharp
using System.Numerics;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result, stored flat inside
/// <see cref="EntityCacheEntry.Batches"/>. For Setup multi-part MeshRefs each
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
/// <see cref="RestPose"/> already containing the
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
/// </summary>
public readonly record struct CachedBatch(
    GroupKey Key,
    ulong BindlessTextureHandle,
    Matrix4x4 RestPose);

/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across
/// (partIdx, batchIdx) and ordered as <c>WbDrawDispatcher.ClassifyBatches</c>
/// produced them. <see cref="LandblockHint"/> lets
/// <see cref="EntityClassificationCache.InvalidateLandblock"/> sweep entries
/// efficiently when a landblock demotes or unloads.
/// </summary>
public sealed class EntityCacheEntry
{
    public required uint EntityId { get; init; }
    public required uint LandblockHint { get; init; }
    public required CachedBatch[] Batches { get; init; }
}
```

- [ ] **Step 5: Create `EntityClassificationCache.cs` skeleton.**

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

```csharp
using System.Collections.Generic;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Cache of per-entity classification results for static entities (those NOT
/// in <c>GameWindow._animatedEntities</c>). Holds one
/// <see cref="EntityCacheEntry"/> per cached entity. The cache is opaque
/// w.r.t. classification logic — it simply stores what callers populate.
///
/// <para>
/// <b>Invariants:</b>
/// <list type="bullet">
/// <item><see cref="Populate"/> overwrites any existing entry for the same id (defensive).</item>
/// <item><see cref="InvalidateEntity"/> is idempotent (no-throw on missing id).</item>
/// <item><see cref="InvalidateLandblock"/> walks all entries; entries whose
///   <see cref="EntityCacheEntry.LandblockHint"/> equals the argument are removed.</item>
/// <item>All operations are render-thread only. No internal locking.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Audit foundation:</b> see
/// <c>docs/research/2026-05-10-tier1-mutation-audit.md</c> for why static
/// entities can be cached and what invalidation is needed.
/// </para>
/// </summary>
public sealed class EntityClassificationCache
{
    private readonly Dictionary<uint, EntityCacheEntry> _entries = new();

    /// <summary>Number of cached entities — for diagnostics.</summary>
    public int Count => _entries.Count;

    /// <summary>
    /// Look up an entity's cached classification. Returns <c>true</c> with
    /// the entry on hit; <c>false</c> with <paramref name="entry"/> set to
    /// <c>null</c> on miss.
    /// </summary>
    public bool TryGet(uint entityId, out EntityCacheEntry? entry)
        => _entries.TryGetValue(entityId, out entry);
}
```

- [ ] **Step 6: Run the test to verify it passes.**

```powershell
dotnet build
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
```

Expected: `Passed: 1, Failed: 0`.

- [ ] **Step 7: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/CachedBatch.cs src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
test -f src/AcDream.App/Properties/AssemblyInfo.cs && git add src/AcDream.App/Properties/AssemblyInfo.cs
git commit -m "feat(render #53): EntityClassificationCache skeleton + first test

Adds CachedBatch, EntityCacheEntry, and EntityClassificationCache with
just TryGet (returns false on empty). The skeleton compiles and the first
test (TryGet_EmptyCache_ReturnsFalse) passes. Subsequent tasks add
Populate, InvalidateEntity, InvalidateLandblock, and the dispatcher
integration. Per spec design §6.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: `Populate` + roundtrip + Setup pre-flatten tests

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

Adds tests #2, #3, #9, #10, #14 from the spec test plan. All exercise the populate-then-tryget round-trip including the Setup pre-flatten shape.

- [ ] **Step 1: Write the failing tests.**

Append to `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs` (just BEFORE the `private static CachedBatch MakeCachedBatch` helper):

```csharp
    [Fact]
    public void Populate_ThenTryGet_ReturnsBatchesInOrder()
    {
        var cache = new EntityClassificationCache();
        var batches = new[]
        {
            MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA),
            MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB),
        };

        cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);

        cache.TryGet(100, out var entry).Should().BeTrue();
        entry!.EntityId.Should().Be(100u);
        entry.LandblockHint.Should().Be(0xA9B40000u);
        entry.Batches.Should().Equal(batches);
    }

    [Fact]
    public void Populate_OverridesExistingEntry()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        cache.Populate(100, 0u, new[] { MakeCachedBatch(2, 0, 12, 0xCC) });

        cache.TryGet(100, out var entry).Should().BeTrue();
        entry!.Batches.Should().HaveCount(1);
        entry.Batches[0].BindlessTextureHandle.Should().Be(0xCCu);
    }

    [Fact]
    public void Count_TracksLiveEntries()
    {
        var cache = new EntityClassificationCache();
        cache.Count.Should().Be(0);

        cache.Populate(1, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        cache.Count.Should().Be(1);

        cache.Populate(2, 0u, new[] { MakeCachedBatch(2, 0, 6, 0xAA) });
        cache.Count.Should().Be(2);

        // Re-populate same id — should not double-count.
        cache.Populate(1, 0u, new[] { MakeCachedBatch(3, 0, 6, 0xBB) });
        cache.Count.Should().Be(2);
    }

    [Fact]
    public void Populate_WithEmptyBatches_StoresEmptyEntry()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(entityId: 7, landblockHint: 0u, System.Array.Empty<CachedBatch>());

        cache.TryGet(7, out var entry).Should().BeTrue();
        entry!.Batches.Should().BeEmpty();
    }

    [Fact]
    public void Populate_SetupMultiPart_StoresFlatBatchPerSubPart()
    {
        // Synthetic Setup with 3 subParts × 2 batches each = 6 flat entries.
        // This pins the spec §3 Q4 decision: pre-flatten Setup multi-parts at
        // populate time so the per-frame hot path is branchless.
        var cache = new EntityClassificationCache();
        var batches = new CachedBatch[6];
        for (int subPart = 0; subPart < 3; subPart++)
        for (int b = 0; b < 2; b++)
        {
            batches[subPart * 2 + b] = MakeCachedBatch(
                ibo: (uint)(subPart + 1),
                firstIndex: (uint)(b * 6),
                indexCount: 6,
                texHandle: (ulong)(0x100 + subPart * 2 + b));
        }
        cache.Populate(99, 0u, batches);

        cache.TryGet(99, out var entry).Should().BeTrue();
        entry!.Batches.Should().HaveCount(6);
        entry.Batches[0].BindlessTextureHandle.Should().Be(0x100u);
        entry.Batches[5].BindlessTextureHandle.Should().Be(0x105u);
    }
```

- [ ] **Step 2: Run tests, verify they fail to compile.**

```powershell
dotnet build
```

Expected: build error — `Populate` does not exist on `EntityClassificationCache`.

- [ ] **Step 3: Implement `Populate`.**

Add to `EntityClassificationCache.cs`:

```csharp
    /// <summary>
    /// Insert or overwrite a cache entry for <paramref name="entityId"/>.
    /// Defensive: if an entry already exists, replaces it.
    /// </summary>
    public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
    {
        _entries[entityId] = new EntityCacheEntry
        {
            EntityId = entityId,
            LandblockHint = landblockHint,
            Batches = batches,
        };
    }
```

- [ ] **Step 4: Run all cache tests.**

```powershell
dotnet build
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
```

Expected: 6 tests pass (1 from Task 2 + 5 new).

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
git commit -m "feat(render #53): EntityClassificationCache.Populate + roundtrip tests

Implements Populate (insert-or-overwrite) and adds 5 tests covering the
populate→TryGet round-trip including the Setup pre-flatten shape. Per
spec test plan §7.1 tests #2, #3, #9, #10, #14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: `InvalidateEntity` + tests #4, #5

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

- [ ] **Step 1: Write the failing tests.**

Append (just before the `MakeCachedBatch` helper):

```csharp
    [Fact]
    public void InvalidateEntity_RemovesEntry()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        cache.TryGet(100, out _).Should().BeTrue();

        cache.InvalidateEntity(100);

        cache.TryGet(100, out var entry).Should().BeFalse();
        entry.Should().BeNull();
        cache.Count.Should().Be(0);
    }

    [Fact]
    public void InvalidateEntity_OnMissingId_NoThrow()
    {
        var cache = new EntityClassificationCache();
        var act = () => cache.InvalidateEntity(99999);
        act.Should().NotThrow();
        cache.Count.Should().Be(0);
    }
```

- [ ] **Step 2: Run tests, verify they fail to compile.**

```powershell
dotnet build
```

Expected: build error — `InvalidateEntity` not defined.

- [ ] **Step 3: Implement `InvalidateEntity`.**

Add to `EntityClassificationCache.cs`:

```csharp
    /// <summary>
    /// Remove the cache entry for <paramref name="entityId"/>. No-op if the
    /// id isn't cached.
    /// </summary>
    public void InvalidateEntity(uint entityId)
    {
        _entries.Remove(entityId);
    }
```

- [ ] **Step 4: Run tests.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
```

Expected: 8 tests pass.

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
git commit -m "feat(render #53): EntityClassificationCache.InvalidateEntity + tests

Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
spec §7.1 lock in the contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: `InvalidateLandblock` + tests #6, #7, #8

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

- [ ] **Step 1: Write the failing tests.**

Append (just before the `MakeCachedBatch` helper):

```csharp
    [Fact]
    public void InvalidateLandblock_RemovesAllMatchingEntries()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        cache.Populate(2, 0xA9B40000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
        cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });
        cache.Count.Should().Be(3);

        cache.InvalidateLandblock(0xA9B40000u);

        cache.Count.Should().Be(0);
        cache.TryGet(1, out _).Should().BeFalse();
        cache.TryGet(2, out _).Should().BeFalse();
        cache.TryGet(3, out _).Should().BeFalse();
    }

    [Fact]
    public void InvalidateLandblock_LeavesNonMatchingEntries()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        cache.Populate(2, 0xA9B50000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
        cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });

        cache.InvalidateLandblock(0xA9B40000u);

        cache.Count.Should().Be(1);
        cache.TryGet(1, out _).Should().BeFalse();
        cache.TryGet(2, out var keep).Should().BeTrue();
        keep!.LandblockHint.Should().Be(0xA9B50000u);
        cache.TryGet(3, out _).Should().BeFalse();
    }

    [Fact]
    public void InvalidateLandblock_OnMissingLb_NoThrow()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
        var act = () => cache.InvalidateLandblock(0xDEADBEEFu);
        act.Should().NotThrow();
        cache.Count.Should().Be(1);
    }
```

- [ ] **Step 2: Run tests, verify failure.**

```powershell
dotnet build
```

Expected: build error — `InvalidateLandblock` not defined.

- [ ] **Step 3: Implement `InvalidateLandblock`.**

Add to `EntityClassificationCache.cs`:

```csharp
    /// <summary>
    /// Remove every cache entry whose <see cref="EntityCacheEntry.LandblockHint"/>
    /// equals <paramref name="landblockId"/>. Used by the streaming pipeline
    /// when a landblock demotes from near to far or unloads. No-op if no
    /// entries match.
    /// </summary>
    public void InvalidateLandblock(uint landblockId)
    {
        if (_entries.Count == 0) return;

        // Collect the ids to remove first to avoid mutating the dict during iteration.
        // Buffered locally because the typical case removes ~all entries in the LB
        // (which is still small relative to the total cache).
        List<uint>? toRemove = null;
        foreach (var (id, entry) in _entries)
        {
            if (entry.LandblockHint == landblockId)
            {
                toRemove ??= new List<uint>();
                toRemove.Add(id);
            }
        }
        if (toRemove is null) return;
        foreach (var id in toRemove) _entries.Remove(id);
    }
```

- [ ] **Step 4: Run tests.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
```

Expected: 11 tests pass (1 + 5 + 2 + 3).

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
git commit -m "feat(render #53): EntityClassificationCache.InvalidateLandblock + tests

Sweep-by-landblock removal for the streaming demote/unload path. Tests
#6, #7, #8 from spec §7.1 lock in: (a) all matching entries removed, (b)
non-matching entries preserved, (c) idempotent on missing LB.

Phase 1 (cache foundation) complete. 11 cache tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Phase 1 checkpoint

- [ ] **Run full suite + N.5b sentinel before moving to Phase 2.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: 94 + 11 = at least 105 passing in the filter (the new EntityClassificationCacheTests are matched by `Wb`).

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1699` (8 pre-existing + 11 new cache tests added on top of 1688 baseline).

If anything regresses here, STOP and diagnose before Phase 2.

---

## Phase 2: Dispatcher integration (Tasks 6-10)

### Task 6: Plumb landblockId through `_walkScratch`

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (~lines 116, 192, 220, 241-247, 367, 273, 299-300)
- Modify: existing tests in `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` if they construct walkScratch tuples

The cache populates `LandblockHint` from the walk's outer-loop `LandblockEntry.LandblockId`. Today the inner `_walkScratch` is `List<(WorldEntity Entity, int MeshRefIndex)>` — no LB. Extend to a 3-tuple including the landblock id.

- [ ] **Step 1: Find every reference to the existing 2-tuple shape.**

```powershell
Select-String -Path src/**/*.cs, tests/**/*.cs -Pattern "List<\(WorldEntity"
Select-String -Path src/**/*.cs, tests/**/*.cs -Pattern "WalkResult"
```

Expected hits: `WbDrawDispatcher.cs` (declaration + WalkResult type + body), possibly `WbDrawDispatcherBucketingTests.cs`.

- [ ] **Step 2: Update the `_walkScratch` field type and `WalkResult.ToDraw` type.**

In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`:

Change line 116:
```csharp
private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new();
```
to:
```csharp
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
```

Change line 192:
```csharp
public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
```
to:
```csharp
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
```

- [ ] **Step 3: Update `WalkEntities` (the test-friendly overload) signature.**

Change line 220-233:
```csharp
internal static WalkResult WalkEntities(
    IEnumerable<LandblockEntry> landblockEntries,
    FrustumPlanes? frustum,
    uint? neverCullLandblockId,
    HashSet<uint>? visibleCellIds,
    HashSet<uint>? animatedEntityIds)
{
    var scratch = new List<(WorldEntity Entity, int MeshRefIndex)>();
    var result = new WalkResult { ToDraw = scratch };
    WalkEntitiesInto(
        landblockEntries, frustum, neverCullLandblockId,
        visibleCellIds, animatedEntityIds, scratch, ref result);
    return result;
}
```
to:
```csharp
internal static WalkResult WalkEntities(
    IEnumerable<LandblockEntry> landblockEntries,
    FrustumPlanes? frustum,
    uint? neverCullLandblockId,
    HashSet<uint>? visibleCellIds,
    HashSet<uint>? animatedEntityIds)
{
    var scratch = new List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)>();
    var result = new WalkResult { ToDraw = scratch };
    WalkEntitiesInto(
        landblockEntries, frustum, neverCullLandblockId,
        visibleCellIds, animatedEntityIds, scratch, ref result);
    return result;
}
```

- [ ] **Step 4: Update `WalkEntitiesInto` signature + body.**

Change line 241-247 to take the new tuple shape:

```csharp
internal static void WalkEntitiesInto(
    IEnumerable<LandblockEntry> landblockEntries,
    FrustumPlanes? frustum,
    uint? neverCullLandblockId,
    HashSet<uint>? visibleCellIds,
    HashSet<uint>? animatedEntityIds,
    List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
    ref WalkResult result)
```

Inside the body, every `scratch.Add((entity, i))` becomes `scratch.Add((entity, i, entry.LandblockId))`. Two such lines: ~273 (animated-only branch) and ~299-300 (full walk branch). Concretely:

Line ~273 (inside the animated-only frustum-culled branch):
```csharp
for (int i = 0; i < entity.MeshRefs.Count; i++)
    scratch.Add((entity, i, entry.LandblockId));
```

Line ~299-300 (inside the full walk branch):
```csharp
for (int i = 0; i < entity.MeshRefs.Count; i++)
    scratch.Add((entity, i, entry.LandblockId));
```

- [ ] **Step 5: Update the consumer in `Draw`.**

At line 367:
```csharp
foreach (var (entity, partIdx) in _walkScratch)
```
becomes:
```csharp
foreach (var (entity, partIdx, landblockId) in _walkScratch)
```

The `landblockId` is unused for now (consumed in Task 9 for `Populate`'s `landblockHint` argument). Suppress any `landblockId` unused-variable warning by prefixing `_` if necessary, but only if the C# compiler emits a warning (it shouldn't for tuple deconstruction).

- [ ] **Step 6: Build to verify the type plumbed cleanly.**

```powershell
dotnet build
```

Expected: `Build succeeded. 0 Error(s)`. If existing tests in `WbDrawDispatcherBucketingTests.cs` reference the 2-tuple shape, update them to the 3-tuple form (add `0u` or a deterministic landblock id as the third element).

- [ ] **Step 7: Run full suite.**

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1699` — same as Phase 1 checkpoint. The walk now carries an extra field but no behavior changed yet.

- [ ] **Step 8: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
git commit -m "refactor(render #53): plumb landblockId through WbDrawDispatcher walkScratch

Extends the walk scratch tuple from (entity, meshRefIndex) to
(entity, meshRefIndex, landblockId). The dispatcher's per-entity loop now
has the landblock id available for EntityClassificationCache.Populate's
landblockHint argument (consumed in Task 9). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: Wire `EntityClassificationCache` into the dispatcher ctor + `GameWindow`

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (ctor signature + private field)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (instantiate + pass)
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` (existing test fixtures pass an empty cache)

- [ ] **Step 1: Add the field + ctor parameter to `WbDrawDispatcher`.**

In `WbDrawDispatcher.cs`, add a private readonly field next to the others (~line 70):

```csharp
private readonly EntityClassificationCache _cache;
```

Update the ctor signature at line 142-148:

```csharp
public WbDrawDispatcher(
    GL gl,
    Shader shader,
    TextureCache textures,
    WbMeshAdapter meshAdapter,
    EntitySpawnAdapter entitySpawnAdapter,
    BindlessSupport bindless,
    EntityClassificationCache classificationCache)
```

Add the assignment at the end of the ctor body (~line 165), with the existing null-checks:

```csharp
ArgumentNullException.ThrowIfNull(classificationCache);
_cache = classificationCache;
```

- [ ] **Step 2: Construct and pass the cache from `GameWindow`.**

Find the `WbDrawDispatcher` instantiation in `src/AcDream.App/Rendering/GameWindow.cs`:

```powershell
Select-String -Path src/AcDream.App/Rendering/GameWindow.cs -Pattern "new WbDrawDispatcher"
```

Add a private field on `GameWindow`:

```csharp
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
```

(Place it adjacent to the existing `_animatedEntities` field at line ~160 — they're conceptually paired.)

Update the `new WbDrawDispatcher(...)` call site to include the new argument:

```csharp
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
    /* … existing args … */,
    _classificationCache);
```

- [ ] **Step 3: Update existing dispatcher tests.**

In `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`, find every `new WbDrawDispatcher(...)` and append `new EntityClassificationCache()` as the final argument. (If tests use a builder/helper method, update that.)

```powershell
Select-String -Path tests/AcDream.Core.Tests/Rendering/Wb/*.cs -Pattern "new WbDrawDispatcher"
```

For each hit, add the new argument.

- [ ] **Step 4: Build to verify everything compiled.**

```powershell
dotnet build
```

Expected: `Build succeeded. 0 Error(s)`.

- [ ] **Step 5: Run full suite.**

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1699`. The cache is wired into the dispatcher but isn't used yet — no behavior change.

- [ ] **Step 6: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
git commit -m "feat(render #53): inject EntityClassificationCache into WbDrawDispatcher

Adds the cache as a constructor parameter on WbDrawDispatcher and a
private field on GameWindow. The cache is passed through but not yet
consumed by Draw — that wires up in Task 9 (cache miss / populate) and
Task 10 (cache hit / fast path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: Extend `ClassifyBatches` with optional collector

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (lines 707-759 — the `ClassifyBatches` method)

- [ ] **Step 1: Change `ClassifyBatches` signature.**

Change the method declaration at line 707:

```csharp
private void ClassifyBatches(
    ObjectRenderData renderData,
    ulong gfxObjId,
    Matrix4x4 model,
    WorldEntity entity,
    MeshRef meshRef,
    ulong palHash,
    AcSurfaceMetadataTable metaTable)
```

to:

```csharp
private void ClassifyBatches(
    ObjectRenderData renderData,
    ulong gfxObjId,
    Matrix4x4 model,
    WorldEntity entity,
    MeshRef meshRef,
    ulong palHash,
    AcSurfaceMetadataTable metaTable,
    Matrix4x4 restPose,
    List<CachedBatch>? collector = null)
```

The new `restPose` parameter is the model-matrix component WITHOUT `entityWorld` baked in — i.e. `meshRef.PartTransform` for non-Setup, or `subPart.PartTransform * meshRef.PartTransform` for Setup. Caller computes it.

- [ ] **Step 2: Append to the collector inside the per-batch loop.**

At the bottom of the for loop (after `grp.Matrices.Add(model);` at line 757), add:

```csharp
            collector?.Add(new CachedBatch(key, texHandle, restPose));
```

The full updated block (lines 738-758):

```csharp
            var key = new GroupKey(
                batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
                batch.IndexCount, texHandle, texLayer, translucency);

            if (!_groups.TryGetValue(key, out var grp))
            {
                grp = new InstanceGroup
                {
                    Ibo = batch.IBO,
                    FirstIndex = batch.FirstIndex,
                    BaseVertex = (int)batch.BaseVertex,
                    IndexCount = batch.IndexCount,
                    BindlessTextureHandle = texHandle,
                    TextureLayer = texLayer,
                    Translucency = translucency,
                };
                _groups[key] = grp;
            }
            grp.Matrices.Add(model);
            collector?.Add(new CachedBatch(key, texHandle, restPose));
        }
    }
```

- [ ] **Step 3: Update `ClassifyBatches` call sites in `Draw` to pass `restPose`.**

At line 411 (Setup branch):
```csharp
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
```
becomes:
```csharp
var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose);
```

At line 418 (non-Setup branch):
```csharp
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
```
becomes:
```csharp
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform);
```

(Use named-arg form on the non-Setup branch to avoid name collision with the Setup branch's local `restPose`.)

- [ ] **Step 4: Build + test.**

```powershell
dotnet build
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1699`. No behavior change yet — collector defaults to null.

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "feat(render #53): add optional CachedBatch collector to ClassifyBatches

ClassifyBatches now accepts a restPose parameter (the model-matrix
component without entityWorld baked in) and an optional collector. When
collector is non-null, each classified batch is appended as a CachedBatch
record. Defaults preserve today's behavior. Used in Task 9 to populate
the cache on a static-entity miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 9: Wire dispatcher cache-miss path (populate on first frame; no fast-path yet)

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (around lines 367-423)

This task adds the populate logic without the cache-hit fast path. After this task, every static entity's slow path runs exactly once (first frame visible) and produces a populated cache entry; subsequent frames still run the slow path because the fast-path branch isn't in yet. Task 10 adds the fast path.

The split is deliberate so we can land + verify each half independently.

- [ ] **Step 1: Add the populate scratch field.**

Near the other per-frame scratch fields (~line 116):

```csharp
private readonly List<CachedBatch> _populateScratch = new();
```

- [ ] **Step 2: Restructure the per-entity loop in `Draw`.**

Replace lines 367-423 (the foreach + body up through `if (diag && drewAny) _entitiesDrawn++;`) with:

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

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

    bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;

    // Compute palette-override hash ONCE per entity (perf #4).
    ulong palHash = 0;
    if (entity.PaletteOverride is not null)
        palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);

    var meshRef = entity.MeshRefs[partIdx];
    ulong gfxObjId = meshRef.GfxObjId;

    var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
    if (renderData is null)
    {
        if (diag) _meshesMissing++;
        continue;
    }
    if (anyVao == 0) anyVao = renderData.VAO;

    // Cache-miss path (animated entities skip cache entirely).
    // Static entities collect into _populateScratch on the first frame
    // they're visible, so the cache has fresh data for the next frame.
    var collector = isAnimated ? null : _populateScratch;
    collector?.Clear();

    bool drewAny = false;
    if (renderData.IsSetup && renderData.SetupParts.Count > 0)
    {
        foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
        {
            var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
            if (partData is null) continue;

            var model = ComposePartWorldMatrix(
                entityWorld, meshRef.PartTransform, partTransform);
            var restPose = partTransform * meshRef.PartTransform;

            ClassifyBatches(partData, partGfxObjId, model, entity, meshRef,
                palHash, metaTable, restPose, collector);
            drewAny = true;
        }
    }
    else
    {
        var model = meshRef.PartTransform * entityWorld;
        ClassifyBatches(renderData, gfxObjId, model, entity, meshRef,
            palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
        drewAny = true;
    }

    if (collector is not null && collector.Count > 0)
    {
        // Populate cache for static entity on cache-miss.
        // Each entity classifies once at first visibility; subsequent frames
        // will hit the fast path (added in Task 10).
        _cache.Populate(entity.Id, landblockId, collector.ToArray());
    }

    if (diag && drewAny) _entitiesDrawn++;
}
```

- [ ] **Step 3: Build + test.**

```powershell
dotnet build
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1699`. The slow path now also populates the cache, but visual + per-frame behavior is unchanged (we don't read from the cache yet).

- [ ] **Step 4: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "feat(render #53): cache-miss populate on first frame for static entities

Restructures Draw's per-entity loop: animated entities still skip the
cache entirely, but static entities now collect their classification into
_populateScratch and call cache.Populate at the end of the iteration.

Cache fast-path (skip slow classification on cache hit) lands in Task 10.
This intermediate state is verifiable: behavior unchanged, but the cache
is being populated as entities render. Diagnostic-friendly split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 10: Wire dispatcher cache-hit fast path + integration tests #11, #12

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`

- [ ] **Step 1: Add the cache-hit branch.**

In `WbDrawDispatcher.Draw`, just after the `bool isAnimated = ...` line and BEFORE the `palHash` computation, add:

```csharp
    // Fast path: cache hit on a static entity. Skip classification entirely
    // and append cached (RestPose * entityWorld) matrices to the matching
    // groups. The DEBUG cross-check (added in Task 13) asserts the
    // membership predicate held at hit time.
    if (!isAnimated && _cache.TryGet(entity.Id, out var cachedEntry))
    {
        foreach (var cached in cachedEntry!.Batches)
        {
            if (!_groups.TryGetValue(cached.Key, out var grp))
            {
                grp = new InstanceGroup
                {
                    Ibo = cached.Key.Ibo,
                    FirstIndex = cached.Key.FirstIndex,
                    BaseVertex = cached.Key.BaseVertex,
                    IndexCount = cached.Key.IndexCount,
                    BindlessTextureHandle = cached.Key.BindlessTextureHandle,
                    TextureLayer = cached.Key.TextureLayer,
                    Translucency = cached.Key.Translucency,
                };
                _groups[cached.Key] = grp;
            }
            grp.Matrices.Add(cached.RestPose * entityWorld);
        }

        if (anyVao == 0)
        {
            // Need a VAO for the GL phase. Look up the first MeshRef's
            // mesh data once (cheap dict lookup, not a re-classify).
            var firstMeshRef = entity.MeshRefs[partIdx];
            var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
            if (firstRenderData is not null) anyVao = firstRenderData.VAO;
        }

        if (diag) { _entitiesDrawn++; }
        continue;
    }
```

(Note: `_entitiesSeen++` already fired at the top of the loop body; only `_entitiesDrawn++` here.)

- [ ] **Step 2: Write integration test #11 — static entity routes through cache.**

In `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`, add a test that:

```csharp
    [Fact]
    public void Draw_StaticEntity_PopulatesCacheOnFirstFrameAndHitsOnSecond()
    {
        var cache = new EntityClassificationCache();
        // Use the existing test fixture builder (whatever shape WbDrawDispatcherBucketingTests
        // already uses). Pass `cache` as the new ctor argument.
        // Construct one synthetic static WorldEntity in landblockEntries.
        cache.Count.Should().Be(0);

        // … existing fixture: construct dispatcher + adapter + entity …
        // … invoke Draw once …

        // First frame: cache populates.
        cache.Count.Should().BeGreaterThan(0);
        int firstCount = cache.Count;

        // … invoke Draw again with the same entity …

        // Second frame: cache hit — no double-populate. cache.Count is stable.
        cache.Count.Should().Be(firstCount);
    }
```

If the existing test fixture doesn't expose a spy / counter on `WbMeshAdapter`, this test asserts indirectly: after first Draw, `cache.Count == 1`; after second Draw, `cache.Count == 1` still (no double-populate, which would re-overwrite — `Populate` overwrite still leaves Count==1, so this test asserts that the populate path is reached on the first Draw and is NOT reached on the second Draw via a stronger spy if the fixture supports one; otherwise the weaker count-stability assert is acceptable).

If a spy is feasible, prefer it. Pseudocode:

```csharp
var spyAdapter = new SpyMeshAdapter(realAdapter);
// ... construct dispatcher with spyAdapter ...
spyAdapter.TryGetRenderDataCallCount.Should().Be(N_first_frame_lookups);
// ... invoke second Draw ...
spyAdapter.TryGetRenderDataCallCount.Should().Be(N_first_frame_lookups + 1);
//   ↑ +1 for the single VAO lookup in the cache-hit branch, NOT +N for re-classification.
```

Choose whichever the existing fixture supports.

- [ ] **Step 3: Write integration test #12 — animated entity bypasses cache.**

```csharp
    [Fact]
    public void Draw_AnimatedEntity_DoesNotPopulateCache()
    {
        var cache = new EntityClassificationCache();
        // Construct dispatcher + adapter + one WorldEntity flagged in
        // animatedEntityIds. Invoke Draw.
        var animatedIds = new HashSet<uint> { /* entity.Id */ };
        // … invoke Draw with animatedEntityIds: animatedIds …

        // Cache should never be populated for animated entities.
        cache.Count.Should().Be(0);
    }
```

- [ ] **Step 4: Run integration tests.**

```powershell
dotnet build
dotnet test --no-build --filter "FullyQualifiedName~WbDrawDispatcherBucketingTests"
```

Expected: existing dispatcher tests + 2 new cache integration tests all pass.

- [ ] **Step 5: Run full suite.**

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1701` (1688 baseline + 11 cache tests + 2 integration tests = 1701).

- [ ] **Step 6: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
git commit -m "feat(render #53): cache-hit fast path + dispatcher integration tests

WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose × entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache. Animated entities skip the cache entirely.

Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan §7.2.

Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Phase 2 checkpoint

- [ ] **Run sentinel + full suite.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: ≥ 107 passing (94 sentinel + 11 cache + 2 integration). 0 failures.

```powershell
dotnet test --no-build
```

Expected: 1701 passed, 8 failed (pre-existing).

---

## Phase 3: Invalidation hooks (Tasks 11-12)

### Task 11: Wire `InvalidateEntity` from `RemoveLiveEntityByServerGuid` + test #15

**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~2935)
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

- [ ] **Step 1: Write test #15 (despawn-respawn cycle).**

Append to `EntityClassificationCacheTests.cs` (just before `MakeCachedBatch`):

```csharp
    [Fact]
    public void DespawnRespawn_UnderReusedId_RepopulatesFresh()
    {
        // Pins the audit's ObjDescEvent contract (audit §1):
        //   ObjDescEvent is despawn + respawn (with a NEW local entity.Id),
        //   never an in-place mutation. Even when an id IS reused
        //   (theoretical — _liveEntityIdCounter is monotonic in practice),
        //   the cache must serve fresh data after invalidation.
        var cache = new EntityClassificationCache();
        var batchesV1 = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
        var batchesV2 = new[] { MakeCachedBatch(2, 6, 12, 0xCC) };

        cache.Populate(100, 0xA9B40000u, batchesV1);
        cache.InvalidateEntity(100);
        cache.Populate(100, 0xA9B40000u, batchesV2);

        cache.TryGet(100, out var entry).Should().BeTrue();
        entry!.Batches.Should().Equal(batchesV2);
        entry.Batches[0].BindlessTextureHandle.Should().Be(0xCCu);
    }
```

- [ ] **Step 2: Run the test, verify it passes (it tests existing API).**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~DespawnRespawn"
```

Expected: pass. (This test pins behavior the cache class already provides; no implementation change needed for the test itself.)

- [ ] **Step 3: Wire `InvalidateEntity` in `GameWindow.RemoveLiveEntityByServerGuid`.**

In `src/AcDream.App/Rendering/GameWindow.cs`, find line ~2935:

```csharp
_animatedEntities.Remove(existingEntity.Id);
```

Add immediately after:

```csharp
_classificationCache.InvalidateEntity(existingEntity.Id);
```

- [ ] **Step 4: Build + run full suite.**

```powershell
dotnet build
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1702`.

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
git commit -m "feat(render #53): wire EntityClassificationCache.InvalidateEntity at despawn

GameWindow.RemoveLiveEntityByServerGuid now invalidates the entity's
cache entry next to the existing _animatedEntities.Remove(). Fires for
DeleteObject (0xF747) and the dedup leg of ObjDescEvent (0xF625).

Adds test #15 (despawn-respawn under reused id repopulates fresh) per
spec §7.5 — pins the audit's ObjDescEvent-as-despawn-respawn contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 12: Wire `InvalidateLandblock` callback into `GpuWorldState.RemoveEntitiesFromLandblock`

**Files:**
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `GpuWorldState` instantiation site)

Per spec §5.3 W3b: pass an `Action<uint>?` callback into `GpuWorldState`'s ctor so when `RemoveEntitiesFromLandblock` clears a landblock's entity list, the callback fires once per landblock id.

- [ ] **Step 1: Add the callback parameter to `GpuWorldState`.**

In `src/AcDream.App/Streaming/GpuWorldState.cs`, find the ctor (or primary ctor declaration). Add a new optional parameter `Action<uint>? onLandblockUnloaded = null`. Store as a field.

```csharp
private readonly Action<uint>? _onLandblockUnloaded;

// in ctor:
_onLandblockUnloaded = onLandblockUnloaded;
```

Modify `RemoveEntitiesFromLandblock` (line 373) to invoke the callback BEFORE zeroing the entity list:

```csharp
public void RemoveEntitiesFromLandblock(uint landblockId)
{
    uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
    if (!_loaded.TryGetValue(canonical, out var lb)) return;
    if (_wbSpawnAdapter is not null)
        _wbSpawnAdapter.OnLandblockUnloaded(canonical);

    // Phase Post-A.5 #53: invalidate the EntityClassificationCache for this
    // landblock before we drop the entity list. Wired via callback at
    // GameWindow construction; null when the cache isn't relevant (tests).
    _onLandblockUnloaded?.Invoke(canonical);

    _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
    _pendingByLandblock.Remove(canonical);
    RebuildFlatView();
}
```

- [ ] **Step 2: Wire the callback at `GameWindow`.**

Find the `new GpuWorldState(...)` invocation:

```powershell
Select-String -Path src/AcDream.App/Rendering/GameWindow.cs -Pattern "new GpuWorldState"
```

Add the new argument:

```csharp
_worldState = new GpuWorldState(
    /* … existing args … */,
    onLandblockUnloaded: _classificationCache.InvalidateLandblock);
```

- [ ] **Step 3: Update existing `GpuWorldState` test fixtures.**

```powershell
Select-String -Path tests/**/*.cs -Pattern "new GpuWorldState"
```

For each hit, the existing tests can omit the new optional parameter (it defaults to null). No change required unless a specific test wants to assert the callback fires.

- [ ] **Step 4: Build + run full suite.**

```powershell
dotnet build
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1702` (no new tests in this task — invalidation behavior is exercised indirectly through visual + perf gates, plus the optional unit test in Step 5).

- [ ] **Step 5: (Optional) Add a streaming integration test.**

If `GpuWorldStateTwoTierTests.cs` makes it easy, add:

```csharp
    [Fact]
    public void RemoveEntitiesFromLandblock_FiresUnloadCallbackBeforeClearingEntities()
    {
        uint? observed = null;
        var state = new GpuWorldState(
            /* … existing args … */,
            onLandblockUnloaded: id => observed = id);

        // Set up: add a synthetic entity to LB 0xA9B40000 via AppendLiveEntity.
        // ...
        state.RemoveEntitiesFromLandblock(0xA9B40000u);

        observed.Should().Be(0xA9B4FFFFu);  // canonicalized
    }
```

If the fixture is heavy, defer.

- [ ] **Step 6: Commit.**

```bash
git add src/AcDream.App/Streaming/GpuWorldState.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Streaming/*.cs
git commit -m "feat(render #53): wire EntityClassificationCache.InvalidateLandblock at LB demote/unload

GpuWorldState.RemoveEntitiesFromLandblock now invokes an optional
Action<uint> callback before zeroing the entity list. GameWindow wires
this to EntityClassificationCache.InvalidateLandblock so cache entries
get swept on LB demote (Near→Far) and unload. Per spec §5.3 W3b.

Phase 3 (invalidation hooks) complete. The cache now stays correct across
all spec-identified mutation events: despawn, ObjDescEvent (despawn+
respawn), LB demote, LB unload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Phase 3 checkpoint

- [ ] **Run sentinel + full suite.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: 0 failures.

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1702`.

---

## Phase 4: DEBUG cross-check (Task 13)

### Task 13: Add DEBUG cross-check + test #13

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (call cross-check on cache hit, DEBUG-only)
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`

- [ ] **Step 1: Add the cross-check method to the cache.**

Append to `EntityClassificationCache.cs`:

```csharp
#if DEBUG
    /// <summary>
    /// Asserts that the cached entry for <paramref name="entityId"/> still
    /// matches what fresh classification would produce. Catches the prior
    /// Tier 1 bug class — silent caching of mutable per-frame state — by
    /// firing <see cref="System.Diagnostics.Debug.Assert"/> when any cached
    /// field has drifted from live state.
    ///
    /// <para>
    /// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose)
    /// reconstructed from the same path the populate ran. The cache iterates
    /// its stored entries in parallel and asserts equality.
    /// </para>
    ///
    /// <para>
    /// Zero cost in Release. In DEBUG, called once per static-entity cache
    /// hit per frame — adds modest overhead. Acceptable for dev runs.
    /// </para>
    /// </summary>
    public void DebugCrossCheck(uint entityId, IReadOnlyList<CachedBatch> liveBatches)
    {
        if (!_entries.TryGetValue(entityId, out var entry)) return;

        System.Diagnostics.Debug.Assert(
            entry.Batches.Length == liveBatches.Count,
            $"EntityClassificationCache: batch count mismatch for entity {entityId}: cached={entry.Batches.Length} live={liveBatches.Count}");

        for (int i = 0; i < entry.Batches.Length && i < liveBatches.Count; i++)
        {
            var cached = entry.Batches[i];
            var live = liveBatches[i];
            System.Diagnostics.Debug.Assert(
                cached.Key.Equals(live.Key),
                $"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}");
            System.Diagnostics.Debug.Assert(
                cached.BindlessTextureHandle == live.BindlessTextureHandle,
                $"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}");
            System.Diagnostics.Debug.Assert(
                MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f),
                $"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}");
        }
    }

    private static bool MatrixApproxEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b, float epsilon)
    {
        return System.MathF.Abs(a.M11 - b.M11) <= epsilon && System.MathF.Abs(a.M12 - b.M12) <= epsilon &&
               System.MathF.Abs(a.M13 - b.M13) <= epsilon && System.MathF.Abs(a.M14 - b.M14) <= epsilon &&
               System.MathF.Abs(a.M21 - b.M21) <= epsilon && System.MathF.Abs(a.M22 - b.M22) <= epsilon &&
               System.MathF.Abs(a.M23 - b.M23) <= epsilon && System.MathF.Abs(a.M24 - b.M24) <= epsilon &&
               System.MathF.Abs(a.M31 - b.M31) <= epsilon && System.MathF.Abs(a.M32 - b.M32) <= epsilon &&
               System.MathF.Abs(a.M33 - b.M33) <= epsilon && System.MathF.Abs(a.M34 - b.M34) <= epsilon &&
               System.MathF.Abs(a.M41 - b.M41) <= epsilon && System.MathF.Abs(a.M42 - b.M42) <= epsilon &&
               System.MathF.Abs(a.M43 - b.M43) <= epsilon && System.MathF.Abs(a.M44 - b.M44) <= epsilon;
    }
#endif
```

- [ ] **Step 2: Wire the cross-check into the dispatcher's cache-hit path.**

In `WbDrawDispatcher.Draw`, inside the cache-hit branch from Task 10, AFTER appending matrices, add (DEBUG-only):

```csharp
#if DEBUG
        // Cross-check guard: assert the membership predicate held at hit time.
        // The full re-classification cross-check (spec §6.5) is a stretch goal;
        // this simpler assert catches the prior Tier 1 bug class — a static
        // entity that turns out to actually be animated would fire here.
        System.Diagnostics.Debug.Assert(
            !isAnimated,
            $"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
#endif
```

(The full live-state cross-check requires re-running ClassifyBatches with a live collector, which is non-trivial to plumb into the per-entity branch; ship the predicate assert and file a follow-up issue if the team wants the full cross-check later. The unit test in Step 3 still covers `DebugCrossCheck` directly.)

- [ ] **Step 3: Write test #13 — DEBUG cross-check fires on synthetic mismatch.**

Append to `EntityClassificationCacheTests.cs`:

```csharp
#if DEBUG
    [Fact]
    public void DebugCrossCheck_BatchCountMismatch_FiresAssert()
    {
        var cache = new EntityClassificationCache();
        cache.Populate(100, 0u, new[]
        {
            MakeCachedBatch(1, 0, 6, 0xAA),
            MakeCachedBatch(1, 6, 6, 0xBB),
        });

        // Synthetic "live" with fewer batches → should fire Debug.Assert.
        var liveBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };

        // Capture Debug.Assert via a custom TraceListener.
        var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
        System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
        System.Diagnostics.Trace.Listeners.Clear();
        var asserts = new List<string>();
        System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));

        try
        {
            cache.DebugCrossCheck(100, liveBatches);
        }
        finally
        {
            System.Diagnostics.Trace.Listeners.Clear();
            foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
        }

        asserts.Should().NotBeEmpty();
        string joined = string.Join(" ", asserts);
        joined.Should().Contain("batch count mismatch");
    }

    [Fact]
    public void DebugCrossCheck_RestPoseMatch_NoAssert()
    {
        var cache = new EntityClassificationCache();
        var batches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
        cache.Populate(100, 0u, batches);

        var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
        System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
        System.Diagnostics.Trace.Listeners.Clear();
        var asserts = new List<string>();
        System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));

        try
        {
            cache.DebugCrossCheck(100, batches);
        }
        finally
        {
            System.Diagnostics.Trace.Listeners.Clear();
            foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
        }

        asserts.Should().BeEmpty();
    }

    private sealed class CaptureListener : System.Diagnostics.TraceListener
    {
        private readonly List<string> _captured;
        public CaptureListener(List<string> captured) { _captured = captured; }
        public override void Write(string? message) { if (message != null) _captured.Add(message); }
        public override void WriteLine(string? message) { if (message != null) _captured.Add(message); }
        public override void Fail(string? message, string? detailMessage)
        {
            _captured.Add($"{message}: {detailMessage}");
        }
        public override void Fail(string? message) { if (message != null) _captured.Add(message); }
    }
#endif
```

- [ ] **Step 4: Build + run full suite.**

```powershell
dotnet build
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1704` (two new DEBUG-only tests; in DEBUG configuration both run).

- [ ] **Step 5: Commit.**

```bash
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
git commit -m "feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class

Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
asserts cached state matches a live re-classification. Wires a simpler
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
isAnimated == false on cache hit). Tests #13a and #13b cover the
batch-count mismatch and clean-match cases via a custom TraceListener
that captures Debug.Assert calls.

Zero cost in Release. In DEBUG, the assert fires immediately if a future
regression mutates static-entity state outside the audit's known write
sites — the same failure mode that bit the prior Tier 1 attempt.

Phase 4 complete. Cache + invalidation + safety net all in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Phase 4 checkpoint

- [ ] **Run sentinel + full suite.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: 0 failures.

```powershell
dotnet test --no-build
```

Expected: `Failed: 8, Passed: 1704` in DEBUG (or 1702 in Release where the `#if DEBUG` tests are excluded).

---

## Phase 5: Verification gates (Tasks 14-16)

### Task 14: Pre-launch sanity — full suite + sentinel + grep for TODO/FIXME

- [ ] **Step 1: Final build green check.**

```powershell
dotnet build
```

Expected: `Build succeeded. 0 Error(s)`.

- [ ] **Step 2: Full test pass.**

```powershell
dotnet test --no-build
```

Expected: 1704 (or 1702 in Release) passing, 8 pre-existing physics/input failures unchanged.

- [ ] **Step 3: Sentinel filter pass.**

```powershell
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```

Expected: 0 failures.

- [ ] **Step 4: Grep for any leftover TODO/FIXME the implementation introduced.**

```powershell
Select-String -Path src/AcDream.App/Rendering/Wb/*.cs -Pattern "TODO|FIXME|XXX"
```

Expected: any hits should be intentional (e.g. cross-check stretch-goal note); fix or document if not.

---

### Task 15: Visual gate (USER REQUIRED)

This step requires the user to launch the live client and visually verify the change.

- [ ] **Step 1: Confirm baseline behavior (before any visual claims).**

The user reports build green + tests passing. Implementation agent confirms the spec's acceptance criteria items 1-7 are checked.

- [ ] **Step 2: Launch the client.**

```powershell
$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src/AcDream.App/AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch-tier1-visual.log
```

- [ ] **Step 3: User walks Holtburg → North Yanshi at horizon-safe preset.**

Confirm visually:
- A nearby NPC (any creature) animates normally — limbs move, idle breathing visible.
- The Holtburg lifestone crystal (Z=94 platform) renders correctly and animates (rotation / glow).
- Static buildings render at correct positions (no offsets, no missing parts).
- No new visual artifacts.

If any of the above fail, **STOP**: file a sub-issue, diagnose, and either fix or revert before continuing.

- [ ] **Step 4: User reports visual gate result.**

Implementation agent records the user's confirmation.

---

### Task 16: Perf gate (USER REQUIRED)

- [ ] **Step 1: Launch with `[WB-DIAG]` enabled in Release config.**

```powershell
$env:ACDREAM_WB_DIAG = "1"
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src/AcDream.App/AcDream.App.csproj -c Release 2>&1 | Tee-Object -FilePath perf-tier1-after.log
```

(Release build — perf measurements should match what users see, not DEBUG with cross-check overhead.)

- [ ] **Step 2: User stands at Holtburg center for ≥ 30 seconds at horizon-safe preset.**

Defaults: NEAR_RADIUS=4, FAR_RADIUS=12, MSAA=0, A2C=0, ANISOTROPIC=4, MAX_COMPLETIONS=2.

- [ ] **Step 3: Capture `[WB-DIAG]` output from the log.**

```powershell
Select-String -Path perf-tier1-after.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 5
```

Expected output format (from existing dispatcher):
```
[WB-DIAG] entSeen=… entDrawn=… meshMissing=0 drawsIssued=… instances=… groups=… cpu_us=<MEDIAN>m/<P95>p95 gpu_us=…m/…p95
```

- [ ] **Step 4: Verify perf gate.**

Check `cpu_us=<MEDIAN>m/<P95>p95`:
- `MEDIAN ≤ 2000` (≤ 2.0 ms — spec budget).
- `P95 ≤ 2500` (≤ 2.5 ms).
- No `BUDGET_OVER` flag.

Compare against the pre-Tier-1 baseline (~3500 / ~4000 from the post-A.5 state). Expected: ~50% reduction in median.

- [ ] **Step 5: Record results.**

If perf gate passes, proceed to Phase 6 (ship). Document the actual numbers in the closing commit message.

If perf gate FAILS (median > 2.0 ms), this is a signal that:
- Cache hit rate is lower than expected (animated entities dominate visible set).
- OR per-frame matrix mults still dominate (consider Q3 option M revisit).
- OR a cache invalidation is firing too aggressively (visible thrashing).

Diagnose with `cache.Count` over time + the existing `entSeen` / `entDrawn` counters. Do NOT ship without hitting the gate; either fix or escalate per spec §11.

---

## Phase 6: Ship (Task 17)

### Task 17: Update ISSUES, CLAUDE.md, memory; final commit; merge

**Files:**
- Modify: `docs/ISSUES.md`
- Modify: `CLAUDE.md`
- Modify: `~/.claude/projects/.../memory/project_phase_a5_state.md` (or new memory entry if a new gotcha surfaced)

- [ ] **Step 1: Move issue #53 to "Recently closed" in `docs/ISSUES.md`.**

Find the `## #53` block under "Active issues". Move it to "Recently closed" with the closing commit SHA. Add a one-line resolution summary citing the audit + spec + perf result.

- [ ] **Step 2: Update `CLAUDE.md` "Currently in flight".**

Find the line:

```
**Currently in flight: Post-A.5 polish — Tier 1 retry (only remaining priority).**
```

Replace with the post-A.5-complete state. Update the recently-shipped narrative to mention #53.

- [ ] **Step 3: Update memory if new gotchas surfaced.**

If implementation surfaced any gotchas (e.g. unexpected animated/static transitions, an LB invalidation edge case, etc.) that other agents would benefit from, add a memory entry under `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/` and add a one-line link in `MEMORY.md`.

If no new gotchas surfaced, add a one-line note to `project_phase_a5_state.md` documenting the Tier 1 closure + final perf number.

- [ ] **Step 4: Final commit.**

```bash
git add docs/ISSUES.md CLAUDE.md
# also memory if updated
git commit -m "ship(post-A.5 #53): Tier 1 entity-classification cache — closes ISSUE #53

Static-only cache + DEBUG cross-check + invalidation hooks lands per spec
docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md.

Perf gate: entity dispatcher cpu_us median <ACTUAL>m / <ACTUAL>p95 at
horizon-safe preset (radius=4/12) on AMD Radeon RX 9070 XT @ 1440p.
Spec target was ≤2000m/≤2500p95. Baseline was ~3500m/~4000p95.

Visual gate: NPC animates, lifestone renders, buildings at correct
positions — confirmed by user 2026-05-10.

Closes the post-A.5 polish phase. Issues #52, #54, #53 all closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

- [ ] **Step 5: Merge to main.**

The user merges the worktree branch via the same pattern as the prior session:

```bash
# from main:
git checkout main
git merge claude/friendly-varahamihira-7b8664 --no-ff -m "Merge branch 'claude/friendly-varahamihira-7b8664' — Tier 1 entity-classification cache (closes #53)"
git push origin main
```

(Implementation agent does not push without explicit user authorization. The merge step is included for the user's reference.)

---

## Self-Review checklist (run after writing the plan — completed inline)

- [x] **Spec coverage.** Every section of the spec maps to a task:
  - Spec §1 (problem) → motivation, no task.
  - Spec §3 Q1 (DEBUG cross-check) → Task 13.
  - Spec §3 Q2 (separate class) → Tasks 2-5.
  - Spec §3 Q3 (rest pose) → Task 8 (RestPose param) + Task 10 (cache-hit fast path).
  - Spec §3 Q4 (Setup pre-flatten) → Task 8 (passes the right product) + Task 3 test #14.
  - Spec §3 Q5 (thorough tests) → Tasks 2-5, 10, 11, 13 (12 cache + 2 integration + 2 DEBUG).
  - Spec §5.3 invalidation wiring → Task 11 (per-entity), Task 12 (per-LB W3b).
  - Spec §6.1-6.5 component contracts → Tasks 2-13.
  - Spec §7 test plan → Tasks 2, 3, 4, 5, 10, 11, 13.
  - Spec §8 sequencing → matches Tasks 1-17.
  - Spec §9 acceptance criteria → Tasks 14-17.
  - Spec §11 open implementation choices: W3b chosen (Task 12); GroupKey internal-at-namespace (Task 1); ResolveLandblockHint via walk plumbing (Task 6 + 9); _populateScratch field (Task 9).

- [x] **Placeholder scan.** Searched plan for "TBD", "TODO", "implement later", etc. No matches outside intentional context (e.g. the `<ACTUAL>` perf-number placeholders in the final commit message — to be filled by the implementer with measured values).

- [x] **Type consistency.** `CachedBatch`, `EntityCacheEntry`, `EntityClassificationCache` names + signatures match across Tasks 2-13. `GroupKey` is `internal` at namespace scope from Task 1 onward. Tuple shape `(WorldEntity Entity, int MeshRefIndex, uint LandblockId)` consistent in Tasks 6, 9, 10.

---

## Execution

This plan is ready for execution. Two options:

**1. Subagent-Driven (recommended)** — fresh subagent per task; main session reviews each task before dispatching the next; fast iteration.

**2. Inline Execution** — execute tasks in this session via `superpowers:executing-plans`; batch execution with checkpoints between phases.

Both options preserve the TDD discipline (test before implementation in every step). Visual + perf gates (Tasks 15-16) require the user's eyes regardless of execution model.
