# Phase N.4 — Rendering Pipeline Foundation 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:** Adopt WB's `ObjectMeshManager` + `TextureAtlasManager` as acdream's rendering pipeline foundation. Two-tier split (atlas for shared procedural content; per-instance path for customized server-spawned entities). Animation by per-draw matrix composition with `AnimationSequencer` untouched. Streaming integration via ~200 LOC adapter shim. Surface metadata preserved via side-table (no fork patches). Single shippable phase, 3-4 weeks. Ships no visible change.

**Architecture:** Strangler-fig substitution behind `ACDREAM_USE_WB_FOUNDATION=1` feature flag. Conformance tests run before substitution per N.1/N.3 pattern. Week-by-week sequencing minimizes "broken in middle" state: week 1 brings up plumbing + atlas for static scenery; week 2 wires streaming; week 3 adds per-instance path + animation; week 4 polishes + ships. Foundation enables N.5/N.6/N.7/N.8 to be smaller integration phases on top.

**Tech Stack:** .NET 10 / C# 13 · Silk.NET.OpenGL (transitively via WB) · Chorizite.OpenGLSDLBackend (already referenced) · BCnEncoder.Net (transitively) · xUnit · `Chorizite.Core.Render` interfaces.

**Spec:** [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../specs/2026-05-08-phase-n4-rendering-foundation-design.md) — read FIRST. Everything below assumes you've read it.
**Parent design:** [docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md](../specs/2026-05-08-phase-n-worldbuilder-migration-design.md)
**Inventory:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
**Roadmap:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — N.4 entry

**Prerequisites:**
- Phase N.0 shipped (commit `c8782c9`) — WB submodule + project references wired up.
- Phase N.1 shipped (merge `1978ef9`) — scenery via WB helpers.
- Phase N.3 shipped (merge `13132f9`) — texture decode via WB `TextureHelpers`.
- Build green, 883 tests passing, 8 pre-existing failures only.
- Worktree: `.claude/worktrees/quirky-jepsen-fd60f1` on branch `claude/quirky-jepsen-fd60f1`.

---

## File Plan

| File | Disposition | Responsibility |
|---|---|---|
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | NEW | Owns the `ObjectMeshManager` instance. Exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` / `Dispose`. The single seam between acdream and WB's render pipeline. |
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | NEW | Record holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog` (the AC-specific surface properties WB's `MeshBatchData` doesn't carry). |
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | NEW | `Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>` side-table. Populated at mesh-extraction time; queried at draw time. Thread-safe (`ConcurrentDictionary`). |
| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | NEW | Streaming-loader hook. Walks `LandblockEntry.Setups[]` / `Statics[]` and calls `WbMeshAdapter.IncrementRefCount` per unique GfxObj. Companion unload path. |
| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | NEW | Network-spawn hook. Routes `CreateObject` to per-instance path via existing `TextureCache.GetOrUploadWithPaletteOverride`. Builds per-entity `AnimatedEntityState`. |
| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | NEW | Per-entity render state for animated entities: `partGfxObjOverrides` (AnimPartChange), `hiddenMask` (HiddenParts), reference to existing `AnimationSequencer`. |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | NEW | Per-frame draw loop. Walks visible entities, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table, issues GL draws. |
| `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` | NEW | Static flag gate: `WbFoundationFlag.IsEnabled` reads `ACDREAM_USE_WB_FOUNDATION` env var once at process start, exposes a single `bool`. Other call sites import this rather than re-reading the env var. |
| `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs` | NEW | Conformance: our `GfxObjMesh.Build` vs WB's algorithm output. |
| `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs` | NEW | Conformance: our `SetupMesh.Flatten` vs WB's setup-parts walk. |
| `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs` | NEW | Conformance: per-instance customization decode produces identical RGBA8 vs `TextureCache.GetOrUploadWithPaletteOverride`. |
| `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs` | NEW | Round-trip + thread-safety smoke. |
| `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs` | NEW | Register/unregister + dedup. |
| `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs` | NEW | Routes CreateObject with palette override to per-instance path. |
| `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs` | NEW | Entity × animation × rest-pose matrix composition. |
| `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs` | NEW | Bitmask suppression. |
| `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs` | NEW | Override resolution. |
| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | MODIFY | Internal swap: `EnsureUploaded` and `Draw` route through `WbMeshAdapter` when `WbFoundationFlag.IsEnabled`. Public surface unchanged. **N.6 fully replaces this file.** |
| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | MODIFY | Same pattern — internal swap, public surface unchanged. **N.6 fully replaces this file.** |
| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | `GetOrUpload(surfaceId)` (atlas-tier callers) routes through `WbMeshAdapter` when flag on. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep current behavior. |
| `src/AcDream.App/Streaming/GpuWorldState.cs` | MODIFY | `AddLandblock` / `RemoveLandblock` call `LandblockSpawnAdapter` when flag on. `AppendLiveEntity` calls `EntitySpawnAdapter` when flag on. Pending-spawn list mechanism preserved verbatim. |
| `src/AcDream.App/Rendering/GameWindow.cs` | MODIFY | Construct `WbMeshAdapter` + `AcSurfaceMetadataTable` + `WbDrawDispatcher` on init. Dispose on shutdown. |
| `docs/plans/2026-04-11-roadmap.md` | MODIFY (final task) | Mark N.4 shipped after visual verification. |
| `CLAUDE.md` | MODIFY (early task) | Add pointer to this plan in the "Roadmap discipline" section so future agents pick it up. |

**Why this structure:** the `Wb/` subfolder isolates everything new in N.4 from existing renderers. After N.6 fully replaces `StaticMeshRenderer` / `InstancedMeshRenderer`, the `Wb/` folder becomes the canonical rendering implementation. Each new file has one responsibility. Existing files are touched minimally; the bulk of N.4 lives in the new folder.

---

## Plan Living-Document Convention

This plan is the **execution source of truth** for N.4. It is updated as tasks land:

- After each commit that completes a task, mark the task's checkboxes ✅ and append the commit SHA next to the task header.
- If a task uncovers an architectural surprise that requires re-planning, add a **`### Adjustment N`** subsection under the affected task with the date, what changed, and why. Do not silently rewrite earlier tasks.
- If a downstream task changes shape because of an earlier task's outcome, append the changes to the downstream task in-place rather than scattering deltas.
- Final commit for the phase updates this header note from "Living document — work in progress" to "Final state at <date> — phase shipped (merge `<sha>`)."

Status: **Final state at 2026-05-08 — phase shipped.** All tasks
complete; `ACDREAM_USE_WB_FOUNDATION` flipped default-on. Visual
verification at Holtburg passed. Three bugs surfaced + resolved during
Task 26 are documented as Adjustments 7-9 below and as gotchas in
CLAUDE.md. Followup work moves to N.5 (modern rendering path: bindless
+ multi-draw indirect).

**Progress (2026-05-08):** Weeks 1 + 2 + 3 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural — same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only.

**Next: Task 22** (Week 4) — `WbDrawDispatcher` full draw loop. The first task that actually draws through WB and unlocks the dual-pipeline-cost mitigation from Adjustment 3.

| Task | Status | Commit |
|---|---|---|
| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
| 4 — Setup-flatten conformance | ✅ | `ed73fc5` |
| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
| 6 — WbDatReaderAdapter | ✅ OBSOLETED (Adj. 1) | `502c3a8` |
| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` |
| 10 — Week 1 wrap-up | ✅ | `c49c6ed` |
| 11 — LandblockSpawnAdapter | ✅ | `669768d` |
| 12 — Wire into GpuWorldState | ✅ | `931a690` |
| 13 — Memory budget verification | ✅ deferred to Task 22 (Adj. 3) | — |
| 14 — Pending-spawn integration test | ✅ | `f4f0101` |
| Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` |
| 15 — Week 2 wrap-up | ✅ | `36f7a60` |
| 16+18+19 — AnimatedEntityState + AnimPartChange + HiddenParts | ✅ | `ce72c57` |
| 17 — EntitySpawnAdapter | ✅ + Adj. 4 | `c02c307` |
| 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) |
| 21 — Week 3 wrap-up | ✅ | (this commit) |
| 22+23 — WbDrawDispatcher + side-table population | ✅ | `01cff41` |
| 22+23 fixup — load triggers + SurfaceId source | ✅ | `943652d` |
| 22+23 perf — FirstIndex/BaseVertex + #47 + grouped instanced | ✅ | `7b41efc` |
| 22+23 perf 1-4 — drop dead lookup, sort, cull, hash memo | ✅ | `573526d` |
| 24 — Sky-pass preservation check | ✅ structural (independent) | `5df9135` |
| 25 — Component micro-tests round-out | ✅ all spec tests covered | — |
| 26 — Visual verification + flag default-on | ✅ | (this commit) |
| 27 — Delete legacy code paths | ⚠️ deferred to N.6 (legacy retained as flag-off escape hatch) | — |
| 28 — Update memory + ISSUES + finalize plan | ✅ | (this commit) |

---

## Week 1 — Plumbing + Atlas for Static Scenery + Conformance

Goal of week 1: WB infrastructure wired up behind feature flag. Conformance tests pass. Static scenery routes through `ObjectMeshManager` when flag is on. Everything else still uses old path. **Done when: build green, all conformance tests pass, flag-on Holtburg roam visually identical to flag-off.**

### Task 1: Wb folder skeleton + `WbFoundationFlag`

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs`

- [ ] **Step 1.1: Create the Wb folder by creating the flag file**

```csharp
namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
/// Read once at static-init time; all consumers import this rather than
/// re-reading the env var per call (env-var lookups on Windows are not
/// free at hot-path cadence).
///
/// <para>
/// Set <c>ACDREAM_USE_WB_FOUNDATION=1</c> to route static-scenery + atlas
/// content through WB's <c>ObjectMeshManager</c>; per-instance customized
/// content (server <c>CreateObject</c> entities) takes the existing
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> path either
/// way. Flag becomes default-on at end of Phase N.4 after visual
/// verification.
/// </para>
/// </summary>
public static class WbFoundationFlag
{
    public static bool IsEnabled { get; } =
        System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
}
```

- [ ] **Step 1.2: Build to verify the new folder compiles**

Run: `dotnet build --verbosity quiet`
Expected: 0 errors. The folder exists implicitly because the file's namespace declares it.

- [ ] **Step 1.3: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
git commit -m "$(cat <<'EOF'
phase(N.4): WbFoundationFlag scaffold for ACDREAM_USE_WB_FOUNDATION env var

Creates the src/AcDream.App/Rendering/Wb/ folder and the static flag
gate that other call sites will import. Read once at static-init time.
Set ACDREAM_USE_WB_FOUNDATION=1 to enable WB foundation routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 2: `AcSurfaceMetadata` + `AcSurfaceMetadataTable`

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs`
- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`

- [ ] **Step 2.1: Write failing test**

Create `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`:

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class AcSurfaceMetadataTableTests
{
    [Fact]
    public void Add_ThenLookup_RoundTripsSameMetadata()
    {
        var table = new AcSurfaceMetadataTable();
        var meta = new AcSurfaceMetadata(
            Translucency: TranslucencyKind.AlphaBlend,
            Luminosity: 0.5f,
            Diffuse: 0.8f,
            SurfOpacity: 0.7f,
            NeedsUvRepeat: true,
            DisableFog: false);

        table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);

        Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
        Assert.Equal(meta, got);
    }

    [Fact]
    public void Lookup_MissingKey_ReturnsFalse()
    {
        var table = new AcSurfaceMetadataTable();
        Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
    }

    [Fact]
    public void Add_OverwritesPreviousMetadata()
    {
        var table = new AcSurfaceMetadataTable();
        var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
        var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);

        table.Add(0xAAAA, 0, first);
        table.Add(0xAAAA, 0, second);

        Assert.True(table.TryLookup(0xAAAA, 0, out var got));
        Assert.Equal(second, got);
    }

    [Fact]
    public void Add_FromMultipleThreads_IsThreadSafe()
    {
        var table = new AcSurfaceMetadataTable();
        var threads = new System.Threading.Tasks.Task[8];
        for (int t = 0; t < 8; t++)
        {
            int threadIdx = t;
            threads[t] = System.Threading.Tasks.Task.Run(() =>
            {
                for (int i = 0; i < 1000; i++)
                {
                    ulong key = (ulong)(threadIdx * 1000 + i);
                    table.Add(key, 0, new AcSurfaceMetadata(
                        TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
                }
            });
        }
        System.Threading.Tasks.Task.WaitAll(threads);

        // 8000 entries should be present.
        for (int t = 0; t < 8; t++)
        for (int i = 0; i < 1000; i++)
            Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
    }
}
```

