using System; using System.Collections.Generic; using System.Linq; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Vk; namespace AcDream.App.Tests.Rendering.Gpu.Vk; /// /// Campaign V slice V6b — the CPU block-compression codec and mip chain /// (plan §4.3). /// /// This code exists because Vulkan cannot vkCmdBlitImage into a /// compressed image, so the mip chain for every DXT surface in the game has to /// be built on the CPU. Two properties matter more than image quality: /// /// 1. It is DETERMINISTIC. The offline pixel gate compares captures from /// separate processes, so a chain that varied run to run would make every /// textured surface look like a regression. Integer arithmetic throughout is /// what guarantees it, and these tests are what prove it. /// 2. It preserves BC1's one-bit cut-out. Retail's foliage and grates are DXT1 /// with the three-colour transparent mode; an encoder that quantised those /// texels to an opaque colour would fill in every leaf. /// public sealed class BlockCompressionTests { private static byte[] SolidRgba(int width, int height, byte r, byte g, byte b, byte a = 255) { var pixels = new byte[width * height * 4]; for (int i = 0; i < width * height; i++) { pixels[(i * 4) + 0] = r; pixels[(i * 4) + 1] = g; pixels[(i * 4) + 2] = b; pixels[(i * 4) + 3] = a; } return pixels; } // ── sizes ──────────────────────────────────────────────────────────────── // The RHI contract's enums are internal, so these stay [Fact]s over an // inline table rather than [Theory]s — xUnit needs public test methods and a // public method cannot take an internal parameter. [Fact] public void BlockSizesMatchTheDxtFormats() { Assert.Equal(8, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc1Unorm)); Assert.Equal(16, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc2Unorm)); Assert.Equal(16, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc3Unorm)); } [Fact] public void LevelSizeRoundsUpToWholeBlocks() { // A 5x5 BC1 level still needs 2x2 blocks: the edge block is partly // outside the image and is still stored in full. Assert.Equal(4 * 8, BlockCompressionCodec.LevelSizeBytes(GpuTextureFormat.Bc1Unorm, 5, 5)); // And a level smaller than one block is still one block. Assert.Equal(8, BlockCompressionCodec.LevelSizeBytes(GpuTextureFormat.Bc1Unorm, 1, 1)); } [Fact] public void FullMipChainReachesOneByOne() { Assert.Equal(9, VulkanTextureFormatMapping.FullMipLevelCount(256, 256)); Assert.Equal(1, VulkanTextureFormatMapping.FullMipLevelCount(1, 1)); // Non-square: the chain runs until BOTH axes are 1. Assert.Equal(9, VulkanTextureFormatMapping.FullMipLevelCount(256, 4)); } [Fact] public void LevelExtentFloorsAtOneTexel() { Assert.Equal((64, 16), VulkanTextureFormatMapping.LevelExtent(256, 64, 2)); Assert.Equal((1, 1), VulkanTextureFormatMapping.LevelExtent(4, 4, 9)); } // ── round trips ────────────────────────────────────────────────────────── [Fact] public void SolidColourSurvivesTheRoundTripExactlyInEveryFormat() { GpuTextureFormat[] formats = [ GpuTextureFormat.Bc1Unorm, GpuTextureFormat.Bc2Unorm, GpuTextureFormat.Bc3Unorm, ]; // 8-bit values that are exactly representable in RGB565 (multiples of // the quantisation step), so "exact" is a fair thing to demand. byte[] source = SolidRgba(8, 8, r: 0x00, g: 0x00, b: 0xFF); foreach (GpuTextureFormat format in formats) { byte[] encoded = BlockCompressionCodec.EncodeLevel(format, source, 8, 8); byte[] decoded = BlockCompressionCodec.DecodeLevel(format, encoded, 8, 8); Assert.Equal(source, decoded); } } [Fact] public void Bc3PreservesASolidAlphaExactly() { byte[] source = SolidRgba(4, 4, 0xFF, 0xFF, 0xFF, a: 0x42); byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 4, 4); byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc3Unorm, encoded, 4, 4); for (int texel = 0; texel < 16; texel++) Assert.Equal(0x42, decoded[(texel * 4) + 3]); } [Fact] public void Bc1KeepsCutOutTexelsFullyTransparent() { // A checkerboard of opaque white and cut-out texels — the shape of every // leaf and grate in the world. byte[] source = SolidRgba(4, 4, 0xFF, 0xFF, 0xFF); for (int texel = 0; texel < 16; texel += 2) source[(texel * 4) + 3] = 0; byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 4, 4); byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc1Unorm, encoded, 4, 4); for (int texel = 0; texel < 16; texel++) { byte alpha = decoded[(texel * 4) + 3]; if (texel % 2 == 0) Assert.Equal(0, alpha); else Assert.Equal(255, alpha); } } [Fact] public void Bc1UsesThreeColourModeOnlyWhenTheBlockHasCutOutTexels() { byte[] opaque = SolidRgba(4, 4, 0x20, 0x40, 0x60); byte[] withHoles = SolidRgba(4, 4, 0x20, 0x40, 0x60); withHoles[3] = 0; byte[] opaqueBlock = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, opaque, 4, 4); byte[] holedBlock = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, withHoles, 4, 4); ushort opaqueC0 = (ushort)(opaqueBlock[0] | (opaqueBlock[1] << 8)); ushort opaqueC1 = (ushort)(opaqueBlock[2] | (opaqueBlock[3] << 8)); ushort holedC0 = (ushort)(holedBlock[0] | (holedBlock[1] << 8)); ushort holedC1 = (ushort)(holedBlock[2] | (holedBlock[3] << 8)); // Four-colour mode is c0 > c1; three-colour (with transparency) is // c0 <= c1. The bit pattern IS the mode — there is no other flag. Assert.True(opaqueC0 > opaqueC1, "an opaque block must use BC1's four-colour mode"); Assert.True(holedC0 <= holedC1, "a block with a cut-out texel must use BC1's three-colour mode"); } [Fact] public void GradientRoundTripStaysCloseRatherThanCollapsing() { // A horizontal red ramp: exactly the case where a broken endpoint search // silently flattens a block to one colour. var source = new byte[4 * 4 * 4]; for (int texel = 0; texel < 16; texel++) { source[(texel * 4) + 0] = (byte)((texel % 4) * 80); source[(texel * 4) + 3] = 255; } byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 4, 4); byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc1Unorm, encoded, 4, 4); var distinct = new HashSet(); for (int texel = 0; texel < 16; texel++) { distinct.Add(decoded[texel * 4]); Assert.InRange(Math.Abs(decoded[texel * 4] - source[texel * 4]), 0, 12); } Assert.Equal(4, distinct.Count); } // ── determinism ────────────────────────────────────────────────────────── [Fact] public void EncodingTheSameBytesTwiceProducesTheSameBytes() { var random = new Random(20260728); var source = new byte[16 * 16 * 4]; random.NextBytes(source); byte[] first = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 16, 16); byte[] second = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 16, 16); Assert.Equal(first, second); } [Fact] public void TheWholeMipChainIsReproducible() { var random = new Random(1189998819991197253L.GetHashCode()); var source = new byte[32 * 32 * 4]; random.NextBytes(source); IReadOnlyList first = BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 32, 32, 6); IReadOnlyList second = BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 32, 32, 6); Assert.Equal(first.Count, second.Count); for (int i = 0; i < first.Count; i++) Assert.Equal(first[i].Data, second[i].Data); } // ── the chain ──────────────────────────────────────────────────────────── [Fact] public void ChainHalvesEachLevelAndStopsAtOneByOne() { byte[] source = SolidRgba(16, 16, 0x00, 0xFF, 0x00); IReadOnlyList levels = BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 16, 16, 5); Assert.Equal([1, 2, 3, 4], levels.Select(level => level.MipLevel)); Assert.Equal([8, 4, 2, 1], levels.Select(level => level.Width)); Assert.Equal([8, 4, 2, 1], levels.Select(level => level.Height)); // Even a 1x1 level occupies one whole block. Assert.Equal(8, levels[^1].Data.Length); } [Fact] public void ChainOfAUniformImageIsThatColourAtEveryLevel() { byte[] source = SolidRgba(16, 16, 0x00, 0x00, 0xFF); IReadOnlyList levels = BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 16, 16, 5); foreach (BlockCompressionMipChain.Level level in levels) { byte[] decoded = BlockCompressionCodec.DecodeLevel( GpuTextureFormat.Bc1Unorm, level.Data, level.Width, level.Height); for (int texel = 0; texel < level.Width * level.Height; texel++) { Assert.Equal(0x00, decoded[texel * 4]); Assert.Equal(0x00, decoded[(texel * 4) + 1]); Assert.Equal(0xFF, decoded[(texel * 4) + 2]); } } } [Fact] public void ChainStartsFromACompressedLevelZeroWithoutReEncodingIt() { byte[] source = SolidRgba(8, 8, 0xFF, 0x00, 0x00); byte[] level0 = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 8, 8); IReadOnlyList levels = BlockCompressionMipChain.BuildCompressed(GpuTextureFormat.Bc1Unorm, level0, 8, 8, 4); // Level 0 is not returned: it was uploaded verbatim, and re-encoding it // would be a lossy round trip of data that is already right. Assert.Equal([1, 2, 3], levels.Select(level => level.MipLevel)); } [Fact] public void ChainOfASingleLevelImageIsEmpty() { byte[] source = SolidRgba(4, 4, 1, 2, 3); Assert.Empty(BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 4, 4, 1)); } // ── the filter ─────────────────────────────────────────────────────────── [Fact] public void DownsampleAveragesTwoByTwoBlocksWithRoundHalfUp() { // Two texels of 0 and two of 1 average to 0.5, which rounds to 1. byte[] source = [ 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, ]; (byte[] output, int width, int height) = BlockCompressionMipChain.Downsample(source, 2, 2); Assert.Equal(1, width); Assert.Equal(1, height); Assert.Equal([1, 1, 1, 1], output); } [Fact] public void DownsampleClampsAtOddExtentsRatherThanReadingOutOfBounds() { byte[] source = SolidRgba(3, 3, 10, 20, 30); (byte[] output, int width, int height) = BlockCompressionMipChain.Downsample(source, 3, 3); Assert.Equal(1, width); Assert.Equal(1, height); Assert.Equal([10, 20, 30, 255], output); } [Fact] public void DownsampleAveragesAlphaWithoutPremultiplying() { // Opaque white beside a fully transparent texel. Premultiplying would // darken the surviving colour; averaging keeps it white at half alpha, // which is what stops every leaf fringe going grey down the chain. byte[] source = [ 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, ]; (byte[] output, _, _) = BlockCompressionMipChain.Downsample(source, 2, 2); Assert.Equal(255, output[0]); Assert.Equal(255, output[1]); Assert.Equal(255, output[2]); Assert.Equal(128, output[3]); } // ── the table's slot policy ────────────────────────────────────────────── [Fact] public void TextureSlotsAreHandedOutLowestFirst() { var slots = new VulkanTextureSlotAllocator(16); Assert.Equal(0u, slots.Allocate()); Assert.Equal(1u, slots.Allocate()); Assert.Equal(2u, slots.Allocate()); Assert.Equal(3u, slots.HighWater); } [Fact] public void AReleasedSlotIsReusedAndTheHighWaterDoesNotGrow() { var slots = new VulkanTextureSlotAllocator(16); slots.Allocate(); uint second = slots.Allocate(); slots.Allocate(); slots.Release(second); Assert.Equal(second, slots.Allocate()); Assert.Equal(3u, slots.HighWater); } [Fact] public void ReleasingTheSameSlotTwiceIsRejected() { var slots = new VulkanTextureSlotAllocator(16); uint slot = slots.Allocate(); slots.Release(slot); // Two live textures sharing one table index is a silent // wrong-texture bug, so this has to be loud. Assert.Throws(() => slots.Release(slot)); } [Fact] public void RunningOutOfSlotsNamesTheCapacityToRaise() { var slots = new VulkanTextureSlotAllocator(2); slots.Allocate(); slots.Allocate(); InvalidOperationException error = Assert.Throws(() => slots.Allocate()); Assert.Contains("TextureTableCapacity", error.Message, StringComparison.Ordinal); } }