using System; using System.Collections.Generic; using System.IO; using System.Linq; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Vk; namespace AcDream.App.Tests.Rendering.Gpu.Vk; /// /// Campaign V slice V6i-2: every compiled SPIR-V module's descriptor decorations /// must match the descriptor set layouts the backend creates. /// /// Why this test exists. Slice V6i measured that /// terrain_modern.vert's TerrainClip block was declared /// layout(std140, binding = 2) with no ACDREAM_UBO_SET, so under /// the Vulkan dialect it landed in set 0 binding 2 — which set 0's layout /// declares as a STORAGE buffer. A terrain pipeline built against the shared /// layout was therefore malformed. Nothing caught it: GL expands the macro to /// nothing and keeps its UBO and SSBO namespaces separate, the shader compiled /// cleanly for both backends, and no terrain pipeline had ever been created on /// Vulkan. Plan §5.5.8 recorded a second instance of the same class — sky's /// UniformSkyParams at binding 4 was absent from the layout — found the /// same way, by hand, months apart. /// /// A one-word omission in a shader that cannot fail to compile, cannot /// fail on the shipping backend, and is only wrong on a backend nothing has run /// yet, is exactly the shape of defect that needs a test rather than an audit. /// Reading the committed .spv is what makes the assertion about what the /// GPU will actually be handed, not about what the GLSL looks like. /// public sealed class VulkanShaderDescriptorContractTests { private static string RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) directory = directory.Parent; return directory?.FullName ?? throw new InvalidOperationException("Could not locate the repository root from the test binary."); } private static string SpirvDirectory() => Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv"); /// One interface variable's set, binding and storage class. private readonly record struct ShaderResource( string Module, uint Id, SpirvStorageClass StorageClass, uint? Set, uint? Binding); private enum SpirvStorageClass : uint { UniformConstant = 0, Uniform = 2, StorageBuffer = 12, } /// /// The smallest SPIR-V reader that can answer this question: walk the /// instruction stream collecting OpDecorate DescriptorSet/Binding and /// the storage class of every OpVariable. No type or name recovery is /// needed, so no dependency is either. /// private static IReadOnlyList ReadResources(string path) { byte[] bytes = File.ReadAllBytes(path); Assert.True(bytes.Length >= 20 && bytes.Length % 4 == 0, $"{path} is not a SPIR-V module."); var words = new uint[bytes.Length / 4]; Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length); Assert.Equal(0x07230203u, words[0]); const uint OpDecorate = 71; const uint OpVariable = 59; const uint DecorationBinding = 33; const uint DecorationDescriptorSet = 34; var sets = new Dictionary(); var bindings = new Dictionary(); var variables = new List<(uint Id, SpirvStorageClass StorageClass)>(); int index = 5; while (index < words.Length) { uint header = words[index]; int wordCount = (int)(header >> 16); uint opcode = header & 0xFFFF; Assert.True(wordCount > 0, $"{path} has a zero-length instruction at word {index}."); if (opcode == OpDecorate && wordCount >= 4) { uint target = words[index + 1]; uint decoration = words[index + 2]; if (decoration == DecorationDescriptorSet) sets[target] = words[index + 3]; else if (decoration == DecorationBinding) bindings[target] = words[index + 3]; } else if (opcode == OpVariable && wordCount >= 4) { variables.Add((words[index + 2], (SpirvStorageClass)words[index + 3])); } index += wordCount; } string module = Path.GetFileName(path); return [ .. variables.Select(v => new ShaderResource( module, v.Id, v.StorageClass, sets.TryGetValue(v.Id, out uint s) ? s : null, bindings.TryGetValue(v.Id, out uint b) ? b : null)), ]; } private static IReadOnlyList AllResources() => [ .. Directory .EnumerateFiles(SpirvDirectory(), "*.spv") .OrderBy(path => path, StringComparer.Ordinal) .SelectMany(ReadResources), ]; /// /// The regression itself, named. TerrainClip is the only uniform block /// terrain_modern.vert declares besides SceneLighting, so /// asserting the module's uniform bindings as a set pins it exactly. /// [Fact] public void TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet() { uint[] uniformBindings = [ .. ReadResources(Path.Combine(SpirvDirectory(), "terrain_modern.vert.spv")) .Where(r => r.StorageClass == SpirvStorageClass.Uniform) .Select(r => { Assert.Equal(GpuBindingModel.UniformSet, r.Set); return r.Binding!.Value; }) .Order(), ]; Assert.Equal( [GpuBindingModel.UniformSceneLighting, VulkanPipelineLayouts.UniformTerrainClip], uniformBindings); } /// /// The general rule the specific case is an instance of: a uniform block may /// only live at a binding /// actually declares, in the set it declares them in. /// [Fact] public void EveryUniformBlockLandsAtADeclaredUniformBinding() { string[] violations = [ .. AllResources() .Where(r => r.StorageClass == SpirvStorageClass.Uniform) .Where(r => r.Set != GpuBindingModel.UniformSet || r.Binding is null || !VulkanPipelineLayouts.IsDeclaredUniformBinding(r.Binding.Value)) .Select(r => $"{r.Module}: uniform block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} " + $"binding {r.Binding?.ToString() ?? "(none)"}; set 1 declares " + $"[{string.Join(", ", VulkanPipelineLayouts.DeclaredUniformBindings)}]."), ]; Assert.Empty(violations); } /// /// The other half of the same contract: set 0 is storage buffers only, and /// within the range pins. A /// uniform block straying into set 0 fails this as well as the test above, /// which is the point — the two layouts must partition the bindings. /// [Fact] public void EveryStorageBlockLandsInTheStorageSetWithinItsDeclaredRange() { string[] violations = [ .. AllResources() .Where(r => r.StorageClass == SpirvStorageClass.StorageBuffer) .Where(r => r.Set != 0 || r.Binding is null || r.Binding >= GpuBindingModel.StorageBindingCount) .Select(r => $"{r.Module}: storage block %{r.Id} is at set {r.Set?.ToString() ?? "(none)"} " + $"binding {r.Binding?.ToString() ?? "(none)"}; set 0 declares bindings " + $"0..{GpuBindingModel.StorageBindingCount - 1}."), ]; Assert.Empty(violations); } /// /// The sampled-texture table is one variable-count descriptor array at set 2 /// binding 0. Any other combined-image-sampler would need a layout that does /// not exist. /// [Fact] public void EverySampledTextureIsTheSharedTable() { string[] violations = [ .. AllResources() .Where(r => r.StorageClass == SpirvStorageClass.UniformConstant && r.Set is not null) .Where(r => r.Set != GpuBindingModel.TextureTableSet || r.Binding != GpuBindingModel.TextureTableBinding) .Select(r => $"{r.Module}: sampled resource %{r.Id} is at set {r.Set} binding " + $"{r.Binding?.ToString() ?? "(none)"}; the only declared table is set " + $"{GpuBindingModel.TextureTableSet} binding {GpuBindingModel.TextureTableBinding}."), ]; Assert.Empty(violations); } }