diff --git a/docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md b/docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md new file mode 100644 index 0000000..91d6210 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md @@ -0,0 +1,2023 @@ +# 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`. 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`; `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?` 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; + +/// +/// Bucket identity for 's per-frame group dictionary. +/// Two (entity, batch) pairs that share the same render +/// in a single glMultiDrawElementsIndirect draw command. Promoted to +/// internal at file scope (was a private nested type) so +/// can store it inside +/// without depending on dispatcher internals. +/// +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) " +``` + +--- + +### 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; + +/// +/// Per-(entity, partIdx, batchIdx) classification result, stored flat inside +/// . For Setup multi-part MeshRefs each +/// subPart contributes its own entries, with +/// already containing the +/// subPart.PartTransform * meshRef.PartTransform product. +/// +public readonly record struct CachedBatch( + GroupKey Key, + ulong BindlessTextureHandle, + Matrix4x4 RestPose); + +/// +/// One entity's cached classification. is flat across +/// (partIdx, batchIdx) and ordered as WbDrawDispatcher.ClassifyBatches +/// produced them. lets +/// sweep entries +/// efficiently when a landblock demotes or unloads. +/// +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; + +/// +/// Cache of per-entity classification results for static entities (those NOT +/// in GameWindow._animatedEntities). Holds one +/// per cached entity. The cache is opaque +/// w.r.t. classification logic — it simply stores what callers populate. +/// +/// +/// Invariants: +/// +/// overwrites any existing entry for the same id (defensive). +/// is idempotent (no-throw on missing id). +/// walks all entries; entries whose +/// equals the argument are removed. +/// All operations are render-thread only. No internal locking. +/// +/// +/// +/// +/// Audit foundation: see +/// docs/research/2026-05-10-tier1-mutation-audit.md for why static +/// entities can be cached and what invalidation is needed. +/// +/// +public sealed class EntityClassificationCache +{ + private readonly Dictionary _entries = new(); + + /// Number of cached entities — for diagnostics. + public int Count => _entries.Count; + + /// + /// Look up an entity's cached classification. Returns true with + /// the entry on hit; false with set to + /// null on miss. + /// + 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) " +``` + +--- + +### 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()); + + 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 + /// + /// Insert or overwrite a cache entry for . + /// Defensive: if an entry already exists, replaces it. + /// + 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) " +``` + +--- + +### 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 + /// + /// Remove the cache entry for . No-op if the + /// id isn't cached. + /// + 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) " +``` + +--- + +### 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 + /// + /// Remove every cache entry whose + /// equals . Used by the streaming pipeline + /// when a landblock demotes from near to far or unloads. No-op if no + /// entries match. + /// + 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? toRemove = null; + foreach (var (id, entry) in _entries) + { + if (entry.LandblockHint == landblockId) + { + toRemove ??= new List(); + 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) " +``` + +--- + +### 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 landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? 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 landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? 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 landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? 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) " +``` + +--- + +### 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) " +``` + +--- + +### 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? 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) " +``` + +--- + +### 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 _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) " +``` + +--- + +### 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 { /* 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) " +``` + +--- + +### 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) " +``` + +--- + +### 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?` 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? onLandblockUnloaded = null`. Store as a field. + +```csharp +private readonly Action? _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()); + _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 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) " +``` + +--- + +### 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 + /// + /// Asserts that the cached entry for still + /// matches what fresh classification would produce. Catches the prior + /// Tier 1 bug class — silent caching of mutable per-frame state — by + /// firing when any cached + /// field has drifted from live state. + /// + /// + /// 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. + /// + /// + /// + /// Zero cost in Release. In DEBUG, called once per static-entity cache + /// hit per frame — adds modest overhead. Acceptable for dev runs. + /// + /// + public void DebugCrossCheck(uint entityId, IReadOnlyList 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(); + 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(); + 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 _captured; + public CaptureListener(List 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) " +``` + +--- + +### 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=m/p95 gpu_us=…m/…p95 +``` + +- [ ] **Step 4: Verify perf gate.** + +Check `cpu_us=m/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 m / 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) " +``` + +- [ ] **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 `` 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.