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>
2023 lines
73 KiB
Markdown
2023 lines
73 KiB
Markdown
# Tier 1 Entity-Classification Cache Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Drop `WbDrawDispatcher` entity dispatcher CPU median from ~3.5 ms to ≤ 2.0 ms by caching per-entity classification results for static entities, while holding animation correctness via a `_animatedEntities` membership predicate and a DEBUG cross-check guard.
|
||
|
||
**Architecture:** New pure-CPU class `EntityClassificationCache` (separate file, ctor-injected into the dispatcher) holds `Dictionary<entityId, CachedBatch[]>`. Dispatcher checks `_animatedEntities` membership at the top of the per-entity loop; static entities go through the cache (miss → populate; hit → fast path that walks the cached flat batch list and appends `RestPose * entityWorld` matrices). Two invalidation hooks: `InvalidateEntity` from `RemoveLiveEntityByServerGuid` (live despawn) and `InvalidateLandblock` from `GpuWorldState.RemoveEntitiesFromLandblock` (LB demote/unload, wired via callback at `GameWindow` construction). DEBUG-only cross-check recomputes live state and asserts it matches cached, catching the prior Tier 1 bug class.
|
||
|
||
**Tech Stack:** C# / .NET 10 preview / Silk.NET / xUnit / FluentAssertions. Repository at `C:\Users\erikn\source\repos\acdream`. Worktree branch `claude/friendly-varahamihira-7b8664`.
|
||
|
||
**Spec foundation:** [docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md](../specs/2026-05-10-issue-53-tier1-cache-design.md).
|
||
**Audit foundation:** [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Status | Responsibility |
|
||
|---|---|---|
|
||
| `src/AcDream.App/Rendering/Wb/GroupKey.cs` | NEW | Top-level `internal record struct GroupKey` — extracted from `WbDrawDispatcher` so the cache can reference it without touching dispatcher internals |
|
||
| `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs` | NEW | Pure-CPU cache class; `Dictionary<uint, EntityCacheEntry>`; `TryGet` / `Populate` / `InvalidateEntity` / `InvalidateLandblock` + DEBUG cross-check |
|
||
| `src/AcDream.App/Rendering/Wb/CachedBatch.cs` | NEW | Top-level `public readonly record struct CachedBatch` + `public sealed class EntityCacheEntry` |
|
||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFIED | Add cache ctor param; restructure `Draw` per-entity branch; extend `ClassifyBatches` with optional collector |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs` | MODIFIED | Construct `EntityClassificationCache`; pass to dispatcher; wire `InvalidateEntity` at the despawn site (line ~2935); wire `InvalidateLandblock` callback into `GpuWorldState` ctor |
|
||
| `src/AcDream.App/Streaming/GpuWorldState.cs` | MODIFIED | Optional `Action<uint>?` invalidation callback parameter on the constructor; invoked from `RemoveEntitiesFromLandblock` |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs` | NEW | 12+ pure-CPU tests covering TryGet / Populate / Invalidate paths + Setup pre-flatten + DEBUG cross-check |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` | MODIFIED | +2 integration tests for cache routing; existing tests adapted for new ctor param |
|
||
|
||
**Out of scope (do NOT touch):** `mesh_modern.vert`, `mesh_modern.frag`, `TerrainModernRenderer`, `WbMeshAdapter`, `TextureCache`, sky/particles/EnvCell renderers, GPU upload pipeline.
|
||
|
||
---
|
||
|
||
## Pre-flight (do these before Task 1)
|
||
|
||
- [ ] **Confirm working tree clean and on the worktree branch.**
|
||
|
||
```bash
|
||
git status
|
||
git branch --show-current
|
||
```
|
||
|
||
Expected: `working tree clean`, current branch `claude/friendly-varahamihira-7b8664`.
|
||
|
||
- [ ] **Confirm baseline: build green + 1688/8 tests + 94/94 N.5b sentinel.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`.
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: `Passed! - Failed: 0, Passed: 94, Skipped: 0, Total: 94`.
|
||
|
||
If submodules missing: `git submodule update --init --recursive references/WorldBuilder`.
|
||
|
||
---
|
||
|
||
## Phase 1: Cache foundation (Tasks 1-5)
|
||
|
||
### Task 1: Extract `GroupKey` to its own file
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/GroupKey.cs`
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:923-930` (remove the nested type)
|
||
|
||
This is a mechanical refactor so the cache can reference `GroupKey` without depending on `WbDrawDispatcher`'s private members.
|
||
|
||
- [ ] **Step 1: Create the new file.**
|
||
|
||
`src/AcDream.App/Rendering/Wb/GroupKey.cs`:
|
||
|
||
```csharp
|
||
using AcDream.Core.Meshing;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Bucket identity for <see cref="WbDrawDispatcher"/>'s per-frame group dictionary.
|
||
/// Two (entity, batch) pairs that share the same <see cref="GroupKey"/> render
|
||
/// in a single <c>glMultiDrawElementsIndirect</c> draw command. Promoted to
|
||
/// <c>internal</c> at file scope (was a private nested type) so
|
||
/// <see cref="EntityClassificationCache"/> can store it inside <see cref="CachedBatch"/>
|
||
/// without depending on dispatcher internals.
|
||
/// </summary>
|
||
internal readonly record struct GroupKey(
|
||
uint Ibo,
|
||
uint FirstIndex,
|
||
int BaseVertex,
|
||
int IndexCount,
|
||
ulong BindlessTextureHandle,
|
||
uint TextureLayer,
|
||
TranslucencyKind Translucency);
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the nested `GroupKey` from the dispatcher.**
|
||
|
||
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, delete lines 923-930 (the `private readonly record struct GroupKey(...)` block). Leave the surrounding code unchanged.
|
||
|
||
- [ ] **Step 3: Build to verify the refactor compiled.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`. If it fails because some test or code referenced `WbDrawDispatcher.GroupKey`, change those references to use the bare `GroupKey` (now `internal` at namespace scope).
|
||
|
||
- [ ] **Step 4: Run the full test suite to verify no behavior change.**
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1688` (baseline preserved).
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/GroupKey.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||
git commit -m "refactor(render): extract WbDrawDispatcher.GroupKey to internal type at namespace scope
|
||
|
||
Mechanical refactor: GroupKey was a private nested record struct on
|
||
WbDrawDispatcher. The upcoming EntityClassificationCache (ISSUE #53) needs
|
||
to store GroupKey inside CachedBatch records, so it must be visible to
|
||
both the dispatcher and the cache. Promoting to internal at file scope is
|
||
the smallest change that achieves this.
|
||
|
||
No behavior change. 1688 tests pass; 8 pre-existing failures unchanged.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Skeleton — `EntityClassificationCache` + `CachedBatch` + first test
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/CachedBatch.cs`
|
||
- Create: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
The test file references the `internal` `GroupKey`; if `AcDream.App` doesn't already grant `InternalsVisibleTo("AcDream.Core.Tests")`, add it as part of this task.
|
||
|
||
- [ ] **Step 1: Write the first failing test.**
|
||
|
||
`tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`:
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Meshing;
|
||
using FluentAssertions;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public class EntityClassificationCacheTests
|
||
{
|
||
[Fact]
|
||
public void TryGet_EmptyCache_ReturnsFalse()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
bool found = cache.TryGet(entityId: 42, out var entry);
|
||
found.Should().BeFalse();
|
||
entry.Should().BeNull();
|
||
}
|
||
|
||
private static CachedBatch MakeCachedBatch(
|
||
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
|
||
{
|
||
var key = new GroupKey(
|
||
Ibo: ibo,
|
||
FirstIndex: firstIndex,
|
||
BaseVertex: 0,
|
||
IndexCount: indexCount,
|
||
BindlessTextureHandle: texHandle,
|
||
TextureLayer: 0,
|
||
Translucency: TranslucencyKind.Opaque);
|
||
return new CachedBatch(key, texHandle, Matrix4x4.Identity);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `InternalsVisibleTo` if needed.**
|
||
|
||
Check if `AcDream.App` already exposes internals to `AcDream.Core.Tests`:
|
||
|
||
```powershell
|
||
Select-String -Path src/AcDream.App/**/*.cs, src/AcDream.App/AcDream.App.csproj -Pattern "InternalsVisibleTo"
|
||
```
|
||
|
||
If no hit, add a new file `src/AcDream.App/Properties/AssemblyInfo.cs`:
|
||
|
||
```csharp
|
||
using System.Runtime.CompilerServices;
|
||
|
||
[assembly: InternalsVisibleTo("AcDream.Core.Tests")]
|
||
```
|
||
|
||
(Place it under `Properties/` to follow the conventional .NET assembly-info pattern; if `AcDream.App` already has another conventional location, use that instead.)
|
||
|
||
- [ ] **Step 3: Run the test to verify it fails to compile.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
|
||
```
|
||
|
||
Expected: build error — `EntityClassificationCache`, `CachedBatch` don't exist yet.
|
||
|
||
- [ ] **Step 4: Create `CachedBatch.cs`.**
|
||
|
||
`src/AcDream.App/Rendering/Wb/CachedBatch.cs`:
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Per-(entity, partIdx, batchIdx) classification result, stored flat inside
|
||
/// <see cref="EntityCacheEntry.Batches"/>. For Setup multi-part MeshRefs each
|
||
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
|
||
/// <see cref="RestPose"/> already containing the
|
||
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
|
||
/// </summary>
|
||
public readonly record struct CachedBatch(
|
||
GroupKey Key,
|
||
ulong BindlessTextureHandle,
|
||
Matrix4x4 RestPose);
|
||
|
||
/// <summary>
|
||
/// One entity's cached classification. <see cref="Batches"/> is flat across
|
||
/// (partIdx, batchIdx) and ordered as <c>WbDrawDispatcher.ClassifyBatches</c>
|
||
/// produced them. <see cref="LandblockHint"/> lets
|
||
/// <see cref="EntityClassificationCache.InvalidateLandblock"/> sweep entries
|
||
/// efficiently when a landblock demotes or unloads.
|
||
/// </summary>
|
||
public sealed class EntityCacheEntry
|
||
{
|
||
public required uint EntityId { get; init; }
|
||
public required uint LandblockHint { get; init; }
|
||
public required CachedBatch[] Batches { get; init; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `EntityClassificationCache.cs` skeleton.**
|
||
|
||
`src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`:
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Cache of per-entity classification results for static entities (those NOT
|
||
/// in <c>GameWindow._animatedEntities</c>). Holds one
|
||
/// <see cref="EntityCacheEntry"/> per cached entity. The cache is opaque
|
||
/// w.r.t. classification logic — it simply stores what callers populate.
|
||
///
|
||
/// <para>
|
||
/// <b>Invariants:</b>
|
||
/// <list type="bullet">
|
||
/// <item><see cref="Populate"/> overwrites any existing entry for the same id (defensive).</item>
|
||
/// <item><see cref="InvalidateEntity"/> is idempotent (no-throw on missing id).</item>
|
||
/// <item><see cref="InvalidateLandblock"/> walks all entries; entries whose
|
||
/// <see cref="EntityCacheEntry.LandblockHint"/> equals the argument are removed.</item>
|
||
/// <item>All operations are render-thread only. No internal locking.</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Audit foundation:</b> see
|
||
/// <c>docs/research/2026-05-10-tier1-mutation-audit.md</c> for why static
|
||
/// entities can be cached and what invalidation is needed.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class EntityClassificationCache
|
||
{
|
||
private readonly Dictionary<uint, EntityCacheEntry> _entries = new();
|
||
|
||
/// <summary>Number of cached entities — for diagnostics.</summary>
|
||
public int Count => _entries.Count;
|
||
|
||
/// <summary>
|
||
/// Look up an entity's cached classification. Returns <c>true</c> with
|
||
/// the entry on hit; <c>false</c> with <paramref name="entry"/> set to
|
||
/// <c>null</c> on miss.
|
||
/// </summary>
|
||
public bool TryGet(uint entityId, out EntityCacheEntry? entry)
|
||
=> _entries.TryGetValue(entityId, out entry);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
|
||
```
|
||
|
||
Expected: `Passed: 1, Failed: 0`.
|
||
|
||
- [ ] **Step 7: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/CachedBatch.cs src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
test -f src/AcDream.App/Properties/AssemblyInfo.cs && git add src/AcDream.App/Properties/AssemblyInfo.cs
|
||
git commit -m "feat(render #53): EntityClassificationCache skeleton + first test
|
||
|
||
Adds CachedBatch, EntityCacheEntry, and EntityClassificationCache with
|
||
just TryGet (returns false on empty). The skeleton compiles and the first
|
||
test (TryGet_EmptyCache_ReturnsFalse) passes. Subsequent tasks add
|
||
Populate, InvalidateEntity, InvalidateLandblock, and the dispatcher
|
||
integration. Per spec design §6.1.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `Populate` + roundtrip + Setup pre-flatten tests
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
Adds tests #2, #3, #9, #10, #14 from the spec test plan. All exercise the populate-then-tryget round-trip including the Setup pre-flatten shape.
|
||
|
||
- [ ] **Step 1: Write the failing tests.**
|
||
|
||
Append to `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs` (just BEFORE the `private static CachedBatch MakeCachedBatch` helper):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Populate_ThenTryGet_ReturnsBatchesInOrder()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
var batches = new[]
|
||
{
|
||
MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA),
|
||
MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB),
|
||
};
|
||
|
||
cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);
|
||
|
||
cache.TryGet(100, out var entry).Should().BeTrue();
|
||
entry!.EntityId.Should().Be(100u);
|
||
entry.LandblockHint.Should().Be(0xA9B40000u);
|
||
entry.Batches.Should().Equal(batches);
|
||
}
|
||
|
||
[Fact]
|
||
public void Populate_OverridesExistingEntry()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
cache.Populate(100, 0u, new[] { MakeCachedBatch(2, 0, 12, 0xCC) });
|
||
|
||
cache.TryGet(100, out var entry).Should().BeTrue();
|
||
entry!.Batches.Should().HaveCount(1);
|
||
entry.Batches[0].BindlessTextureHandle.Should().Be(0xCCu);
|
||
}
|
||
|
||
[Fact]
|
||
public void Count_TracksLiveEntries()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Count.Should().Be(0);
|
||
|
||
cache.Populate(1, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
cache.Count.Should().Be(1);
|
||
|
||
cache.Populate(2, 0u, new[] { MakeCachedBatch(2, 0, 6, 0xAA) });
|
||
cache.Count.Should().Be(2);
|
||
|
||
// Re-populate same id — should not double-count.
|
||
cache.Populate(1, 0u, new[] { MakeCachedBatch(3, 0, 6, 0xBB) });
|
||
cache.Count.Should().Be(2);
|
||
}
|
||
|
||
[Fact]
|
||
public void Populate_WithEmptyBatches_StoresEmptyEntry()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(entityId: 7, landblockHint: 0u, System.Array.Empty<CachedBatch>());
|
||
|
||
cache.TryGet(7, out var entry).Should().BeTrue();
|
||
entry!.Batches.Should().BeEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public void Populate_SetupMultiPart_StoresFlatBatchPerSubPart()
|
||
{
|
||
// Synthetic Setup with 3 subParts × 2 batches each = 6 flat entries.
|
||
// This pins the spec §3 Q4 decision: pre-flatten Setup multi-parts at
|
||
// populate time so the per-frame hot path is branchless.
|
||
var cache = new EntityClassificationCache();
|
||
var batches = new CachedBatch[6];
|
||
for (int subPart = 0; subPart < 3; subPart++)
|
||
for (int b = 0; b < 2; b++)
|
||
{
|
||
batches[subPart * 2 + b] = MakeCachedBatch(
|
||
ibo: (uint)(subPart + 1),
|
||
firstIndex: (uint)(b * 6),
|
||
indexCount: 6,
|
||
texHandle: (ulong)(0x100 + subPart * 2 + b));
|
||
}
|
||
cache.Populate(99, 0u, batches);
|
||
|
||
cache.TryGet(99, out var entry).Should().BeTrue();
|
||
entry!.Batches.Should().HaveCount(6);
|
||
entry.Batches[0].BindlessTextureHandle.Should().Be(0x100u);
|
||
entry.Batches[5].BindlessTextureHandle.Should().Be(0x105u);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify they fail to compile.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: build error — `Populate` does not exist on `EntityClassificationCache`.
|
||
|
||
- [ ] **Step 3: Implement `Populate`.**
|
||
|
||
Add to `EntityClassificationCache.cs`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Insert or overwrite a cache entry for <paramref name="entityId"/>.
|
||
/// Defensive: if an entry already exists, replaces it.
|
||
/// </summary>
|
||
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
|
||
{
|
||
_entries[entityId] = new EntityCacheEntry
|
||
{
|
||
EntityId = entityId,
|
||
LandblockHint = landblockHint,
|
||
Batches = batches,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run all cache tests.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
|
||
```
|
||
|
||
Expected: 6 tests pass (1 from Task 2 + 5 new).
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
git commit -m "feat(render #53): EntityClassificationCache.Populate + roundtrip tests
|
||
|
||
Implements Populate (insert-or-overwrite) and adds 5 tests covering the
|
||
populate→TryGet round-trip including the Setup pre-flatten shape. Per
|
||
spec test plan §7.1 tests #2, #3, #9, #10, #14.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `InvalidateEntity` + tests #4, #5
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests.**
|
||
|
||
Append (just before the `MakeCachedBatch` helper):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void InvalidateEntity_RemovesEntry()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
cache.TryGet(100, out _).Should().BeTrue();
|
||
|
||
cache.InvalidateEntity(100);
|
||
|
||
cache.TryGet(100, out var entry).Should().BeFalse();
|
||
entry.Should().BeNull();
|
||
cache.Count.Should().Be(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void InvalidateEntity_OnMissingId_NoThrow()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
var act = () => cache.InvalidateEntity(99999);
|
||
act.Should().NotThrow();
|
||
cache.Count.Should().Be(0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify they fail to compile.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: build error — `InvalidateEntity` not defined.
|
||
|
||
- [ ] **Step 3: Implement `InvalidateEntity`.**
|
||
|
||
Add to `EntityClassificationCache.cs`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Remove the cache entry for <paramref name="entityId"/>. No-op if the
|
||
/// id isn't cached.
|
||
/// </summary>
|
||
public void InvalidateEntity(uint entityId)
|
||
{
|
||
_entries.Remove(entityId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
|
||
```
|
||
|
||
Expected: 8 tests pass.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
git commit -m "feat(render #53): EntityClassificationCache.InvalidateEntity + tests
|
||
|
||
Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
|
||
spec §7.1 lock in the contract.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `InvalidateLandblock` + tests #6, #7, #8
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests.**
|
||
|
||
Append (just before the `MakeCachedBatch` helper):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void InvalidateLandblock_RemovesAllMatchingEntries()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
cache.Populate(2, 0xA9B40000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
|
||
cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });
|
||
cache.Count.Should().Be(3);
|
||
|
||
cache.InvalidateLandblock(0xA9B40000u);
|
||
|
||
cache.Count.Should().Be(0);
|
||
cache.TryGet(1, out _).Should().BeFalse();
|
||
cache.TryGet(2, out _).Should().BeFalse();
|
||
cache.TryGet(3, out _).Should().BeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void InvalidateLandblock_LeavesNonMatchingEntries()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
cache.Populate(2, 0xA9B50000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
|
||
cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });
|
||
|
||
cache.InvalidateLandblock(0xA9B40000u);
|
||
|
||
cache.Count.Should().Be(1);
|
||
cache.TryGet(1, out _).Should().BeFalse();
|
||
cache.TryGet(2, out var keep).Should().BeTrue();
|
||
keep!.LandblockHint.Should().Be(0xA9B50000u);
|
||
cache.TryGet(3, out _).Should().BeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void InvalidateLandblock_OnMissingLb_NoThrow()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
|
||
var act = () => cache.InvalidateLandblock(0xDEADBEEFu);
|
||
act.Should().NotThrow();
|
||
cache.Count.Should().Be(1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: build error — `InvalidateLandblock` not defined.
|
||
|
||
- [ ] **Step 3: Implement `InvalidateLandblock`.**
|
||
|
||
Add to `EntityClassificationCache.cs`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// Remove every cache entry whose <see cref="EntityCacheEntry.LandblockHint"/>
|
||
/// equals <paramref name="landblockId"/>. Used by the streaming pipeline
|
||
/// when a landblock demotes from near to far or unloads. No-op if no
|
||
/// entries match.
|
||
/// </summary>
|
||
public void InvalidateLandblock(uint landblockId)
|
||
{
|
||
if (_entries.Count == 0) return;
|
||
|
||
// Collect the ids to remove first to avoid mutating the dict during iteration.
|
||
// Buffered locally because the typical case removes ~all entries in the LB
|
||
// (which is still small relative to the total cache).
|
||
List<uint>? toRemove = null;
|
||
foreach (var (id, entry) in _entries)
|
||
{
|
||
if (entry.LandblockHint == landblockId)
|
||
{
|
||
toRemove ??= new List<uint>();
|
||
toRemove.Add(id);
|
||
}
|
||
}
|
||
if (toRemove is null) return;
|
||
foreach (var id in toRemove) _entries.Remove(id);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~EntityClassificationCacheTests"
|
||
```
|
||
|
||
Expected: 11 tests pass (1 + 5 + 2 + 3).
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
git commit -m "feat(render #53): EntityClassificationCache.InvalidateLandblock + tests
|
||
|
||
Sweep-by-landblock removal for the streaming demote/unload path. Tests
|
||
#6, #7, #8 from spec §7.1 lock in: (a) all matching entries removed, (b)
|
||
non-matching entries preserved, (c) idempotent on missing LB.
|
||
|
||
Phase 1 (cache foundation) complete. 11 cache tests passing.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 1 checkpoint
|
||
|
||
- [ ] **Run full suite + N.5b sentinel before moving to Phase 2.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: 94 + 11 = at least 105 passing in the filter (the new EntityClassificationCacheTests are matched by `Wb`).
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1699` (8 pre-existing + 11 new cache tests added on top of 1688 baseline).
|
||
|
||
If anything regresses here, STOP and diagnose before Phase 2.
|
||
|
||
---
|
||
|
||
## Phase 2: Dispatcher integration (Tasks 6-10)
|
||
|
||
### Task 6: Plumb landblockId through `_walkScratch`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (~lines 116, 192, 220, 241-247, 367, 273, 299-300)
|
||
- Modify: existing tests in `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` if they construct walkScratch tuples
|
||
|
||
The cache populates `LandblockHint` from the walk's outer-loop `LandblockEntry.LandblockId`. Today the inner `_walkScratch` is `List<(WorldEntity Entity, int MeshRefIndex)>` — no LB. Extend to a 3-tuple including the landblock id.
|
||
|
||
- [ ] **Step 1: Find every reference to the existing 2-tuple shape.**
|
||
|
||
```powershell
|
||
Select-String -Path src/**/*.cs, tests/**/*.cs -Pattern "List<\(WorldEntity"
|
||
Select-String -Path src/**/*.cs, tests/**/*.cs -Pattern "WalkResult"
|
||
```
|
||
|
||
Expected hits: `WbDrawDispatcher.cs` (declaration + WalkResult type + body), possibly `WbDrawDispatcherBucketingTests.cs`.
|
||
|
||
- [ ] **Step 2: Update the `_walkScratch` field type and `WalkResult.ToDraw` type.**
|
||
|
||
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`:
|
||
|
||
Change line 116:
|
||
```csharp
|
||
private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new();
|
||
```
|
||
to:
|
||
```csharp
|
||
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
|
||
```
|
||
|
||
Change line 192:
|
||
```csharp
|
||
public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
|
||
```
|
||
to:
|
||
```csharp
|
||
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
|
||
```
|
||
|
||
- [ ] **Step 3: Update `WalkEntities` (the test-friendly overload) signature.**
|
||
|
||
Change line 220-233:
|
||
```csharp
|
||
internal static WalkResult WalkEntities(
|
||
IEnumerable<LandblockEntry> landblockEntries,
|
||
FrustumPlanes? frustum,
|
||
uint? neverCullLandblockId,
|
||
HashSet<uint>? visibleCellIds,
|
||
HashSet<uint>? animatedEntityIds)
|
||
{
|
||
var scratch = new List<(WorldEntity Entity, int MeshRefIndex)>();
|
||
var result = new WalkResult { ToDraw = scratch };
|
||
WalkEntitiesInto(
|
||
landblockEntries, frustum, neverCullLandblockId,
|
||
visibleCellIds, animatedEntityIds, scratch, ref result);
|
||
return result;
|
||
}
|
||
```
|
||
to:
|
||
```csharp
|
||
internal static WalkResult WalkEntities(
|
||
IEnumerable<LandblockEntry> landblockEntries,
|
||
FrustumPlanes? frustum,
|
||
uint? neverCullLandblockId,
|
||
HashSet<uint>? visibleCellIds,
|
||
HashSet<uint>? animatedEntityIds)
|
||
{
|
||
var scratch = new List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)>();
|
||
var result = new WalkResult { ToDraw = scratch };
|
||
WalkEntitiesInto(
|
||
landblockEntries, frustum, neverCullLandblockId,
|
||
visibleCellIds, animatedEntityIds, scratch, ref result);
|
||
return result;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `WalkEntitiesInto` signature + body.**
|
||
|
||
Change line 241-247 to take the new tuple shape:
|
||
|
||
```csharp
|
||
internal static void WalkEntitiesInto(
|
||
IEnumerable<LandblockEntry> landblockEntries,
|
||
FrustumPlanes? frustum,
|
||
uint? neverCullLandblockId,
|
||
HashSet<uint>? visibleCellIds,
|
||
HashSet<uint>? animatedEntityIds,
|
||
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
||
ref WalkResult result)
|
||
```
|
||
|
||
Inside the body, every `scratch.Add((entity, i))` becomes `scratch.Add((entity, i, entry.LandblockId))`. Two such lines: ~273 (animated-only branch) and ~299-300 (full walk branch). Concretely:
|
||
|
||
Line ~273 (inside the animated-only frustum-culled branch):
|
||
```csharp
|
||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||
scratch.Add((entity, i, entry.LandblockId));
|
||
```
|
||
|
||
Line ~299-300 (inside the full walk branch):
|
||
```csharp
|
||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||
scratch.Add((entity, i, entry.LandblockId));
|
||
```
|
||
|
||
- [ ] **Step 5: Update the consumer in `Draw`.**
|
||
|
||
At line 367:
|
||
```csharp
|
||
foreach (var (entity, partIdx) in _walkScratch)
|
||
```
|
||
becomes:
|
||
```csharp
|
||
foreach (var (entity, partIdx, landblockId) in _walkScratch)
|
||
```
|
||
|
||
The `landblockId` is unused for now (consumed in Task 9 for `Populate`'s `landblockHint` argument). Suppress any `landblockId` unused-variable warning by prefixing `_` if necessary, but only if the C# compiler emits a warning (it shouldn't for tuple deconstruction).
|
||
|
||
- [ ] **Step 6: Build to verify the type plumbed cleanly.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`. If existing tests in `WbDrawDispatcherBucketingTests.cs` reference the 2-tuple shape, update them to the 3-tuple form (add `0u` or a deterministic landblock id as the third element).
|
||
|
||
- [ ] **Step 7: Run full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1699` — same as Phase 1 checkpoint. The walk now carries an extra field but no behavior changed yet.
|
||
|
||
- [ ] **Step 8: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
|
||
git commit -m "refactor(render #53): plumb landblockId through WbDrawDispatcher walkScratch
|
||
|
||
Extends the walk scratch tuple from (entity, meshRefIndex) to
|
||
(entity, meshRefIndex, landblockId). The dispatcher's per-entity loop now
|
||
has the landblock id available for EntityClassificationCache.Populate's
|
||
landblockHint argument (consumed in Task 9). No behavior change.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Wire `EntityClassificationCache` into the dispatcher ctor + `GameWindow`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (ctor signature + private field)
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (instantiate + pass)
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` (existing test fixtures pass an empty cache)
|
||
|
||
- [ ] **Step 1: Add the field + ctor parameter to `WbDrawDispatcher`.**
|
||
|
||
In `WbDrawDispatcher.cs`, add a private readonly field next to the others (~line 70):
|
||
|
||
```csharp
|
||
private readonly EntityClassificationCache _cache;
|
||
```
|
||
|
||
Update the ctor signature at line 142-148:
|
||
|
||
```csharp
|
||
public WbDrawDispatcher(
|
||
GL gl,
|
||
Shader shader,
|
||
TextureCache textures,
|
||
WbMeshAdapter meshAdapter,
|
||
EntitySpawnAdapter entitySpawnAdapter,
|
||
BindlessSupport bindless,
|
||
EntityClassificationCache classificationCache)
|
||
```
|
||
|
||
Add the assignment at the end of the ctor body (~line 165), with the existing null-checks:
|
||
|
||
```csharp
|
||
ArgumentNullException.ThrowIfNull(classificationCache);
|
||
_cache = classificationCache;
|
||
```
|
||
|
||
- [ ] **Step 2: Construct and pass the cache from `GameWindow`.**
|
||
|
||
Find the `WbDrawDispatcher` instantiation in `src/AcDream.App/Rendering/GameWindow.cs`:
|
||
|
||
```powershell
|
||
Select-String -Path src/AcDream.App/Rendering/GameWindow.cs -Pattern "new WbDrawDispatcher"
|
||
```
|
||
|
||
Add a private field on `GameWindow`:
|
||
|
||
```csharp
|
||
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
|
||
```
|
||
|
||
(Place it adjacent to the existing `_animatedEntities` field at line ~160 — they're conceptually paired.)
|
||
|
||
Update the `new WbDrawDispatcher(...)` call site to include the new argument:
|
||
|
||
```csharp
|
||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||
/* … existing args … */,
|
||
_classificationCache);
|
||
```
|
||
|
||
- [ ] **Step 3: Update existing dispatcher tests.**
|
||
|
||
In `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`, find every `new WbDrawDispatcher(...)` and append `new EntityClassificationCache()` as the final argument. (If tests use a builder/helper method, update that.)
|
||
|
||
```powershell
|
||
Select-String -Path tests/AcDream.Core.Tests/Rendering/Wb/*.cs -Pattern "new WbDrawDispatcher"
|
||
```
|
||
|
||
For each hit, add the new argument.
|
||
|
||
- [ ] **Step 4: Build to verify everything compiled.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`.
|
||
|
||
- [ ] **Step 5: Run full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1699`. The cache is wired into the dispatcher but isn't used yet — no behavior change.
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
|
||
git commit -m "feat(render #53): inject EntityClassificationCache into WbDrawDispatcher
|
||
|
||
Adds the cache as a constructor parameter on WbDrawDispatcher and a
|
||
private field on GameWindow. The cache is passed through but not yet
|
||
consumed by Draw — that wires up in Task 9 (cache miss / populate) and
|
||
Task 10 (cache hit / fast path).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Extend `ClassifyBatches` with optional collector
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (lines 707-759 — the `ClassifyBatches` method)
|
||
|
||
- [ ] **Step 1: Change `ClassifyBatches` signature.**
|
||
|
||
Change the method declaration at line 707:
|
||
|
||
```csharp
|
||
private void ClassifyBatches(
|
||
ObjectRenderData renderData,
|
||
ulong gfxObjId,
|
||
Matrix4x4 model,
|
||
WorldEntity entity,
|
||
MeshRef meshRef,
|
||
ulong palHash,
|
||
AcSurfaceMetadataTable metaTable)
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
private void ClassifyBatches(
|
||
ObjectRenderData renderData,
|
||
ulong gfxObjId,
|
||
Matrix4x4 model,
|
||
WorldEntity entity,
|
||
MeshRef meshRef,
|
||
ulong palHash,
|
||
AcSurfaceMetadataTable metaTable,
|
||
Matrix4x4 restPose,
|
||
List<CachedBatch>? collector = null)
|
||
```
|
||
|
||
The new `restPose` parameter is the model-matrix component WITHOUT `entityWorld` baked in — i.e. `meshRef.PartTransform` for non-Setup, or `subPart.PartTransform * meshRef.PartTransform` for Setup. Caller computes it.
|
||
|
||
- [ ] **Step 2: Append to the collector inside the per-batch loop.**
|
||
|
||
At the bottom of the for loop (after `grp.Matrices.Add(model);` at line 757), add:
|
||
|
||
```csharp
|
||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||
```
|
||
|
||
The full updated block (lines 738-758):
|
||
|
||
```csharp
|
||
var key = new GroupKey(
|
||
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
|
||
batch.IndexCount, texHandle, texLayer, translucency);
|
||
|
||
if (!_groups.TryGetValue(key, out var grp))
|
||
{
|
||
grp = new InstanceGroup
|
||
{
|
||
Ibo = batch.IBO,
|
||
FirstIndex = batch.FirstIndex,
|
||
BaseVertex = (int)batch.BaseVertex,
|
||
IndexCount = batch.IndexCount,
|
||
BindlessTextureHandle = texHandle,
|
||
TextureLayer = texLayer,
|
||
Translucency = translucency,
|
||
};
|
||
_groups[key] = grp;
|
||
}
|
||
grp.Matrices.Add(model);
|
||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update `ClassifyBatches` call sites in `Draw` to pass `restPose`.**
|
||
|
||
At line 411 (Setup branch):
|
||
```csharp
|
||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
|
||
```
|
||
becomes:
|
||
```csharp
|
||
var restPose = partTransform * meshRef.PartTransform;
|
||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose);
|
||
```
|
||
|
||
At line 418 (non-Setup branch):
|
||
```csharp
|
||
var model = meshRef.PartTransform * entityWorld;
|
||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
|
||
```
|
||
becomes:
|
||
```csharp
|
||
var model = meshRef.PartTransform * entityWorld;
|
||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform);
|
||
```
|
||
|
||
(Use named-arg form on the non-Setup branch to avoid name collision with the Setup branch's local `restPose`.)
|
||
|
||
- [ ] **Step 4: Build + test.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1699`. No behavior change yet — collector defaults to null.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||
git commit -m "feat(render #53): add optional CachedBatch collector to ClassifyBatches
|
||
|
||
ClassifyBatches now accepts a restPose parameter (the model-matrix
|
||
component without entityWorld baked in) and an optional collector. When
|
||
collector is non-null, each classified batch is appended as a CachedBatch
|
||
record. Defaults preserve today's behavior. Used in Task 9 to populate
|
||
the cache on a static-entity miss.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Wire dispatcher cache-miss path (populate on first frame; no fast-path yet)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (around lines 367-423)
|
||
|
||
This task adds the populate logic without the cache-hit fast path. After this task, every static entity's slow path runs exactly once (first frame visible) and produces a populated cache entry; subsequent frames still run the slow path because the fast-path branch isn't in yet. Task 10 adds the fast path.
|
||
|
||
The split is deliberate so we can land + verify each half independently.
|
||
|
||
- [ ] **Step 1: Add the populate scratch field.**
|
||
|
||
Near the other per-frame scratch fields (~line 116):
|
||
|
||
```csharp
|
||
private readonly List<CachedBatch> _populateScratch = new();
|
||
```
|
||
|
||
- [ ] **Step 2: Restructure the per-entity loop in `Draw`.**
|
||
|
||
Replace lines 367-423 (the foreach + body up through `if (diag && drewAny) _entitiesDrawn++;`) with:
|
||
|
||
```csharp
|
||
foreach (var (entity, partIdx, landblockId) in _walkScratch)
|
||
{
|
||
if (diag) _entitiesSeen++;
|
||
|
||
var entityWorld =
|
||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||
Matrix4x4.CreateTranslation(entity.Position);
|
||
|
||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||
|
||
// Compute palette-override hash ONCE per entity (perf #4).
|
||
ulong palHash = 0;
|
||
if (entity.PaletteOverride is not null)
|
||
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
|
||
|
||
var meshRef = entity.MeshRefs[partIdx];
|
||
ulong gfxObjId = meshRef.GfxObjId;
|
||
|
||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||
if (renderData is null)
|
||
{
|
||
if (diag) _meshesMissing++;
|
||
continue;
|
||
}
|
||
if (anyVao == 0) anyVao = renderData.VAO;
|
||
|
||
// Cache-miss path (animated entities skip cache entirely).
|
||
// Static entities collect into _populateScratch on the first frame
|
||
// they're visible, so the cache has fresh data for the next frame.
|
||
var collector = isAnimated ? null : _populateScratch;
|
||
collector?.Clear();
|
||
|
||
bool drewAny = false;
|
||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||
{
|
||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||
{
|
||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||
if (partData is null) continue;
|
||
|
||
var model = ComposePartWorldMatrix(
|
||
entityWorld, meshRef.PartTransform, partTransform);
|
||
var restPose = partTransform * meshRef.PartTransform;
|
||
|
||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef,
|
||
palHash, metaTable, restPose, collector);
|
||
drewAny = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var model = meshRef.PartTransform * entityWorld;
|
||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef,
|
||
palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
|
||
drewAny = true;
|
||
}
|
||
|
||
if (collector is not null && collector.Count > 0)
|
||
{
|
||
// Populate cache for static entity on cache-miss.
|
||
// Each entity classifies once at first visibility; subsequent frames
|
||
// will hit the fast path (added in Task 10).
|
||
_cache.Populate(entity.Id, landblockId, collector.ToArray());
|
||
}
|
||
|
||
if (diag && drewAny) _entitiesDrawn++;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build + test.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1699`. The slow path now also populates the cache, but visual + per-frame behavior is unchanged (we don't read from the cache yet).
|
||
|
||
- [ ] **Step 4: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||
git commit -m "feat(render #53): cache-miss populate on first frame for static entities
|
||
|
||
Restructures Draw's per-entity loop: animated entities still skip the
|
||
cache entirely, but static entities now collect their classification into
|
||
_populateScratch and call cache.Populate at the end of the iteration.
|
||
|
||
Cache fast-path (skip slow classification on cache hit) lands in Task 10.
|
||
This intermediate state is verifiable: behavior unchanged, but the cache
|
||
is being populated as entities render. Diagnostic-friendly split.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Wire dispatcher cache-hit fast path + integration tests #11, #12
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`
|
||
|
||
- [ ] **Step 1: Add the cache-hit branch.**
|
||
|
||
In `WbDrawDispatcher.Draw`, just after the `bool isAnimated = ...` line and BEFORE the `palHash` computation, add:
|
||
|
||
```csharp
|
||
// Fast path: cache hit on a static entity. Skip classification entirely
|
||
// and append cached (RestPose * entityWorld) matrices to the matching
|
||
// groups. The DEBUG cross-check (added in Task 13) asserts the
|
||
// membership predicate held at hit time.
|
||
if (!isAnimated && _cache.TryGet(entity.Id, out var cachedEntry))
|
||
{
|
||
foreach (var cached in cachedEntry!.Batches)
|
||
{
|
||
if (!_groups.TryGetValue(cached.Key, out var grp))
|
||
{
|
||
grp = new InstanceGroup
|
||
{
|
||
Ibo = cached.Key.Ibo,
|
||
FirstIndex = cached.Key.FirstIndex,
|
||
BaseVertex = cached.Key.BaseVertex,
|
||
IndexCount = cached.Key.IndexCount,
|
||
BindlessTextureHandle = cached.Key.BindlessTextureHandle,
|
||
TextureLayer = cached.Key.TextureLayer,
|
||
Translucency = cached.Key.Translucency,
|
||
};
|
||
_groups[cached.Key] = grp;
|
||
}
|
||
grp.Matrices.Add(cached.RestPose * entityWorld);
|
||
}
|
||
|
||
if (anyVao == 0)
|
||
{
|
||
// Need a VAO for the GL phase. Look up the first MeshRef's
|
||
// mesh data once (cheap dict lookup, not a re-classify).
|
||
var firstMeshRef = entity.MeshRefs[partIdx];
|
||
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
|
||
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
|
||
}
|
||
|
||
if (diag) { _entitiesDrawn++; }
|
||
continue;
|
||
}
|
||
```
|
||
|
||
(Note: `_entitiesSeen++` already fired at the top of the loop body; only `_entitiesDrawn++` here.)
|
||
|
||
- [ ] **Step 2: Write integration test #11 — static entity routes through cache.**
|
||
|
||
In `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`, add a test that:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Draw_StaticEntity_PopulatesCacheOnFirstFrameAndHitsOnSecond()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
// Use the existing test fixture builder (whatever shape WbDrawDispatcherBucketingTests
|
||
// already uses). Pass `cache` as the new ctor argument.
|
||
// Construct one synthetic static WorldEntity in landblockEntries.
|
||
cache.Count.Should().Be(0);
|
||
|
||
// … existing fixture: construct dispatcher + adapter + entity …
|
||
// … invoke Draw once …
|
||
|
||
// First frame: cache populates.
|
||
cache.Count.Should().BeGreaterThan(0);
|
||
int firstCount = cache.Count;
|
||
|
||
// … invoke Draw again with the same entity …
|
||
|
||
// Second frame: cache hit — no double-populate. cache.Count is stable.
|
||
cache.Count.Should().Be(firstCount);
|
||
}
|
||
```
|
||
|
||
If the existing test fixture doesn't expose a spy / counter on `WbMeshAdapter`, this test asserts indirectly: after first Draw, `cache.Count == 1`; after second Draw, `cache.Count == 1` still (no double-populate, which would re-overwrite — `Populate` overwrite still leaves Count==1, so this test asserts that the populate path is reached on the first Draw and is NOT reached on the second Draw via a stronger spy if the fixture supports one; otherwise the weaker count-stability assert is acceptable).
|
||
|
||
If a spy is feasible, prefer it. Pseudocode:
|
||
|
||
```csharp
|
||
var spyAdapter = new SpyMeshAdapter(realAdapter);
|
||
// ... construct dispatcher with spyAdapter ...
|
||
spyAdapter.TryGetRenderDataCallCount.Should().Be(N_first_frame_lookups);
|
||
// ... invoke second Draw ...
|
||
spyAdapter.TryGetRenderDataCallCount.Should().Be(N_first_frame_lookups + 1);
|
||
// ↑ +1 for the single VAO lookup in the cache-hit branch, NOT +N for re-classification.
|
||
```
|
||
|
||
Choose whichever the existing fixture supports.
|
||
|
||
- [ ] **Step 3: Write integration test #12 — animated entity bypasses cache.**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Draw_AnimatedEntity_DoesNotPopulateCache()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
// Construct dispatcher + adapter + one WorldEntity flagged in
|
||
// animatedEntityIds. Invoke Draw.
|
||
var animatedIds = new HashSet<uint> { /* entity.Id */ };
|
||
// … invoke Draw with animatedEntityIds: animatedIds …
|
||
|
||
// Cache should never be populated for animated entities.
|
||
cache.Count.Should().Be(0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run integration tests.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build --filter "FullyQualifiedName~WbDrawDispatcherBucketingTests"
|
||
```
|
||
|
||
Expected: existing dispatcher tests + 2 new cache integration tests all pass.
|
||
|
||
- [ ] **Step 5: Run full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1701` (1688 baseline + 11 cache tests + 2 integration tests = 1701).
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
|
||
git commit -m "feat(render #53): cache-hit fast path + dispatcher integration tests
|
||
|
||
WbDrawDispatcher.Draw now branches on cache hit before running classification:
|
||
on hit, walks the cached flat batch list and appends RestPose × entityWorld
|
||
to the matching groups; on miss, runs today's classification and populates
|
||
the cache. Animated entities skip the cache entirely.
|
||
|
||
Adds dispatcher integration tests #11 (static entity populates + reuses)
|
||
and #12 (animated bypasses) per spec test plan §7.2.
|
||
|
||
Phase 2 (dispatcher integration) complete. End-to-end caching now live.
|
||
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 2 checkpoint
|
||
|
||
- [ ] **Run sentinel + full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: ≥ 107 passing (94 sentinel + 11 cache + 2 integration). 0 failures.
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: 1701 passed, 8 failed (pre-existing).
|
||
|
||
---
|
||
|
||
## Phase 3: Invalidation hooks (Tasks 11-12)
|
||
|
||
### Task 11: Wire `InvalidateEntity` from `RemoveLiveEntityByServerGuid` + test #15
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~2935)
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
- [ ] **Step 1: Write test #15 (despawn-respawn cycle).**
|
||
|
||
Append to `EntityClassificationCacheTests.cs` (just before `MakeCachedBatch`):
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void DespawnRespawn_UnderReusedId_RepopulatesFresh()
|
||
{
|
||
// Pins the audit's ObjDescEvent contract (audit §1):
|
||
// ObjDescEvent is despawn + respawn (with a NEW local entity.Id),
|
||
// never an in-place mutation. Even when an id IS reused
|
||
// (theoretical — _liveEntityIdCounter is monotonic in practice),
|
||
// the cache must serve fresh data after invalidation.
|
||
var cache = new EntityClassificationCache();
|
||
var batchesV1 = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
|
||
var batchesV2 = new[] { MakeCachedBatch(2, 6, 12, 0xCC) };
|
||
|
||
cache.Populate(100, 0xA9B40000u, batchesV1);
|
||
cache.InvalidateEntity(100);
|
||
cache.Populate(100, 0xA9B40000u, batchesV2);
|
||
|
||
cache.TryGet(100, out var entry).Should().BeTrue();
|
||
entry!.Batches.Should().Equal(batchesV2);
|
||
entry.Batches[0].BindlessTextureHandle.Should().Be(0xCCu);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test, verify it passes (it tests existing API).**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~DespawnRespawn"
|
||
```
|
||
|
||
Expected: pass. (This test pins behavior the cache class already provides; no implementation change needed for the test itself.)
|
||
|
||
- [ ] **Step 3: Wire `InvalidateEntity` in `GameWindow.RemoveLiveEntityByServerGuid`.**
|
||
|
||
In `src/AcDream.App/Rendering/GameWindow.cs`, find line ~2935:
|
||
|
||
```csharp
|
||
_animatedEntities.Remove(existingEntity.Id);
|
||
```
|
||
|
||
Add immediately after:
|
||
|
||
```csharp
|
||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||
```
|
||
|
||
- [ ] **Step 4: Build + run full suite.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1702`.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
git commit -m "feat(render #53): wire EntityClassificationCache.InvalidateEntity at despawn
|
||
|
||
GameWindow.RemoveLiveEntityByServerGuid now invalidates the entity's
|
||
cache entry next to the existing _animatedEntities.Remove(). Fires for
|
||
DeleteObject (0xF747) and the dedup leg of ObjDescEvent (0xF625).
|
||
|
||
Adds test #15 (despawn-respawn under reused id repopulates fresh) per
|
||
spec §7.5 — pins the audit's ObjDescEvent-as-despawn-respawn contract.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Wire `InvalidateLandblock` callback into `GpuWorldState.RemoveEntitiesFromLandblock`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `GpuWorldState` instantiation site)
|
||
|
||
Per spec §5.3 W3b: pass an `Action<uint>?` callback into `GpuWorldState`'s ctor so when `RemoveEntitiesFromLandblock` clears a landblock's entity list, the callback fires once per landblock id.
|
||
|
||
- [ ] **Step 1: Add the callback parameter to `GpuWorldState`.**
|
||
|
||
In `src/AcDream.App/Streaming/GpuWorldState.cs`, find the ctor (or primary ctor declaration). Add a new optional parameter `Action<uint>? onLandblockUnloaded = null`. Store as a field.
|
||
|
||
```csharp
|
||
private readonly Action<uint>? _onLandblockUnloaded;
|
||
|
||
// in ctor:
|
||
_onLandblockUnloaded = onLandblockUnloaded;
|
||
```
|
||
|
||
Modify `RemoveEntitiesFromLandblock` (line 373) to invoke the callback BEFORE zeroing the entity list:
|
||
|
||
```csharp
|
||
public void RemoveEntitiesFromLandblock(uint landblockId)
|
||
{
|
||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||
if (!_loaded.TryGetValue(canonical, out var lb)) return;
|
||
if (_wbSpawnAdapter is not null)
|
||
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
|
||
|
||
// Phase Post-A.5 #53: invalidate the EntityClassificationCache for this
|
||
// landblock before we drop the entity list. Wired via callback at
|
||
// GameWindow construction; null when the cache isn't relevant (tests).
|
||
_onLandblockUnloaded?.Invoke(canonical);
|
||
|
||
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
|
||
_pendingByLandblock.Remove(canonical);
|
||
RebuildFlatView();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the callback at `GameWindow`.**
|
||
|
||
Find the `new GpuWorldState(...)` invocation:
|
||
|
||
```powershell
|
||
Select-String -Path src/AcDream.App/Rendering/GameWindow.cs -Pattern "new GpuWorldState"
|
||
```
|
||
|
||
Add the new argument:
|
||
|
||
```csharp
|
||
_worldState = new GpuWorldState(
|
||
/* … existing args … */,
|
||
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
|
||
```
|
||
|
||
- [ ] **Step 3: Update existing `GpuWorldState` test fixtures.**
|
||
|
||
```powershell
|
||
Select-String -Path tests/**/*.cs -Pattern "new GpuWorldState"
|
||
```
|
||
|
||
For each hit, the existing tests can omit the new optional parameter (it defaults to null). No change required unless a specific test wants to assert the callback fires.
|
||
|
||
- [ ] **Step 4: Build + run full suite.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1702` (no new tests in this task — invalidation behavior is exercised indirectly through visual + perf gates, plus the optional unit test in Step 5).
|
||
|
||
- [ ] **Step 5: (Optional) Add a streaming integration test.**
|
||
|
||
If `GpuWorldStateTwoTierTests.cs` makes it easy, add:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void RemoveEntitiesFromLandblock_FiresUnloadCallbackBeforeClearingEntities()
|
||
{
|
||
uint? observed = null;
|
||
var state = new GpuWorldState(
|
||
/* … existing args … */,
|
||
onLandblockUnloaded: id => observed = id);
|
||
|
||
// Set up: add a synthetic entity to LB 0xA9B40000 via AppendLiveEntity.
|
||
// ...
|
||
state.RemoveEntitiesFromLandblock(0xA9B40000u);
|
||
|
||
observed.Should().Be(0xA9B4FFFFu); // canonicalized
|
||
}
|
||
```
|
||
|
||
If the fixture is heavy, defer.
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/GpuWorldState.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/Streaming/*.cs
|
||
git commit -m "feat(render #53): wire EntityClassificationCache.InvalidateLandblock at LB demote/unload
|
||
|
||
GpuWorldState.RemoveEntitiesFromLandblock now invokes an optional
|
||
Action<uint> callback before zeroing the entity list. GameWindow wires
|
||
this to EntityClassificationCache.InvalidateLandblock so cache entries
|
||
get swept on LB demote (Near→Far) and unload. Per spec §5.3 W3b.
|
||
|
||
Phase 3 (invalidation hooks) complete. The cache now stays correct across
|
||
all spec-identified mutation events: despawn, ObjDescEvent (despawn+
|
||
respawn), LB demote, LB unload.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 3 checkpoint
|
||
|
||
- [ ] **Run sentinel + full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: 0 failures.
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1702`.
|
||
|
||
---
|
||
|
||
## Phase 4: DEBUG cross-check (Task 13)
|
||
|
||
### Task 13: Add DEBUG cross-check + test #13
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (call cross-check on cache hit, DEBUG-only)
|
||
- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
|
||
|
||
- [ ] **Step 1: Add the cross-check method to the cache.**
|
||
|
||
Append to `EntityClassificationCache.cs`:
|
||
|
||
```csharp
|
||
#if DEBUG
|
||
/// <summary>
|
||
/// Asserts that the cached entry for <paramref name="entityId"/> still
|
||
/// matches what fresh classification would produce. Catches the prior
|
||
/// Tier 1 bug class — silent caching of mutable per-frame state — by
|
||
/// firing <see cref="System.Diagnostics.Debug.Assert"/> when any cached
|
||
/// field has drifted from live state.
|
||
///
|
||
/// <para>
|
||
/// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose)
|
||
/// reconstructed from the same path the populate ran. The cache iterates
|
||
/// its stored entries in parallel and asserts equality.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Zero cost in Release. In DEBUG, called once per static-entity cache
|
||
/// hit per frame — adds modest overhead. Acceptable for dev runs.
|
||
/// </para>
|
||
/// </summary>
|
||
public void DebugCrossCheck(uint entityId, IReadOnlyList<CachedBatch> liveBatches)
|
||
{
|
||
if (!_entries.TryGetValue(entityId, out var entry)) return;
|
||
|
||
System.Diagnostics.Debug.Assert(
|
||
entry.Batches.Length == liveBatches.Count,
|
||
$"EntityClassificationCache: batch count mismatch for entity {entityId}: cached={entry.Batches.Length} live={liveBatches.Count}");
|
||
|
||
for (int i = 0; i < entry.Batches.Length && i < liveBatches.Count; i++)
|
||
{
|
||
var cached = entry.Batches[i];
|
||
var live = liveBatches[i];
|
||
System.Diagnostics.Debug.Assert(
|
||
cached.Key.Equals(live.Key),
|
||
$"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}");
|
||
System.Diagnostics.Debug.Assert(
|
||
cached.BindlessTextureHandle == live.BindlessTextureHandle,
|
||
$"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}");
|
||
System.Diagnostics.Debug.Assert(
|
||
MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f),
|
||
$"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}");
|
||
}
|
||
}
|
||
|
||
private static bool MatrixApproxEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b, float epsilon)
|
||
{
|
||
return System.MathF.Abs(a.M11 - b.M11) <= epsilon && System.MathF.Abs(a.M12 - b.M12) <= epsilon &&
|
||
System.MathF.Abs(a.M13 - b.M13) <= epsilon && System.MathF.Abs(a.M14 - b.M14) <= epsilon &&
|
||
System.MathF.Abs(a.M21 - b.M21) <= epsilon && System.MathF.Abs(a.M22 - b.M22) <= epsilon &&
|
||
System.MathF.Abs(a.M23 - b.M23) <= epsilon && System.MathF.Abs(a.M24 - b.M24) <= epsilon &&
|
||
System.MathF.Abs(a.M31 - b.M31) <= epsilon && System.MathF.Abs(a.M32 - b.M32) <= epsilon &&
|
||
System.MathF.Abs(a.M33 - b.M33) <= epsilon && System.MathF.Abs(a.M34 - b.M34) <= epsilon &&
|
||
System.MathF.Abs(a.M41 - b.M41) <= epsilon && System.MathF.Abs(a.M42 - b.M42) <= epsilon &&
|
||
System.MathF.Abs(a.M43 - b.M43) <= epsilon && System.MathF.Abs(a.M44 - b.M44) <= epsilon;
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the cross-check into the dispatcher's cache-hit path.**
|
||
|
||
In `WbDrawDispatcher.Draw`, inside the cache-hit branch from Task 10, AFTER appending matrices, add (DEBUG-only):
|
||
|
||
```csharp
|
||
#if DEBUG
|
||
// Cross-check guard: assert the membership predicate held at hit time.
|
||
// The full re-classification cross-check (spec §6.5) is a stretch goal;
|
||
// this simpler assert catches the prior Tier 1 bug class — a static
|
||
// entity that turns out to actually be animated would fire here.
|
||
System.Diagnostics.Debug.Assert(
|
||
!isAnimated,
|
||
$"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
|
||
#endif
|
||
```
|
||
|
||
(The full live-state cross-check requires re-running ClassifyBatches with a live collector, which is non-trivial to plumb into the per-entity branch; ship the predicate assert and file a follow-up issue if the team wants the full cross-check later. The unit test in Step 3 still covers `DebugCrossCheck` directly.)
|
||
|
||
- [ ] **Step 3: Write test #13 — DEBUG cross-check fires on synthetic mismatch.**
|
||
|
||
Append to `EntityClassificationCacheTests.cs`:
|
||
|
||
```csharp
|
||
#if DEBUG
|
||
[Fact]
|
||
public void DebugCrossCheck_BatchCountMismatch_FiresAssert()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
cache.Populate(100, 0u, new[]
|
||
{
|
||
MakeCachedBatch(1, 0, 6, 0xAA),
|
||
MakeCachedBatch(1, 6, 6, 0xBB),
|
||
});
|
||
|
||
// Synthetic "live" with fewer batches → should fire Debug.Assert.
|
||
var liveBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
|
||
|
||
// Capture Debug.Assert via a custom TraceListener.
|
||
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
|
||
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
|
||
System.Diagnostics.Trace.Listeners.Clear();
|
||
var asserts = new List<string>();
|
||
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
|
||
|
||
try
|
||
{
|
||
cache.DebugCrossCheck(100, liveBatches);
|
||
}
|
||
finally
|
||
{
|
||
System.Diagnostics.Trace.Listeners.Clear();
|
||
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
|
||
}
|
||
|
||
asserts.Should().NotBeEmpty();
|
||
string joined = string.Join(" ", asserts);
|
||
joined.Should().Contain("batch count mismatch");
|
||
}
|
||
|
||
[Fact]
|
||
public void DebugCrossCheck_RestPoseMatch_NoAssert()
|
||
{
|
||
var cache = new EntityClassificationCache();
|
||
var batches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
|
||
cache.Populate(100, 0u, batches);
|
||
|
||
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
|
||
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
|
||
System.Diagnostics.Trace.Listeners.Clear();
|
||
var asserts = new List<string>();
|
||
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
|
||
|
||
try
|
||
{
|
||
cache.DebugCrossCheck(100, batches);
|
||
}
|
||
finally
|
||
{
|
||
System.Diagnostics.Trace.Listeners.Clear();
|
||
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
|
||
}
|
||
|
||
asserts.Should().BeEmpty();
|
||
}
|
||
|
||
private sealed class CaptureListener : System.Diagnostics.TraceListener
|
||
{
|
||
private readonly List<string> _captured;
|
||
public CaptureListener(List<string> captured) { _captured = captured; }
|
||
public override void Write(string? message) { if (message != null) _captured.Add(message); }
|
||
public override void WriteLine(string? message) { if (message != null) _captured.Add(message); }
|
||
public override void Fail(string? message, string? detailMessage)
|
||
{
|
||
_captured.Add($"{message}: {detailMessage}");
|
||
}
|
||
public override void Fail(string? message) { if (message != null) _captured.Add(message); }
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 4: Build + run full suite.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1704` (two new DEBUG-only tests; in DEBUG configuration both run).
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
|
||
git commit -m "feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class
|
||
|
||
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
|
||
asserts cached state matches a live re-classification. Wires a simpler
|
||
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
|
||
isAnimated == false on cache hit). Tests #13a and #13b cover the
|
||
batch-count mismatch and clean-match cases via a custom TraceListener
|
||
that captures Debug.Assert calls.
|
||
|
||
Zero cost in Release. In DEBUG, the assert fires immediately if a future
|
||
regression mutates static-entity state outside the audit's known write
|
||
sites — the same failure mode that bit the prior Tier 1 attempt.
|
||
|
||
Phase 4 complete. Cache + invalidation + safety net all in place.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 4 checkpoint
|
||
|
||
- [ ] **Run sentinel + full suite.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: 0 failures.
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: `Failed: 8, Passed: 1704` in DEBUG (or 1702 in Release where the `#if DEBUG` tests are excluded).
|
||
|
||
---
|
||
|
||
## Phase 5: Verification gates (Tasks 14-16)
|
||
|
||
### Task 14: Pre-launch sanity — full suite + sentinel + grep for TODO/FIXME
|
||
|
||
- [ ] **Step 1: Final build green check.**
|
||
|
||
```powershell
|
||
dotnet build
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`.
|
||
|
||
- [ ] **Step 2: Full test pass.**
|
||
|
||
```powershell
|
||
dotnet test --no-build
|
||
```
|
||
|
||
Expected: 1704 (or 1702 in Release) passing, 8 pre-existing physics/input failures unchanged.
|
||
|
||
- [ ] **Step 3: Sentinel filter pass.**
|
||
|
||
```powershell
|
||
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
|
||
```
|
||
|
||
Expected: 0 failures.
|
||
|
||
- [ ] **Step 4: Grep for any leftover TODO/FIXME the implementation introduced.**
|
||
|
||
```powershell
|
||
Select-String -Path src/AcDream.App/Rendering/Wb/*.cs -Pattern "TODO|FIXME|XXX"
|
||
```
|
||
|
||
Expected: any hits should be intentional (e.g. cross-check stretch-goal note); fix or document if not.
|
||
|
||
---
|
||
|
||
### Task 15: Visual gate (USER REQUIRED)
|
||
|
||
This step requires the user to launch the live client and visually verify the change.
|
||
|
||
- [ ] **Step 1: Confirm baseline behavior (before any visual claims).**
|
||
|
||
The user reports build green + tests passing. Implementation agent confirms the spec's acceptance criteria items 1-7 are checked.
|
||
|
||
- [ ] **Step 2: Launch the client.**
|
||
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||
$env:ACDREAM_LIVE = "1"
|
||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||
$env:ACDREAM_TEST_PORT = "9000"
|
||
$env:ACDREAM_TEST_USER = "testaccount"
|
||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||
dotnet run --project src/AcDream.App/AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch-tier1-visual.log
|
||
```
|
||
|
||
- [ ] **Step 3: User walks Holtburg → North Yanshi at horizon-safe preset.**
|
||
|
||
Confirm visually:
|
||
- A nearby NPC (any creature) animates normally — limbs move, idle breathing visible.
|
||
- The Holtburg lifestone crystal (Z=94 platform) renders correctly and animates (rotation / glow).
|
||
- Static buildings render at correct positions (no offsets, no missing parts).
|
||
- No new visual artifacts.
|
||
|
||
If any of the above fail, **STOP**: file a sub-issue, diagnose, and either fix or revert before continuing.
|
||
|
||
- [ ] **Step 4: User reports visual gate result.**
|
||
|
||
Implementation agent records the user's confirmation.
|
||
|
||
---
|
||
|
||
### Task 16: Perf gate (USER REQUIRED)
|
||
|
||
- [ ] **Step 1: Launch with `[WB-DIAG]` enabled in Release config.**
|
||
|
||
```powershell
|
||
$env:ACDREAM_WB_DIAG = "1"
|
||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||
$env:ACDREAM_LIVE = "1"
|
||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||
$env:ACDREAM_TEST_PORT = "9000"
|
||
$env:ACDREAM_TEST_USER = "testaccount"
|
||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||
dotnet run --project src/AcDream.App/AcDream.App.csproj -c Release 2>&1 | Tee-Object -FilePath perf-tier1-after.log
|
||
```
|
||
|
||
(Release build — perf measurements should match what users see, not DEBUG with cross-check overhead.)
|
||
|
||
- [ ] **Step 2: User stands at Holtburg center for ≥ 30 seconds at horizon-safe preset.**
|
||
|
||
Defaults: NEAR_RADIUS=4, FAR_RADIUS=12, MSAA=0, A2C=0, ANISOTROPIC=4, MAX_COMPLETIONS=2.
|
||
|
||
- [ ] **Step 3: Capture `[WB-DIAG]` output from the log.**
|
||
|
||
```powershell
|
||
Select-String -Path perf-tier1-after.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 5
|
||
```
|
||
|
||
Expected output format (from existing dispatcher):
|
||
```
|
||
[WB-DIAG] entSeen=… entDrawn=… meshMissing=0 drawsIssued=… instances=… groups=… cpu_us=<MEDIAN>m/<P95>p95 gpu_us=…m/…p95
|
||
```
|
||
|
||
- [ ] **Step 4: Verify perf gate.**
|
||
|
||
Check `cpu_us=<MEDIAN>m/<P95>p95`:
|
||
- `MEDIAN ≤ 2000` (≤ 2.0 ms — spec budget).
|
||
- `P95 ≤ 2500` (≤ 2.5 ms).
|
||
- No `BUDGET_OVER` flag.
|
||
|
||
Compare against the pre-Tier-1 baseline (~3500 / ~4000 from the post-A.5 state). Expected: ~50% reduction in median.
|
||
|
||
- [ ] **Step 5: Record results.**
|
||
|
||
If perf gate passes, proceed to Phase 6 (ship). Document the actual numbers in the closing commit message.
|
||
|
||
If perf gate FAILS (median > 2.0 ms), this is a signal that:
|
||
- Cache hit rate is lower than expected (animated entities dominate visible set).
|
||
- OR per-frame matrix mults still dominate (consider Q3 option M revisit).
|
||
- OR a cache invalidation is firing too aggressively (visible thrashing).
|
||
|
||
Diagnose with `cache.Count` over time + the existing `entSeen` / `entDrawn` counters. Do NOT ship without hitting the gate; either fix or escalate per spec §11.
|
||
|
||
---
|
||
|
||
## Phase 6: Ship (Task 17)
|
||
|
||
### Task 17: Update ISSUES, CLAUDE.md, memory; final commit; merge
|
||
|
||
**Files:**
|
||
- Modify: `docs/ISSUES.md`
|
||
- Modify: `CLAUDE.md`
|
||
- Modify: `~/.claude/projects/.../memory/project_phase_a5_state.md` (or new memory entry if a new gotcha surfaced)
|
||
|
||
- [ ] **Step 1: Move issue #53 to "Recently closed" in `docs/ISSUES.md`.**
|
||
|
||
Find the `## #53` block under "Active issues". Move it to "Recently closed" with the closing commit SHA. Add a one-line resolution summary citing the audit + spec + perf result.
|
||
|
||
- [ ] **Step 2: Update `CLAUDE.md` "Currently in flight".**
|
||
|
||
Find the line:
|
||
|
||
```
|
||
**Currently in flight: Post-A.5 polish — Tier 1 retry (only remaining priority).**
|
||
```
|
||
|
||
Replace with the post-A.5-complete state. Update the recently-shipped narrative to mention #53.
|
||
|
||
- [ ] **Step 3: Update memory if new gotchas surfaced.**
|
||
|
||
If implementation surfaced any gotchas (e.g. unexpected animated/static transitions, an LB invalidation edge case, etc.) that other agents would benefit from, add a memory entry under `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/` and add a one-line link in `MEMORY.md`.
|
||
|
||
If no new gotchas surfaced, add a one-line note to `project_phase_a5_state.md` documenting the Tier 1 closure + final perf number.
|
||
|
||
- [ ] **Step 4: Final commit.**
|
||
|
||
```bash
|
||
git add docs/ISSUES.md CLAUDE.md
|
||
# also memory if updated
|
||
git commit -m "ship(post-A.5 #53): Tier 1 entity-classification cache — closes ISSUE #53
|
||
|
||
Static-only cache + DEBUG cross-check + invalidation hooks lands per spec
|
||
docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md.
|
||
|
||
Perf gate: entity dispatcher cpu_us median <ACTUAL>m / <ACTUAL>p95 at
|
||
horizon-safe preset (radius=4/12) on AMD Radeon RX 9070 XT @ 1440p.
|
||
Spec target was ≤2000m/≤2500p95. Baseline was ~3500m/~4000p95.
|
||
|
||
Visual gate: NPC animates, lifestone renders, buildings at correct
|
||
positions — confirmed by user 2026-05-10.
|
||
|
||
Closes the post-A.5 polish phase. Issues #52, #54, #53 all closed.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 5: Merge to main.**
|
||
|
||
The user merges the worktree branch via the same pattern as the prior session:
|
||
|
||
```bash
|
||
# from main:
|
||
git checkout main
|
||
git merge claude/friendly-varahamihira-7b8664 --no-ff -m "Merge branch 'claude/friendly-varahamihira-7b8664' — Tier 1 entity-classification cache (closes #53)"
|
||
git push origin main
|
||
```
|
||
|
||
(Implementation agent does not push without explicit user authorization. The merge step is included for the user's reference.)
|
||
|
||
---
|
||
|
||
## Self-Review checklist (run after writing the plan — completed inline)
|
||
|
||
- [x] **Spec coverage.** Every section of the spec maps to a task:
|
||
- Spec §1 (problem) → motivation, no task.
|
||
- Spec §3 Q1 (DEBUG cross-check) → Task 13.
|
||
- Spec §3 Q2 (separate class) → Tasks 2-5.
|
||
- Spec §3 Q3 (rest pose) → Task 8 (RestPose param) + Task 10 (cache-hit fast path).
|
||
- Spec §3 Q4 (Setup pre-flatten) → Task 8 (passes the right product) + Task 3 test #14.
|
||
- Spec §3 Q5 (thorough tests) → Tasks 2-5, 10, 11, 13 (12 cache + 2 integration + 2 DEBUG).
|
||
- Spec §5.3 invalidation wiring → Task 11 (per-entity), Task 12 (per-LB W3b).
|
||
- Spec §6.1-6.5 component contracts → Tasks 2-13.
|
||
- Spec §7 test plan → Tasks 2, 3, 4, 5, 10, 11, 13.
|
||
- Spec §8 sequencing → matches Tasks 1-17.
|
||
- Spec §9 acceptance criteria → Tasks 14-17.
|
||
- Spec §11 open implementation choices: W3b chosen (Task 12); GroupKey internal-at-namespace (Task 1); ResolveLandblockHint via walk plumbing (Task 6 + 9); _populateScratch field (Task 9).
|
||
|
||
- [x] **Placeholder scan.** Searched plan for "TBD", "TODO", "implement later", etc. No matches outside intentional context (e.g. the `<ACTUAL>` perf-number placeholders in the final commit message — to be filled by the implementer with measured values).
|
||
|
||
- [x] **Type consistency.** `CachedBatch`, `EntityCacheEntry`, `EntityClassificationCache` names + signatures match across Tasks 2-13. `GroupKey` is `internal` at namespace scope from Task 1 onward. Tuple shape `(WorldEntity Entity, int MeshRefIndex, uint LandblockId)` consistent in Tasks 6, 9, 10.
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
This plan is ready for execution. Two options:
|
||
|
||
**1. Subagent-Driven (recommended)** — fresh subagent per task; main session reviews each task before dispatching the next; fast iteration.
|
||
|
||
**2. Inline Execution** — execute tasks in this session via `superpowers:executing-plans`; batch execution with checkpoints between phases.
|
||
|
||
Both options preserve the TDD discipline (test before implementation in every step). Visual + perf gates (Tasks 15-16) require the user's eyes regardless of execution model.
|