docs(N.3): mark Phase N.3 shipped + commit implementation plan

Roadmap: N.3 row added to shipped table; sub-phase block updated from
ahead-estimate to shipped summary. Document header date bumped.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 11:37:52 +02:00
parent d467c4cf24
commit 8d166afc62
2 changed files with 736 additions and 8 deletions

View file

@ -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

View file

@ -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;
/// <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.