- [ ] **Step 2.2: Run test to verify it fails**

Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
Expected: COMPILE FAIL — types don't exist.

- [ ] **Step 2.3: Create `AcSurfaceMetadata.cs`**

```csharp
using AcDream.Core.Meshing;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
/// doesn't carry. Computed at mesh-extraction time and looked up by the
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
///
/// <para>
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
/// behavior is preserved bit-for-bit through the migration.
/// </para>
/// </summary>
public sealed record AcSurfaceMetadata(
    TranslucencyKind Translucency,
    float Luminosity,
    float Diffuse,
    float SurfOpacity,
    bool NeedsUvRepeat,
    bool DisableFog);
```

- [ ] **Step 2.4: Create `AcSurfaceMetadataTable.cs`**

```csharp
using System.Collections.Concurrent;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
/// is extracted; queried at draw time.
///
/// <para>
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
/// identity because batch objects can be evicted and re-loaded by WB's
/// LRU; the (gfxObj, surface) pair is stable across cycles.
/// </para>
/// </summary>
public sealed class AcSurfaceMetadataTable
{
    private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();

    public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
        => _table[(gfxObjId, surfaceIdx)] = meta;

    public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
        => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);

    public void Clear() => _table.Clear();
}
```

- [ ] **Step 2.5: Run tests to verify pass**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
Expected: 4/4 PASS.

- [ ] **Step 2.6: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): AcSurfaceMetadata side-table for WB-pristine surface props

Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
queried by the draw dispatcher. ConcurrentDictionary because mesh
extraction happens on background workers.

No fork patches required — keeps WB's MeshBatchData pristine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 3: Mesh-extraction conformance test

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs`

This test proves our existing `GfxObjMesh.Build` produces the same vertex + index output as WB's algorithm. Per [GfxObjMesh.cs:24](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs:24), our code is already a faithful port of WB's `BuildPolygonIndices` — this test pins that fact.

- [ ] **Step 3.1: Write the test**

```csharp
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;

namespace AcDream.Core.Tests.Rendering.Wb;

/// <summary>
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
/// would for the same input GfxObj. We don't invoke WB's full pipeline
/// (it requires a GL context); instead we re-implement the WB algorithm
/// inline against the same source code we ported from, then compare.
///
/// <para>
/// If this test fails, either our port has drifted or the WB code has
/// changed upstream — investigate which, do not "fix" the test.
/// </para>
/// </summary>
public sealed class MeshExtractionConformanceTests
{
    [Fact]
    public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
    {
        var gfxObj = MakeUnitQuadGfxObj();

        var ours = GfxObjMesh.Build(gfxObj, dats: null);

        Assert.Single(ours);
        var sub = ours[0];
        // Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
        Assert.Equal(4, sub.Vertices.Length);
        Assert.Equal(6, sub.Indices.Length);
        // Fan from vertex 0: (0,1,2) and (0,2,3).
        Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
    }

    [Fact]
    public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
    {
        var gfxObj = MakeUnitQuadGfxObj();
        // Force the polygon to be double-sided via Stippling.Both.
        var poly = gfxObj.Polygons[0];
        poly.Stippling = StipplingType.Both;
        poly.NegSurface = 0;  // same surface idx for both sides

        var ours = GfxObjMesh.Build(gfxObj, dats: null);

        // One sub-mesh per (surfaceIdx, isNeg) bucket.
        Assert.Equal(2, ours.Count);
        // Negative-side bucket has reversed winding.
        var neg = ours.First(s => s.Indices.SequenceEqual(new uint[] { 2, 1, 0, 3, 2, 0 }));
        Assert.NotNull(neg);
    }

    [Fact]
    public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
    {
        var gfxObj = MakeUnitQuadGfxObj();
        var poly = gfxObj.Polygons[0];
        poly.Stippling = StipplingType.None;  // no explicit Negative flag
        poly.SidesType = CullMode.Clockwise;  // AC's "double-sided via SidesType" convention
        poly.NegSurface = 0;

        var ours = GfxObjMesh.Build(gfxObj, dats: null);

        Assert.Equal(2, ours.Count);
    }

    [Fact]
    public void Build_NoPosFlag_OnlyEmitsNegSide()
    {
        var gfxObj = MakeUnitQuadGfxObj();
        var poly = gfxObj.Polygons[0];
        poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
        poly.NegSurface = 0;

        var ours = GfxObjMesh.Build(gfxObj, dats: null);

        Assert.Single(ours);
    }

    [Fact]
    public void Build_NegUVIndices_AppliedToNegSideVertices()
    {
        var gfxObj = MakeUnitQuadGfxObj();
        var poly = gfxObj.Polygons[0];
        poly.Stippling = StipplingType.Both;
        poly.NegSurface = 0;
        // Default UV index 0 maps to UV (0,0); NegUVIndices=[2,2,2,2] should
        // map to UV (1,1) on the neg side.
        poly.NegUVIndices = [2, 2, 2, 2];

        var ours = GfxObjMesh.Build(gfxObj, dats: null);

        var posSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(0, 0));
        var negSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(1, 1));
        Assert.NotNull(posSide);
        Assert.NotNull(negSide);
    }

    /// <summary>
    /// Build a synthetic 1×1 quad GfxObj with UV indices [0,1,2,3] mapping
    /// to UVs [(0,0), (1,0), (1,1), (0,1)]. Default surface index 0,
    /// PosSurface=0, NegSurface=0. No Stippling flags (caller may set).
    /// </summary>
    private static GfxObj MakeUnitQuadGfxObj()
    {
        var gfx = new GfxObj { Surfaces = [0u] };

        // Vertices: 4 corners with UV at each corner.
        gfx.VertexArray = new VertexArray();
        for (ushort i = 0; i < 4; i++)
        {
            var sw = new SwVertex
            {
                Origin = new Vector3(i % 2, i / 2, 0),
                Normal = new Vector3(0, 0, 1),
                UVs = new System.Collections.Generic.List<UV>
                {
                    new UV { U = i % 2, V = i / 2 },
                    new UV { U = 0.5f, V = 0.5f },
                    new UV { U = 1, V = 1 },
                },
            };
            gfx.VertexArray.Vertices[i] = sw;
        }

        // One quad polygon with vertex sequence [0,1,2,3] and PosUVIndices [0,0,0,0].
        var poly = new Polygon
        {
            VertexIds = [0, 1, 2, 3],
            PosUVIndices = [0, 0, 0, 0],
            NegUVIndices = [],
            PosSurface = 0,
            NegSurface = -1,
            Stippling = StipplingType.None,
            SidesType = CullMode.Counterclockwise,  // single-sided by default
        };
        gfx.Polygons[0] = poly;
        return gfx;
    }
}
```

- [ ] **Step 3.2: Run test to verify pass (since algorithm already ported)**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~MeshExtractionConformanceTests" --verbosity normal`
Expected: 5/5 PASS. If any test fails, investigate before continuing — it means our port has drifted.

- [ ] **Step 3.3: Commit**

```bash
git add tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
git commit -m "$(cat <<'EOF'
test(N.4): mesh-extraction conformance pinning GfxObjMesh.Build behavior

Five tests covering: simple quad, double-sided via Stippling.Both,
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
NoPos-only emission, and NegUVIndices application to neg-side vertices.

These pin GfxObjMesh.Build's output as the conformance baseline before
N.4 substitutes it with WB's pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 4: Setup-flatten conformance test

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs`

- [ ] **Step 4.1: Write the test**

```csharp
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;

namespace AcDream.Core.Tests.Rendering.Wb;

/// <summary>
/// Conformance: our <see cref="SetupMesh.Flatten"/> must produce the same
/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
/// Resting → Default → first available) before substitution.
/// </summary>
public sealed class SetupFlattenConformanceTests
{
    [Fact]
    public void Flatten_NoFrames_FallsBackToIdentity()
    {
        var setup = new Setup { Parts = [0x01000001ul] };

        var refs = SetupMesh.Flatten(setup);

        Assert.Single(refs);
        Assert.Equal(0x01000001ul, refs[0].GfxObjId);
        // No frame → identity transform, identity scale.
        Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
    }

    [Fact]
    public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
    {
        var setup = new Setup { Parts = [0x01000001ul] };
        var anim = new AnimationFrame
        {
            Frames =
            [
                new Frame
                {
                    Origin = new Vector3(10, 20, 30),
                    Orientation = Quaternion.CreateFromYawPitchRoll(0, 0, 0),
                },
            ],
        };
        setup.PlacementFrames[Placement.Default] = anim;

        var refs = SetupMesh.Flatten(setup);

        // Translation column should encode Origin.
        Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
    }

    [Fact]
    public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
    {
        var setup = new Setup { Parts = [0x01000001ul] };
        setup.PlacementFrames[Placement.Default] = new AnimationFrame
        {
            Frames = [new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity }],
        };
        setup.PlacementFrames[Placement.Resting] = new AnimationFrame
        {
            Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
        };

        var refs = SetupMesh.Flatten(setup);

        // Resting wins.
        Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
    }

    [Fact]
    public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
    {
        var setup = new Setup { Parts = [0x01000001ul] };
        setup.PlacementFrames[Placement.Resting] = new AnimationFrame
        {
            Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
        };
        var motionOverride = new AnimationFrame
        {
            Frames = [new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity }],
        };

        var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);

        Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
    }

    [Fact]
    public void Flatten_DefaultScalePerPart_AppliedToTransform()
    {
        var setup = new Setup
        {
            Parts = [0x01000001ul, 0x01000002ul],
            DefaultScale = [new Vector3(2, 2, 2), new Vector3(3, 3, 3)],
        };

        var refs = SetupMesh.Flatten(setup);

        // Identity-frame * scale-2 → diagonal matrix with 2s.
        Assert.Equal(2f, refs[0].PartTransform.M11);
        Assert.Equal(3f, refs[1].PartTransform.M11);
    }
}
```

- [ ] **Step 4.2: Run test, verify pass**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~SetupFlattenConformanceTests" --verbosity normal`
Expected: 5/5 PASS.

- [ ] **Step 4.3: Commit**

```bash
git add tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs
git commit -m "$(cat <<'EOF'
test(N.4): setup-flatten conformance pinning placement-frame fallback chain

Five tests covering: identity (no frames), Default frame, Resting beats
Default, motion override beats Resting, DefaultScale per part. Pins
SetupMesh.Flatten's behavior as the conformance baseline before N.4
routes it through WB's setup-parts walk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 5: `WbMeshAdapter` skeleton

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs`

- [ ] **Step 5.1: Write failing test**

```csharp
using AcDream.App.Rendering.Wb;
using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class WbMeshAdapterTests
{
    [Fact]
    public void Construct_WithNullGl_ThrowsArgumentNull()
    {
        Assert.Throws<System.ArgumentNullException>(() =>
            new WbMeshAdapter(gl: null!, dats: NullDats(), logger: NullLogger<WbMeshAdapter>.Instance));
    }

    [Fact]
    public void Dispose_DisposesUnderlyingMeshManager_NoThrow()
    {
        // Without a real GL context we can't fully construct ObjectMeshManager,
        // but we can verify the adapter's Dispose path is safe to invoke when
        // the manager is null (early-init failure path).
        var adapter = WbMeshAdapter.CreateUninitialized();
        adapter.Dispose();  // should not throw
    }

    private static DatCollection NullDats() => DatCollection.Empty;
}
```

