diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml
index 2149e6b3..7d498278 100644
--- a/.github/workflows/headless-portability.yml
+++ b/.github/workflows/headless-portability.yml
@@ -311,7 +311,16 @@ jobs:
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
- --filter "FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk"
+ --filter "FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk&Lane!=Vulkan"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Test the Vulkan hardware lane on lavapipe
+ shell: pwsh
+ run: |
+ dotnet test `
+ tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
+ -c Release `
+ --filter "Lane=Vulkan"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# (a) + (b): one run, two gates. The harness opens a real window, runs the
diff --git a/docs/release-gate.md b/docs/release-gate.md
index 4aba7774..8bdfcfd6 100644
--- a/docs/release-gate.md
+++ b/docs/release-gate.md
@@ -12,9 +12,10 @@ The command verifies that `AcDream.slnx` contains every `.csproj` under `src/`,
then discovers and runs every hermetic test in every default test assembly once
in a fresh Release process. It does not retry failures. Tests carrying an
explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`,
-`Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
-`Status=KnownFailure` are excluded from the hermetic total and run through
-their owned lane instead. The graph currently contains 54 projects,
+`Manual`, `Timing`, `Windows`, `Linux`, `Vulkan`, or `SystemFont`),
+`Purpose=Diagnostic`, or `Status=KnownFailure` are excluded from the hermetic
+total and run through their owned lane instead. The graph currently contains
+54 projects,
including all 17 maintained .NET tools and three render-pack SDK samples;
data-dependent tools and SDK samples are built but are not executed as tests.
@@ -108,6 +109,18 @@ and Forgejo fails a task that stops reporting as a zombie.
## Non-hermetic test lanes
+`Lane=Vulkan` owns tests that require a Vulkan loader, a Vulkan 1.3-capable
+physical device with a graphics queue, and coherent host-visible readback
+memory. The portable Release gate excludes this capability lane. The
+`linux-vulkan` CI job installs lavapipe and runs it explicitly; missing Vulkan
+capability is a failure there, not a skip. Run the same dedicated lane locally
+on a capable device with:
+
+```powershell
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release `
+ --filter 'Lane=Vulkan'
+```
+
Installed-DAT tests require an explicit opt-in and a retail DAT directory:
```powershell
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
index 4959eb55..b4fae7e1 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
@@ -19,6 +19,7 @@ public sealed unsafe class MeshModernSharedIndexOffscreenTests
private const uint Prefix = 2;
private static readonly object VulkanLock = new();
+ [Trait("Lane", "Vulkan")]
[Fact]
public void CommittedProductionOrdinaryShader_RendersLocalSidecarsAtNonzeroTransformPrefix()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs
new file mode 100644
index 00000000..90b9f860
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs
@@ -0,0 +1,84 @@
+using System.Text.RegularExpressions;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Keeps the capability-owned pixel witness out of the portable gate while
+/// requiring the lavapipe job to execute the complete trait-owned lane.
+/// This contract reads repository text only and never initializes Vulkan.
+///
+public sealed class VulkanLaneOwnershipContractTests
+{
+ private const string WitnessMethod =
+ "CommittedProductionOrdinaryShader_RendersLocalSidecarsAtNonzeroTransformPrefix";
+
+ [Fact]
+ public void HardwareWitness_PortableFilterAndLavapipeOwnerStayAligned()
+ {
+ string root = RepositoryRoot();
+ string witness = File.ReadAllText(Path.Combine(
+ root,
+ "tests", "AcDream.App.Tests", "Rendering", "Gpu", "Vk",
+ "MeshModernSharedIndexOffscreenTests.cs"));
+ Assert.Matches(
+ new Regex(
+ "\\[Trait\\(\"Lane\", \"Vulkan\"\\)\\]\\s*"
+ + "\\[Fact\\]\\s*public void " + WitnessMethod + "\\(",
+ RegexOptions.CultureInvariant),
+ witness);
+ Assert.Equal(1, Count(witness, "[Trait(\"Lane\", \"Vulkan\")]"));
+
+ string releaseGate = File.ReadAllText(Path.Combine(root, "tools", "run-release-gate.ps1"));
+ Match defaultFilter = Regex.Match(
+ releaseGate,
+ "\\[string\\]\\$TestFilter\\s*=\\s*'(?[^']+)'",
+ RegexOptions.CultureInvariant);
+ Assert.True(defaultFilter.Success, "Could not locate the portable Release gate's default filter.");
+ Assert.Contains("Lane!=Vulkan", defaultFilter.Groups["filter"].Value, StringComparison.Ordinal);
+ Assert.Equal(1, Count(defaultFilter.Groups["filter"].Value, "Lane!=Vulkan"));
+
+ string workflow = File.ReadAllText(Path.Combine(
+ root, ".github", "workflows", "headless-portability.yml"));
+ int install = workflow.IndexOf("- name: Install lavapipe, the Vulkan loader and Xvfb", StringComparison.Ordinal);
+ int portable = workflow.IndexOf("- name: Test the Vulkan backend's platform-independent decisions", StringComparison.Ordinal);
+ int hardware = workflow.IndexOf("- name: Test the Vulkan hardware lane on lavapipe", StringComparison.Ordinal);
+ Assert.True(install >= 0 && install < portable && portable < hardware);
+
+ string portableStep = Step(workflow, portable);
+ Assert.Contains(
+ "--filter \"FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk&Lane!=Vulkan\"",
+ portableStep,
+ StringComparison.Ordinal);
+
+ string hardwareStep = Step(workflow, hardware);
+ Assert.DoesNotContain("FullyQualifiedName", hardwareStep, StringComparison.Ordinal);
+ Assert.DoesNotContain(WitnessMethod, hardwareStep, StringComparison.Ordinal);
+ Assert.Contains("--filter \"Lane=Vulkan\"", hardwareStep, StringComparison.Ordinal);
+ }
+
+ private static string Step(string workflow, int start)
+ {
+ int next = workflow.IndexOf(" - name:", start + 1, StringComparison.Ordinal);
+ return next < 0 ? workflow[start..] : workflow[start..next];
+ }
+
+ private static int Count(string value, string needle)
+ {
+ int count = 0;
+ for (int index = 0; (index = value.IndexOf(needle, index, StringComparison.Ordinal)) >= 0;)
+ {
+ count++;
+ index += needle.Length;
+ }
+ return count;
+ }
+
+ 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.");
+ }
+}
diff --git a/tools/run-release-gate.ps1 b/tools/run-release-gate.ps1
index 8e7194e3..c041d59d 100644
--- a/tools/run-release-gate.ps1
+++ b/tools/run-release-gate.ps1
@@ -24,7 +24,7 @@ param(
[int]$BuildTimeoutSeconds = 900,
[int]$TestTimeoutSeconds = 600,
[int]$HangTimeoutSeconds = 180,
- [string]$TestFilter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure',
+ [string]$TestFilter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=Vulkan&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure',
[switch]$SkipRestore,
[switch]$SkipBuild
)