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>
27 KiB
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
- 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).
- Hold p95 at ≤ 2.5 ms.
- Hold animation correctness — NPCs animate, the lifestone crystal animates, the player animates, no frozen poses.
- Hold N.5b conformance sentinel: 94/94 passing (
TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence) throughout. - Hold full test baseline: 1688 passing, 8 pre-existing physics/input failures unchanged.
- 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
MeshRefsreference,Position,Rotation,PaletteOverride,HiddenPartsMask,ParentCellId, andScaleare stable from spawn to despawn IF AND ONLY IF the entity is NOT inGameWindow._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:
-
Per-entity despawn at GameWindow.cs:2933-2935 — add
_classificationCache.InvalidateEntity(existingEntity.Id);next to_animatedEntities.Remove(...). -
Landblock demote / unload —
GpuWorldState.RemoveEntitiesFromLandblockis the choke point. Wire one of:- (W1) Add an
Action<uint /*entityId*/>?callback parameter;GameWindowwires it to_classificationCache.InvalidateEntity. Cleaner separation. - (W2) Pass the cache directly into
GpuWorldState. Less ceremony. - (W3) Call
_classificationCache.InvalidateLandblock(landblockId)fromStreamingController.Tickbefore invokingRemoveEntitiesFromLandblock. Requires the cache to maintainLandblockHintcorrectly per entry.
Implementation plan picks one. My lean: (W3) — the cache already needs
LandblockHintfor the sweep, andStreamingControlleris the natural lifecycle owner. - (W1) Add an
§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:
Populateoverwrites any existing entry forentityId(defensive: handles a populate that races with a partial despawn).InvalidateEntityis idempotent (no-throw on missing id).InvalidateLandblockwalks all entries; entries whoseLandblockHint == landblockIdare removed.TryGetis 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:)
- Skeleton + tests 1-10. Add
CachedBatch,EntityCacheEntry,EntityClassificationCache. Tests 1-10 in the new file. Cache exists but isn't wired to anything yet. - Setup pre-flatten test (test 14) + populate path. Synthetic
CachedBatch[]populate; verifyCountandTryGetround-trip on multi-part data shapes. - Wire dispatcher: cache miss + populate. Modify
WbDrawDispatcher.DrawandClassifyBatches. First-frame static entity populates; subsequent frames still go through slow path (cache hit branch not yet in). Build green. - Wire dispatcher: cache hit + DEBUG cross-check. Cache-hit fast path. Tests 11, 12, 13 added.
- Wire invalidation hooks.
InvalidateEntityfromRemoveLiveEntityByServerGuid;InvalidateLandblockper chosen W1/W2/W3 from §5.3. Test 15. - Visual gate. Launch + walk Holtburg → North Yanshi at horizon-safe preset. Verify NPC animates, lifestone renders, buildings at correct positions.
- 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. - Ship. Commit chain. Close #53 in
docs/ISSUES.mdRecently closed. UpdateCLAUDE.md"Currently in flight" (closes the post-A.5 polish phase). Update memory if any new gotchas surfaced.
§9. Acceptance criteria (whole spec)
EntityClassificationCache.csexists with the public surface in §6.1.WbDrawDispatcheraccepts the cache via ctor and routes static entities through the cache; animated entities bypass.RemoveLiveEntityByServerGuidinvokesInvalidateEntity.- 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.mdor 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
ClassifyBatchesflow unchanged. - Touch the GPU upload pipeline (
_instanceSsbo,_batchSsbo,_indirectBuffer). Same upload shape; just less CPU work to produce the inputs. - Touch terrain.
TerrainModernRendereralready 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.fragpair is unchanged. - Add new bindless texture handles.
TextureCacheis 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.
GroupKeyvisibility. Todayprivateinside the dispatcher. Either promote tointernal(withinAcDream.App) or pass an opaque payload through the cache. Either works. Lean: promote tointernal.ResolveLandblockHintplacement. On the dispatcher (uses dispatcher state for live-spawn entities) or on the cache (passed in by caller)? Lean: dispatcher computes it, passes toPopulate._populateScratchreuse. Per-frame field on the dispatcher (matches_walkScratchpattern) or per-call allocation? Lean: field, matching_walkScratch.- Test fixtures. Synthetic
WorldEntity/MeshRefinstances may need helper builders. Lean: add a smallEntityClassificationCacheTestFixtures.csif 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.