using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace AcDream.App.Tests.Rendering.Gpu.Vk; /// /// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it /// was compiled from. /// /// Plan §4.6 rules out runtime shader compilation — CI has no Vulkan SDK, /// and a native shaderc dependency plus a startup cost would be paid for shaders /// that never change at runtime — so the .spv artifacts are committed. /// The obvious hazard follows immediately: someone edits a shader, the GL /// backend picks it up because it compiles GLSL at startup, and the Vulkan /// backend silently keeps rendering the old one. This test is what turns that /// into a red build. /// /// It also pins which production shaders are Vulkan-expressible TODAY. As /// of Campaign V slice V11 (which deleted the one pair that never was — /// mesh.vert/mesh.frag, the pre-modern-pipeline shader the N.5 ship /// amendment's mandatory modern path made unreachable) every remaining pair /// compiles. A future non-ready pair's failure is a specific source-level fact /// belonging to a renderer-port slice that has not landed — not a toolchain gap. /// Recording them here means the next slice inherits an inventory rather than a /// rediscovery. /// public sealed class VulkanShaderManifestTests { private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message); private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList Stages); private sealed record Manifest(string Note, IReadOnlyList Shaders); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; 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 ShadersDirectory() => Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders"); private static string SpirvDirectory() => Path.Combine(ShadersDirectory(), "spv"); private static Manifest ReadManifest() { string path = Path.Combine(SpirvDirectory(), "shaders.manifest.json"); Assert.True(File.Exists(path), $"The shader manifest is missing at {path}. Run tools/compile-shaders.ps1."); return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? throw new InvalidOperationException("The shader manifest could not be parsed."); } private static string Sha256OfSource(string path) { // Line endings are normalised before hashing so a checkout with a // different core.autocrlf setting does not report every shader stale. string text = File.ReadAllText(path).Replace("\r\n", "\n"); return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); } [Fact] public void EveryGlslPairIsRecordedInTheManifest() { Manifest manifest = ReadManifest(); string[] pairs = Directory .EnumerateFiles(ShadersDirectory(), "*.vert") .Select(Path.GetFileNameWithoutExtension) .Where(name => name is not null && File.Exists(Path.Combine(ShadersDirectory(), $"{name}.frag"))) .Select(name => name!) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal)); } [Fact] public void CommittedSpirvIsNotStaleAgainstItsGlslSource() { Manifest manifest = ReadManifest(); var stale = new List(); foreach (ShaderEntry shader in manifest.Shaders) { foreach (StageEntry stage in shader.Stages) { string source = Path.Combine(ShadersDirectory(), $"{shader.Name}.{stage.Stage}"); if (!File.Exists(source)) { stale.Add($"{shader.Name}.{stage.Stage}: the GLSL source no longer exists"); continue; } string actual = Sha256OfSource(source); if (!string.Equals(actual, stage.SourceSha256, StringComparison.Ordinal)) stale.Add($"{shader.Name}.{stage.Stage}: source changed since the .spv was built"); } } Assert.True( stale.Count == 0, "Committed SPIR-V is out of date. Run tools/compile-shaders.ps1 and commit the result.\n " + string.Join("\n ", stale)); } [Fact] public void EveryShaderTheManifestCallsReadyHasBothSpirvArtifacts() { Manifest manifest = ReadManifest(); foreach (ShaderEntry shader in manifest.Shaders.Where(entry => entry.VulkanReady)) { foreach (string stage in (string[])["vert", "frag"]) { string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv"); Assert.True(File.Exists(path), $"{shader.Name} is marked Vulkan-ready but {path} is missing."); long length = new FileInfo(path).Length; Assert.True(length > 0 && length % 4 == 0, $"{path} is not a whole number of SPIR-V words."); } } } [Fact] public void ShadersTheManifestCallsUnreadyHaveNoStaleSpirvLeftBehind() { Manifest manifest = ReadManifest(); foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady)) { foreach (string stage in (string[])["vert", "frag"]) { string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv"); // A leftover .spv from an earlier attempt would be loaded // happily by the device and would be a shader nobody can account // for. Assert.False(File.Exists(path), $"{shader.Name} is not Vulkan-ready but {path} exists."); } } } [Fact] public void EveryUnreadyShaderRecordsWhyItCannotBeCompiledYet() { Manifest manifest = ReadManifest(); foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady)) { Assert.Contains(shader.Stages, stage => !stage.Compiled && !string.IsNullOrWhiteSpace(stage.Message)); } } [Fact] public void TheRhiVerificationShaderIsCompiled() { Manifest manifest = ReadManifest(); ShaderEntry probe = Assert.Single( manifest.Shaders, shader => string.Equals(shader.Name, "vk_probe", StringComparison.Ordinal)); // Plan §4.11 wants the capability probe to build a real pipeline from // committed .spv; slice V5 deferred that to V6c because no toolchain // existed. If this pair ever stops compiling, the Vulkan backend has no // pipeline it can build at all. Assert.True(probe.VulkanReady, "vk_probe must compile — the whole Vulkan backend draws with it."); } }