(Note: the tests above are minimal. Full coverage of the adapter happens
once it has real methods to exercise — Tasks 7-8.)

- [ ] **Step 5.2: Run, expect compile fail**

Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
Expected: COMPILE FAIL — type doesn't exist, `DatCollection.Empty` may not exist.

- [ ] **Step 5.3: Create the adapter**

Create `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`:

```csharp
using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Single seam between acdream and WB's render pipeline. Owns the
/// <see cref="ObjectMeshManager"/> instance and exposes a stable acdream-
/// shaped API (<see cref="IncrementRefCount"/> / <see cref="DecrementRefCount"/> /
/// <see cref="GetRenderData"/>) so the rest of the renderer doesn't need to
/// know about WB's types directly.
///
/// <para>
/// Instantiated once at <c>GameWindow</c> init when
/// <see cref="WbFoundationFlag.IsEnabled"/> is true. When the flag is off,
/// no instance is constructed and call sites fall through to the legacy
/// renderer paths.
/// </para>
/// </summary>
public sealed class WbMeshAdapter : System.IDisposable
{
    private readonly ObjectMeshManager? _meshManager;
    private readonly OpenGLGraphicsDevice? _graphicsDevice;
    private bool _disposed;

    public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger)
    {
        System.ArgumentNullException.ThrowIfNull(gl);
        System.ArgumentNullException.ThrowIfNull(dats);
        System.ArgumentNullException.ThrowIfNull(logger);

        // OpenGLGraphicsDevice is the host WB's ObjectMeshManager needs.
        // Constructed once and owned by the adapter for the process lifetime.
        _graphicsDevice = new OpenGLGraphicsDevice(gl);

        // ObjectMeshManager wants its own ILogger<ObjectMeshManager>;
        // we use NullLogger to avoid the wrong category, the adapter's
        // own logger handles the acdream-side trail.
        var omLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<ObjectMeshManager>.Instance;
        var datsAdapter = new WbDatReaderAdapter(dats);  // see Task 6
        _meshManager = new ObjectMeshManager(_graphicsDevice, datsAdapter, omLogger);
    }

    private WbMeshAdapter()
    {
        // Uninitialized constructor — only for tests that need a Dispose-safe
        // instance without a real GL context.
        _meshManager = null;
        _graphicsDevice = null;
    }

    internal static WbMeshAdapter CreateUninitialized() => new();

    /// <summary>
    /// Get GPU-side render data for an object id, blocking on background
    /// preparation if the upload hasn't finished yet. Returns null if the
    /// object can't be loaded (e.g., dat missing).
    /// </summary>
    public Chorizite.OpenGLSDLBackend.Lib.ObjectRenderData? GetRenderData(ulong id)
        => _meshManager?.GetRenderData(id);

    public void IncrementRefCount(ulong id) => _meshManager?.IncrementRefCount(id);

    public void DecrementRefCount(ulong id) => _meshManager?.DecrementRefCount(id);

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _meshManager?.Dispose();
        _graphicsDevice?.Dispose();
    }
}
```

- [ ] **Step 5.4: Build to expose compilation issues**

Run: `dotnet build --verbosity quiet`
Expected: Compile errors will reveal what we need:
- `WbDatReaderAdapter` does not exist yet (Task 6 creates it)
- `OpenGLGraphicsDevice` constructor signature may differ

For now, comment out the `_graphicsDevice = new OpenGLGraphicsDevice(gl);` and `_meshManager = new ObjectMeshManager(...)` lines and provide a TODO comment marker so the test file compiles. Replace with:

```csharp
// Real init defers to Task 6 (dat reader adapter) + Task 9 (full bring-up).
// During Task 5 the adapter is a stub that returns null/no-ops everywhere.
_graphicsDevice = null;
_meshManager = null;
```

- [ ] **Step 5.5: Add `DatCollection.Empty` if it doesn't exist**

If `DatCollection.Empty` is missing, the test won't compile. Check:

Run: `grep -rn "DatCollection.Empty" src/`

If absent, the test should be adjusted to construct an empty `DatCollection` directly (or skipped — the meaningful adapter tests come later). Update the test as needed; the test's only job at this stage is to verify Dispose is safe.

- [ ] **Step 5.6: Run tests**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
Expected: 2/2 PASS (with the stub init).

- [ ] **Step 5.7: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): WbMeshAdapter skeleton — single seam to WB ObjectMeshManager

Stub init pending Task 6 (dat reader adapter) + Task 9 (full bring-up).
Public API: IncrementRefCount / DecrementRefCount / GetRenderData /
Dispose. Construction-safe (validates args), Dispose-safe (no-op when
underlying manager is null).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 6: ~~WbDatReaderAdapter~~ — OBSOLETED 2026-05-08

**Adjustment 1 (2026-05-08):** discovered during pre-Task-6 grep that
WB ships `WorldBuilder.Shared.Services.DefaultDatReaderWriter`, a
concrete `IDatReaderWriter` implementation that takes a dat-directory
path and constructs all four databases (Portal / HighRes / Language +
CellRegions) internally. We can instantiate it directly with the same
`%USERPROFILE%\Documents\Asheron's Call` path acdream's `DatCollection`
uses; both will open the same dat files with separate handles. Memory
cost: ~50-100 MB of duplicate index caches, acceptable for foundation
work. Task 9 incorporates the construction step directly.

If memory pressure surfaces during week 2 stress testing, revisit by
writing a real bridge that shares index caches with our `DatCollection`.

**No work for this task — skip and proceed to Task 7.**

---

### Adjustment 2 (2026-05-08): Task 9 routing reverted — tier decision belongs at spawn-callback layer

**Discovered during Week 1 visual smoke test**: with flag on, characters /
NPCs disappeared along with static scenery. Root cause: Task 9 routed
**all** `InstancedMeshRenderer.EnsureUploaded` calls through
`WbMeshAdapter.IncrementRefCount` and marked their cache entries with
`WbManagedSentinel`. But `InstancedMeshRenderer` is used for both tiers
in production:

- **Atlas-tier** call sites: `_pendingCellMeshes` drain
  ([GameWindow.cs:5137](../../../src/AcDream.App/Rendering/GameWindow.cs:5137)),
  per-MeshRef GfxObj loop on `lb.Entities`
  ([:5155](../../../src/AcDream.App/Rendering/GameWindow.cs:5155)).
- **Per-instance-tier** call sites: per-part loop in spawn handling
  ([:2302](../../../src/AcDream.App/Rendering/GameWindow.cs:2302)) — this is
  character / creature rendering driven by server `CreateObject`.

The renderer is **tier-blind by design**: it doesn't know spawn source.
Putting routing logic there violates separation of concerns. The spec's
Data-Flow section already specifies the right placement — routing happens
at the **spawn-callback layer**:

- `LandblockSpawnAdapter.OnLandblockLoaded(...)` (Task 11) calls
  `IncrementRefCount` per unique GfxObj — atlas-tier only.
- `EntitySpawnAdapter.OnCreate(entity)` (Task 17) routes through
  per-instance path (`TextureCache.GetOrUploadWithPaletteOverride`) —
  never calls `IncrementRefCount` for atlas.

**Resolution:** reverted Task 9's renderer-level routing. Removed the
sentinel logic and the 4 sentinel-skip checks in
`InstancedMeshRenderer`. **Kept** the `_wbMeshAdapter` constructor
parameter (unused for now) so `GameWindow.cs` doesn't shift when
later tasks need adapter access. Kept all the real WB pipeline
construction in `WbMeshAdapter` (verified working under flag-off).

**Week 1 endpoint shifts:** "WB infrastructure constructed; flag-on and
flag-off visually identical." Routing arrives in Week 2 (Task 11) at
the correct layer. Smoke verification is now: flag-on === flag-off.

---

### Adjustment 3 (2026-05-08): flag-on FPS regression — root-caused, deferred to Task 22

**Discovered during Task 13 stress test** (radius 7, flag-on). Visible
FPS drop + rising frame latency vs flag-off baseline. Initial guess
was the staged-upload queue leaking memory; we shipped
`WbMeshAdapter.Tick()` (commit `bf53cb4`) to drain
`_meshManager.StagedMeshData` + `_graphicsDevice._glThreadQueue` per
frame. Result: leak fixed, but **FPS unchanged**.

**Real cause: dual-pipeline cost.** Flag-on runs both rendering
pipelines in parallel without yet collecting any savings:

1. **Background workers (4-wide).** `ObjectMeshManager` spins up
   `MaxParallelLoads = 4` worker threads decoding GfxObj polygons,
   building texture atlases, encoding batches. Contends with the
   render thread for CPU cores.
2. **Duplicate GL upload.** `Tick()` calls `UploadMeshData` per
   staged mesh, creating VAO/VBO/IBO + atlas texture uploads. Real
   per-call GL state churn on the render thread.
3. **Duplicate I/O.** `DefaultDatReaderWriter` opens its own dat file
   handles and rebuilds its own index cache (~50-100 MB) alongside
   our existing `DatCollection`. Memory bandwidth + GC churn.
4. **Legacy renderer keeps doing the same work.** Per Adjustment 2,
   `InstancedMeshRenderer` is tier-blind — it still uploads VAO /
   VBO / IBO for the same atlas-tier content WB is also building.
   **We literally double the prep cost** for every atlas-tier GfxObj.

The savings from WB's atlas batching only materialize when **Task 22
(`WbDrawDispatcher`) lands and the legacy renderer can short-circuit
its upload for atlas-tier content**. At that point WB owns atlas-tier
draw and `InstancedMeshRenderer` skips its own upload + draw work
for those entities. Until then, flag-on pays both costs.

**Decision: do not fix now.** Plan Risk #5 explicitly anticipated this:

> Performance regression during integration of week 1's "atlas for
> static scenery, old path for everything else" mixed state.
> Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1`
> during weeks 1-3; default-off until week 4 visual verification.

Default-off (the user's daily experience) is byte-identical to
pre-N.4. Flag-on is dev-only until Week 4. Task 22 must wire the
legacy-renderer short-circuit for atlas-tier content as part of
landing the dispatcher; the cost cannot be amortized any earlier
without violating Adjustment 2's tier-blind-renderer principle.

`Tick()` stays — it fixed a real memory leak and is required
infrastructure for Task 22 anyway. We just paid for it without
seeing FPS recovery yet.

---

### Adjustment 4 (2026-05-08): WorldEntity lacks HiddenParts + AnimPartChange fields — deferred plumbing

**Discovered during Task 17 implementation.** `EntitySpawnAdapter.OnCreate`
needed to populate `AnimatedEntityState` with the entity's `HiddenParts`
mask + `AnimPartChange` override map. But: `WorldEntity` (the per-frame
render-side struct) does not currently expose either field. Both pieces
of customization data live on the network-layer spawn record and are
consumed before the `WorldEntity` is built.

**Resolution.** Task 17 ships the adapter scaffolding with a TODO comment
acknowledging the gap. The created `AnimatedEntityState` always has an
empty override map + zero hidden mask. Per-instance customizations like
"hide this character's head" won't take effect with flag-on until the
plumbing lands.

**Why this is safe to defer.** No production path consumes
`AnimatedEntityState`'s override / hidden data yet — Task 22's
`WbDrawDispatcher` is the first consumer. By the time Task 22 lands, we
either:
1. Add `HiddenPartsMask` + `AnimPartChanges` fields to `WorldEntity` and
   populate them at spawn time. Small change to the network → render
   pipeline.
2. Inject them into `EntitySpawnAdapter.OnCreate` via a separate
   parameter that the spawn handler provides directly (sidesteps the
   `WorldEntity` change).

Option 1 is cleaner long-term; Option 2 is faster for landing Task 22
without touching WorldEntity. Decision deferred to Task 22 brainstorm.

### Adjustment 5 (2026-05-08): Task 20 (per-instance decode conformance) is structural, not byte-comparison

**Original plan.** Task 20 was supposed to compare RGBA8 output of
"old path" (`TextureCache.GetOrUploadWithPaletteOverride` direct) vs
"new path" (`EntitySpawnAdapter` → `ITextureCachePerInstance` →
`TextureCache.GetOrUploadWithPaletteOverride`) to prove byte-identity.

**Reality.** Both paths call the **same function**. The new path adds a
seam interface (`ITextureCachePerInstance`) for testability but does
not modify the decode logic — the bytes are identical by construction.
A test asserting byte-equality would be tautological.

**Resolution.** Existing `EntitySpawnAdapterTests` cover the routing
behavior (does the adapter call the cache with the right args?). The
decode-byte conformance is structural: same function = same output.
Mark Task 20 ✅ structurally; no separate test file.

### Adjustment 6 (2026-05-08): Resolved Adjustment 4 — Option A (fields on WorldEntity)

**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges`
plumbing decision to Task 22. Two options:
- **A**: add fields to `WorldEntity`, populate at spawn time
- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate`

**Decision: Option A.** Reasoning:
1. The data is already computed at spawn time in GameWindow's CreateObject
   handler — adding two fields is a 4-line change.
2. Option B would spread network-layer types across the streaming subsystem,
   violating the same separation-of-concerns principle as Adjustment 2.
3. The 0xF625 ObjDescEvent (appearance update) replays through the same
   spawn path, so WorldEntity fields work automatically for hot-swap updates.

**Implementation:**
- `WorldEntity` gains `PartOverrides: IReadOnlyList<PartOverride>` (default
  empty) and `HiddenPartsMask: ulong` (default 0).
- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct
  in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`.
- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)`
  and `state.SetPartOverride(...)` for each override.
- GameWindow's CreateObject handler builds the `PartOverride[]` from the
  server-sent `AnimPartChanges` list.

### Adjustment 7 (2026-05-08, Task 26 visual verification): IncrementRefCount doesn't trigger mesh load

**Discovered when** Task 26's first launch showed only terrain — zero entities visible. Diagnostic counters (added the same launch via `ACDREAM_WB_DIAG=1`) showed `entitiesSeen=14M, entitiesDrawn=14M, drawsIssued=0` — every entity was visited but no draws were issued because `TryGetRenderData` returned null for everything.

**Root cause.** WB's `ObjectMeshManager.IncrementRefCount(id)` only bumps a usage counter — it does NOT trigger mesh loading. Loading is fired separately by `PrepareMeshDataAsync(id, isSetup)`, which dispatches to a background worker pool; the result auto-enqueues to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`) which our existing `WbMeshAdapter.Tick()` already drains.

The N.4 plan assumed `IncrementRefCount` was lifecycle-aware (it isn't). `LandblockSpawnAdapter` and the original `EntitySpawnAdapter` both called `IncrementRefCount` and stopped — meshes never loaded.

**Fix** (commit `943652d`):
- `WbMeshAdapter.IncrementRefCount` now calls `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` on first registration. `isSetup: false` is correct because acdream's MeshRefs already carry expanded per-part GfxObj ids (0x01XXXXXX) — WB's Setup-expansion path is unused.
- `EntitySpawnAdapter` gained an optional `IWbMeshAdapter` constructor parameter. Per-instance entities (server-spawned characters / NPCs) had been entirely skipped by `LandblockSpawnAdapter` (which filters `ServerGuid != 0`); their GfxObjs now get registered + loaded at `OnCreate` and decremented at `OnRemove`. Includes both `MeshRefs.GfxObjId` AND `PartOverrides.GfxObjId` so weapon/clothing/helmet swaps load too.

**Lesson preserved.** Future cross-session work touching WB: **`IncrementRefCount` is not lifecycle-aware. Call `PrepareMeshDataAsync` to trigger loads.** Documented in CLAUDE.md "WB integration cribs" section.

### Adjustment 8 (2026-05-08, Task 26 visual verification): SurfaceId lives in batch.Key.SurfaceId

**Discovered when** the second Task 26 launch showed `drawsIssued=4.8M/5s` (draws ARE happening) but ZERO entities visible. Inspection of `ResolveTexture` showed it was returning early because `batch.SurfaceId == 0` for every batch.

**Root cause.** WB's `ObjectMeshManager.UploadGfxObjMeshData` (line 1746 of `ObjectMeshManager.cs`) constructs `ObjectRenderBatch` and sets `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct that contains a `SurfaceId` field) but does NOT populate the top-level `ObjectRenderBatch.SurfaceId` property. That property exists on the type but stays at its default 0.

**Fix** (commit `943652d`): `WbDrawDispatcher.ResolveTexture` reads `batch.Key.SurfaceId` instead of `batch.SurfaceId`. Also handles the dummy `0xFFFFFFFF` case used by WB's environment edge wireframes.

**Lesson preserved.** **`ObjectRenderBatch.SurfaceId` is not populated by WB. Read `batch.Key.SurfaceId`.** Documented in CLAUDE.md.

### Adjustment 9 (2026-05-08, Task 26 visual verification): Modern rendering uses one global VAO/VBO/IBO

**Discovered when** the third Task 26 launch finally showed real draws — but as "exploded" character body parts scattered around the world with no scenery. Visual was completely broken even though the GL pipeline was clearly issuing draws and binding textures correctly.

**Root cause.** WB's `ObjectMeshManager` has two rendering paths controlled by `_useModernRendering = HasOpenGL43 && HasBindless`. On any modern GPU (which is everything we target), modern is true and ALL meshes share a single `GlobalMeshBuffer` — one VAO, one VBO, one IBO. Each batch's `IBO` field points to that ONE global IBO; the batch's actual slice is identified by `FirstIndex` (offset into IBO, in indices) and `BaseVertex` (offset into VBO, in vertices). The dispatcher was issuing `glDrawElementsInstanced` with `indices=0` and no base vertex — so every entity drew the same first triangle of the global mesh starting at offset 0. That produced exactly the "exploded parts at scattered positions" symptom.

**Fix** (commit `7b41efc`): switch to `glDrawElementsInstancedBaseVertexBaseInstance`, pass `(void*)(batch.FirstIndex * sizeof(ushort))` as the indices argument, pass `(int)batch.BaseVertex` as base vertex. The grouped-instanced refactor in the same commit additionally uses `BaseInstance` to slice into the shared instance VBO per group.

**Bonus discovery:** because all meshes share one VAO under modern rendering, the dispatcher only needs to bind the VAO ONCE per frame (not per draw). Every draw goes to the same VAO. Significant CPU savings.

**Lesson preserved.** **WB's modern rendering path packs everything into one global VAO/VBO/IBO. Honor `FirstIndex` and `BaseVertex`.** Documented in CLAUDE.md.

### Adjustment 10 (2026-05-08, Task 26 visual verification): AnimatedEntityState overrides clobber Issue #47 close-detail mesh

**Discovered when** Task 26's fourth launch showed scenery + connected characters — but characters were "bulky and missing detail" compared to the legacy renderer. Recognized as a re-occurrence of Issue #47 (resolved 2026-05-06 via `GfxObjDegradeResolver`).

**Root cause.** Adjustment 6 stored AnimPartChanges on `WorldEntity.PartOverrides` using the raw `NewModelId` from the network packet — without applying `GfxObjDegradeResolver`. GameWindow's spawn path correctly resolves base GfxObjs (e.g., upper arm `0x01000055`, 14 verts/17 polys) to their close-detail equivalents (`0x01001795`, 32 verts/60 polys) and bakes the result into `MeshRefs`. But `WbDrawDispatcher` then called `animState.ResolvePartGfxObj(partIdx, meshRefGfxObjId)` which returned the raw (low-detail) override from `PartOverrides`, undoing the degrade.

**Fix** (commit `7b41efc`): the dispatcher trusts `MeshRefs` as the source of truth and does NOT re-apply `animState.ResolvePartGfxObj` at draw time. `AnimatedEntityState` overrides become relevant only for hot-swap appearance updates (0xF625 `ObjDescEvent`) which today rebuild MeshRefs anyway. `IsPartHidden` similarly skipped — `HiddenPartsMask` is never populated by spawn code (legacy renderer also doesn't check it).

**Lesson preserved.** **`MeshRefs` is the source of truth at draw time** — GameWindow's spawn path bakes overrides + degrades into it. Don't re-apply overrides downstream.

### Task 6 (original — kept for history)

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs`

WB's `ObjectMeshManager` constructor takes `IDatReaderWriter` (from `Chorizite.DatReaderWriter`). We use `DatReaderWriter.DatCollection` (vendored as a separate library). The two interfaces are similar but not identical. This task builds the adapter.

- [ ] **Step 6.1: Read WB's `IDatReaderWriter` interface**

Run: `grep -n "interface IDatReaderWriter" references/WorldBuilder/`

Read the interface to identify which methods are actually called by `ObjectMeshManager`. Likely just `Portal.TryGet<T>(id, out T)` and similar accessors.

- [ ] **Step 6.2: Write failing test**

```csharp
using AcDream.App.Rendering.Wb;
using DatReaderWriter;
using DatReaderWriter.DBObjs;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class WbDatReaderAdapterTests
{
    [Fact]
    public void Portal_TryGet_DelegatesToUnderlyingDats()
    {
        var dats = new DatCollection();
        // Inject a known surface into the test dat collection.
        var surface = new Surface { Id = 0x08001234 };
        dats.Portal.Insert(surface);

        var adapter = new WbDatReaderAdapter(dats);

        Assert.True(adapter.Portal.TryGet<Surface>(0x08001234, out var got));
        Assert.Equal(surface.Id, got.Id);
    }

    [Fact]
    public void Portal_TryGet_MissingId_ReturnsFalse()
    {
        var adapter = new WbDatReaderAdapter(new DatCollection());
        Assert.False(adapter.Portal.TryGet<Surface>(0xDEADBEEF, out _));
    }
}
```

- [ ] **Step 6.3: Run, expect compile fail**

Expected: COMPILE FAIL — `WbDatReaderAdapter` doesn't exist.

- [ ] **Step 6.4: Create the adapter**

The exact code depends on WB's `IDatReaderWriter` shape. The pattern is a thin pass-through:

```csharp
using DatReaderWriter;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Adapter from acdream's <see cref="DatCollection"/> (vendored from
/// upstream <c>DatReaderWriter</c>) to the <c>IDatReaderWriter</c>
/// interface WB's <c>ObjectMeshManager</c> consumes. Pass-through where
/// possible; reshapes calls to match WB's expected interface where the
/// libraries diverge.
/// </summary>
public sealed class WbDatReaderAdapter : Chorizite.DatReaderWriter.IDatReaderWriter
{
    private readonly DatCollection _dats;

    public WbDatReaderAdapter(DatCollection dats)
    {
        System.ArgumentNullException.ThrowIfNull(dats);
        _dats = dats;
    }

    public Chorizite.DatReaderWriter.IDatDatabase Portal => new PortalAdapter(_dats);
    public Chorizite.DatReaderWriter.IDatDatabase Cell => new CellAdapter(_dats);
    public Chorizite.DatReaderWriter.IDatDatabase HighRes => new HighResAdapter(_dats);
    public Chorizite.DatReaderWriter.IDatDatabase Local => new LocalAdapter(_dats);

    private sealed class PortalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
    private sealed class CellAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
    private sealed class HighResAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
    private sealed class LocalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
}
```

The exact method set depends on `IDatDatabase`. Investigate via `grep` and fill in.

- [ ] **Step 6.5: Adjustment marker**

If `IDatReaderWriter`'s shape is more complex than expected (e.g., async readers, MEMORY-mapped access), add an Adjustment subsection here describing what was discovered and how the adapter changed.

- [ ] **Step 6.6: Run tests**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbDatReaderAdapterTests" --verbosity normal`
Expected: 2/2 PASS.

- [ ] **Step 6.7: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): WbDatReaderAdapter — bridge DatCollection to WB IDatReaderWriter

Pass-through adapter so WB's ObjectMeshManager can consume our
DatCollection without us refactoring to Chorizite.DatReaderWriter
directly. Maintains four sub-database accessors (Portal/Cell/
HighRes/Local).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 7: Wire `WbMeshAdapter` into `GameWindow` lifecycle (gated by flag)

**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`

- [ ] **Step 7.1: Locate `GameWindow.OnLoad` and the renderer construction**

Run: `grep -n "new TextureCache\|new StaticMeshRenderer\|new InstancedMeshRenderer" src/AcDream.App/Rendering/GameWindow.cs`

Identify where existing renderers are constructed during init.

- [ ] **Step 7.2: Add `WbMeshAdapter` field + construct under flag gate**

Add a private field:
```csharp
private WbMeshAdapter? _wbMeshAdapter;
```

In `OnLoad` (or wherever renderers are constructed), after `_textures` is built:
```csharp
if (WbFoundationFlag.IsEnabled)
{
    var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<WbMeshAdapter>.Instance;
    _wbMeshAdapter = new WbMeshAdapter(gl, _dats, logger);
    System.Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
}
```

In `OnClose` / `Dispose`:
```csharp
_wbMeshAdapter?.Dispose();
```

- [ ] **Step 7.3: Build to verify no breakage with flag default-off**

Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
Expected: build green, all 8 + 4 + 5 + 5 + 2 = 24 new tests pass on top of the 883 baseline. (Pre-existing 8 failures unchanged.)

- [ ] **Step 7.4: Commit**

```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
phase(N.4): construct WbMeshAdapter in GameWindow under feature flag

Enabled only when ACDREAM_USE_WB_FOUNDATION=1. Dispose paired with
window shutdown. No call sites use it yet — wiring of TextureCache /
StaticMeshRenderer / GpuWorldState happens in Tasks 8-10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: CLAUDE.md pointer to this plan

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 8.1: Add a "Currently in flight" pointer near the top**

Edit `CLAUDE.md`. After the "Roadmap discipline" section's intro (around the section that mentions `docs/superpowers/specs/*.md`), insert:

```markdown
**Currently in flight: Phase N.4 — Rendering Pipeline Foundation.** Plan
at `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
This is a 3-4 week phase adopting WB's `ObjectMeshManager` +
`TextureAtlasManager` as our shared rendering infrastructure. The plan
is a living document — task checkboxes get marked as commits land,
adjustments are appended in-place, weeks 2-4 may be revised based on
week 1 discoveries. Read the plan's "Plan Living-Document Convention"
section before contributing.
```

- [ ] **Step 8.2: Commit**

```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs: point CLAUDE.md at the in-flight N.4 plan

Future agents picking up the project should see the N.4 plan as
authoritative for rendering work. Pointer lives near the Roadmap
discipline section. Living-doc convention noted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 9: Wire static-scenery path through `WbMeshAdapter`

**Files:**
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs`

This is the first behavioral change: when the flag is on, static-scenery uploads route through `WbMeshAdapter` instead of building VAO/VBO/EBO inline.

- [ ] **Step 9.1: Locate `EnsureUploaded` in `StaticMeshRenderer.cs`**

Currently uploads sub-meshes directly. We're adding a flag-gated alternate path.

- [ ] **Step 9.2: Add adapter reference + flag-gated upload**

Modify `StaticMeshRenderer` to accept an optional `WbMeshAdapter`:

```csharp
public sealed unsafe class StaticMeshRenderer : IDisposable
{
    // ...existing fields...
    private readonly WbMeshAdapter? _wbMeshAdapter;

    public StaticMeshRenderer(
        GL gl,
        Shader shader,
        TextureCache textures,
        WbMeshAdapter? wbMeshAdapter = null)  // optional injection
    {
        _gl = gl;
        _shader = shader;
        _textures = textures;
        _wbMeshAdapter = wbMeshAdapter;
    }

    public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
    {
        if (_gpuByGfxObj.ContainsKey(gfxObjId))
            return;

        if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
        {
            // New path: route to ObjectMeshManager. WB will background-prep
            // and upload; we mark this gfxObj as "WB-managed" in our local
            // cache via a sentinel entry so the draw loop knows to look
            // there instead of in _gpuByGfxObj.
            _wbMeshAdapter.IncrementRefCount(gfxObjId);
            _gpuByGfxObj[gfxObjId] = WbManagedSentinel;
            return;
        }

        // Legacy path: build VAO/VBO/EBO inline.
        var list = new List<SubMeshGpu>(subMeshes.Count);
        foreach (var sm in subMeshes)
            list.Add(UploadSubMesh(sm));
        _gpuByGfxObj[gfxObjId] = list;
    }

    private static readonly List<SubMeshGpu> WbManagedSentinel = new(0);
}
```

- [ ] **Step 9.3: Update `Draw` to look up WB-managed entries differently**

In the draw loop, when iterating entities, check the sentinel:

```csharp
if (object.ReferenceEquals(_gpuByGfxObj[gfxObjId], WbManagedSentinel))
{
    // Draw via WbDrawDispatcher — implementation in Task 17.
    // For week 1 the dispatcher is a stub that no-ops; the entity simply
    // doesn't render. This is fine for the flag-gated build; week 4's
    // visual verification is the gate where this must work.
    continue;
}
```

This intentionally leaves a behavioral gap in week 1: with the flag on, static scenery does NOT render correctly. **This is expected.** The full draw path lands in Task 17 (week 4). Week 1's success criterion is "build green, conformance tests pass, no regressions with flag OFF."

- [ ] **Step 9.4: Pass adapter to constructor in `GameWindow`**

In `GameWindow.OnLoad`:
```csharp
_staticMeshRenderer = new StaticMeshRenderer(gl, staticShader, _textures, _wbMeshAdapter);
```

- [ ] **Step 9.5: Build, run all tests, smoke-test with flag off**

Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
Expected: build green, 883+24 = 907 tests pass, 8 pre-existing failures.

Smoke-test launch with flag off (default) — Holtburg should render identically to before.

- [ ] **Step 9.6: Commit**

```bash
git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
phase(N.4): route static-scenery uploads through WbMeshAdapter under flag

When ACDREAM_USE_WB_FOUNDATION=1, StaticMeshRenderer.EnsureUploaded
calls WbMeshAdapter.IncrementRefCount instead of building VAO/VBO/EBO
inline. Local cache uses a sentinel entry to mark WB-managed gfxObjs.

The draw loop currently skips WB-managed entries (they don't render
yet). This is expected: the full draw path arrives in Task 17 / week 4.
Week 1's success is "no regressions with flag OFF."

Default-off — flag must be set explicitly to test the new path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 10: Week 1 wrap-up — verify clean baseline + commit week 1 status

**Files:** none (verification + status update)

- [ ] **Step 10.1: Full test run + build**

Run: `dotnet build --verbosity quiet 2>&1 | tail -5 && dotnet test --verbosity quiet 2>&1 | tail -5`
Expected: 0 errors. 907+ tests pass, 8 pre-existing failures only.

- [ ] **Step 10.2: Smoke-test with flag off**

Launch the client with default env (flag OFF). Walk Holtburg briefly. Confirm: no visual change vs pre-N.4 main.

- [ ] **Step 10.3: Smoke-test with flag on (expect partial breakage)**

Launch with `$env:ACDREAM_USE_WB_FOUNDATION = "1"`. Confirm: client launches, scenery is missing or partially missing (expected — WbDrawDispatcher is a stub). Stop the client.

- [ ] **Step 10.4: Update plan checkboxes**

In this plan file, mark Tasks 1-9 as ✅ with their commit SHAs. Append a "Week 1 status: COMPLETE — date YYYY-MM-DD" note at the start of Week 2.

- [ ] **Step 10.5: Commit**

```bash
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
git commit -m "$(cat <<'EOF'
docs(N.4): mark week 1 complete in living-doc plan

WB infrastructure wired up behind ACDREAM_USE_WB_FOUNDATION flag.
Conformance tests pinned (mesh extraction + setup flatten). Static
scenery routes through WbMeshAdapter when flag is on; rendering
completion deferred to Task 17 (week 4).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

## Week 2 — Streaming Integration

Goal of week 2: `LandblockSpawnAdapter` + `LandblockUnloadAdapter` wired through `GpuWorldState`. Memory budget verified under long-roam stress. Pending-spawn list still works. **Done when: `ObjectMeshManager` ref counts balance across landblock load/unload, GPU memory stable on long roam.**

### Task 11: `LandblockSpawnAdapter`

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs`

- [ ] **Step 11.1: Write failing tests**

```csharp
using System.Linq;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class LandblockSpawnAdapterTests
{
    [Fact]
    public void OnLandblockLoaded_RegistersIncrementForEachUniqueGfxObj()
    {
        var adapter = MakeAdapter(out var captured);

        var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul, 0x02000001ul],
                                staticIds: [0x01000010ul]);
        adapter.OnLandblockLoaded(0x12340000u, lb);

        // Three unique ids despite duplicate setup id.
        Assert.Equal(3, captured.IncrementCalls.Count);
        Assert.Contains(0x02000001ul, captured.IncrementCalls);
        Assert.Contains(0x02000002ul, captured.IncrementCalls);
        Assert.Contains(0x01000010ul, captured.IncrementCalls);
    }

    [Fact]
    public void OnLandblockUnloaded_RegistersMatchingDecrements()
    {
        var adapter = MakeAdapter(out var captured);

        var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul],
                                staticIds: []);
        adapter.OnLandblockLoaded(0x12340000u, lb);
        adapter.OnLandblockUnloaded(0x12340000u);

        Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x));
    }

    [Fact]
    public void OnLandblockUnloaded_UnknownLandblock_NoOp()
    {
        var adapter = MakeAdapter(out var captured);

        adapter.OnLandblockUnloaded(0xDEADBEEFu);  // never loaded

        Assert.Empty(captured.DecrementCalls);
    }

    private static LandblockSpawnAdapter MakeAdapter(out CapturingAdapterMock captured)
    {
        captured = new CapturingAdapterMock();
        return new LandblockSpawnAdapter(captured);
    }

    private sealed class CapturingAdapterMock : IWbMeshAdapter
    {
        public List<ulong> IncrementCalls { get; } = new();
        public List<ulong> DecrementCalls { get; } = new();
        public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
        public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
    }

    private static LoadedLandblock MakeLandblock(ulong[] setupIds, ulong[] staticIds)
    {
        // Synthetic LoadedLandblock for test; the test only cares about the
        // unique GfxObj ids reachable from Setups + Statics. Field shape may
        // need adjustment to match LoadedLandblock's actual constructor.
        return new LoadedLandblock(
            setups: setupIds.Select(id => new SetupSpawn(id)).ToList(),
            statics: staticIds.Select(id => new StaticSpawn(id)).ToList());
    }
}
```

(Note: `IWbMeshAdapter` is a new interface — see Step 11.3. `LoadedLandblock` constructor shape may need adjustment to whatever the codebase uses.)

- [ ] **Step 11.2: Add `IWbMeshAdapter` interface + extract from `WbMeshAdapter`**

Modify `WbMeshAdapter.cs` to implement an interface so the adapter can be mocked:

```csharp
public interface IWbMeshAdapter
{
    void IncrementRefCount(ulong id);
    void DecrementRefCount(ulong id);
}

public sealed class WbMeshAdapter : System.IDisposable, IWbMeshAdapter
{
    // ...existing impl...
}
```

- [ ] **Step 11.3: Create `LandblockSpawnAdapter`**

```csharp
using System.Collections.Generic;
using AcDream.Core.World;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
/// reference-count lifecycle. Walks <c>LoadedLandblock.Setups</c> and
/// <c>LoadedLandblock.Statics</c> for unique GfxObj/Setup ids; calls
/// <c>IncrementRefCount</c> on load and matching <c>DecrementRefCount</c>
/// on unload.
///
/// <para>
/// Maintains a <c>Dictionary&lt;landblockId, HashSet&lt;ulong&gt;&gt;</c>
/// snapshot of which ids each landblock holds, so unload can match the
/// load 1:1 without re-walking the (now-released) landblock data.
/// </para>
/// </summary>
public sealed class LandblockSpawnAdapter
{
    private readonly IWbMeshAdapter _adapter;
    private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();

    public LandblockSpawnAdapter(IWbMeshAdapter adapter)
    {
        System.ArgumentNullException.ThrowIfNull(adapter);
        _adapter = adapter;
    }

    public void OnLandblockLoaded(uint landblockId, LoadedLandblock lb)
    {
        var unique = new HashSet<ulong>();
        foreach (var setup in lb.Setups) unique.Add(setup.GfxObjId);
        foreach (var stat in lb.Statics) unique.Add(stat.GfxObjId);

        _idsByLandblock[landblockId] = unique;
        foreach (var id in unique) _adapter.IncrementRefCount(id);
    }

    public void OnLandblockUnloaded(uint landblockId)
    {
        if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
        foreach (var id in unique) _adapter.DecrementRefCount(id);
        _idsByLandblock.Remove(landblockId);
    }
}
```

- [ ] **Step 11.4: Run tests, verify pass**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~LandblockSpawnAdapter" --verbosity normal`
Expected: 3/3 PASS.

- [ ] **Step 11.5: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): LandblockSpawnAdapter bridges streaming to WB ref counts

