acdream/docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md
Erik 2f8a574b92 docs(post-A.5 #53): Tier 1 cache — implementation plan (writing-plans)
17-task TDD-style plan for the Tier 1 entity-classification cache, sized
~5-7 days. Phases:

  Phase 1 (Tasks 1-5):  Cache foundation — extract GroupKey, build the
                        cache class with TryGet/Populate/InvalidateEntity/
                        InvalidateLandblock, and 11 pure-CPU tests.
  Phase 2 (Tasks 6-10): Dispatcher integration — plumb landblockId
                        through the walk scratch, inject the cache,
                        wire ClassifyBatches collector + cache-miss
                        populate + cache-hit fast path. +2 integration
                        tests.
  Phase 3 (Tasks 11-12): Invalidation hooks — wire InvalidateEntity from
                        RemoveLiveEntityByServerGuid + InvalidateLandblock
                        from GpuWorldState.RemoveEntitiesFromLandblock
                        via callback (W3b per spec §5.3).
  Phase 4 (Task 13):    DEBUG cross-check — assert membership predicate
                        + DebugCrossCheck method + 2 unit tests via
                        TraceListener capture.
  Phase 5 (Tasks 14-16): Verification — full suite + sentinel + visual
                        gate (user) + perf gate (user, ≤2.0 ms median).
  Phase 6 (Task 17):    Ship — ISSUES + CLAUDE.md + memory + final commit.

Plan resolves spec §11 open implementation choices: W3b for LB invalidation,
GroupKey at namespace internal, ResolveLandblockHint plumbed via walk
scratch, _populateScratch as a field.

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

73 KiB
Raw Blame History

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. Audit foundation: docs/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.
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.
dotnet build

Expected: Build succeeded. 0 Error(s).

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:

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.
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.
dotnet test --no-build

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

  • Step 5: Commit.
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:

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:

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:

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.
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:

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:

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.
dotnet build
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"

Expected: Passed: 1, Failed: 0.

  • Step 7: Commit.
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):

    [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.
dotnet build

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

  • Step 3: Implement Populate.

Add to EntityClassificationCache.cs:

    /// <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.
dotnet build
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"

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

  • Step 5: Commit.
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):

    [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.
dotnet build

Expected: build error — InvalidateEntity not defined.

  • Step 3: Implement InvalidateEntity.

Add to EntityClassificationCache.cs:

    /// <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.
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"

Expected: 8 tests pass.

  • Step 5: Commit.
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):

    [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.
dotnet build

Expected: build error — InvalidateLandblock not defined.

  • Step 3: Implement InvalidateLandblock.

Add to EntityClassificationCache.cs:

    /// <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.
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"

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

  • Step 5: Commit.
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.
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).

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.
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:

private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new();

to:

private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();

Change line 192:

public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;

to:

public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
  • Step 3: Update WalkEntities (the test-friendly overload) signature.

Change line 220-233:

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:

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:

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):

for (int i = 0; i < entity.MeshRefs.Count; i++)
    scratch.Add((entity, i, entry.LandblockId));

Line ~299-300 (inside the full walk branch):

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:

foreach (var (entity, partIdx) in _walkScratch)

becomes:

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

private readonly EntityClassificationCache _cache;

Update the ctor signature at line 142-148:

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:

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:

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

Add a private field on GameWindow:

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:

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

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.
dotnet build

Expected: Build succeeded. 0 Error(s).

  • Step 5: Run full suite.
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.
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:

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

to:

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:

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

The full updated block (lines 738-758):

            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):

ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);

becomes:

var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose);

At line 418 (non-Setup branch):

var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);

becomes:

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.
dotnet build
dotnet test --no-build

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

  • Step 5: Commit.
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):

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:

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.
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.
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:

    // 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:

    [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:

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.
    [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.
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.
dotnet test --no-build

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

  • Step 6: Commit.
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.
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.

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):

    [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).
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:

_animatedEntities.Remove(existingEntity.Id);

Add immediately after:

_classificationCache.InvalidateEntity(existingEntity.Id);
  • Step 4: Build + run full suite.
dotnet build
dotnet test --no-build

Expected: Failed: 8, Passed: 1702.

  • Step 5: Commit.
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.

private readonly Action<uint>? _onLandblockUnloaded;

// in ctor:
_onLandblockUnloaded = onLandblockUnloaded;

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

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:

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

Add the new argument:

_worldState = new GpuWorldState(
    /* … existing args … */,
    onLandblockUnloaded: _classificationCache.InvalidateLandblock);
  • Step 3: Update existing GpuWorldState test fixtures.
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.
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:

    [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.
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.
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"

Expected: 0 failures.

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:

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

#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:

#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.
dotnet build
dotnet test --no-build

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

  • Step 5: Commit.
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.
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"

Expected: 0 failures.

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.
dotnet build

Expected: Build succeeded. 0 Error(s).

  • Step 2: Full test pass.
dotnet test --no-build

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

  • Step 3: Sentinel filter pass.
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.
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.
$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.
$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.
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.
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:

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

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

  • 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.