diff --git a/src/AcDream.Core/Textures/.gitkeep b/src/AcDream.Core/Textures/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/AcDream.Core/Textures/DecodedTexture.cs b/src/AcDream.Core/Textures/DecodedTexture.cs
new file mode 100644
index 0000000..130cecf
--- /dev/null
+++ b/src/AcDream.Core/Textures/DecodedTexture.cs
@@ -0,0 +1,10 @@
+namespace AcDream.Core.Textures;
+
+public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height)
+{
+ /// 1x1 magenta fallback for missing/unsupported textures.
+ public static readonly DecodedTexture Magenta = new(
+ Rgba8: [0xFF, 0x00, 0xFF, 0xFF],
+ Width: 1,
+ Height: 1);
+}
diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs
new file mode 100644
index 0000000..481b947
--- /dev/null
+++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs
@@ -0,0 +1,71 @@
+using BCnEncoder.Decoder;
+using BCnEncoder.Shared;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+
+namespace AcDream.Core.Textures;
+
+public static class SurfaceDecoder
+{
+ private static readonly BcDecoder BcDecoder = new();
+
+ ///
+ /// Decode a RenderSurface's pixel bytes into RGBA8. Returns
+ /// for unsupported formats, null data, or corrupt sizing.
+ ///
+ public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
+ {
+ if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0)
+ return DecodedTexture.Magenta;
+
+ try
+ {
+ return rs.Format switch
+ {
+ PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs),
+ PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1),
+ PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2),
+ PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3),
+ _ => DecodedTexture.Magenta,
+ };
+ }
+ catch
+ {
+ return DecodedTexture.Magenta;
+ }
+ }
+
+ 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];
+ // Source layout per pixel: B, G, R, A → swap to R, G, B, A
+ for (int i = 0; i < rs.Width * rs.Height; i++)
+ {
+ int s = i * 4;
+ rgba[s + 0] = rs.SourceData[s + 2]; // R <- R
+ rgba[s + 1] = rs.SourceData[s + 1]; // G <- G
+ rgba[s + 2] = rs.SourceData[s + 0]; // B <- B
+ rgba[s + 3] = rs.SourceData[s + 3]; // A <- A
+ }
+ 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);
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ for (int i = 0; i < pixels.Length; i++)
+ {
+ int s = i * 4;
+ rgba[s + 0] = pixels[i].r;
+ rgba[s + 1] = pixels[i].g;
+ rgba[s + 2] = pixels[i].b;
+ rgba[s + 3] = pixels[i].a;
+ }
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
new file mode 100644
index 0000000..dadcb97
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
@@ -0,0 +1,89 @@
+using AcDream.Core.Textures;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+
+namespace AcDream.Core.Tests.Textures;
+
+public class SurfaceDecoderTests
+{
+ [Fact]
+ public void Decode_A8R8G8B8_ConvertsToRgba8()
+ {
+ // Source format is B, G, R, A in memory (little-endian ARGB).
+ // One 2x2 image: red, green, blue, white pixels.
+ var src = new byte[]
+ {
+ 0x00, 0x00, 0xFF, 0xFF, // red (B=0, G=0, R=255, A=255)
+ 0x00, 0xFF, 0x00, 0xFF, // green
+ 0xFF, 0x00, 0x00, 0xFF, // blue
+ 0xFF, 0xFF, 0xFF, 0xFF, // white
+ };
+ var rs = new RenderSurface
+ {
+ Width = 2,
+ Height = 2,
+ Format = PixelFormat.PFID_A8R8G8B8,
+ SourceData = src,
+ };
+
+ var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
+
+ Assert.Equal(2, decoded.Width);
+ Assert.Equal(2, decoded.Height);
+ Assert.Equal(16, decoded.Rgba8.Length); // 2*2*4
+ // red pixel, in RGBA: 255, 0, 0, 255
+ Assert.Equal(0xFF, decoded.Rgba8[0]);
+ Assert.Equal(0x00, decoded.Rgba8[1]);
+ Assert.Equal(0x00, decoded.Rgba8[2]);
+ Assert.Equal(0xFF, decoded.Rgba8[3]);
+ }
+
+ [Fact]
+ public void Decode_UnsupportedFormat_ReturnsMagenta()
+ {
+ var rs = new RenderSurface
+ {
+ Width = 4,
+ Height = 4,
+ Format = PixelFormat.PFID_INDEX16, // not implemented path
+ SourceData = new byte[32],
+ };
+
+ var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
+
+ Assert.Same(DecodedTexture.Magenta, decoded);
+ }
+
+ [Fact]
+ public void Decode_NullSourceData_ReturnsMagenta()
+ {
+ var rs = new RenderSurface
+ {
+ Width = 4,
+ Height = 4,
+ Format = PixelFormat.PFID_A8R8G8B8,
+ SourceData = null!,
+ };
+
+ var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
+
+ Assert.Same(DecodedTexture.Magenta, decoded);
+ }
+
+ [Fact]
+ public void Decode_TruncatedA8R8G8B8_ReturnsMagenta()
+ {
+ // Buffer too small for width*height*4.
+ var rs = new RenderSurface
+ {
+ Width = 2,
+ Height = 2,
+ Format = PixelFormat.PFID_A8R8G8B8,
+ SourceData = new byte[8], // should be 16
+ };
+
+ var decoded = SurfaceDecoder.DecodeRenderSurface(rs);
+
+ Assert.Same(DecodedTexture.Magenta, decoded);
+ }
+}