OnLandblockLoaded walks Setups + Statics for unique GfxObj ids and
calls IncrementRefCount per id. OnLandblockUnloaded matches with
DecrementRefCount. Per-landblock id-set snapshot ensures unload pairs
1:1 with load even when underlying data is released.

IWbMeshAdapter interface extracted from WbMeshAdapter to enable
mocking in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 12: Wire `LandblockSpawnAdapter` into `GpuWorldState`

**Files:**
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`

- [ ] **Step 12.1: Add adapter field + flag-gated calls in `GpuWorldState`**

Modify `GpuWorldState`'s `AddLandblock` and `RemoveLandblock`:

```csharp
private readonly Wb.LandblockSpawnAdapter? _wbSpawnAdapter;

public GpuWorldState(Wb.LandblockSpawnAdapter? wbSpawnAdapter = null)
{
    _wbSpawnAdapter = wbSpawnAdapter;
}

public void AddLandblock(LoadedLandblock landblock)
{
    // ...existing logic...
    if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
        _wbSpawnAdapter.OnLandblockLoaded(landblock.LandblockId, landblock);
}

public void RemoveLandblock(uint landblockId)
{
    if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
        _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
    // ...existing logic...
}
```

- [ ] **Step 12.2: Construct adapter in `GameWindow`**

In `GameWindow.OnLoad`:
```csharp
LandblockSpawnAdapter? wbSpawnAdapter = null;
if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
    wbSpawnAdapter = new LandblockSpawnAdapter(_wbMeshAdapter);
