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

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

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

to:

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

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

to:

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

And update the no-palette overload from:

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

to:

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

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

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

to:

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

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

to:

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

```csharp
using Chorizite.OpenGLSDLBackend.Lib;
```

- [ ] **Step 3.2: Replace `DecodeIndex16`**

Replace the body of `DecodeIndex16` with:

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

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

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

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

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

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

And add the two new private methods:

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

```markdown
### #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**

```powershell
dotnet build --verbosity quiet
$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
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.
