using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; 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 { // Exact binaries from the pre-campaign retail-authoritative renderer at // 5ca029d3. New opt-in pack shaders may be added, but recompiling these // with a different toolchain is itself an unreviewed default-path change. private static readonly IReadOnlyDictionary RetailOracleSpirvSha256 = new Dictionary(StringComparer.Ordinal) { ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", // Re-pinned 2026-09-04 (Campaign OVERHAUL S5-c4): retail's // single-pass building detail material now runs inside the base // fragment. Final alpha is authoredOpacity*liveFade*detail.a^2; // CLIP tests that value while detail-off retains base-alpha logic. // Fix round 1 additionally carries the exact SetSurface alpha-test // reference and raw-additive fog suppression into this same pass. ["mesh_modern.frag.spv"] = "bd47fe8a33e0f1d025fe48fd95b034ba6fe591a6d20e88e636c86238d8773db1", // S5-c4 replaces the padding word at batch offset 4 with authored // surface opacity and forwards it plus exact detail category/flags // flat to the fragment shader. The 16-byte ABI is unchanged. ["mesh_modern.vert.spv"] = "1ff1b7118153ba0fddfcdfca39bd973a4b8f6b46db6db772e9886061d15561d8", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", // Re-pinned 2026-08-27: portal-view clip slots now travel with // deferred billboard particles, matching retail PortalList draws. // Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same // deletion as mesh_modern.vert above — the binding=2 ClipRegionBuf // fetch, gl_ClipDistance loop, and gl_PerVertex redeclaration are // gone; the aClipSlot vertex attribute stays declared, unread. ["particle.vert.spv"] = "95ce6ecf834930a92da5c5fe9aef513b38b5ba104704b98c1606af71fe17eaf3", ["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c", // Same contract for full-mesh particle geometry. // Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same // ClipRegionBuf/gl_ClipDistance/gl_PerVertex deletion as // particle.vert above. ["particle_mesh.vert.spv"] = "043482b97c2ed036511692f89c75a0a6c298aba48cb519e5e3aff7fe7ba6371b", ["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc", // Re-pinned 2026-09-02 (Campaign OVERHAUL S1 hygiene): the b3b7d922 // investigation checkpoint removed the #117 mark-pass NDC bias and the // #129 eye-span cap from portal_depth.vert (the exact retail depth-state // candidate: ALWAYS / write / no stencil; far-Z punch, true-depth seal). // Its acceptance belongs to OVERHAUL S4 (gate G3); this pin records the // binary that is actually on the branch so accidental drift is still caught. // Re-pinned 2026-09-03 (S4-c1 C0): the far-Z punch constant now writes // retail's EXACT bits (uintBitsToFloat(0x3F7FFFEFu), // D3DPolyRender::DrawPortalPolyInternal @0x0059bc90's tail) instead of // the decimal literal 0.99999988f, which round-tripped to a DIFFERENT // bit pattern (0x3F7FFFFE — FARTHER from the camera, larger z/w, than // retail's real constant by fifteen ULPs; fix round 1 F4 corrected this // comment's direction — see T1, // PortalDepthVert_FarPunchConstant_MatchesRetailExactBits, in THIS file). ["portal_depth.vert.spv"] = "51c60d0924d62c61548efcf5f9e7672a121b1b68ca0a06755e32f1a4d73a8acf", // sky.frag re-pinned 2026-08-23: the dome's fog blend lost its // 0.2 floor and is now applied only under an AdminEnvirons fog // override, retail's GameSky::Draw @0x00506FF0 rule (see // SkyFogRuleTests). A deliberate default-path change, reviewed // with the world-fog-range fix in the same commit. ["sky.frag.spv"] = "1c4ae77056837cbdc188f8cfcc4b0e8851647cdfaf398f25d8c8ff489ef84d57", // sky.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3 chunk 4 fix // round 2, L3): the screen-space TerrainClip UBO (binding=2) and // its gl_ClipDistance writes are deleted outright — retail draws // the sky ONCE, unclipped (GameSky::Draw(sky,0) @0x0050633c), and // that mechanism's only CPU-side writer was already retired at // fix round 1's K3, leaving the shader-side declaration dead. ["sky.vert.spv"] = "7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5", ["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9", // terrain_modern.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3 // chunk 4 fix round 2, L3): same deletion as sky.vert above — // retail never view-clips terrain (LScape::draw draws whole // landblocks; RenderDeviceD3D::DrawBlock @0x005a17c0). ["terrain_modern.vert.spv"] = "8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c", ["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df", ["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798", ["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42", ["vk_probe.vert.spv"] = "6c3260b45644033d607727cbd2e11fb4f60eb4a5b18bfd0997710f2ca518a023", }; 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); if (text.Contains("#include \"", StringComparison.Ordinal)) { text = ExpandIncludes( text, Path.GetDirectoryName(path)!, []); } text = text.Replace("\r\n", "\n", StringComparison.Ordinal); return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); } private static string ExpandIncludes( string source, string sourceDirectory, HashSet active) { var output = new StringBuilder(); foreach (string line in source.Replace("\r\n", "\n").Split('\n')) { string trimmed = line.Trim(); if (!trimmed.StartsWith("#include \"", StringComparison.Ordinal) || !trimmed.EndsWith('"')) { output.AppendLine(line); continue; } string key = trimmed[10..^1]; Assert.DoesNotContain("..", key, StringComparison.Ordinal); Assert.DoesNotContain('\\', key); string include = Path.GetFullPath(Path.Combine(sourceDirectory, key)); Assert.StartsWith( Path.GetFullPath(sourceDirectory) + Path.DirectorySeparatorChar, include, StringComparison.Ordinal); Assert.True(File.Exists(include), $"GLSL include '{key}' is missing."); Assert.True(active.Add(include), $"GLSL include '{key}' is cyclic."); output.AppendLine($"// ---- begin include: {key} ----"); output.Append(ExpandIncludes(File.ReadAllText(include), sourceDirectory, active)); output.AppendLine($"// ---- end include: {key} ----"); active.Remove(include); } return output.ToString(); } [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 PreCampaignRetailSpirvBinariesRemainByteExact() { foreach ((string fileName, string expectedSha256) in RetailOracleSpirvSha256) { string path = Path.Combine(SpirvDirectory(), fileName); Assert.True(File.Exists(path), $"Retail shader oracle '{fileName}' is missing."); string actual = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); Assert.Equal(expectedSha256, actual); } } [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."); } /// /// S4-c1 C0 (T1): D3DPolyRender::DrawPortalPolyInternal /// @0x0059bc90's tail writes the far-Z punch depth from the EXACT bit /// pattern 0x3F7FFFEF — the depth-lifecycle spec's "Current /// acdream correspondence" table's "wrong constant" row, and /// docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md §6's R1/C0. /// This is a SOURCE pin, deliberately independent of /// 's compiled /// hash pin above: it reads the punch line directly out of the GLSL and /// reinterprets whatever numeric literal it finds as raw bits, so a /// future edit that swaps in an "equivalent-looking" decimal literal (or /// any other constant) is caught even before anyone re-runs /// tools/compile-shaders.ps1. Recognizes both the current /// uintBitsToFloat(0x...u) form and a plain decimal float literal /// (the pre-C0 shape), so it can also be pointed at old source to prove /// it used to fail. At `d0c981212` (pre-C0) the line read /// clipPos.z = clipPos.w * 0.99999988;, whose bits are /// 0x3F7FFFFE (confirmed via /// BitConverter.SingleToInt32Bits(0.99999988f)) — FIFTEEN ULPs /// off from retail's 0x3F7FFFEF; running this exact assertion /// against that source produces (verified by hand-editing the line back /// to the old literal and re-running this test during S4-c1's own /// implementation — the commit body carries the same transcript): /// "Assert.Equal() Failure: Values differ\nExpected: 1065353199\nActual: 1065353214" /// (1065353199 = 0x3F7FFFEF, 1065353214 = 0x3F7FFFFE). /// MUTATION: change the punch literal to anything other than bits /// 0x3F7FFFEF (restore the old decimal, or substitute a different /// hex constant) — below fails. /// [Fact] public void PortalDepthVert_FarPunchConstant_MatchesRetailExactBits() { string source = File.ReadAllText(Path.Combine(ShadersDirectory(), "portal_depth.vert")); string line = source .Split('\n') .Select(l => l.Trim()) .SingleOrDefault(l => l.StartsWith("clipPos.z = clipPos.w * ", StringComparison.Ordinal)) ?? throw new InvalidOperationException( "portal_depth.vert no longer has a 'clipPos.z = clipPos.w * ;' punch " + "line for T1 to read — did the far-Z punch assignment move or get restructured?"); uint bits = ParsePunchLiteralBits(line); Assert.Equal(0x3F7FFFEFu, bits); } private static uint ParsePunchLiteralBits(string assignmentLine) { // assignmentLine looks like: // "clipPos.z = clipPos.w * uintBitsToFloat(0x3F7FFFEFu);" (post-C0) // or: // "clipPos.z = clipPos.w * 0.99999988;" (pre-C0) const string prefix = "clipPos.z = clipPos.w * "; string rhs = assignmentLine[prefix.Length..]; int commentStart = rhs.IndexOf("//", StringComparison.Ordinal); if (commentStart >= 0) rhs = rhs[..commentStart]; rhs = rhs.Trim().TrimEnd(';', ' '); Match hexMatch = Regex.Match(rhs, @"uintBitsToFloat\(\s*0x([0-9A-Fa-f]+)u?\s*\)"); if (hexMatch.Success) return Convert.ToUInt32(hexMatch.Groups[1].Value, 16); string literal = rhs.TrimEnd('f', 'F'); if (float.TryParse(literal, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)) return unchecked((uint)BitConverter.SingleToInt32Bits(value)); throw new InvalidOperationException( $"portal_depth.vert's punch literal '{rhs}' is neither a uintBitsToFloat(0x...) call " + "nor a plain float literal T1 knows how to reinterpret as bits."); } }