acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderDescriptorContractTests.cs
Erik f7344758f8 fix(render): Campaign V slice V6i-2 commit 1 — the terrain clip block reaches set 1
Plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred:
terrain_modern.vert declared

    layout(std140, binding = 2) uniform TerrainClip { ... }

with no ACDREAM_UBO_SET, so under the Vulkan dialect the block landed in set 0
binding 2 — which set 0's layout declares as a STORAGE buffer. Any terrain
pipeline built against the shared pipeline layout was therefore malformed.
Nothing had 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 has ever been created on Vulkan. sky.vert declares the SAME
block correctly and is the precedent, so this is a one-word omission, not a
numbering question.

spirv-dis on spv/terrain_modern.vert.spv, before and after:

    before   %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
             OpDecorate %372 DescriptorSet 0 / Binding 2
    after    OpDecorate %372 DescriptorSet 1 / Binding 2

with %_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8 — TerrainClip's
{ int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; } — in both.

The same commit closes §5.5.8's second recorded gap. Set 1's layout declared
only bindings 1 and 3, so it was missing BOTH the terrain clip block and
UniformSkyParams at binding 4, which sky.vert and sky.frag have compiled to
SPIR-V since V6e. Both are now declared, all four dynamic, which is half
Vulkan's guaranteed maxDescriptorSetUniformBuffersDynamic of 8 and is asserted
by the capability gate as before.

Membership and ORDER now come from one predicate — IsDeclaredUniformBinding —
that the layout, the descriptor writes and vkCmdBindDescriptorSets's
dynamic-offset array are all built from, the same shape V6g gave set 0. The
three had been restated separately, which is exactly how a fifth binding would
have gone wrong the same way.

Both gaps were found by hand, months apart, and neither could fail on the
shipping backend. VulkanShaderDescriptorContractTests reads the committed .spv
and asserts the partition instead: every uniform block at a declared set-1
binding, every storage block inside set 0's declared range, every sampled
resource in the one texture table. Checked out against the pre-fix .spv, two of
its four tests fail.

Gates: Release build; App tests 4,090 / 3 skips (4,086 baseline plus four);
strict GL offline pixel gate vs 0ca802cd 3.02e-05 (17 px of 563,200, inside the
documented 9–31 px control band, 33x under threshold) — expected, since GL
executes not one changed statement; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings and no [shutdown] diagnostic on either stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:31:13 +02:00

223 lines
9 KiB
C#

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;
/// <summary>
/// Campaign V slice V6i-2: every compiled SPIR-V module's descriptor decorations
/// must match the descriptor set layouts the backend creates.
///
/// <para><b>Why this test exists.</b> Slice V6i measured that
/// <c>terrain_modern.vert</c>'s <c>TerrainClip</c> block was declared
/// <c>layout(std140, binding = 2)</c> with no <c>ACDREAM_UBO_SET</c>, 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
/// <c>UniformSkyParams</c> at binding 4 was absent from the layout — found the
/// same way, by hand, months apart.</para>
///
/// <para>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 <c>.spv</c> is what makes the assertion about what the
/// GPU will actually be handed, not about what the GLSL looks like.</para>
/// </summary>
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");
/// <summary>One interface variable's set, binding and storage class.</summary>
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,
}
/// <summary>
/// The smallest SPIR-V reader that can answer this question: walk the
/// instruction stream collecting <c>OpDecorate</c> DescriptorSet/Binding and
/// the storage class of every <c>OpVariable</c>. No type or name recovery is
/// needed, so no dependency is either.
/// </summary>
private static IReadOnlyList<ShaderResource> 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<uint, uint>();
var bindings = new Dictionary<uint, uint>();
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<ShaderResource> AllResources() =>
[
.. Directory
.EnumerateFiles(SpirvDirectory(), "*.spv")
.OrderBy(path => path, StringComparer.Ordinal)
.SelectMany(ReadResources),
];
/// <summary>
/// The regression itself, named. <c>TerrainClip</c> is the only uniform block
/// <c>terrain_modern.vert</c> declares besides <c>SceneLighting</c>, so
/// asserting the module's uniform bindings as a set pins it exactly.
/// </summary>
[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);
}
/// <summary>
/// The general rule the specific case is an instance of: a uniform block may
/// only live at a binding <see cref="VulkanPipelineLayouts.CreateUniformSetLayout"/>
/// actually declares, in the set it declares them in.
/// </summary>
[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);
}
/// <summary>
/// The other half of the same contract: set 0 is storage buffers only, and
/// within the range <see cref="GpuBindingModel.StorageBindingCount"/> 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}