using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.Core.Tests.Textures; /// /// Conformance tests proving byte-identical output between our hand-rolled /// SurfaceDecoder paths and WorldBuilder's TextureHelpers.Fill* methods. /// These tests run BEFORE any substitution — they prove equivalence first. /// If a test fails, the formats diverge and that's a real finding. /// public class TextureDecodeConformanceTests { // ---- helpers --------------------------------------------------------------- private static Palette MakePalette(params ColorARGB[] colors) { var pal = new Palette(); foreach (var c in colors) pal.Colors.Add(c); return pal; } private static ColorARGB Rgba(byte r, byte g, byte b, byte a = 0xFF) => new ColorARGB { Red = r, Green = g, Blue = b, Alpha = a }; // Inline our current DecodeIndex16 logic for the conformance baseline. private static byte[] OurDecodeIndex16(byte[] src, Palette palette, int width, int height, bool isClipMap = false) { var rgba = new byte[width * height * 4]; int paletteMax = palette.Colors.Count - 1; for (int i = 0; i < width * height; i++) { int s = i * 2; ushort idx = (ushort)(src[s] | (src[s + 1] << 8)); if (idx > paletteMax) idx = 0; var c = palette.Colors[idx]; int d = i * 4; if (isClipMap && idx < 8) { rgba[d + 0] = 0; rgba[d + 1] = 0; rgba[d + 2] = 0; rgba[d + 3] = 0; } else { rgba[d + 0] = c.Red; rgba[d + 1] = c.Green; rgba[d + 2] = c.Blue; rgba[d + 3] = c.Alpha; } } return rgba; } // Inline our current DecodeP8 logic. private static byte[] OurDecodeP8(byte[] src, Palette palette, int width, int height, bool isClipMap = false) { var rgba = new byte[width * height * 4]; int paletteMax = palette.Colors.Count - 1; for (int i = 0; i < width * height; i++) { int idx = src[i]; if (idx > paletteMax) idx = 0; var c = palette.Colors[idx]; int d = i * 4; if (isClipMap && idx < 8) { rgba[d + 0] = 0; rgba[d + 1] = 0; rgba[d + 2] = 0; rgba[d + 3] = 0; } else { rgba[d + 0] = c.Red; rgba[d + 1] = c.Green; rgba[d + 2] = c.Blue; rgba[d + 3] = c.Alpha; } } return rgba; } // Inline our current DecodeA8R8G8B8 logic (BGRA on-disk → RGBA). private static byte[] OurDecodeA8R8G8B8(byte[] src, int width, int height) { var rgba = new byte[width * height * 4]; for (int i = 0; i < width * height; i++) { int s = i * 4; rgba[s + 0] = src[s + 2]; // R rgba[s + 1] = src[s + 1]; // G rgba[s + 2] = src[s + 0]; // B rgba[s + 3] = src[s + 3]; // A } return rgba; } // Inline our current DecodeR8G8B8 logic (BGR on-disk → RGBA with A=255). private static byte[] OurDecodeR8G8B8(byte[] src, int width, int height) { var rgba = new byte[width * height * 4]; for (int i = 0; i < width * height; i++) { int s = i * 3; int d = i * 4; rgba[d + 0] = src[s + 2]; // R rgba[d + 1] = src[s + 1]; // G rgba[d + 2] = src[s + 0]; // B rgba[d + 3] = 0xFF; // A = opaque } return rgba; } // Inline our current DecodeA8 logic (R=G=B=A=val — "additive" mode). private static byte[] OurDecodeA8(byte[] src, int width, int height) { var rgba = new byte[width * height * 4]; for (int i = 0; i < width * height; i++) { byte a = src[i]; int d = i * 4; rgba[d + 0] = a; rgba[d + 1] = a; rgba[d + 2] = a; rgba[d + 3] = a; } return rgba; } // ---- tests ----------------------------------------------------------------- /// /// Test 1: INDEX16 normal mode — 2×2 image with two palette entries. /// WB's FillIndex16 and our DecodeIndex16 must produce identical RGBA bytes. /// [Fact] public void FillIndex16_MatchesOurDecodeIndex16() { // 2×2 INDEX16: pixels 0,1,1,0 (indices into a 2-color palette) var src = new byte[] { 0x00, 0x00, // pixel(0,0) → palette index 0 0x01, 0x00, // pixel(1,0) → palette index 1 0x01, 0x00, // pixel(0,1) → palette index 1 0x00, 0x00, // pixel(1,1) → palette index 0 }; var palette = MakePalette( Rgba(0xFF, 0x00, 0x00), // index 0 = red Rgba(0x00, 0x00, 0xFF) // index 1 = blue ); var expected = OurDecodeIndex16(src, palette, 2, 2); var actual = new byte[2 * 2 * 4]; TextureHelpers.FillIndex16(src, palette, actual, 2, 2); Assert.Equal(expected, actual); } /// /// Test 2: INDEX16 clipmap mode — indices below 8 must produce transparent pixels. /// Both implementations share the same clipmap alpha-key convention from retail ACViewer. /// [Fact] public void FillIndex16_ClipMap_MatchesOurClipMapBehavior() { // 4×1 INDEX16: indices 0, 1, 7, 8 // In clipmap mode, indices 0..7 → transparent; index 8 → palette color. var src = new byte[] { 0x00, 0x00, // index 0 → transparent 0x01, 0x00, // index 1 → transparent 0x07, 0x00, // index 7 → transparent 0x08, 0x00, // index 8 → opaque }; // Build a 16-entry palette so indices 0–8 are all valid. var palette = new Palette(); for (int i = 0; i < 16; i++) palette.Colors.Add(Rgba(0xAA, 0xBB, 0xCC)); var expected = OurDecodeIndex16(src, palette, 4, 1, isClipMap: true); var actual = new byte[4 * 1 * 4]; TextureHelpers.FillIndex16(src, palette, actual, 4, 1, isClipMap: true); Assert.Equal(expected, actual); } /// /// Test 3: P8 (8-bit palette index) — 2×2 image. /// WB FillP8 and our DecodeP8 must produce identical RGBA output. /// [Fact] public void FillP8_MatchesOurDecodeP8() { // 2×2 P8: bytes are direct palette indices var src = new byte[] { 0x00, 0x01, 0x01, 0x00 }; var palette = MakePalette( Rgba(0x10, 0x20, 0x30), // index 0 Rgba(0x40, 0x50, 0x60) // index 1 ); var expected = OurDecodeP8(src, palette, 2, 2); var actual = new byte[2 * 2 * 4]; TextureHelpers.FillP8(src, palette, actual, 2, 2); Assert.Equal(expected, actual); } /// /// Test 4: A8R8G8B8 (BGRA on-disk → RGBA) — 2×1 image. /// WB FillA8R8G8B8 and our DecodeA8R8G8B8 both swap B↔R. /// [Fact] public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8() { // On-disk layout: B, G, R, A per pixel var src = new byte[] { 0x00, 0x00, 0xFF, 0xFF, // pixel 0: B=0, G=0, R=255, A=255 → red 0xFF, 0x00, 0x00, 0x80, // pixel 1: B=255, G=0, R=0, A=128 → blue, semi-transparent }; var expected = OurDecodeA8R8G8B8(src, 2, 1); var actual = new byte[2 * 1 * 4]; TextureHelpers.FillA8R8G8B8(src, actual, 2, 1); Assert.Equal(expected, actual); } /// /// Test 5: R8G8B8 (BGR on-disk → RGBA, alpha forced 255) — 2×1 image. /// Both implementations output R,G,B,255 for each 3-byte BGR triple. /// [Fact] public void FillR8G8B8_MatchesOurDecodeR8G8B8() { // On-disk layout: B, G, R per pixel (24-bit BGR) var src = new byte[] { 0x00, 0x00, 0xFF, // pixel 0: B=0, G=0, R=255 → red 0x00, 0xFF, 0x00, // pixel 1: B=0, G=255, R=0 → green }; var expected = OurDecodeR8G8B8(src, 2, 1); var actual = new byte[2 * 1 * 4]; TextureHelpers.FillR8G8B8(src, actual, 2, 1); Assert.Equal(expected, actual); } /// /// Test 6: A8 in additive mode — FillA8Additive replicates the byte into all four /// channels (R=G=B=A=val). This is identical to our current DecodeA8 behavior, /// which is used for terrain blending alpha masks. /// [Fact] public void FillA8Additive_MatchesOurDecodeA8() { // 4×1 single-byte-per-pixel alpha values var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; var expected = OurDecodeA8(src, 4, 1); var actual = new byte[4 * 1 * 4]; TextureHelpers.FillA8Additive(src, actual, 4, 1); Assert.Equal(expected, actual); // Spot-check: each input byte fans out to all four channels Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, actual[0..4]); Assert.Equal(new byte[] { 0x40, 0x40, 0x40, 0x40 }, actual[4..8]); Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, actual[12..16]); } /// /// Test 7: A8 non-additive (FillA8) documents WB's behavior that DIFFERS from ours. /// WB's FillA8 sets R=G=B=255 and A=input_byte. /// Our DecodeA8 sets R=G=B=A=input_byte (the additive mode, used for terrain blending). /// This test proves the divergence exists and documents the WB behavior explicitly. /// [Fact] public void FillA8_NonAdditive_ProducesWhitePlusAlpha() { var src = new byte[] { 0x00, 0x80, 0xFF }; // 3×1 var actual = new byte[3 * 1 * 4]; TextureHelpers.FillA8(src, actual, 3, 1); // WB non-additive: R=G=B=255, A=input byte Assert.Equal(new byte[] { 255, 255, 255, 0x00 }, actual[0..4]); // alpha=0 Assert.Equal(new byte[] { 255, 255, 255, 0x80 }, actual[4..8]); // alpha=128 Assert.Equal(new byte[] { 255, 255, 255, 0xFF }, actual[8..12]); // alpha=255 // Confirm this DIFFERS from our current DecodeA8 behavior (R=G=B=A=val). var ourDecoded = OurDecodeA8(src, 3, 1); Assert.NotEqual(ourDecoded, actual); // divergence is intentional — both are documented } /// /// Test 8: R5G6B5 (16-bit packed RGB, no alpha) — WB format we don't implement yet. /// Verifies the expected bit-expansion: 5-bit red → 8-bit by left-shifting 3, /// 6-bit green → 8-bit by left-shifting 2, 5-bit blue → 8-bit by left-shifting 3. /// Alpha is always 255. /// [Fact] public void FillR5G6B5_ProducesExpectedRgba() { // Encode a single pixel: R=0x1F (31), G=0x3F (63), B=0x1F (31) // Packed as 16-bit little-endian: bits 15-11=R, 10-5=G, 4-0=B // val = (0x1F << 11) | (0x3F << 5) | 0x1F = 0xFFFF var src = new byte[] { 0xFF, 0xFF }; // 1×1 pixel: all channels maxed var actual = new byte[1 * 1 * 4]; TextureHelpers.FillR5G6B5(src, actual, 1, 1); // R = (0x1F << 3) = 0xF8, G = (0x3F << 2) = 0xFC, B = (0x1F << 3) = 0xF8, A = 255 Assert.Equal((byte)0xF8, actual[0]); // R Assert.Equal((byte)0xFC, actual[1]); // G Assert.Equal((byte)0xF8, actual[2]); // B Assert.Equal((byte)255, actual[3]); // A always opaque // Test a second pixel: pure red = R=31, G=0, B=0 // val = (0x1F << 11) = 0xF800 var srcRed = new byte[] { 0x00, 0xF8 }; // little-endian 0xF800 var actualRed = new byte[4]; TextureHelpers.FillR5G6B5(srcRed, actualRed, 1, 1); Assert.Equal((byte)0xF8, actualRed[0]); // R = 31 << 3 = 0xF8 Assert.Equal((byte)0x00, actualRed[1]); // G = 0 Assert.Equal((byte)0x00, actualRed[2]); // B = 0 Assert.Equal((byte)255, actualRed[3]); // A } /// /// Test 9: A4R4G4B4 (16-bit packed ARGB, 4 bits per channel) — WB format we don't implement yet. /// Each 4-bit value is expanded to 8-bit by multiplying by 17 (0x11), /// so 0xF → 255, 0x8 → 136, 0x0 → 0. /// Bit layout: val bits 15-12=A, 11-8=R, 7-4=G, 3-0=B. /// [Fact] public void FillA4R4G4B4_ProducesExpectedRgba() { // Encode one pixel: A=0xF(255), R=0xA(170), G=0x5(85), B=0x0(0) // val = (0xF << 12) | (0xA << 8) | (0x5 << 4) | 0x0 = 0xFA50 // little-endian bytes: 0x50, 0xFA var src = new byte[] { 0x50, 0xFA }; // 1×1 var actual = new byte[1 * 1 * 4]; TextureHelpers.FillA4R4G4B4(src, actual, 1, 1); // R = 0xA * 17 = 170, G = 0x5 * 17 = 85, B = 0x0 * 17 = 0, A = 0xF * 17 = 255 Assert.Equal((byte)(0xA * 17), actual[0]); // R = 170 Assert.Equal((byte)(0x5 * 17), actual[1]); // G = 85 Assert.Equal((byte)(0x0 * 17), actual[2]); // B = 0 Assert.Equal((byte)(0xF * 17), actual[3]); // A = 255 // Also test the zero case: all channels 0 var srcZero = new byte[] { 0x00, 0x00 }; var actualZero = new byte[4]; TextureHelpers.FillA4R4G4B4(srcZero, actualZero, 1, 1); Assert.Equal(new byte[] { 0, 0, 0, 0 }, actualZero); } }