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