acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
Erik a13cff884f ci(render): Campaign V slice V9 - the Vulkan gate runs on lavapipe
The first CI job in this project's history that renders a frame.

The whole row rests on a decision V6g already made and paid for. When
section 5.5.8 cut set 0 from ten dynamic storage descriptors to four, four
was not merely under the RX 9070 XT's eight - it is Vulkan's guaranteed
minimum, so no conformant device can fail the layout. That is what makes a
software-device row possible at all. Every other requirement was then
checked against Mesa's lvp_device.c rather than assumed, and all seventeen
features the gate demands are true on lavapipe - including
samplerAnisotropy, which V7 made load-bearing eight commits ago and which a
software rasterizer would have been entirely within its rights to decline.

Three things had to exist before the job could:

1. The harness could not stop. VulkanBringUpHost presents until its window
   closes, which is right at a desk and impossible in CI, where nothing ever
   closes a window. ACDREAM_VULKAN_PROBE_FRAMES gives it a budget; unset or
   malformed is zero, which keeps the interactive behaviour, so no existing
   invocation changes. The budget never cuts the capture short - the loop
   stays open until the screenshot has been attempted - because a run whose
   entire product is a PNG must not be able to exit green with an empty
   artifact directory. The decision is a pure static method, tested without
   a window or a driver.

2. tools/compile-shaders.ps1 was Windows-only and nobody had noticed,
   because nothing had ever run it anywhere else. It built its paths from
   embedded 'src\AcDream.App\...' literals; a backslash is a separator on
   Windows and an ordinary filename character everywhere else, so on Linux
   that is one long nonexistent file name.

3. The report's jq paths were invisible to the compiler. Renaming a record
   property or swapping the enum converter would have left every test green
   and turned CI red on someone else's branch days later, with a failure
   that reads like a driver problem. VulkanCapabilityReportContractTests
   pins the exact strings the job greps and pins its packed-version
   arithmetic against VulkanApiVersion's own unpacking.

The job, eleven steps: install lavapipe and Xvfb; record vulkaninfo as
evidence; publish linux-x64; run the Gpu.Vk tests on a second operating
system; probe the gate under a 24-bit Xvfb screen (the default is 8-bit,
which leaves the X11 WSI without a usable visual) and assert an accepting
verdict on a Cpu device at API >= 1.3 with a clean active probe; assert the
captured PNG is a real frame by IHDR dimensions and byte count; re-run with
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore and assert exit 4 with an
actionable refusal; recompile the shaders and compare. Artifacts upload on
always(), so a red run ships its own diagnosis.

The .spv step is what ties the committed binaries to their sources. The
existing App test hashes GLSL against the manifest, which catches "edited a
shader, forgot to recompile"; nothing caught a stale or hand-edited .spv.
Verified on Windows before shipping: 19/19 artifacts byte-identical to a
fresh compile, zero drift.

No GL-versus-Vulkan pixel compare, for two independent reasons recorded in
section 5.5.20: linux-graphical asserts exit 4, so there is no left-hand
side, and the probe renders synthetic scenes rather than the DAT world CI
cannot have. The two jobs now say something sharper than a pixel diff would
have - on the same software Mesa stack, GL is refused and Vulkan is accepted
and draws. Physical Linux GPU and Wayland rows stay deferred on the Slice L
precedent; no hosted runner offers either.

Gates: Release build green, zero errors. App tests 4,152 / 3 skipped against
a 4,134 / 3 baseline at this branch's base (9b7f4343) - eighteen new, all
from this slice. Workflow validated by a real YAML parse plus an Actions
schema check and bash -n over all nine extracted run blocks; no actionlint
was available locally and none was downloaded. The job itself has not run:
its first execution is the CI run this commit triggers, and the V9 row stays
partial until that is green.

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

316 lines
12 KiB
C#

