acdream/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
Erik 8d166afc62 docs(N.3): mark Phase N.3 shipped + commit implementation plan
Roadmap: N.3 row added to shipped table; sub-phase block updated from
ahead-estimate to shipped summary. Document header date bumped.

Plan: docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
captures the audit + per-format substitution strategy + A8 isAdditive
divergence resolution that drove this phase.

No ISSUES.md update — visual verification at Holtburg is the remaining
gate; if the A8 non-additive change produces a visible delta on entity
textures, an issue gets filed there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:37:52 +02:00

25 KiB

Phase N.3 — Texture Decoding via WorldBuilder 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: Replace acdream's hand-rolled pixel-format decoders in SurfaceDecoder with calls to WorldBuilder's TextureHelpers.Fill* methods for every format WB covers (INDEX16, P8, A8R8G8B8, R8G8B8, A8, A8Additive, R5G6B5, A4R4G4B4). Keep our decoders for formats WB lacks (X8R8G8B8, DXT1/3/5 with clipmap postprocess, SolidColor with translucency). Add conformance tests proving byte-identical output for each substituted format. Add the two previously-unsupported formats (R5G6B5, A4R4G4B4) as a bonus.

Architecture: In-place substitution inside SurfaceDecoder. Each private Decode* method that has a WB equivalent gets rewritten to allocate a byte[], call TextureHelpers.Fill* into it, and return a DecodedTexture. The critical A8 divergence is resolved by adding an isAdditive parameter to DecodeRenderSurface — callers that know the SurfaceType pass it, terrain alpha callers (which always use the additive/replicate path) pass isAdditive: true. No feature flag — conformance tests prove equivalence before substitution, so the old code is deleted in the same pass.

Tech Stack: .NET 10 / C# 13, Chorizite.OpenGLSDLBackend (already referenced via AcDream.Core.csproj), DatReaderWriter for RenderSurface / Palette / PixelFormat types, BCnEncoder.Net for DXT (stays ours), xUnit for tests.

Spec: docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md Inventory: docs/architecture/worldbuilder-inventory.md Handoff: docs/research/2026-05-08-phase-n3-handoff.md

Prerequisite: Phase N.0 shipped (submodule wired), Phase N.1 shipped (scenery migration). AcDream.Core.csproj already references Chorizite.OpenGLSDLBackend.


Audit Summary

# Our function WB equivalent Action
1 DecodeIndex16 TextureHelpers.FillIndex16 Substitute
2 DecodeP8 TextureHelpers.FillP8 Substitute
3 DecodeA8R8G8B8 TextureHelpers.FillA8R8G8B8 Substitute
4 DecodeR8G8B8 TextureHelpers.FillR8G8B8 Substitute
5 DecodeA8 TextureHelpers.FillA8 + FillA8Additive Substitute (additive-aware)
6 DecodeX8R8G8B8 None Keep ours
7 DecodeBc (DXT1/3/5) None in TextureHelpers Keep ours
8 DecodeSolidColor Different semantics Keep ours
9 (missing) TextureHelpers.FillR5G6B5 Add new
10 (missing) TextureHelpers.FillA4R4G4B4 Add new

A8 divergence detail

  • Our current DecodeA8: R=G=B=A=val (all four channels = alpha byte)
  • WB FillA8: R=G=B=255, A=val (white + alpha)
  • WB FillA8Additive: R=G=B=A=val (same as our current behavior)

WB dispatches based on surface.Type.HasFlag(SurfaceType.Additive):

  • Additive surfaces → FillA8Additive (R=G=B=A=val)
  • Non-additive surfaces → FillA8 (R=G=B=255, A=val)

Our current code always does the additive path. This is correct for terrain alpha masks (used as blend weights where .r channel = .a channel matters) but diverges from WB for non-additive A8 entity textures. Resolution: thread an isAdditive flag through the decode API.


File Plan

File Disposition Responsibility
src/AcDream.Core/Textures/SurfaceDecoder.cs MODIFY Replace 5 private decode methods with WB TextureHelpers.Fill* calls. Add isAdditive parameter to DecodeRenderSurface. Add R5G6B5 + A4R4G4B4 format cases. Keep X8R8G8B8, DXT, SolidColor.
src/AcDream.App/Rendering/TextureCache.cs MODIFY Pass surface.Type.HasFlag(SurfaceType.Additive) as isAdditive to SurfaceDecoder.DecodeRenderSurface.
src/AcDream.App/Rendering/TerrainAtlas.cs MODIFY Pass isAdditive: true to SurfaceDecoder.DecodeRenderSurface in TryDecodeAlphaMap (terrain alpha masks always use the replicate-all-channels path).
tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs NEW Per-format conformance tests: synthetic byte arrays decoded by both our old logic and WB's TextureHelpers.Fill*, asserting byte-identical output.