_gpuWorldState = new GpuWorldState(wbSpawnAdapter);
```

- [ ] **Step 12.3: Build + tests + smoke-test flag off**

Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
Expected: build green, all tests pass.

Smoke-test with flag off: verify no regressions.

- [ ] **Step 12.4: Commit**

```bash
git add src/AcDream.App/Streaming/GpuWorldState.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
phase(N.4): wire LandblockSpawnAdapter through GpuWorldState

AddLandblock/RemoveLandblock now drive WB ref counts when flag is on.
Pending-spawn list mechanism untouched — adapter is invoked only when
a landblock fully loads (drains pending), not when a spawn parks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 13: Memory budget + LRU verification under stress

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MemoryBudgetTests.cs` (optional — likely manual verification)

- [ ] **Step 13.1: Manual stress test plan**

This is a verification task, not an implementation task. Document the plan:

1. Launch with `ACDREAM_USE_WB_FOUNDATION=1` and `ACDREAM_STREAM_RADIUS=7`.
2. Walk in a straight line for ~5 minutes (covers 50+ landblocks in/out of radius).
3. Monitor GPU memory in window title bar.
4. Acceptance: GPU memory grows to ~steady-state value (depending on hardware, somewhere under 1 GB) and stays there. If it grows unboundedly, LRU eviction isn't firing.

Run with: `$env:ACDREAM_USE_WB_FOUNDATION = "1"; $env:ACDREAM_STREAM_RADIUS = "7"; dotnet run --project src\AcDream.App\AcDream.App.csproj 2>&1 | Tee-Object -FilePath "n4-stress.log"`

- [ ] **Step 13.2: Run with the user**

Hand off to user. User walks for 5+ minutes. User reports observed peak memory + final stable memory.

- [ ] **Step 13.3: Document outcome**

If memory is stable: append "Memory budget verified at <peak MB> peak / <stable MB> steady" to this task.

If memory grows unboundedly: investigate. Likely causes:
- Adapter fails to call `DecrementRefCount` in some path (check for unload logging).
- WB's LRU eviction interacts badly with our streaming radius hysteresis.
- Memory budget set too high for the test hardware.

Do not commit a "fix" until root cause is understood. Add an Adjustment subsection here documenting what was found.

- [ ] **Step 13.4: Commit (verification-only commit if memory was clean)**

```bash
# No code changes; just an empty commit to mark verification complete in history.
git commit --allow-empty -m "$(cat <<'EOF'
verify(N.4): memory budget + LRU eviction stable under 5min/r=7 roam

GPU memory peak: <fill in MB>. Steady-state: <fill in MB>. Eviction
fires correctly on landblock unload. LandblockSpawnAdapter ref-count
balance verified through repeated traversal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 14: Pending-spawn list integration verification

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs`

The pending-spawn list ([feedback_phase_a1_hotfix_saga.md](memory/feedback_phase_a1_hotfix_saga.md)) parks `CreateObject` events that arrive before their landblock streams in. We need to verify this still works with the WB foundation.

- [ ] **Step 14.1: Write integration test**

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.Core.World;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class PendingSpawnIntegrationTests
{
    [Fact]
    public void LiveEntity_BeforeLandblock_Pends_ThenDrains_OnLoad()
    {
        var captured = new CapturingAdapterMock();
        var spawnAdapter = new LandblockSpawnAdapter(captured);
        var state = new GpuWorldState(spawnAdapter);

        // Live entity for landblock 0x12340000 arrives first.
        var entity = new WorldEntity { /* with LandblockId = 0x12340000 */ };
        state.AppendLiveEntity(entity);

        Assert.Equal(1, state.PendingLiveEntityCount);
        Assert.Empty(captured.IncrementCalls);  // not registered yet

        // Now landblock arrives.
        var lb = new LoadedLandblock(/* ... */);
        state.AddLandblock(lb);

        // Pending entity drains; adapter sees landblock-side increments.
        Assert.True(captured.IncrementCalls.Count > 0);
        Assert.Equal(0, state.PendingLiveEntityCount);
    }
}
```

(Test-fixture details depend on `WorldEntity` and `LoadedLandblock` constructors.)

- [ ] **Step 14.2: Run, verify pass**

If the test fails, the pending-spawn list path doesn't drain through the adapter. Either fix the adapter wiring in `GpuWorldState.AddLandblock` to handle pending entities, or document an Adjustment.

- [ ] **Step 14.3: Commit**

```bash
git add tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
git commit -m "$(cat <<'EOF'
test(N.4): pending-spawn list still drains correctly with WB adapter

Verifies CreateObject-before-landblock parks → drains on landblock
arrival. Adapter sees ref-count increments only after drain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 15: Week 2 wrap-up

- [ ] **Step 15.1: Full test suite + roam**

Run all tests. Roam at radius 7 with flag on for 5 minutes. Confirm: stable memory, no crashes, ref counts balance.

- [ ] **Step 15.2: Update plan checkboxes**

Mark Tasks 11-14 ✅ with commit SHAs. Append "Week 2 status: COMPLETE — date YYYY-MM-DD" at start of Week 3.

- [ ] **Step 15.3: Commit plan update**

```bash
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
git commit -m "docs(N.4): mark week 2 complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```

---

## Week 3 — Per-instance + Animation

Goal: per-instance customization works correctly. Animated creatures render with their server-sent overrides applied. AnimPartChange + HiddenParts honored. **Done when: drudge / chicken / banderling render with correct customizations under flag on.**

### Task 16: `AnimatedEntityState` type

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs`

- [ ] **Step 16.1: Write failing test**

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.Animation;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class AnimatedEntityStateTests
{
    [Fact]
    public void DefaultState_HasNoOverridesAndNoHiddenParts()
    {
        var state = new AnimatedEntityState(MakeSequencer());

        Assert.False(state.IsPartHidden(0));
        Assert.False(state.IsPartHidden(63));
        Assert.False(state.TryGetPartOverride(0, out _));
    }

    [Fact]
    public void SetHiddenPart_BitmaskIsApplied()
    {
        var state = new AnimatedEntityState(MakeSequencer());

        state.HideParts(hiddenMask: 0b1010);

        Assert.False(state.IsPartHidden(0));
        Assert.True(state.IsPartHidden(1));
        Assert.False(state.IsPartHidden(2));
        Assert.True(state.IsPartHidden(3));
    }

    [Fact]
    public void SetPartOverride_ResolvedAtLookup()
    {
        var state = new AnimatedEntityState(MakeSequencer());

        state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul);

        Assert.True(state.TryGetPartOverride(5, out var got));
        Assert.Equal(0x01001234ul, got);
        Assert.False(state.TryGetPartOverride(6, out _));
    }

    private static AnimationSequencer MakeSequencer() => new();  // adjust to constructor
}
```

- [ ] **Step 16.2: Create the type**

```csharp
using System.Collections.Generic;
using AcDream.Core.Animation;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Per-entity render state for animated entities. Lives outside WB's
/// mesh cache because it varies per instance (AnimPartChange override
/// map, HiddenParts mask) and per frame (animation transforms produced
/// by the sequencer).
///
/// <para>
/// Instances are created by <c>EntitySpawnAdapter.OnCreate</c> and
/// disposed by <c>EntitySpawnAdapter.OnRemove</c>.
/// </para>
/// </summary>
public sealed class AnimatedEntityState
{
    private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
    private ulong _hiddenMask = 0;
    public AnimationSequencer Sequencer { get; }

    public AnimatedEntityState(AnimationSequencer sequencer)
    {
        System.ArgumentNullException.ThrowIfNull(sequencer);
        Sequencer = sequencer;
    }

    public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;

    public bool IsPartHidden(int partIdx)
    {
        if (partIdx < 0 || partIdx >= 64) return false;
        return (_hiddenMask & (1ul << partIdx)) != 0;
    }

    public void SetPartOverride(int partIdx, ulong gfxObjId)
        => _partGfxObjOverrides[partIdx] = gfxObjId;

    public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
        => _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
}
```

- [ ] **Step 16.3: Run, verify pass**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AnimatedEntityStateTests" --verbosity normal`
Expected: 3/3 PASS.

- [ ] **Step 16.4: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): AnimatedEntityState — per-entity render state

Holds AnimPartChange override map + HiddenParts bitmask + reference
to existing AnimationSequencer. Lives outside WB's mesh cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 17: `EntitySpawnAdapter` — route CreateObject to per-instance path

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs`
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`

- [ ] **Step 17.1: Write failing test**

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class EntitySpawnAdapterTests
{
    [Fact]
    public void OnCreate_WithPaletteOverride_RoutesToPerInstanceCache()
    {
        var captured = new CapturingTextureCacheMock();
        var adapter = new EntitySpawnAdapter(captured);

        var entity = new WorldEntity
        {
            // Set up with non-trivial PaletteOverride so we can verify routing.
            ObjDescBuilder = new ObjDescBuilder { PaletteOverride = MakeOverride() },
        };

        adapter.OnCreate(entity);

        Assert.True(captured.PaletteOverrideCalled);
    }

    [Fact]
    public void OnCreate_WithoutCustomization_StillRegistersForCleanup()
    {
        var captured = new CapturingTextureCacheMock();
        var adapter = new EntitySpawnAdapter(captured);

        adapter.OnCreate(new WorldEntity());
        adapter.OnRemove(0xDEADBEEFu);  // doesn't crash on unknown id

        // Adapter tracks created entities for OnRemove cleanup.
        Assert.True(true);  // smoke
    }
}
```

- [ ] **Step 17.2: Create adapter**

```csharp
using System.Collections.Generic;
using AcDream.Core.World;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Routes network-spawned <c>CreateObject</c> entities through the per-
/// instance rendering path. Every entity sent by the server carries
/// per-instance customization (palette overrides, texture changes,
/// part swaps), so they bypass WB's atlas and use the existing
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> path that
/// already hash-keys overrides for caching.
/// </summary>
public sealed class EntitySpawnAdapter
{
    private readonly TextureCache _textures;
    private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();

