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);
}
}