diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index ee78dc5..daa97ac 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream — strategic roadmap
-**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
+**Status:** Living document. Updated 2026-05-08 for Phase N.3 (texture decode via WB TextureHelpers) shipping.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -58,6 +58,7 @@
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ |
+| N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Tests ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@@ -577,13 +578,19 @@ for our deletions/additions; merge upstream `master` periodically.
`SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight`
/ `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low
risk after N.1's conformance proof on GetNormal.
-- **N.3 — Texture decoding.** Replace our `TextureCache` decode
- pipeline (`src/AcDream.App/Rendering/TextureCache.cs`) with WB's
- `TextureHelpers` (INDEX16, P8, BGRA, DXT, alpha). Touches every
- texture path. **Realistic estimate: 3-5 days** (was 2-3) — the GL
- upload path needs adapting and we'll need conformance tests per
- texture format. Handoff doc:
- `docs/research/2026-05-08-phase-n3-handoff.md`.
+- **✓ SHIPPED — N.3 — Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder`
+ now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's
+ `TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val
+ always; WB splits additive vs non-additive) was resolved by threading an
+ `isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks
+ pass `isAdditive: true` (matches our prior behavior, preserves the
+ shader's `.r` blend-weight read), entity surfaces pass
+ `surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4
+ formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and
+ SolidColor remain ours (no WB equivalent). **9 conformance tests prove
+ byte-identical equivalence per format** before substitution; updated
+ `SurfaceDecoderTests` to match the new A8 split semantics. Visual
+ verification at Holtburg pending.
- **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs`
with calls to WB's `ObjectMeshManager`. Character-appearance
behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain
diff --git a/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
new file mode 100644
index 0000000..57d6f26
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
@@ -0,0 +1,721 @@
+# 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;
+
+///
+/// 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.
+///
+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 "
+```
+
+---
+
+## 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 "
+```
+
+---
+
+## 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 "
+```
+
+---
+
+## 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 "
+```
+
+---
+
+## 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.