acdream/tools/ShaderCompiler/Program.cs
Erik 777f60708d ci(render): make V9's first CI run green on both operating systems
The lavapipe job did the thing it was built to do on its first attempt.
It accepted a Cpu device at API 1.4, created a device and read pixels
back, captured a real frame, and exited 4 when a feature was forced
unsupported. Three other things were red, and none of them were the
Vulkan backend.

The shader-freshness step aborted for two separate Linux faults in the
compiler tool. Disposing the Silk.NET API container unloads the native
module, and dlclose-ing libshaderc_shared.so leaves glslang's
process-level teardown running against unmapped code. Bisected with a
four-mode probe on Ubuntu 24.04: GetApi, CompilerInitialize and
CompilerRelease each exit 0, and adding only the container Dispose turns
the exit into SIGSEGV. That is the 134 CI reported. shaderc's own handles
are still released; the container is not, because the module's lifetime
is the process's and the process is one statement from returning.
Separately, a portable dotnet build leaves the native under
runtimes/linux-x64/native/ and makes reaching it Silk.NET's probing
problem, which it solved on a local Ubuntu 24.04 and did not solve on the
runner. The script now publishes the tool for the host RID, so the native
sits beside the assembly where AppContext.BaseDirectory finds it, and
checks for it by name so a regression says which file is missing rather
than which names failed.

With both fixed, the question section 5.5.20 left open has an answer:
Linux shaderc and Windows shaderc agree byte-for-byte at the pinned Silk
2.23.0. Eighteen of eighteen .spv identical, manifest identical. The byte
comparison stays a byte comparison.

The Windows leg of portable-headless was running sudo apt-get. That step
is older than this campaign - it is red in the 2026-07-27 main run too -
and it was misplaced rather than mis-conditioned. Nothing in that job
opens a display or links GL, and the graphical jobs that do call xvfb-run
take it from the runner image, so the step is deleted rather than
guarded. Every remaining step in the two-operating-system matrix is pwsh;
every bash step now lives in an ubuntu-only job.

The last failure was ours in a quieter way. WaitForCharacterLogOff-
Confirmation expressed its deadline only as a CancellationTokenSource,
whose timeout is published from a thread-pool timer callback, so on a
saturated pool the token stays unsignalled past the deadline while the
loop keeps draining items that are already queued. That is the case the
method exists to bound. Reproduced by pinning the suite to two CPUs on
Linux, which failed 2 of 6 where four CPUs and sixteen were clean, and
where CI failed 3 of 3. The drain now reads the deadline off the
monotonic clock as well; the token still bounds the asynchronous wait.
Ten of ten clean under the same pin. The test is untouched. Filed as

Release build green. App tests 4,152 / 3 skipped against the same 4,152 /
3 measured at base 32f9bcfa. Core.Net 600 / 600.

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

245 lines
10 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Silk.NET.Core.Native;
using Silk.NET.Shaderc;
namespace AcDream.Tools.ShaderCompiler;
/// <summary>
/// Campaign V slice V6c, plan §4.6: compile acdream's GLSL to committed SPIR-V.
///
/// <para>Usage: <c>ShaderCompiler &lt;shaders-dir&gt; &lt;output-dir&gt;</c>.
/// Every <c>.vert</c>/<c>.frag</c> pair in the source directory is compiled
/// through <see cref="VulkanGlslPreamble"/>; successes write
/// <c>{name}.{stage}.spv</c> and failures are recorded with the compiler's own
/// message. Either way the run writes <c>shaders.manifest.json</c> containing
/// the SHA-256 of every GLSL input, which the App test suite re-checks so a
/// source edit that never got recompiled fails a test rather than shipping a
/// stale binary.</para>
///
/// <para>The exit code is 0 when every shader the manifest expects to succeed
/// did. A shader that is not yet Vulkan-expressible is not a build failure — it
/// is a fact about which renderer-port slices are still outstanding, recorded in
/// the manifest so it is reviewable rather than folklore.</para>
/// </summary>
internal static class Program
{
private static unsafe int Main(string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("usage: ShaderCompiler <shaders-dir> <output-dir>");
return 2;
}
string sourceDirectory = Path.GetFullPath(args[0]);
string outputDirectory = Path.GetFullPath(args[1]);
Directory.CreateDirectory(outputDirectory);
string[] names = Directory
.EnumerateFiles(sourceDirectory, "*.vert")
.Select(Path.GetFileNameWithoutExtension)
.Where(name => name is not null)
.Select(name => name!)
.Where(name => File.Exists(Path.Combine(sourceDirectory, $"{name}.frag")))
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
var shaderc = Shaderc.GetApi();
Compiler* compiler = shaderc.CompilerInitialize();
CompileOptions* options = shaderc.CompileOptionsInitialize();
shaderc.CompileOptionsSetTargetEnv(options, TargetEnv.Vulkan, (uint)EnvVersion.Vulkan13);
shaderc.CompileOptionsSetTargetSpirv(options, SpirvVersion.Shaderc16);
// Performance rather than size: these are compiled once, committed, and
// then loaded by every launch forever.
shaderc.CompileOptionsSetOptimizationLevel(options, OptimizationLevel.Performance);
var entries = new List<ShaderManifestEntry>();
int failures = 0;
try
{
foreach (string name in names)
{
var stages = new List<ShaderStageResult>();
// Shared across the pair so a fragment input takes the location
// its vertex output was given, by name.
var locations = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (string stage in (string[])["vert", "frag"])
{
string path = Path.Combine(sourceDirectory, $"{name}.{stage}");
string source = File.ReadAllText(path);
string hash = Sha256(source);
string transformed;
try
{
transformed = GlslVaryingLocations.Apply(
VulkanGlslPreamble.Apply(source, stage),
stage,
locations);
}
catch (Exception error)
{
stages.Add(new ShaderStageResult(stage, hash, false, error.Message));
continue;
}
if (TryCompile(shaderc, compiler, options, transformed, $"{name}.{stage}", stage, out byte[] spirv, out string message))
{
string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv");
File.WriteAllBytes(target, spirv);
stages.Add(new ShaderStageResult(stage, hash, true, null));
}
else
{
string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv");
if (File.Exists(target))
File.Delete(target);
stages.Add(new ShaderStageResult(stage, hash, false, Summarise(message)));
}
}
bool ok = stages.All(stage => stage.Compiled);
if (!ok)
{
failures++;
// Half a pair is worse than none: the device would load the
// surviving stage happily and nobody could account for it.
foreach (string stage in (string[])["vert", "frag"])
{
string orphan = Path.Combine(outputDirectory, $"{name}.{stage}.spv");
if (File.Exists(orphan))
File.Delete(orphan);
}
}
entries.Add(new ShaderManifestEntry(name, ok, stages));
Console.WriteLine(ok
? $"[shaders] {name}: ok"
: $"[shaders] {name}: NOT VULKAN-EXPRESSIBLE YET — {FirstReason(stages)}");
}
}
finally
{
// shaderc's own handles are released; the Silk.NET API container
// deliberately is not. Disposing the container unloads the native
// module, and on Linux dlclose'ing libshaderc_shared.so kills the
// process during exit: glslang registers process-level teardown
// that runs after the module's code has been unmapped. Bisected on
// Ubuntu 24.04 with a four-mode probe - GetApi, CompilerInitialize
// and CompilerRelease all exit 0, and adding only the container
// Dispose turns the exit into SIGSEGV (139), which reached CI as
// "shader compilation failed with exit code 134". Nothing is leaked
// by omitting it: the module's lifetime is the process's, and the
// process is one statement from returning.
shaderc.CompileOptionsRelease(options);
shaderc.CompilerRelease(compiler);
}
var manifest = new ShaderManifest(
"Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.",
entries.OrderBy(entry => entry.Name, StringComparer.Ordinal).ToList());
string manifestPath = Path.Combine(outputDirectory, "shaders.manifest.json");
File.WriteAllText(
manifestPath,
JsonSerializer.Serialize(manifest, ShaderManifestJson.Options) + Environment.NewLine);
Console.WriteLine(
$"[shaders] {entries.Count - failures}/{entries.Count} pair(s) compiled; manifest at {manifestPath}");
return 0;
}
private static unsafe bool TryCompile(
Shaderc shaderc,
Compiler* compiler,
CompileOptions* options,
string source,
string name,
string stage,
out byte[] spirv,
out string message)
{
ShaderKind kind = stage == "vert" ? ShaderKind.VertexShader : ShaderKind.FragmentShader;
byte[] sourceBytes = Encoding.UTF8.GetBytes(source);
byte[] nameBytes = Encoding.UTF8.GetBytes(name + "\0");
byte[] entryBytes = Encoding.UTF8.GetBytes("main\0");
fixed (byte* sourcePointer = sourceBytes)
fixed (byte* namePointer = nameBytes)
fixed (byte* entryPointer = entryBytes)
{
CompilationResult* result = shaderc.CompileIntoSpv(
compiler,
sourcePointer,
(nuint)sourceBytes.Length,
kind,
namePointer,
entryPointer,
options);
try
{
CompilationStatus status = shaderc.ResultGetCompilationStatus(result);
message = SilkMarshal.PtrToString((nint)shaderc.ResultGetErrorMessage(result)) ?? string.Empty;
if (status != CompilationStatus.Success)
{
spirv = [];
return false;
}
nuint length = shaderc.ResultGetLength(result);
spirv = new byte[(int)length];
new ReadOnlySpan<byte>(shaderc.ResultGetBytes(result), (int)length).CopyTo(spirv);
return true;
}
finally
{
shaderc.ResultRelease(result);
}
}
}
/// <summary>First line of a compiler message, which is the one that names the cause.</summary>
private static string Summarise(string message)
{
string[] lines = message
.Replace("\r\n", "\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return lines.Length == 0 ? "unknown compiler failure" : lines[0];
}
private static string FirstReason(IEnumerable<ShaderStageResult> stages) =>
stages.FirstOrDefault(stage => !stage.Compiled)?.Message ?? "unknown";
private static string Sha256(string text)
{
// Line endings are normalised before hashing so a checkout with
// different core.autocrlf settings does not report every shader stale.
byte[] bytes = Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n"));
return Convert.ToHexStringLower(SHA256.HashData(bytes));
}
}
internal sealed record ShaderStageResult(
[property: JsonPropertyName("stage")] string Stage,
[property: JsonPropertyName("sourceSha256")] string SourceSha256,
[property: JsonPropertyName("compiled")] bool Compiled,
[property: JsonPropertyName("message")] string? Message);
internal sealed record ShaderManifestEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("vulkanReady")] bool VulkanReady,
[property: JsonPropertyName("stages")] IReadOnlyList<ShaderStageResult> Stages);
internal sealed record ShaderManifest(
[property: JsonPropertyName("note")] string Note,
[property: JsonPropertyName("shaders")] IReadOnlyList<ShaderManifestEntry> Shaders);
internal static class ShaderManifestJson
{
internal static JsonSerializerOptions Options { get; } = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}