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

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

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

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

27 KiB
Raw Blame History

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

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


§1. Problem

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

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

A first attempt to cache this state — commit 3639a6f, reverted at 9b49009 — froze NPC animation by caching meshRef.PartTransform, which is mutated every frame for entities in _animatedEntities. (memory entry on the failure mode)

This spec is the audit-driven retry.


§2. Goals and non-goals

Goals

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

Non-goals

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

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

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

§4. The invariant

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

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

Six write sites in src/, five static (one-shot at hydration), one dynamic (per-frame in TickAnimations, only for entities in _animatedEntities). All seven Position/Rotation write sites operate on entities in _animatedEntities. PaletteOverride, HiddenPartsMask, ParentCellId, Scale are init-only on WorldEntity. MeshRef is a readonly record struct — no in-place mutation possible. See audit §1, §3, §4.

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


§5. Architecture

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

§5.1 Cache shape

namespace AcDream.App.Rendering.Wb;

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

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

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

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

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

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

§5.2 Dispatcher integration (the per-entity branch)

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

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

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

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

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

        if (diag) _entitiesDrawn++;
        continue;
    }

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

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

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

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

§5.3 Invalidation wiring

Two invalidation events:

  1. Per-entity despawn at GameWindow.cs:2933-2935 — add _classificationCache.InvalidateEntity(existingEntity.Id); next to _animatedEntities.Remove(...).

  2. Landblock demote / unloadGpuWorldState.RemoveEntitiesFromLandblock is the choke point. Wire one of:

    • (W1) Add an Action<uint /*entityId*/>? callback parameter; GameWindow wires it to _classificationCache.InvalidateEntity. Cleaner separation.
    • (W2) Pass the cache directly into GpuWorldState. Less ceremony.
    • (W3) Call _classificationCache.InvalidateLandblock(landblockId) from StreamingController.Tick before invoking RemoveEntitiesFromLandblock. Requires the cache to maintain LandblockHint correctly per entry.

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

§5.4 Failure modes and recovery

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

§6. Components and their contracts

§6.1 EntityClassificationCache (NEW)

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

Public surface:

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

Invariants:

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

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

§6.2 WbDrawDispatcher (MODIFIED)

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

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

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

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

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

§6.3 GameWindow (MODIFIED)

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

Construction: instantiate EntityClassificationCache, pass to dispatcher ctor.

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

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

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

§6.5 DEBUG cross-check

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

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

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


§7. Test plan

§7.1 New tests — EntityClassificationCacheTests.cs

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

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

§7.2 Extended tests — WbDrawDispatcherBucketingTests.cs

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

§7.3 (DEBUG-only) Cross-check test

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

§7.4 Setup pre-flatten lock-in

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

§7.5 Lifecycle integration

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

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

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

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

§8. Sequencing for implementation

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

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

§9. Acceptance criteria (whole spec)

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

§10. What this design explicitly does NOT do

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

§11. Open implementation choices for writing-plans

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

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

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