feat(core): SurfaceDecoder — add PFID_P8, PFID_R8G8B8, and PFID_X8R8G8B8
Entities using 8-bit palette-indexed textures (PFID_P8), uncompressed 24-bit RGB surfaces (PFID_R8G8B8), and 32-bit packed-BGR surfaces (PFID_X8R8G8B8) were all rendering as solid magenta. PFID_P8 uses the same palette-lookup and isClipMap (indices 0..7 → fully transparent) convention as the existing INDEX16 decoder, reading one byte per pixel instead of two. PFID_R8G8B8 and PFID_X8R8G8B8 decode on-disk B,G,R[,X] byte order to R,G,B,255 RGBA8 output for OpenGL PixelFormat.Rgba upload; the X padding byte in X8R8G8B8 is discarded rather than forwarded as alpha. 168 tests green (85 AcDream.Core.Tests + 83 AcDream.Core.Net.Tests), including 9 new SurfaceDecoder tests covering correct channel mapping, clipmap transparency, truncated-data fallback, and palette-missing fallback for P8. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
979802c49c
commit
d379a75984
2 changed files with 273 additions and 0 deletions
|
|
@ -34,11 +34,14 @@ public static class SurfaceDecoder
|
||||||
{
|
{
|
||||||
return rs.Format switch
|
return rs.Format switch
|
||||||
{
|
{
|
||||||
|
PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs),
|
||||||
PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
|
PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
|
||||||
|
PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs),
|
||||||
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
|
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
|
||||||
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
|
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
|
||||||
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
|
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
|
||||||
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
|
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
|
||||||
|
PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap),
|
||||||
PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
|
PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
|
||||||
_ => DecodedTexture.Magenta,
|
_ => DecodedTexture.Magenta,
|
||||||
};
|
};
|
||||||
|
|
@ -152,6 +155,96 @@ public static class SurfaceDecoder
|
||||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode PFID_P8 (8-bit palette index, one byte per pixel) into RGBA8.
|
||||||
|
/// This is the 8-bit sibling of PFID_INDEX16: each byte is a palette index.
|
||||||
|
/// The <paramref name="isClipMap"/> convention (indices 0..7 → fully transparent)
|
||||||
|
/// is identical to the INDEX16 path.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
||||||
|
int paletteMax = palette.Colors.Count - 1;
|
||||||
|
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||||
|
{
|
||||||
|
int idx = rs.SourceData[i];
|
||||||
|
if (idx > paletteMax) idx = 0;
|
||||||
|
var c = palette.Colors[idx];
|
||||||
|
|
||||||
|
int dst = i * 4;
|
||||||
|
if (isClipMap && idx < 8)
|
||||||
|
{
|
||||||
|
rgba[dst + 0] = 0;
|
||||||
|
rgba[dst + 1] = 0;
|
||||||
|
rgba[dst + 2] = 0;
|
||||||
|
rgba[dst + 3] = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rgba[dst + 0] = c.Red;
|
||||||
|
rgba[dst + 1] = c.Green;
|
||||||
|
rgba[dst + 2] = c.Blue;
|
||||||
|
rgba[dst + 3] = c.Alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode PFID_R8G8B8 (24-bit, 3 bytes per pixel) into RGBA8 with alpha=255.
|
||||||
|
/// AC stores R8G8B8 on disk in B,G,R byte order (confirmed by ACE's
|
||||||
|
/// GetImageColorArray: <c>byte b = reader.ReadByte(); g = ...; r = ...;</c>).
|
||||||
|
/// Output is R,G,B,255 in RGBA8 order for OpenGL PixelFormat.Rgba upload.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
||||||
|
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||||
|
{
|
||||||
|
int src = i * 3;
|
||||||
|
int dst = i * 4;
|
||||||
|
// On-disk byte order: B, G, R (little-endian 24-bit BGR, same as DX PFID_R8G8B8)
|
||||||
|
rgba[dst + 0] = rs.SourceData[src + 2]; // R
|
||||||
|
rgba[dst + 1] = rs.SourceData[src + 1]; // G
|
||||||
|
rgba[dst + 2] = rs.SourceData[src + 0]; // B
|
||||||
|
rgba[dst + 3] = 0xFF; // A = opaque
|
||||||
|
}
|
||||||
|
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode PFID_X8R8G8B8 (32-bit, 4 bytes per pixel) into RGBA8 with alpha=255.
|
||||||
|
/// AC stores X8R8G8B8 on disk in B,G,R,X byte order (DirectX little-endian
|
||||||
|
/// convention: low byte = B). The X (high) byte is unused padding and is
|
||||||
|
/// discarded — it is NOT treated as alpha. Output is R,G,B,255 for OpenGL.
|
||||||
|
/// </summary>
|
||||||
|
private static DecodedTexture DecodeX8R8G8B8(RenderSurface rs)
|
||||||
|
{
|
||||||
|
int expectedBytes = rs.Width * rs.Height * 4;
|
||||||
|
if (rs.SourceData.Length < expectedBytes)
|
||||||
|
return DecodedTexture.Magenta;
|
||||||
|
|
||||||
|
var rgba = new byte[expectedBytes];
|
||||||
|
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||||
|
{
|
||||||
|
int s = i * 4;
|
||||||
|
// On-disk byte order: B, G, R, X (little-endian 32-bit; high byte X is padding)
|
||||||
|
rgba[s + 0] = rs.SourceData[s + 2]; // R
|
||||||
|
rgba[s + 1] = rs.SourceData[s + 1]; // G
|
||||||
|
rgba[s + 2] = rs.SourceData[s + 0]; // B
|
||||||
|
rgba[s + 3] = 0xFF; // A = opaque (X byte discarded)
|
||||||
|
}
|
||||||
|
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||||
|
}
|
||||||
|
|
||||||
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format)
|
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format)
|
||||||
{
|
{
|
||||||
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
|
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
|
||||||
|
|
|
||||||
|
|
@ -222,4 +222,184 @@ public class SurfaceDecoderTests
|
||||||
Assert.Equal(0xFF, decoded.Rgba8[15]);
|
Assert.Equal(0xFF, decoded.Rgba8[15]);
|
||||||
Assert.Equal(0xAA, decoded.Rgba8[12]);
|
Assert.Equal(0xAA, decoded.Rgba8[12]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PFID_P8 tests -------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_P8_LooksUpPaletteForEachByte()
|
||||||
|
{
|
||||||
|
// 2x1 surface: pixel 0 → palette index 0 (red), pixel 1 → palette index 1 (blue).
|
||||||
|
var palette = new Palette();
|
||||||
|
palette.Colors.Add(new ColorARGB { Alpha = 0xFF, Red = 0xFF, Green = 0x00, Blue = 0x00 }); // index 0 = red
|
||||||
|
palette.Colors.Add(new ColorARGB { Alpha = 0xFF, Red = 0x00, Green = 0x00, Blue = 0xFF }); // index 1 = blue
|
||||||
|
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_P8,
|
||||||
|
SourceData = new byte[] { 0x00, 0x01 }, // indices
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||||
|
|
||||||
|
Assert.Equal(8, decoded.Rgba8.Length); // 2 pixels * 4 channels
|
||||||
|
// Pixel 0: red
|
||||||
|
Assert.Equal(new byte[] { 0xFF, 0x00, 0x00, 0xFF }, decoded.Rgba8[0..4]);
|
||||||
|
// Pixel 1: blue
|
||||||
|
Assert.Equal(new byte[] { 0x00, 0x00, 0xFF, 0xFF }, decoded.Rgba8[4..8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_P8_ClipMap_ZerosAlphaForLowIndices()
|
||||||
|
{
|
||||||
|
// 4x1 surface with indices 0, 3, 7, 8.
|
||||||
|
// isClipMap=true → indices 0..7 should be fully transparent; index 8 opaque.
|
||||||
|
var palette = new Palette();
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
palette.Colors.Add(new ColorARGB { Alpha = 0xFF, Red = 0xCC, Green = 0xDD, Blue = 0xEE });
|
||||||
|
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 4,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_P8,
|
||||||
|
SourceData = new byte[] { 0x00, 0x03, 0x07, 0x08 },
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette, isClipMap: true);
|
||||||
|
|
||||||
|
// Indices 0, 3, 7 should be transparent.
|
||||||
|
Assert.Equal(0, decoded.Rgba8[3]); // pixel 0 alpha
|
||||||
|
Assert.Equal(0, decoded.Rgba8[7]); // pixel 1 alpha
|
||||||
|
Assert.Equal(0, decoded.Rgba8[11]); // pixel 2 alpha
|
||||||
|
// Index 8 should be opaque with palette color.
|
||||||
|
Assert.Equal(0xFF, decoded.Rgba8[15]);
|
||||||
|
Assert.Equal(0xCC, decoded.Rgba8[12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_P8_WithoutPalette_ReturnsMagenta()
|
||||||
|
{
|
||||||
|
// P8 without palette passed → falls through to magenta.
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_P8,
|
||||||
|
SourceData = new byte[] { 0x00, 0x01 },
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
|
||||||
|
Assert.Same(DecodedTexture.Magenta, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_P8_TruncatedData_ReturnsMagenta()
|
||||||
|
{
|
||||||
|
var palette = new Palette();
|
||||||
|
palette.Colors.Add(new ColorARGB { Alpha = 0xFF, Red = 0xAA, Green = 0xBB, Blue = 0xCC });
|
||||||
|
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 4,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_P8,
|
||||||
|
SourceData = new byte[] { 0x00, 0x00 }, // expects 4 bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||||
|
|
||||||
|
Assert.Same(DecodedTexture.Magenta, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PFID_R8G8B8 tests ---------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_R8G8B8_ConvertsToRgba8WithOpaqueAlpha()
|
||||||
|
{
|
||||||
|
// PFID_R8G8B8 is stored on disk as B,G,R (little-endian 24-bit BGR).
|
||||||
|
// 2x1 surface: first pixel = red (B=0,G=0,R=255), second = green (B=0,G=255,R=0).
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_R8G8B8,
|
||||||
|
SourceData = new byte[]
|
||||||
|
{
|
||||||
|
0x00, 0x00, 0xFF, // B=0, G=0, R=255 → red
|
||||||
|
0x00, 0xFF, 0x00, // B=0, G=255, R=0 → green
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
|
||||||
|
Assert.Equal(8, decoded.Rgba8.Length);
|
||||||
|
// Red pixel → R=255, G=0, B=0, A=255
|
||||||
|
Assert.Equal(new byte[] { 0xFF, 0x00, 0x00, 0xFF }, decoded.Rgba8[0..4]);
|
||||||
|
// Green pixel → R=0, G=255, B=0, A=255
|
||||||
|
Assert.Equal(new byte[] { 0x00, 0xFF, 0x00, 0xFF }, decoded.Rgba8[4..8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_R8G8B8_TruncatedData_ReturnsMagenta()
|
||||||
|
{
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_R8G8B8,
|
||||||
|
SourceData = new byte[] { 0x00, 0x00 }, // expects 6 bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
|
||||||
|
Assert.Same(DecodedTexture.Magenta, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PFID_X8R8G8B8 tests -------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_X8R8G8B8_ConvertsToRgba8DiscardingXByte()
|
||||||
|
{
|
||||||
|
// PFID_X8R8G8B8 is stored on disk as B,G,R,X (DirectX little-endian 32-bit).
|
||||||
|
// The X byte is unused padding — NOT alpha. Output alpha must be 255.
|
||||||
|
// 2x1: first pixel = blue (B=255,G=0,R=0,X=0xDE), second = white (B=255,G=255,R=255,X=0xAD).
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_X8R8G8B8,
|
||||||
|
SourceData = new byte[]
|
||||||
|
{
|
||||||
|
0xFF, 0x00, 0x00, 0xDE, // B=255, G=0, R=0, X=0xDE → blue, alpha forced 255
|
||||||
|
0xFF, 0xFF, 0xFF, 0xAD, // B=255, G=255, R=255, X=0xAD → white, alpha forced 255
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
|
||||||
|
Assert.Equal(8, decoded.Rgba8.Length);
|
||||||
|
// Blue pixel → R=0, G=0, B=255, A=255 (X byte discarded)
|
||||||
|
Assert.Equal(new byte[] { 0x00, 0x00, 0xFF, 0xFF }, decoded.Rgba8[0..4]);
|
||||||
|
// White pixel → R=255, G=255, B=255, A=255 (X byte discarded)
|
||||||
|
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, decoded.Rgba8[4..8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_X8R8G8B8_TruncatedData_ReturnsMagenta()
|
||||||
|
{
|
||||||
|
var rs = new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 2,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_X8R8G8B8,
|
||||||
|
SourceData = new byte[] { 0xFF, 0x00, 0x00, 0xDE }, // expects 8 bytes (2 pixels)
|
||||||
|
};
|
||||||
|
|
||||||
|
var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
|
||||||
|
Assert.Same(DecodedTexture.Magenta, decoded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue