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:
parent
d467c4cf24
commit
8d166afc62
2 changed files with 736 additions and 8 deletions
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue