diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index a33c5b5..9bb6aa6 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -34,11 +34,14 @@ public static class SurfaceDecoder { return rs.Format switch { + PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs), PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), + PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs), PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), 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), _ => DecodedTexture.Magenta, }; @@ -152,6 +155,96 @@ public static class SurfaceDecoder return new DecodedTexture(rgba, rs.Width, rs.Height); } + /// + /// 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 convention (indices 0..7 → fully transparent) + /// is identical to the INDEX16 path. + /// + 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); + } + + /// + /// 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: byte b = reader.ReadByte(); g = ...; r = ...;). + /// Output is R,G,B,255 in RGBA8 order for OpenGL PixelFormat.Rgba upload. + /// + 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); + } + + /// + /// 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. + /// + 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) { var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index 4fb9c78..8110bfc 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -222,4 +222,184 @@ public class SurfaceDecoderTests Assert.Equal(0xFF, decoded.Rgba8[15]); 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); + } }