    public EntitySpawnAdapter(TextureCache textures)
    {
        System.ArgumentNullException.ThrowIfNull(textures);
        _textures = textures;
    }

    public AnimatedEntityState? OnCreate(WorldEntity entity)
    {
        // Build palette override from entity's ObjDesc.SubPalettes (if any).
        var palette = entity.PaletteOverride;
        // For each surface in the entity's mesh chain, decode through
        // the per-instance path. The TextureCache already hash-keys the
        // override, so identical customizations across multiple entities
        // share the cached texture.
        if (palette is not null && palette.SubPalettes.Count > 0)
        {
            foreach (var surfaceId in entity.SurfaceIds)
                _textures.GetOrUploadWithPaletteOverride(surfaceId, null, palette);
        }

        var state = new AnimatedEntityState(entity.AnimationSequencer);

        // Apply HiddenParts mask if set on the entity.
        if (entity.HiddenPartsMask != 0)
            state.HideParts(entity.HiddenPartsMask);

        // Apply AnimPartChange overrides if any.
        foreach (var (partIdx, gfxObjId) in entity.AnimPartChanges)
            state.SetPartOverride(partIdx, gfxObjId);

        _stateByGuid[entity.Guid] = state;
        return state;
    }

    public void OnRemove(uint guid) => _stateByGuid.Remove(guid);

    public AnimatedEntityState? GetState(uint guid)
        => _stateByGuid.TryGetValue(guid, out var s) ? s : null;
}
```

(Note: exact field names like `entity.PaletteOverride`, `entity.SurfaceIds`, `entity.HiddenPartsMask`, `entity.AnimPartChanges` depend on `WorldEntity`'s actual API — adjust at implementation time. If any are missing today, the adapter exposes the gap and we plan a follow-on commit to surface them.)

- [ ] **Step 17.3: Wire into `GpuWorldState`**

In `GpuWorldState.AppendLiveEntity`, when flag is on:
```csharp
if (Wb.WbFoundationFlag.IsEnabled && _wbEntitySpawnAdapter is not null)
    _wbEntitySpawnAdapter.OnCreate(entity);
```

- [ ] **Step 17.4: Run tests, verify**

Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~EntitySpawnAdapter" --verbosity normal`

- [ ] **Step 17.5: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs src/AcDream.App/Streaming/GpuWorldState.cs tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): EntitySpawnAdapter routes CreateObject to per-instance path

Network-spawned entities bypass WB's atlas and use existing
TextureCache.GetOrUploadWithPaletteOverride which already hash-keys
customizations. AnimatedEntityState is constructed per-entity with
HiddenParts mask + AnimPartChange overrides applied at spawn time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 18: AnimPartChange resolution unit tests

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs`

- [ ] **Step 18.1: Write tests for the resolution logic**

The logic that picks "use override gfxObjId or fall back to default" lives in `WbDrawDispatcher` (Task 21). For now, add a small helper method on `AnimatedEntityState` that does the resolution, and test it directly:

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.Animation;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class AnimPartChangeTests
{
    [Fact]
    public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault()
    {
        var state = new AnimatedEntityState(new AnimationSequencer());
        ulong setupDefault = 0x01000001ul;
        Assert.Equal(setupDefault, state.ResolvePartGfxObj(partIdx: 0, setupDefault));
    }

    [Fact]
    public void ResolvePartGfxObj_WithOverride_ReturnsOverride()
    {
        var state = new AnimatedEntityState(new AnimationSequencer());
        state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul);

        Assert.Equal(0x01999999ul, state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
    }
}
```

- [ ] **Step 18.2: Add `ResolvePartGfxObj` method on `AnimatedEntityState`**

```csharp
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
    => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
```

- [ ] **Step 18.3: Run, commit**

```bash
git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): AnimPartChange resolution helper on AnimatedEntityState

ResolvePartGfxObj(partIdx, setupDefault) returns override if set,
else the Setup's part default. Tested standalone; consumed by
WbDrawDispatcher in Task 21.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 19: HiddenParts mask suppression unit tests

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs`

- [ ] **Step 19.1: Write the test**

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.Animation;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class HiddenPartsTests
{
    [Theory]
    [InlineData(0b0000_0000ul, 0, false)]
    [InlineData(0b0000_0001ul, 0, true)]
    [InlineData(0b1000_0000ul, 7, true)]
    [InlineData(0b1000_0000ul, 6, false)]
    [InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)]
    public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected)
    {
        var state = new AnimatedEntityState(new AnimationSequencer());
        state.HideParts(mask);
        Assert.Equal(expected, state.IsPartHidden(partIdx));
    }

    [Fact]
    public void IsPartHidden_NegativeIdx_ReturnsFalse()
    {
        var state = new AnimatedEntityState(new AnimationSequencer());
        state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
        Assert.False(state.IsPartHidden(-1));
    }

    [Fact]
    public void IsPartHidden_PartIdxOver64_ReturnsFalse()
    {
        var state = new AnimatedEntityState(new AnimationSequencer());
        state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
        Assert.False(state.IsPartHidden(64));
    }
}
```

- [ ] **Step 19.2: Run, commit**

```bash
git add tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
git commit -m "$(cat <<'EOF'
test(N.4): HiddenParts mask suppression edge cases

Theory cases for bitmask resolution + bounds checking. Pins
the per-bit semantics consumed by WbDrawDispatcher.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 20: Per-instance decode conformance test

**Files:**
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs`

- [ ] **Step 20.1: Write the conformance test**

```csharp
using AcDream.App.Rendering.Wb;
using AcDream.Core.Textures;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class PerInstanceDecodeConformanceTests
{
    /// <summary>
    /// The new EntitySpawnAdapter routes CreateObject through TextureCache.
    /// GetOrUploadWithPaletteOverride. This test pins that routing — given
    /// the same surface id + palette override, both paths must produce
    /// byte-identical RGBA8.
    /// </summary>
    [Fact]
    public void NewPath_AndOldTextureCachePath_ProduceIdenticalRgba()
    {
        // Build a small synthetic dat with: 1 Surface, 1 SurfaceTexture,
        // 1 RenderSurface (PFID_INDEX16, 4×4), 2 Palettes (base + sub).
        var dats = BuildSyntheticDats();
        var paletteOverride = new PaletteOverride(
            BasePaletteId: 0x04000001u,
            SubPalettes: [new(0x04000002u, Offset: 0, Length: 16)]);

        // Old path
        using var glStub = new GLStub();
        var cacheOld = new TextureCache(glStub.GL, dats);
        var oldHandle = cacheOld.GetOrUploadWithPaletteOverride(
            surfaceId: 0x08000001u, null, paletteOverride);
        var oldBytes = glStub.ReadBackTexture(oldHandle);

        // New path (through EntitySpawnAdapter)
        var entity = new WorldEntity { Guid = 0xCAFE, PaletteOverride = paletteOverride, SurfaceIds = [0x08000001u] };
        var cacheNew = new TextureCache(glStub.GL, dats);
        var adapter = new EntitySpawnAdapter(cacheNew);
        adapter.OnCreate(entity);
        // The adapter calls the same method internally; we just verify
        // the bytes match by re-decoding via the cache directly.
        var newHandle = cacheNew.GetOrUploadWithPaletteOverride(
            surfaceId: 0x08000001u, null, paletteOverride);
        var newBytes = glStub.ReadBackTexture(newHandle);

        Assert.Equal(oldBytes, newBytes);
    }
}
```

(`GLStub` is a test fixture that stands in for a real GL context. If the test infrastructure doesn't have one yet, this test may need to be deferred to integration-time; document an Adjustment if so.)

- [ ] **Step 20.2: Run, verify**

If GLStub exists: run and expect PASS.
If not: replace with a smaller test that compares the decode output directly (without GL), reusing the conformance pattern from N.3.

- [ ] **Step 20.3: Commit**

```bash
git add tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs
git commit -m "$(cat <<'EOF'
test(N.4): per-instance decode conformance — old vs new RGBA8

Verifies EntitySpawnAdapter's per-instance path produces byte-identical
RGBA8 to today's TextureCache.GetOrUploadWithPaletteOverride. Pins
the decode behavior across the substitution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 21: Week 3 wrap-up

- [ ] **Step 21.1: Mark week 3 tasks ✅, run all tests, commit plan update**

Same pattern as Week 1/2 wrap-ups.

---

## Week 4 — Polish + Visual Verification + Ship

Goal: complete `WbDrawDispatcher` so flag-on rendering produces visible output. Side-table populated correctly. Sky pass preserved. Visual verification at 5 named locations. Flag default-on. Phase shipped.

### Task 22: `WbDrawDispatcher` — full draw loop

**Files:**
- Create: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs`

This is the largest task. It implements both atlas-tier and per-instance-tier draw paths with proper matrix composition.

- [ ] **Step 22.1: Write matrix composition test**

```csharp
using System.Numerics;
using AcDream.App.Rendering.Wb;

namespace AcDream.Core.Tests.Rendering.Wb;

public sealed class MatrixCompositionTests
{
    [Fact]
    public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
    {
        var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
        var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4);  // 45° yaw
        var restPose = Matrix4x4.CreateTranslation(1, 0, 0);

        var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);

        // Expected: rest first → animated rotation → entity world translate.
        var expected = restPose * animOverride * entityWorld;
        Assert.Equal(expected, result);
    }
}
```

- [ ] **Step 22.2: Create `WbDrawDispatcher` skeleton with the static helper**

```csharp
using System.Numerics;
using AcDream.App.Rendering.Wb;

namespace AcDream.App.Rendering.Wb;

public sealed class WbDrawDispatcher
{
    public static Matrix4x4 ComposePartWorldMatrix(
        Matrix4x4 entityWorld,
        Matrix4x4 animOverride,
        Matrix4x4 restPose)
        => restPose * animOverride * entityWorld;

    // Full Draw() comes in Step 22.3.
}
```

- [ ] **Step 22.3: Implement full draw loop**

The full draw loop is too large to spell out here in code; it's a structured port of today's `StaticMeshRenderer.Draw` and the per-instance entity rendering, layered on top of WB's `ObjectRenderData`. Implementation guidance:

1. Walk visible atlas-tier entities (those whose gfxObjId is registered in `WbMeshAdapter`):
   - Get `ObjectRenderData` from the adapter.
   - For each batch in `renderData.Batches`: bind atlas + shader + uniforms; for each part in `renderData.SetupParts`: compose world matrix (no animation, identity for static), push uniform, draw.
2. Walk visible per-instance-tier entities (animated):
   - Get `AnimatedEntityState` from `EntitySpawnAdapter`.
   - For each part: skip if hidden; resolve gfxObjId via override or default; get `ObjectRenderData`; look up `AcSurfaceMetadata` from the side-table; compose matrix (entity × animation × rest pose); bind per-instance texture from `TextureCache`; push uniforms (world, lum, diff, fog flag); draw.

Reference: today's `StaticMeshRenderer.Draw` lines 79+ for the existing pattern. Match its frustum-cull behavior and pass structure (opaque + ClipMap, then translucent).

- [ ] **Step 22.4: Replace the "skip WB-managed entries" stub from Task 9**

In `StaticMeshRenderer.Draw`, replace the `if (sentinel) continue` with a call into `WbDrawDispatcher.Draw` for the entity. Or invert: have `GameWindow` call `WbDrawDispatcher.Draw` directly for atlas-tier, and `StaticMeshRenderer.Draw` only handles legacy entries.

- [ ] **Step 22.5: Run tests, smoke-test with flag on**

Run all tests. Then launch with flag on. Holtburg should now render scenery + buildings (atlas tier) AND characters (per-instance tier). Compare side-by-side to flag-off baseline.

- [ ] **Step 22.6: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/StaticMeshRenderer.cs tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): WbDrawDispatcher — full atlas-tier + per-instance draw

Atlas tier: walks visible entities, gets ObjectRenderData from
WbMeshAdapter, draws each batch through the atlas. Per-instance tier:
walks animated entities, resolves AnimPartChange overrides, skips
HiddenParts, composes per-part world matrices (entity × animation ×
rest pose), looks up AcSurfaceMetadata from the side-table, pushes
sky-pass-relevant uniforms (Luminosity / Diffuse / DisableFog),
binds per-instance textures.

Replaces the Task-9 sentinel stub. With flag on, Holtburg now renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 23: Surface metadata side-table population

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` (populate side-table on each `IncrementRefCount`)

