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>
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
isAdditiveparameter toDecodeRenderSurface
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
DecodeA8into 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:
- Terrain textures — grass, dirt, sand transitions look correct (not magenta, not discolored)
- Tree/bush textures — scenery objects textured correctly (clipmap alpha works)
- Building textures — walls, roofs, doors look right
- Sky/clouds — if A8 textures are involved, verify they still render
- 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.