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>
This commit is contained in:
parent
9b7f434376
commit
a13cff884f
8 changed files with 784 additions and 10 deletions
|
|
@ -0,0 +1,90 @@
|
|||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V9: the probe harness's bounded-run decision.
|
||||
///
|
||||
/// <para>The harness presents until its window closes, which is right at a desk
|
||||
/// and impossible in CI — nothing there ever closes a window.
|
||||
/// <c>ACDREAM_VULKAN_PROBE_FRAMES</c> gives it a frame budget instead. Every
|
||||
/// other line of the harness needs a window and a driver; this decision does
|
||||
/// not, so it is tested here rather than left to the lavapipe job to discover.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VulkanBringUpBudgetTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The interactive default. An unset or malformed budget is zero, and zero
|
||||
/// must never retire the loop, or the developer diagnostic this harness
|
||||
/// exists for would close itself the moment it opened.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void ANonPositiveBudgetNeverRetires(int budget)
|
||||
{
|
||||
Assert.False(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
budget,
|
||||
presentedFrames: 10_000,
|
||||
capturesScreenshot: true,
|
||||
screenshotRequested: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLoopRunsUntilTheBudgetIsReached()
|
||||
{
|
||||
Assert.False(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
frameBudget: 30,
|
||||
presentedFrames: 29,
|
||||
capturesScreenshot: false,
|
||||
screenshotRequested: false));
|
||||
Assert.True(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
frameBudget: 30,
|
||||
presentedFrames: 30,
|
||||
capturesScreenshot: false,
|
||||
screenshotRequested: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The invariant the CI render gate rests on: a run that owes a PNG cannot
|
||||
/// exit before the capture has been attempted. The harness captures at frame
|
||||
/// four, so a budget below that would otherwise exit with an empty artifact
|
||||
/// directory and a green step — the one outcome an unattended render gate
|
||||
/// must never produce.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ARunThatOwesAScreenshotStaysOpenUntilItHasBeenAttempted()
|
||||
{
|
||||
Assert.False(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
frameBudget: 2,
|
||||
presentedFrames: 2,
|
||||
capturesScreenshot: true,
|
||||
screenshotRequested: false));
|
||||
Assert.True(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
frameBudget: 2,
|
||||
presentedFrames: 4,
|
||||
capturesScreenshot: true,
|
||||
screenshotRequested: true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no artifact directory there is no PNG to wait for, so the budget is
|
||||
/// the only term.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ARunWithNoArtifactDirectoryRetiresOnTheBudgetAlone()
|
||||
{
|
||||
Assert.True(
|
||||
VulkanBringUpHost.ShouldRetire(
|
||||
frameBudget: 1,
|
||||
presentedFrames: 1,
|
||||
capturesScreenshot: false,
|
||||
screenshotRequested: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
using System;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V9: the capability report is a CI contract, not just a
|
||||
/// diagnostic.
|
||||
///
|
||||
/// <para>The <c>linux-vulkan</c> job in
|
||||
/// <c>.github/workflows/headless-portability.yml</c> asserts the lavapipe run's
|
||||
/// verdict by reading <c>graphical-capabilities-vulkan.json</c> with <c>jq</c>.
|
||||
/// Those <c>jq</c> paths and the enum spellings they compare against are
|
||||
/// invisible to the compiler: renaming a record property or swapping an enum
|
||||
/// converter would leave every existing test green and turn CI red on a
|
||||
/// different branch, days later, with a failure that reads like a driver
|
||||
/// problem. These tests pin the exact strings the job depends on, so the rename
|
||||
/// fails here first and says why.</para>
|
||||
///
|
||||
/// <para>The record below is shaped like lavapipe deliberately — a CPU device
|
||||
/// on X11 reporting Vulkan 1.4 — because that is the device the job runs on.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VulkanCapabilityReportContractTests
|
||||
{
|
||||
private static VulkanCapabilityRecord LavapipeShapedRecord(
|
||||
string? forcedUnsupportedFeature = null)
|
||||
{
|
||||
var record = new VulkanCapabilityRecord(
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"linux-x64",
|
||||
GraphicalHostOperatingSystem.Linux,
|
||||
GraphicalDisplayProtocol.X11,
|
||||
GraphicalDisplayProtocol.X11,
|
||||
"Vulkan 1.4.0",
|
||||
"Vulkan 1.4.305",
|
||||
VulkanApiVersion.Make(1, 4, 305),
|
||||
"llvmpipe (LLVM 19.1.7, 256 bits)",
|
||||
"vendor 0x10005, device 0x0, driver 0.0.1",
|
||||
PhysicalDeviceType.Cpu,
|
||||
0,
|
||||
"automatic",
|
||||
RequestedDeviceOverride: null,
|
||||
ForcedUnsupportedFeature: null,
|
||||
AvailableDevices: [],
|
||||
InstanceExtensions: ["VK_KHR_surface", "VK_KHR_xlib_surface"],
|
||||
DeviceExtensions: ["VK_KHR_swapchain"],
|
||||
GraphicsQueueFamily: 0,
|
||||
PresentQueueFamily: 0,
|
||||
VulkanDeviceFeatureSupport.Complete,
|
||||
VulkanDeviceLimitSupport.Complete,
|
||||
VulkanFormatSupport.Complete,
|
||||
new VulkanSurfaceSupport(
|
||||
PresentSupported: true,
|
||||
SelectedFormat: Format.B8G8R8A8Unorm,
|
||||
SelectedColorSpace: ColorSpaceKHR.SpaceSrgbNonlinearKhr,
|
||||
SelectedPresentMode: PresentModeKHR.FifoKhr,
|
||||
SelectedImageCount: 3,
|
||||
SelectedWidth: 1280,
|
||||
SelectedHeight: 720,
|
||||
SupportsTransferSource: true,
|
||||
AvailableFormats: [Format.B8G8R8A8Unorm],
|
||||
AvailablePresentModes: [PresentModeKHR.FifoKhr]),
|
||||
new VulkanFunctionProbeResult(
|
||||
DeviceCreation: true,
|
||||
DescriptorIndexingLayout: true,
|
||||
PushConstantLayout: true,
|
||||
DynamicRenderingClear: true,
|
||||
TimelineSemaphoreWait: true,
|
||||
HostQueryReset: true,
|
||||
OffscreenReadback: true,
|
||||
Failures: []),
|
||||
SupportFailures: []);
|
||||
|
||||
record = VulkanCapabilityRequirements.Reevaluate(record);
|
||||
return VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
record,
|
||||
forcedUnsupportedFeature);
|
||||
}
|
||||
|
||||
private static JsonElement Report(string? forcedUnsupportedFeature = null)
|
||||
=> JsonDocument
|
||||
.Parse(
|
||||
VulkanCapabilityReportWriter.Serialize(
|
||||
LavapipeShapedRecord(forcedUnsupportedFeature)))
|
||||
.RootElement;
|
||||
|
||||
/// <summary>
|
||||
/// The passing run's assertions, one for one with the "Probe the Vulkan
|
||||
/// capability gate on lavapipe" step.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ThePassingRunCarriesEveryFieldTheCiJobReads()
|
||||
{
|
||||
JsonElement report = Report();
|
||||
|
||||
Assert.Equal(0, report.GetProperty("SupportFailures").GetArrayLength());
|
||||
Assert.Equal("X11", report.GetProperty("ActiveDisplayProtocol").GetString());
|
||||
// The exact spelling the job greps for. Silk.NET's enum member is Cpu;
|
||||
// a converter change that produced "CPU" or "4" would pass every other
|
||||
// test in this project and fail only in CI.
|
||||
Assert.Equal("Cpu", report.GetProperty("DeviceType").GetString());
|
||||
|
||||
JsonElement probe = report.GetProperty("FunctionProbe");
|
||||
Assert.Equal(0, probe.GetProperty("Failures").GetArrayLength());
|
||||
Assert.True(probe.GetProperty("DeviceCreation").GetBoolean());
|
||||
Assert.True(probe.GetProperty("OffscreenReadback").GetBoolean());
|
||||
|
||||
// Present and human-readable: the job prints these to the log so a
|
||||
// failure elsewhere still records which device ran.
|
||||
Assert.False(string.IsNullOrWhiteSpace(report.GetProperty("DeviceName").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(report.GetProperty("DeviceApiVersion").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(report.GetProperty("DriverInfo").GetString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The job unpacks the API version out of <c>DeviceApiVersionPacked</c> with
|
||||
/// jq arithmetic — dividing by 2^22 for the major and by 2^12 for the minor
|
||||
/// — rather than regexing the display string, which would break at 1.10.
|
||||
/// This asserts that arithmetic against the same packing the client uses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ThePackedApiVersionUnpacksTheWayTheCiJobUnpacksIt()
|
||||
{
|
||||
uint packed = Report().GetProperty("DeviceApiVersionPacked").GetUInt32();
|
||||
|
||||
uint major = packed / 4194304;
|
||||
uint minor = packed % 4194304 / 4096;
|
||||
|
||||
Assert.Equal(VulkanApiVersion.Major(packed), major);
|
||||
Assert.Equal(VulkanApiVersion.Minor(packed), minor);
|
||||
Assert.True(major > 1 || (major == 1 && minor >= 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The forced-unsupported run's assertions, one for one with the "Verify the
|
||||
/// forced-unsupported gate exits 4" step. The feature name is the literal
|
||||
/// the workflow passes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheForcedUnsupportedRunCarriesEveryFieldTheCiJobReads()
|
||||
{
|
||||
JsonElement report = Report("timelineSemaphore");
|
||||
|
||||
Assert.Equal(
|
||||
"timelineSemaphore",
|
||||
report.GetProperty("ForcedUnsupportedFeature").GetString());
|
||||
Assert.False(
|
||||
report.GetProperty("Features").GetProperty("TimelineSemaphore").GetBoolean());
|
||||
|
||||
JsonElement failures = report.GetProperty("SupportFailures");
|
||||
Assert.True(failures.GetArrayLength() > 0);
|
||||
// The job matches on the feature name inside the failure sentence.
|
||||
Assert.Contains(
|
||||
failures.EnumerateArray(),
|
||||
failure => failure.GetString()?.Contains(
|
||||
"timelineSemaphore",
|
||||
StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The operator-facing refusal names the report path. The job greps the log
|
||||
/// for the file name, because a gate that refuses without saying where the
|
||||
/// evidence is has failed at the only job it has on a machine nobody owns.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheRefusalMessageNamesTheReportTheJobUploads()
|
||||
{
|
||||
string message = VulkanCapabilityGuard.FormatUnsupportedMessage(
|
||||
LavapipeShapedRecord("timelineSemaphore"),
|
||||
"/tmp/diagnostics/graphical-capabilities-vulkan.json");
|
||||
|
||||
Assert.Contains("graphical-capabilities-vulkan.json", message, StringComparison.Ordinal);
|
||||
Assert.Contains("timelineSemaphore", message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The report file name is what the workflow's VULKAN_REPORT path ends in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheReportFileNameIsTheOneTheWorkflowPathNames()
|
||||
{
|
||||
Assert.Equal(
|
||||
"graphical-capabilities-vulkan.json",
|
||||
VulkanCapabilityGuard.ReportFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -556,4 +556,54 @@ public sealed class RuntimeOptionsTests
|
|||
Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
|
||||
.VulkanForcedUnsupportedFeature);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V9. The frame budget is what lets the probe harness run
|
||||
/// unattended in CI. Zero is the interactive default, so every invocation
|
||||
/// that predates the slice keeps presenting until its window closes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VulkanCapabilityProbeFrames_DefaultsToUnbounded()
|
||||
{
|
||||
Assert.Equal(
|
||||
0,
|
||||
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanCapabilityProbeFrames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("30", 30)]
|
||||
[InlineData("0", 0)]
|
||||
public void VulkanCapabilityProbeFrames_ParsesANonNegativeBudget(
|
||||
string value,
|
||||
int expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
|
||||
.VulkanCapabilityProbeFrames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A malformed budget falls back to the interactive default rather than to
|
||||
/// some invented number: a CI step that meant to bound the run and mistyped
|
||||
/// it should hang and be noticed, not silently capture at a frame count
|
||||
/// nobody asked for.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("many")]
|
||||
[InlineData("30.5")]
|
||||
public void VulkanCapabilityProbeFrames_RejectsMalformedValues(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
0,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VULKAN_PROBE_FRAMES"] = value }))
|
||||
.VulkanCapabilityProbeFrames);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue