acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
Erik cc42edc8e3 fix #427: sky drawn without fog like retail; world fog range is the keyframe's authored MinWorldFog/MaxWorldFog
Two April stand-ins, neither registered, met at the horizon from altitude as
a hard line between the dome's rim and the fog-coloured clear:

1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's
   GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole
   sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable(
   LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via
   SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky
   pass now sets ApplyFog only for (override active && !additive), with no
   floor.
2. WorldRenderFrameBuilder overwrote the authored fog range with one derived
   from the streaming window (538..2189 m always). Retail sets FOGSTART/
   FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog
   (SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with
   no draw-distance scaling; zfar is a constant 4000 m. The builder now
   leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT /
   _END_MULT are deleted from RuntimeOptions.

Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic
harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the
reason. Research note 2026-04-23-sky-fog.md carries a correction banner.
App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night
and rain fog are now retail's shorter authored ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:42:42 +02:00

260 lines
12 KiB
C#

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;
/// <summary>
/// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it
/// was compiled from.
///
/// <para>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 <c>.spv</c> 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.</para>
///
/// <para>It also pins which production shaders are Vulkan-expressible TODAY. As
/// of Campaign V slice V11 (which deleted the one pair that never was —
/// <c>mesh.vert</c>/<c>mesh.frag</c>, 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.</para>
/// </summary>
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<string, string> RetailOracleSpirvSha256 =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140",
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e",
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
// 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"] = "b3b544829f2dd85be04b6b16d78a0490e1fe7884590cb541b95756b7d7511620",
["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
["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<StageEntry> Stages);
private sealed record Manifest(string Note, IReadOnlyList<ShaderEntry> 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<Manifest>(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<string> 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<string>();
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.");
}
}