- [ ] **Step 23.1: Hook population into the adapter**

When `WbMeshAdapter.IncrementRefCount(id)` is called for the first time on an id, walk the resulting mesh data and populate `AcSurfaceMetadataTable` with one entry per (gfxObjId, surfaceIdx) using `GfxObjMesh.Build`'s metadata as the source of truth (since we're keeping that algorithm as the conformance reference).

```csharp
public void IncrementRefCount(ulong id)
{
    if (_meshManager is null) return;
    _meshManager.IncrementRefCount(id);

    // Populate side-table on first registration.
    if (!_metadataPopulated.Add(id)) return;
    PopulateMetadata(id);
}

private readonly HashSet<ulong> _metadataPopulated = new();

private void PopulateMetadata(ulong id)
{
    // Look up the GfxObj from the dat, run GfxObjMesh.Build with our DatCollection,
    // and write each sub-mesh's metadata into _metadataTable.
    if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
    var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfxObj, _dats);
    for (int i = 0; i < subMeshes.Count; i++)
    {
        var sm = subMeshes[i];
        _metadataTable.Add(id, i, new AcSurfaceMetadata(
            sm.Translucency, sm.Luminosity, sm.Diffuse,
            sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
    }
}
```

(`_dats` and `_metadataTable` need to be added as fields. `AcSurfaceMetadataTable` injected in constructor.)

- [ ] **Step 23.2: Add round-trip test**

```csharp
[Fact]
public void IncrementRefCount_PopulatesSideTableMetadata()
{
    var (adapter, table) = MakeAdapterWithDat();
    adapter.IncrementRefCount(0x01000123ul);

    Assert.True(table.TryLookup(0x01000123ul, 0, out var meta));
    Assert.Equal(TranslucencyKind.Opaque, meta.Translucency);
}
```

- [ ] **Step 23.3: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
git commit -m "$(cat <<'EOF'
phase(N.4): populate AcSurfaceMetadata side-table on first ref-count

When a gfxObj is registered for the first time, WbMeshAdapter walks
its sub-meshes via GfxObjMesh.Build and writes per-surface metadata
into the side-table keyed by (gfxObjId, surfaceIdx). Subsequent
draws resolve metadata via O(1) lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 24: Sky-pass preservation check

**Files:**
- (Verification — likely no code changes if side-table flow is right.)

- [ ] **Step 24.1: Examine SkyRenderer's metadata consumption**

Grep for `NeedsUvRepeat` / `DisableFog` / `Luminosity` usage in the sky renderer. Verify that under the WB foundation, these values still flow correctly.

Run: `grep -n "NeedsUvRepeat\|DisableFog\|Luminosity" src/AcDream.App/Rendering/Sky/`

- [ ] **Step 24.2: Smoke-test sky rendering with flag on**

Launch with `ACDREAM_USE_WB_FOUNDATION=1`. Press F7 / F10 to cycle day/night and weather. Visually confirm: clouds blend correctly, sun is bright (luminous), fog respects emissive surfaces.

- [ ] **Step 24.3: If broken, fix and commit; else, commit verification**

If the sky pass renders identically: empty commit marking verification complete.

If broken: investigate. Document an Adjustment under this task. The side-table flow is the most likely failure point.

```bash
git commit --allow-empty -m "$(cat <<'EOF'
verify(N.4): sky pass renders identically under WB foundation

NeedsUvRepeat / DisableFog / Luminosity metadata flows through the
side-table to SkyRenderer correctly. Day/night cycle + weather
visually identical to flag-off baseline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 25: Component micro-tests round-out

**Files:**
- Test: any of the spec-defined micro-tests not yet covered.

- [ ] **Step 25.1: Audit spec's Testing section against existing tests**

Spec lists these micro-tests:
- `LandblockSpawnAdapter_RegistersAndUnregisters` ✅ (Task 11)
- `LandblockSpawnAdapter_DedupesSharedIds` ✅ (Task 11)
- `EntitySpawnAdapter_RoutesToPerInstance` ✅ (Task 17)
- `AnimPartChange_OverridesAtDraw` ✅ (Task 18)
- `HiddenParts_SuppressesDraw` ✅ (Task 19)
- `MatrixComposition_EntityAnimRest` ✅ (Task 22)
- `SurfaceMetadata_SideTableLookup` ✅ (Tasks 2 + 23)

All spec-required micro-tests are covered.

- [ ] **Step 25.2: Verify full test suite green**

Run: `dotnet test --verbosity quiet`
Expected: build green, all new tests pass, 8 pre-existing failures only.

- [ ] **Step 25.3: No commit needed unless new tests added**

---

### Task 26: Visual verification at 5 named locations + flag default-on

**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` — flip default to `true`.
- Modify: `docs/plans/2026-04-11-roadmap.md` — mark N.4 shipped.

This is the human-in-the-loop gate. Identical pattern to N.3 Task 5.

- [ ] **Step 26.1: Build + launch with flag on**

```powershell
dotnet build --verbosity quiet
$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"
$env:ACDREAM_USE_WB_FOUNDATION = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "n4-verify.log"
```

- [ ] **Step 26.2: Visual checks — walk with the user**

Per spec's Testing section:
1. **Holtburg outdoor** — terrain props, scenery, buildings, NPCs, characters. Verify: no missing entities, no magenta squares, no alpha bleeding, no shading regressions, no animation hitches.
2. **Drudge Hideout** (or comparable) — EnvCell, interior lighting, animated creatures.
3. **Foundry** — heavy NPC traffic, customized appearances.
4. **A character with extreme palette overrides** — the +Acdream variant or any heavily-customized server character.
5. **Long roam (5+ minutes)** — GPU memory should stabilize.

- [ ] **Step 26.3: If all pass, flip default-on**

Edit `WbFoundationFlag.cs`:
```csharp
public static bool IsEnabled { get; } =
    System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
    // was: == "1" (default off). Now: != "0" (default on).
```

- [ ] **Step 26.4: Update roadmap to mark N.4 shipped**

In `docs/plans/2026-04-11-roadmap.md`:
- Top "Live ✓" table: add a new row `| N.4 | Rendering pipeline foundation — WB ObjectMeshManager + TextureAtlasManager adopted ... | Live ✓ |`
- N.4 sub-phase block: prepend `**✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped <date>. ...`
- Document header date bumped.

- [ ] **Step 26.5: Commit**

```bash
git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs docs/plans/2026-04-11-roadmap.md
git commit -m "$(cat <<'EOF'
phase(N.4): visual verification passed — flag default-on, N.4 shipped

Walked Holtburg + dungeon + Foundry + customized character + long
roam with the user. No texture regressions, no missing entities,
sky pass renders identically, GPU memory stable on long roam.

Roadmap updated to reflect N.4 in Live ✓ state. Foundation enables
N.5 (terrain), N.6 (static objects), N.7 (env cells), N.8 (sky/
particles) to land as integration phases on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 27: Delete legacy code paths (where safe)

**Files:**
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs` — remove the legacy upload code path and the dual-path branching, since flag is now default-on.
- Modify: `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` — same.
- Note: keep these files as thin pass-through shims; **N.6 fully replaces them.**

- [ ] **Step 27.1: Remove legacy paths**

For each renderer:
- Remove the inline `UploadSubMesh` + VAO/VBO/EBO management code.
- `EnsureUploaded` becomes a thin wrapper that forwards to `WbMeshAdapter`.
- Keep public surface identical so callers don't change.

- [ ] **Step 27.2: Run tests + smoke-test**

Confirm tests green + render still correct after legacy code removal.

- [ ] **Step 27.3: Commit**

```bash
git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/InstancedMeshRenderer.cs
git commit -m "$(cat <<'EOF'
phase(N.4): delete legacy mesh-upload code paths

StaticMeshRenderer and InstancedMeshRenderer become thin pass-through
shims to WbMeshAdapter. N.6 will fully replace these files. Public
surface preserved so callers don't change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

---

### Task 28: Update memory + ISSUES (if applicable) + finalize plan doc

**Files:**
- `memory/MEMORY.md` + new memory file if a durable lesson emerged
- `docs/ISSUES.md` if any cosmetic deltas were filed during visual verification
- `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` — final state header

- [ ] **Step 28.1: Identify any durable lessons**

Review: did N.4 surface any lesson worth saving for future cross-session agents? Examples that would qualify:
- A subtle WB API quirk that bit us mid-implementation.
- A surprising interaction between WB's threading and our streaming.
- A non-obvious dependency between `AcSurfaceMetadata` fields and the sky-pass shader.

If yes: write a memory file under `memory/feedback_*.md` or `memory/project_phase_n4_state.md`. Add a one-liner to `MEMORY.md`.

If no durable lesson: skip.

- [ ] **Step 28.2: File any visual deltas as ISSUES**

If visual verification surfaced cosmetic regressions (e.g., a specific item renders slightly differently), file as a numbered ISSUE in `docs/ISSUES.md`.

- [ ] **Step 28.3: Mark this plan doc as final**

Update the "Plan Living-Document Convention" status line:
- From: `Status: **Living document — work in progress, started 2026-05-08.**`
- To: `Status: **Final state at <date> — phase shipped (merge `<sha>`).**`

Also mark all task checkboxes ✅ with their commit SHAs.

- [ ] **Step 28.4: Commit**

```bash
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md memory/ docs/ISSUES.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(N.4): finalize plan doc — phase complete

Status flipped to "Final state — phase shipped." All task checkboxes
marked with their commit SHAs. Memory updated with durable lessons
(or skipped if none). ISSUES updated if visual verification flagged
cosmetic deltas. CLAUDE.md "Currently in flight" pointer removed.

N.4 is shipped. Foundation is ready for N.5 / N.6 / N.7 / N.8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 28.5: Final merge to main**

```bash
git -C "C:\Users\erikn\source\repos\acdream" merge --no-ff claude/quirky-jepsen-fd60f1 -m "Merge branch 'claude/quirky-jepsen-fd60f1' — Phase N.4 rendering pipeline foundation"
```

Verify build + tests on main. Phase N.4 is complete.

---

## Self-review notes

This plan is intentionally pragmatic about depth:
- Tasks 1-12 are detailed with full code blocks (the foundation stuff that's most knowable today).
- Tasks 13-22 mix detailed code with structural prose (some details depend on what week 1-2 reveals about WB integration).
- Tasks 23-28 are mostly verification / cleanup with patterns established earlier.

If any task discovers a hard architectural surprise mid-execution, append an `### Adjustment N` subsection under that task with the date, what changed, and why — do not silently rewrite earlier tasks (per the Plan Living-Document Convention).

## Acceptance criteria for the whole phase

Per spec — flip each ☐ to ✅ as it lands:

- [ ] All conformance tests pass before substitution lands
- [ ] All component micro-tests pass per spec's Testing section
- [ ] All existing tests still pass (8 pre-existing failures don't count)
- [ ] Build green
- [ ] Visual verification at 5 named locations passes
- [ ] Memory budget enforcement verified under long roam
- [ ] Sky pass renders identically (load-bearing check)