using AcDream.App.Diagnostics;
using AcDream.App.Platform;
using AcDream.App.Rendering;
using Silk.NET.Maths;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// The Vulkan capability-probe and bring-up harness. Reached only when
/// <c>ACDREAM_RENDER_BACKEND=vulkan</c> <b>and</b> <c>ACDREAM_VULKAN_PROBE=1</c>.
///
/// <para><b>What it is for.</b> Answering two questions without starting the
/// client: does this machine pass the Vulkan capability gate, and does the RHI
/// backend actually draw? It opens a window, acquires a
/// <see cref="VulkanGraphicsContext"/> — which runs the gate and writes the
/// report — and presents the V6c/V6d verification scenes until the window
/// closes. The scenes are deliberately synthetic and deliberately asymmetric:
/// the one thing a symmetric layout could never prove is that the
/// negative-viewport Y flip and the capture path agree.</para>
///
/// <para><b>What it is no longer.</b> Through slice V6g this was the whole
/// Vulkan path — a second <c>main()</c> beside <c>GameWindow</c>'s composition.
/// Slice V6h moved production Vulkan onto the real composition host and lifted
/// the instance/surface/device/swapchain sequence out into
/// <see cref="VulkanGraphicsContext"/>, which both now share. What remains here
/// is the harness role the class is named for.</para>
///
/// <para><b>Untested by the implementing slice.</b> Every line below needs a
/// window and a driver. The pure decisions it depends on are unit-tested beside
/// it; the remaining gate is manual.</para>
/// </summary>
internal sealed class VulkanBringUpHost : IDisposable
{
/// <summary>Two frames in flight, matching plan §4.8 and the GL flight controller.</summary>
internal const int FlightCount = 2;
/// <summary>
/// The bring-up clear colour, linear-encoded straight into a UNORM
/// swapchain. A deliberate deep blue: black would be indistinguishable from
/// an unpainted window, and magenta is reserved as the "unresolved texture
/// slot" sentinel everywhere else in this codebase.
/// </summary>
internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f];
private const string ScreenshotName = "vulkan-bringup";
private readonly RuntimeOptions _options;
private readonly GraphicalHostPlatformServices _platform;
private readonly FramePacingPolicy _pacing;
private readonly Action<string> _log;
private IWindow? _window;
private VulkanGraphicsContext? _graphics;
private VulkanRhiScene? _scene;
private VulkanRetainedUiScene? _ui;
private ulong _frameSerial;
private bool _disposed;
internal VulkanBringUpHost(
RuntimeOptions options,
GraphicalHostPlatformServices platform,
bool requestedVSync,
Action<string>? log = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_platform = platform ?? throw new ArgumentNullException(nameof(platform));
_log = log ?? Console.WriteLine;
// Resolved from the same pure policy the GL path uses, so "VSync on means
// FIFO" is one decision expressed once. The monitor refresh is left
// unknown because the harness consumes only UseVSync: there is no
// software pacer here to feed a limit to.
_pacing = FramePacingPolicy.Resolve(
requestedVSync,
_options.UncappedRendering,
monitorRefreshHz: null);
}
/// <summary>The record the gate evaluated, available after <see cref="Run"/> starts.</summary>
internal VulkanCapabilityRecord? Capabilities => _graphics?.Capabilities;
/// <summary>
/// Open the window, pass the capability gate, and present the verification
/// scenes until the window closes. Throws <see cref="NotSupportedException"/>
/// when the gate rejects the device, which <c>Program.cs</c> turns into exit
/// code 4 exactly as it does for the GL gate.
/// </summary>
internal void Run()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CreateWindow();
_graphics = VulkanGraphicsContext.Acquire(
_window!,
_options,
_platform,
_pacing,
// Four samples where the device allows it, so the backbuffer pass
// really resolves rather than rendering straight into the swapchain
// image. Plan §4.10 records that the V7 differential must force MSAA
// off; this is not that gate, and a resolve path that is never
// exercised is a resolve path that does not work.
requestedSampleCount: 4,
_log);
CreateScenes();
Present();
}
private void CreateWindow()
{
var options = WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan capability probe",
VSync = _pacing.UseVSync,
};
_window = Window.Create(options);
_window.Initialize();
}
private void CreateScenes()
{
VulkanGraphicsContext graphics = _graphics!;
_scene = new VulkanRhiScene(graphics.Device, graphics.SampleCount);
// Campaign V slice V6d: the retained UI and the debug lines draw here
// through exactly the classes the client uses, with a generated widget
// tree. Slice V6h's composition host draws the client's own tree; this
// keeps the isolated, session-free version reproducible.
_ui = new VulkanRetainedUiScene(
graphics.Device,
VulkanGraphicsContext.ShaderSpirvDirectory());
_log(
"vulkan: retained UI up — TextRenderer and DebugLineRenderer on the Vulkan device" +
(_ui.HasFont ? string.Empty : " (no system font found; glyph draws are skipped)"));
}
/// <summary>
/// The frame loop: record the verification scenes through the RHI, present,
/// and capture one screenshot once the scene has settled.
///
/// <para>Campaign V slice V9: when <c>ACDREAM_VULKAN_PROBE_FRAMES</c> is
/// positive the loop retires itself after that many presented frames instead
/// of waiting for the window to close. CI has no one to close it. The budget
/// never cuts the capture short — the loop stays open until the screenshot
/// has been attempted — because an unattended run whose whole product is a
/// PNG must not be able to exit without producing one.</para>
/// </summary>
private void Present()
{
IWindow window = _window!;
VulkanGraphicsContext graphics = _graphics!;
VulkanGpuDevice device = graphics.Device;
VulkanRhiScene scene = _scene!;
FrameScreenshotController? screenshots = CreateScreenshotController();
bool screenshotRequested = false;
DateTimeOffset started = DateTimeOffset.UtcNow;
while (!window.IsClosing)
{
window.DoEvents();
if (window.IsClosing)
break;
if (!graphics.PrepareFrame())
{
// Minimised: idle without burning a core, and without pretending
// a zero-area swapchain can be created.
Thread.Sleep(16);
continue;
}
if (!device.TryBeginFrame(out IGpuFrame? frame) || frame is null)
{
graphics.RequestRecreate();
continue;
}
double elapsed = (DateTimeOffset.UtcNow - started).TotalSeconds;
using (frame)
{
scene.Render(frame, graphics.Width, graphics.Height, elapsed);
// After the 3-D scene, in its own single-sampled load/store pass
// against the backbuffer — the same shape the client's HUD phase
// has, and the reason the multisampled pass must resolve rather
// than store.
_ui?.Render(frame, graphics.Width, graphics.Height, elapsed);
}
_frameSerial = (ulong)frame.Serial;
graphics.NoteFrameClosed();
// Capture after a few frames so the timer pool has resolved and the
// ring has cycled through both flight slots at least once.
if (screenshots is not null && !screenshotRequested && _frameSerial >= 4)
{
screenshotRequested = true;
if (screenshots.TryRequest(ScreenshotName, out string error))
{
screenshots.CapturePending((int)graphics.Width, (int)graphics.Height);
ReportTimings(device);
}
else
{
_log($"vulkan: screenshot request rejected: {error}");
}
}
if (ShouldRetire(screenshots, screenshotRequested))
{
_log(
$"vulkan: frame budget of {_options.VulkanCapabilityProbeFrames} " +
"reached; closing the probe.");
break;
}
}
device.WaitIdle();
_log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down.");
}
private bool ShouldRetire(
FrameScreenshotController? screenshots,
bool screenshotRequested) =>
ShouldRetire(
_options.VulkanCapabilityProbeFrames,
_frameSerial,
screenshots is not null,
screenshotRequested);
/// <summary>
/// Campaign V slice V9. The bounded-run decision, pure so it can be tested
/// without a window or a driver.
/// </summary>
/// <param name="frameBudget">
/// <c>ACDREAM_VULKAN_PROBE_FRAMES</c>. Zero or negative means the
/// interactive behaviour: run until the window closes.
/// </param>
/// <param name="presentedFrames">Frames presented so far.</param>
/// <param name="capturesScreenshot">
/// Whether an artifact directory was configured, and so whether this run owes
/// a PNG.
/// </param>
/// <param name="screenshotRequested">Whether that capture has been attempted.</param>
internal static bool ShouldRetire(
int frameBudget,
ulong presentedFrames,
bool capturesScreenshot,
bool screenshotRequested)
{
if (frameBudget <= 0)
return false;
if (presentedFrames < (ulong)frameBudget)
return false;
// A budget below the capture threshold would otherwise exit with an empty
// artifact directory and a green step, which is the one outcome an
// unattended render gate must never produce.
return !capturesScreenshot || screenshotRequested;
}
private void ReportTimings(VulkanGpuDevice device)
{
if (!device.Timers.IsSupported)
{
_log("vulkan: GPU timestamps are unsupported on this device");
return;
}
string offscreen = device.Timers.TryResolve("offscreen", out double offscreenMs)
? $"{offscreenMs:F3} ms"
: "pending";
string main = device.Timers.TryResolve("main", out double mainMs)
? $"{mainMs:F3} ms"
: "pending";
_log($"vulkan: GPU timer scopes — offscreen {offscreen}, main {main}");
}
private FrameScreenshotController? CreateScreenshotController()
{
if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory))
return null;
return new FrameScreenshotController(
// IGpuDevice.CaptureBackbuffer is documented top-left-origin and a
// Vulkan image already is; FrameScreenshotController flips what it
// receives because glReadPixels hands back bottom-up rows. Flipping
// here makes the two cancel, so the PNG is right-side-up.
(width, height) => FrameScreenshotController.FlipRows(
_graphics!.Device.CaptureBackbuffer(width, height),
width,
height),
_options.AutomationArtifactDirectory,
_log);
}
/// <summary>
/// Teardown in strict reverse-construction order. The scenes go before the
/// context: they own buffers, textures, render targets and pipelines whose
/// release routes through the device's retirement queue, so the device has to
/// still be alive to drain it.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_ui?.Dispose();
_ui = null;
_scene?.Dispose();
_scene = null;
_graphics?.Dispose();
_graphics = null;
_window?.Dispose();
_window = null;
}
}