diff --git a/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
new file mode 100644
index 0000000..b7fb62a
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
@@ -0,0 +1,369 @@
+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);
+ }
+}