Task 1: Conformance tests for the 5 clean substitutions

Write tests first, run them to prove our current output matches WB's output for each format. These tests lock in the equivalence BEFORE any code changes — if any test fails, we know the formats actually diverge and must investigate.

Files:

  • Create: tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs

  • Step 1.1: Create the conformance test file with INDEX16 test

Create tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs:

using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;

namespace AcDream.Core.Tests.Textures;

/// <summary>
/// Conformance tests proving WorldBuilder's TextureHelpers.Fill* methods
/// produce byte-identical output to our SurfaceDecoder private methods
/// for each pixel format. These tests run BEFORE the substitution — if
/// one fails, the formats diverge and we must investigate, not "fix" the test.
/// </summary>
public sealed class TextureDecodeConformanceTests
{
    [Fact]
    public void FillIndex16_MatchesOurDecodeIndex16()
    {
        // 2x2 INDEX16 texture: 4 pixels, each a 16-bit LE palette index.
        // Palette: index 0 = (R=10, G=20, B=30, A=255), index 1 = (R=40, G=50, B=60, A=200)
        // Pixel data: [0x0000, 0x0100, 0x0100, 0x0000] (indices 0, 1, 1, 0)
        byte[] src = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
        int w = 2, h = 2;

        var palette = new Palette();
        palette.Colors.Add(new ColorARGB { Red = 10, Green = 20, Blue = 30, Alpha = 255 });
        palette.Colors.Add(new ColorARGB { Red = 40, Green = 50, Blue = 60, Alpha = 200 });

        // Our decode
        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            int si = i * 2;
            ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
            var c = palette.Colors[idx];
            int di = i * 4;
            ours[di + 0] = c.Red;
            ours[di + 1] = c.Green;
            ours[di + 2] = c.Blue;
            ours[di + 3] = c.Alpha;
        }

