acdream/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
Erik 506b86ba86 plan(N.4): full implementation plan + CLAUDE.md pointer
28-task plan covering 4 weeks of work organized as:
- Week 1 (Tasks 1-10): WB plumbing + atlas for static scenery + conformance
- Week 2 (Tasks 11-15): streaming integration + memory budget verification
- Week 3 (Tasks 16-21): per-instance customization + animation
- Week 4 (Tasks 22-28): full draw dispatcher + visual verification + ship

Living document — task checkboxes marked as commits land; adjustments
appended in-place rather than rewriting earlier tasks. Conformance
tests run before substitution per N.1/N.3 pattern. Behind
ACDREAM_USE_WB_FOUNDATION=1 feature flag during weeks 1-3.

CLAUDE.md updated with a "Currently in flight" pointer in the Roadmap
discipline section so future agents pick up the plan as authoritative
for rendering work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:04:21 +02:00

90 KiB
Raw Blame History

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 — read FIRST. Everything below assumes you've read it. Parent design: docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md Inventory: docs/architecture/worldbuilder-inventory.md Roadmap: docs/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 — phase shipped (merge <sha>)."

Status: Living document — work in progress, started 2026-05-08.


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

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

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
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
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
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, our code is already a faithful port of WB's BuildPolygonIndices — this test pins that fact.

  • Step 3.1: Write the test
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
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

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

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:

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:

// 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
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 — bridge our DatCollection to WB's IDatReaderWriter

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

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

private WbMeshAdapter? _wbMeshAdapter;

In OnLoad (or wherever renderers are constructed), after _textures is built:

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:

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

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

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:

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:

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

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:

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

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:

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

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

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

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

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
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
    => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
  • Step 18.3: Run, commit
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

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

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

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

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

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

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

Also mark all task checkboxes with their commit SHAs.

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