        // WB decode
        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillIndex16_ClipMap_MatchesOurClipMapBehavior()
    {
        // Index 3 (< 8) should be transparent, index 10 should be normal
        byte[] src = [0x03, 0x00, 0x0A, 0x00];
        int w = 2, h = 1;

        var palette = new Palette();
        for (int i = 0; i < 16; i++)
            palette.Colors.Add(new ColorARGB { Red = (byte)(i * 10), Green = (byte)(i * 15), Blue = (byte)(i * 5), Alpha = 255 });

        // Our clipmap decode: index < 8 → all zeros
        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            int si = i * 2;
            ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
            int di = i * 4;
            if (idx < 8)
            {
                ours[di] = ours[di + 1] = ours[di + 2] = ours[di + 3] = 0;
            }
            else
            {
                var c = palette.Colors[idx];
                ours[di + 0] = c.Red;
                ours[di + 1] = c.Green;
                ours[di + 2] = c.Blue;
                ours[di + 3] = c.Alpha;
            }
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h, isClipMap: true);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillP8_MatchesOurDecodeP8()
    {
        // 2x2 P8 texture: 4 pixels, each a single-byte palette index.
        byte[] src = [0, 1, 1, 0];
        int w = 2, h = 2;

        var palette = new Palette();
        palette.Colors.Add(new ColorARGB { Red = 100, Green = 110, Blue = 120, Alpha = 255 });
        palette.Colors.Add(new ColorARGB { Red = 200, Green = 210, Blue = 220, Alpha = 180 });

        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            var c = palette.Colors[src[i]];
            int di = i * 4;
            ours[di + 0] = c.Red;
            ours[di + 1] = c.Green;
            ours[di + 2] = c.Blue;
            ours[di + 3] = c.Alpha;
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillP8(src, palette, wb.AsSpan(), w, h);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8()
    {
        // 2x1 A8R8G8B8: on-disk order is B, G, R, A per pixel
        byte[] src = [0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD];
        int w = 2, h = 1;

        // Our decode: swap B,G,R,A → R,G,B,A
        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            int s = i * 4;
            ours[s + 0] = src[s + 2]; // R
            ours[s + 1] = src[s + 1]; // G
            ours[s + 2] = src[s + 0]; // B
            ours[s + 3] = src[s + 3]; // A
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillA8R8G8B8(src, wb.AsSpan(), w, h);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillR8G8B8_MatchesOurDecodeR8G8B8()
    {
        // 2x1 R8G8B8: on-disk order is B, G, R per pixel (3 bytes)
        byte[] src = [0x10, 0x20, 0x30, 0xAA, 0xBB, 0xCC];
        int w = 2, h = 1;

        // Our decode: swap B,G,R → R,G,B,255
        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            int si = i * 3;
            int di = i * 4;
            ours[di + 0] = src[si + 2]; // R
            ours[di + 1] = src[si + 1]; // G
            ours[di + 2] = src[si + 0]; // B
            ours[di + 3] = 0xFF;
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillR8G8B8(src, wb.AsSpan(), w, h);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillA8Additive_MatchesOurDecodeA8()
    {
        // 4x1 A8: each byte replicated to all four channels (our current behavior)
        byte[] src = [0x00, 0x80, 0xFF, 0x42];
        int w = 4, h = 1;

        byte[] ours = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            byte a = src[i];
            int d = i * 4;
            ours[d + 0] = a;
            ours[d + 1] = a;
            ours[d + 2] = a;
            ours[d + 3] = a;
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillA8Additive(src, wb.AsSpan(), w, h);

        Assert.Equal(ours, wb);
    }

    [Fact]
    public void FillA8_NonAdditive_ProducesWhitePlusAlpha()
    {
        // WB's non-additive A8: R=G=B=255, A=val
        // This is DIFFERENT from our current DecodeA8 (which does R=G=B=A=val).
        // This test documents the WB behavior we're adopting for non-additive surfaces.
        byte[] src = [0x00, 0x80, 0xFF, 0x42];
        int w = 4, h = 1;

        byte[] expected = new byte[w * h * 4];
        for (int i = 0; i < w * h; i++)
        {
            int d = i * 4;
            expected[d + 0] = 255;
            expected[d + 1] = 255;
            expected[d + 2] = 255;
            expected[d + 3] = src[i];
        }

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillA8(src, wb.AsSpan(), w, h);

        Assert.Equal(expected, wb);
    }

    [Fact]
    public void FillR5G6B5_ProducesExpectedRgba()
    {
        // R5G6B5: 16-bit packed RGB. Not currently handled by our decoder.
        // White (0xFFFF) → R=248,G=252,B=248,A=255 (bit expansion truncation)
        // Black (0x0000) → R=0,G=0,B=0,A=255
        byte[] src = [0xFF, 0xFF, 0x00, 0x00];
        int w = 2, h = 1;

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillR5G6B5(src, wb.AsSpan(), w, h);

        // Pixel 0: white-ish
        Assert.Equal(248, wb[0]); // R: 31 << 3
        Assert.Equal(252, wb[1]); // G: 63 << 2
        Assert.Equal(248, wb[2]); // B: 31 << 3
        Assert.Equal(255, wb[3]); // A

        // Pixel 1: black
        Assert.Equal(0, wb[4]);
        Assert.Equal(0, wb[5]);
        Assert.Equal(0, wb[6]);
        Assert.Equal(255, wb[7]);
    }

    [Fact]
    public void FillA4R4G4B4_ProducesExpectedRgba()
    {
        // A4R4G4B4: 16-bit packed ARGB. Not currently handled by our decoder.
        // 0xF8C4 → A=15*17=255, R=8*17=136, G=12*17=204, B=4*17=68
        byte[] src = [0xC4, 0xF8];
        int w = 1, h = 1;

        byte[] wb = new byte[w * h * 4];
        TextureHelpers.FillA4R4G4B4(src, wb.AsSpan(), w, h);

        Assert.Equal(136, wb[0]); // R: ((0xF8C4 >> 8) & 0x0F) * 17 = 8*17
        Assert.Equal(204, wb[1]); // G: ((0xF8C4 >> 4) & 0x0F) * 17 = 12*17
        Assert.Equal(68, wb[2]);  // B: (0xF8C4 & 0x0F) * 17 = 4*17
        Assert.Equal(255, wb[3]); // A: ((0xF8C4 >> 12) & 0x0F) * 17 = 15*17
    }
}
  • Step 1.2: Run tests to verify they pass

Run: dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal

Expected: All 9 tests PASS. These tests compare our current algorithm inline against WB's TextureHelpers — if any fail, it means the algorithms actually diverge and we must investigate before proceeding.

  • Step 1.3: Commit
git add tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
git commit -m "test(N.3): conformance tests proving WB TextureHelpers matches our decode

Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
A8Additive (matches our current DecodeA8), A8 non-additive (documents
the divergence), R5G6B5, A4R4G4B4. All run before any substitution —
they prove equivalence, not test the substitution.

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

Task 2: Add isAdditive parameter to SurfaceDecoder and wire A8 split

Thread the isAdditive flag through the decode API so the A8 format can dispatch to either WB path. Update all three callers.

Files:

  • Modify: src/AcDream.Core/Textures/SurfaceDecoder.cs

  • Modify: src/AcDream.App/Rendering/TextureCache.cs

  • Modify: src/AcDream.App/Rendering/TerrainAtlas.cs

  • Step 2.1: Add isAdditive parameter to DecodeRenderSurface

In src/AcDream.Core/Textures/SurfaceDecoder.cs, change the main public overload signature from:

public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)

to:

public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)

And update the PFID_A8/PFID_CUSTOM_LSCAPE_ALPHA case in the switch from:

PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),

to:

PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),

And update the no-palette overload from:

public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
    => DecodeRenderSurface(rs, palette: null);

to:

public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
    => DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
  • Step 2.2: Split DecodeA8 into additive vs non-additive

In SurfaceDecoder.cs, change the DecodeA8 method signature and add the split:

private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
{
    int expected = rs.Width * rs.Height;
    if (rs.SourceData.Length < expected)
        return DecodedTexture.Magenta;

    var rgba = new byte[expected * 4];
    if (isAdditive)
    {
        // Additive: R=G=B=A=val (current behavior, matches WB FillA8Additive)
        for (int i = 0; i < expected; i++)
        {
            byte a = rs.SourceData[i];
            int d = i * 4;
            rgba[d + 0] = a;
            rgba[d + 1] = a;
            rgba[d + 2] = a;
            rgba[d + 3] = a;
        }
    }
    else
    {
        // Non-additive: R=G=B=255, A=val (matches WB FillA8)
        for (int i = 0; i < expected; i++)
        {
            int d = i * 4;
            rgba[d + 0] = 255;
            rgba[d + 1] = 255;
            rgba[d + 2] = 255;
            rgba[d + 3] = rs.SourceData[i];
        }
    }
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 2.3: Update TextureCache to pass isAdditive

In src/AcDream.App/Rendering/TextureCache.cs, in DecodeFromDats, change line 203 from:

return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);

to:

bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
  • Step 2.4: Update TerrainAtlas to pass isAdditive: true

In src/AcDream.App/Rendering/TerrainAtlas.cs, in TryDecodeAlphaMap, change line 322 from:

var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);

to:

var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);

The terrain alpha masks MUST use the additive path (R=G=B=A=val) because our terrain blending shader reads from .r for the blend weight.

  • Step 2.5: Build and test

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

Expected: Build green, all 9 conformance tests still pass.

  • Step 2.6: Commit
git add src/AcDream.Core/Textures/SurfaceDecoder.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/Rendering/TerrainAtlas.cs
git commit -m "refactor(N.3): thread isAdditive through A8 decode path

SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter.
A8/CUSTOM_LSCAPE_ALPHA format splits:
- isAdditive=true:  R=G=B=A=val (terrain alpha, additive entity textures)
- isAdditive=false: R=G=B=255, A=val (non-additive entity textures)

TextureCache passes surface.Type.HasFlag(SurfaceType.Additive).
TerrainAtlas passes isAdditive:true (alpha masks always replicate).
This aligns with WB ObjectMeshManager's dispatch logic.

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

Task 3: Substitute 5 decode methods with WB TextureHelpers calls

Replace the body of each private decode method with a call to the corresponding WB TextureHelpers.Fill* method. Add the two new format cases (R5G6B5, A4R4G4B4).

Files:

  • Modify: src/AcDream.Core/Textures/SurfaceDecoder.cs

  • Step 3.1: Add WB using directive

At the top of SurfaceDecoder.cs, add:

using Chorizite.OpenGLSDLBackend.Lib;
  • Step 3.2: Replace DecodeIndex16

Replace the body of DecodeIndex16 with:

private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap)
{
    int expectedBytes = rs.Width * rs.Height * 2;
    if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
        return DecodedTexture.Magenta;

    var rgba = new byte[rs.Width * rs.Height * 4];
    TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.3: Replace DecodeP8

Replace the body of DecodeP8 with:

private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap)
{
    int expectedBytes = rs.Width * rs.Height;
    if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
        return DecodedTexture.Magenta;

    var rgba = new byte[rs.Width * rs.Height * 4];
    TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.4: Replace DecodeA8R8G8B8

Replace the body of DecodeA8R8G8B8 with:

private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
{
    int expected = rs.Width * rs.Height * 4;
    if (rs.SourceData.Length < expected)
        return DecodedTexture.Magenta;

    var rgba = new byte[expected];
    TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.5: Replace DecodeR8G8B8

Replace the body of DecodeR8G8B8 with:

private static DecodedTexture DecodeR8G8B8(RenderSurface rs)
{
    int expectedBytes = rs.Width * rs.Height * 3;
    if (rs.SourceData.Length < expectedBytes)
        return DecodedTexture.Magenta;

    var rgba = new byte[rs.Width * rs.Height * 4];
    TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.6: Replace DecodeA8

Replace the body of DecodeA8 with:

private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
{
    int expected = rs.Width * rs.Height;
    if (rs.SourceData.Length < expected)
        return DecodedTexture.Magenta;

    var rgba = new byte[expected * 4];
    if (isAdditive)
        TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    else
        TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.7: Add R5G6B5 and A4R4G4B4 cases to the format switch

In the DecodeRenderSurface switch, add two new cases before the _ => DecodedTexture.Magenta default:

PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),

And add the two new private methods:

private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
{
    int expectedBytes = rs.Width * rs.Height * 2;
    if (rs.SourceData.Length < expectedBytes)
        return DecodedTexture.Magenta;

    var rgba = new byte[rs.Width * rs.Height * 4];
    TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}

private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
{
    int expectedBytes = rs.Width * rs.Height * 2;
    if (rs.SourceData.Length < expectedBytes)
        return DecodedTexture.Magenta;

    var rgba = new byte[rs.Width * rs.Height * 4];
    TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
    return new DecodedTexture(rgba, rs.Width, rs.Height);
}
  • Step 3.8: Build and run all tests

Run: dotnet build --verbosity quiet && dotnet test --verbosity quiet

Expected: Build green, 873+ tests pass, 8 pre-existing failures unchanged.

  • Step 3.9: Commit
git add src/AcDream.Core/Textures/SurfaceDecoder.cs
git commit -m "phase(N.3): substitute 5 decode methods with WB TextureHelpers

INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to
TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/
FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours.
X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv).

Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta).

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

Task 4: Update roadmap + ISSUES, final cleanup

Files:

  • Modify: docs/plans/2026-04-11-roadmap.md — mark N.3 shipped

  • Modify: docs/ISSUES.md — file any cosmetic deltas found

  • Step 4.1: Update roadmap

In the roadmap, update the Phase N.3 entry to show shipped status with today's date and commit hash (obtain from git log -1 --format='%h').

  • Step 4.2: File any ISSUES

If the A8 non-additive behavioral change surfaces any visual delta at Holtburg during verification, file it in docs/ISSUES.md. Example:

### #NN: A8 non-additive textures now render white+alpha instead of gray+alpha

**Status:** OPEN
**Phase:** N.3
**Symptom:** [describe if applicable]
**Root cause:** WB's FillA8 outputs R=G=B=255,A=val; our old DecodeA8 output R=G=B=A=val. For non-additive surfaces this is a behavioral change.
**Impact:** [assess after visual verification]

If no visual delta is observed, skip this step — no issue to file.

  • Step 4.3: Commit
git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
git commit -m "docs: mark Phase N.3 shipped, update ISSUES if applicable

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

Task 5: Visual verification (human-in-the-loop)

This task requires the user to launch the client and inspect textures at Holtburg.

  • Step 5.1: Build and launch
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"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
  • Step 5.2: Visual checks

Walk around Holtburg and verify:

  1. Terrain textures — grass, dirt, sand transitions look correct (not magenta, not discolored)
  2. Tree/bush textures — scenery objects textured correctly (clipmap alpha works)
  3. Building textures — walls, roofs, doors look right
  4. Sky/clouds — if A8 textures are involved, verify they still render
  5. Particles — rain/aurora if weather is active

If all look correct, N.3 is done. If regressions found, file in ISSUES.md per the handoff doc's "whackamole stops the migration" rule.