diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml
index 6d3d2b0a..e764b216 100644
--- a/.github/workflows/headless-portability.yml
+++ b/.github/workflows/headless-portability.yml
@@ -21,6 +21,8 @@ on:
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
+ - "tools/ShaderCompiler/**"
+ - "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
@@ -41,6 +43,8 @@ on:
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
+ - "tools/ShaderCompiler/**"
+ - "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:
@@ -202,3 +206,256 @@ jobs:
test "$(jq -r '.Lifecycle.OwnedGlApiCount' "$report")" -eq 0
test "$(jq -r '.Lifecycle.OwnedInputContextCount' "$report")" -eq 0
jq -e '.SupportFailures | length > 0' "$report"
+
+ # Campaign V slice V9. The GL job above proves the unsupported-driver gate
+ # fires on Mesa's software OpenGL; this one proves the Vulkan backend does the
+ # opposite on Mesa's software Vulkan. lavapipe passes the capability gate that
+ # llvmpipe-GL cannot, because mandatory GL_ARB_bindless_texture has no llvmpipe
+ # implementation while every Vulkan feature acdream requires is core 1.3 or a
+ # descriptor-indexing feature lavapipe implements. That makes this the first CI
+ # job in the project's history that renders a frame.
+ #
+ # NOT DONE HERE, deliberately: a GL-versus-Vulkan pixel comparison. Two
+ # independent reasons, either of which alone is disqualifying. First, the GL
+ # job never produces a frame at all: it asserts exit code 4, so there is no
+ # left-hand side. Second, even if llvmpipe-GL could run, the probe harness
+ # renders synthetic verification scenes rather than the world, and the world
+ # needs retail DATs that CI does not have and cannot be given. The real
+ # GL-versus-Vulkan differential is V7's, on the developer machine, against the
+ # DATs, with both clocks pinned. See plan section 5.5.20.
+ linux-vulkan:
+ runs-on: ubuntu-latest
+
+ # Redirect the portable per-user roots into the workspace so the capability
+ # report, which the app writes to its own diagnostics directory rather than
+ # to a path a caller chooses, lands somewhere collectable. Slice L0 made
+ # these XDG-driven precisely so a host could place them.
+ env:
+ XDG_CONFIG_HOME: ${{ github.workspace }}/artifacts/xdg/config
+ XDG_DATA_HOME: ${{ github.workspace }}/artifacts/xdg/data
+ XDG_CACHE_HOME: ${{ github.workspace }}/artifacts/xdg/cache
+ VULKAN_REPORT: ${{ github.workspace }}/artifacts/xdg/cache/acdream/diagnostics/graphical-capabilities-vulkan.json
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Install lavapipe, the Vulkan loader and Xvfb
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ jq \
+ libvulkan1 \
+ mesa-vulkan-drivers \
+ vulkan-tools \
+ xauth \
+ xvfb
+
+ - name: Record the software Vulkan device
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ # Evidence, not configuration: no ICD is forced, because on a runner
+ # with no GPU lavapipe is the only one the loader can find. If that
+ # ever stops being true the DeviceType assertion below turns red
+ # rather than silently measuring different hardware, which is the
+ # outcome this row wants; its whole point is that the gate passes on
+ # the weakest conformant device in existence.
+ ls -l /usr/share/vulkan/icd.d/ || true
+ vulkaninfo --summary 2>&1 | tee artifacts/vulkaninfo-summary.txt
+
+ - name: Build and publish the Linux graphical client
+ shell: pwsh
+ run: |
+ dotnet publish src/AcDream.App/AcDream.App.csproj `
+ -c Release `
+ -r linux-x64 `
+ --self-contained false `
+ -o artifacts/acdream-linux-x64
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Test the Vulkan backend's platform-independent decisions
+ shell: pwsh
+ run: |
+ dotnet test `
+ tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
+ -c Release `
+ --filter "FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ # (a) + (b): one run, two gates. The harness opens a real window, runs the
+ # capability gate against a real device, and presents the V6c/V6d
+ # verification scenes through the real RHI. ACDREAM_VULKAN_PROBE_FRAMES is
+ # what makes it terminate: nothing in CI ever closes a window.
+ - name: Probe the Vulkan capability gate on lavapipe
+ shell: bash
+ run: |
+ set -euo pipefail
+ root=artifacts/acdream-linux-x64
+ out="$PWD/artifacts/vulkan-probe"
+ mkdir -p "$out"
+
+ # 24-bit depth explicitly: xvfb-run's default screen is 8-bit, which
+ # leaves the X11 WSI without a usable visual.
+ ACDREAM_RENDER_BACKEND=vulkan \
+ ACDREAM_VULKAN_PROBE=1 \
+ ACDREAM_VULKAN_PROBE_FRAMES=30 \
+ ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
+ ACDREAM_DISPLAY_PROTOCOL=x11 \
+ ACDREAM_NO_AUDIO=1 \
+ xvfb-run -a -s "-screen 0 1920x1080x24" \
+ "$root/AcDream.App" /tmp/not-needed 2>&1 | tee artifacts/vulkan-probe.log
+
+ test -f "$VULKAN_REPORT"
+ cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-pass.json
+
+ jq -r '"device: \(.DeviceName) (\(.DeviceType)), \(.DeviceApiVersion), \(.DriverInfo)"' \
+ "$VULKAN_REPORT"
+
+ # The gate accepted the device outright.
+ jq -e '.SupportFailures | length == 0' "$VULKAN_REPORT"
+ test "$(jq -r '.ActiveDisplayProtocol' "$VULKAN_REPORT")" = X11
+ # A software device, which is the whole point of this row.
+ test "$(jq -r '.DeviceType' "$VULKAN_REPORT")" = Cpu
+ # Vulkan 1.3 floor, unpacked from VK_MAKE_API_VERSION: major is bits
+ # 22+, minor is bits 12-21. Evaluate() already rejects anything lower,
+ # so this asserts the report agrees with the verdict rather than
+ # re-deriving it.
+ jq -e '
+ ((.DeviceApiVersionPacked / 4194304) | floor) as $major
+ | (((.DeviceApiVersionPacked % 4194304) / 4096) | floor) as $minor
+ | $major > 1 or ($major == 1 and $minor >= 3)' "$VULKAN_REPORT"
+ # Advertisement is not evidence: the active probe created the device,
+ # built the descriptor layouts and a pipeline from committed .spv, drew
+ # an offscreen triangle and read the pixels back.
+ jq -e '.FunctionProbe.Failures | length == 0' "$VULKAN_REPORT"
+ jq -e '.FunctionProbe.DeviceCreation and .FunctionProbe.OffscreenReadback' \
+ "$VULKAN_REPORT"
+
+ - name: Assert the offline render produced a real frame
+ shell: bash
+ run: |
+ set -euo pipefail
+ png=artifacts/vulkan-probe/vulkan-bringup.png
+ test -f "$png"
+
+ # PNG IHDR carries the dimensions at bytes 16..23, big-endian. Reading
+ # them proves the capture path returned a full backbuffer rather than a
+ # stub, without depending on a rasterizer's pixel values.
+ width=$(od -An -tu4 -j16 -N4 --endian=big "$png" | tr -d ' ')
+ height=$(od -An -tu4 -j20 -N4 --endian=big "$png" | tr -d ' ')
+ bytes=$(stat -c%s "$png")
+ echo "captured ${width}x${height}, ${bytes} bytes"
+
+ test "$width" -ge 640
+ test "$height" -ge 360
+ # A uniform-colour frame at this size encodes to a few kilobytes. This
+ # threshold is the "the scene actually drew" line, and is deliberately
+ # a byte count rather than a pixel baseline: lavapipe and any other
+ # rasterizer are free to disagree about shading, and nothing in CI has
+ # a reference frame to disagree with.
+ test "$bytes" -gt 8192
+
+ # (c) The gate's failure path, exercised on a device that actually
+ # supports everything, so the exit-code-4 contract is proven rather than
+ # assumed. Same knob slice V5 built for exactly this.
+ - name: Verify the forced-unsupported gate exits 4
+ shell: bash
+ run: |
+ set -euo pipefail
+ root=artifacts/acdream-linux-x64
+ out="$PWD/artifacts/vulkan-forced"
+ mkdir -p "$out"
+
+ set +e
+ ACDREAM_RENDER_BACKEND=vulkan \
+ ACDREAM_VULKAN_PROBE=1 \
+ ACDREAM_VULKAN_PROBE_FRAMES=30 \
+ ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore \
+ ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
+ ACDREAM_DISPLAY_PROTOCOL=x11 \
+ ACDREAM_NO_AUDIO=1 \
+ xvfb-run -a -s "-screen 0 1920x1080x24" \
+ "$root/AcDream.App" /tmp/not-needed \
+ > artifacts/vulkan-forced.log 2>&1
+ code=$?
+ set -e
+
+ cat artifacts/vulkan-forced.log
+ test -f "$VULKAN_REPORT"
+ cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-forced.json
+
+ test "$code" -eq 4
+ test "$(jq -r '.ForcedUnsupportedFeature' "$VULKAN_REPORT")" = timelineSemaphore
+ test "$(jq -r '.Features.TimelineSemaphore' "$VULKAN_REPORT")" = false
+ jq -e '.SupportFailures | length > 0' "$VULKAN_REPORT"
+ jq -e '.SupportFailures | any(test("timelineSemaphore"))' "$VULKAN_REPORT"
+ # The refusal must be actionable: the operator is told where the full
+ # report is, not merely that something was unsupported.
+ grep -q 'graphical-capabilities-vulkan.json' artifacts/vulkan-forced.log
+
+ # (d) The committed .spv are the only shaders the Vulkan backend ever
+ # loads. An App test already re-hashes the GLSL sources against the
+ # manifest, which catches "edited a shader, forgot to recompile". Nothing
+ # until now tied the committed BINARIES to those sources, so a stale or
+ # hand-edited .spv would have shipped silently. Recompiling here closes
+ # that, and does it on a second operating system.
+ - name: Verify the committed SPIR-V is fresh
+ shell: bash
+ run: |
+ set -euo pipefail
+ committed=src/AcDream.App/Rendering/Shaders/spv
+ fresh="$PWD/artifacts/spv-fresh"
+ rm -rf "$fresh"
+ mkdir -p "$fresh"
+
+ pwsh tools/compile-shaders.ps1 -OutputDirectory "$fresh"
+
+ # The file SET first: a .spv present in one tree and not the other is
+ # drift the per-file compare would never visit.
+ diff <(cd "$committed" && ls -1 | sort) <(cd "$fresh" && ls -1 | sort)
+
+ drift=0
+ for path in "$committed"/*.spv; do
+ name=$(basename "$path")
+ if ! cmp -s "$path" "$fresh/$name"; then
+ echo "DRIFT: $name differs from a fresh compile"
+ echo " committed $(sha256sum "$path" | cut -d' ' -f1) $(stat -c%s "$path") bytes"
+ echo " fresh $(sha256sum "$fresh/$name" | cut -d' ' -f1) $(stat -c%s "$fresh/$name") bytes"
+ drift=$((drift + 1))
+ fi
+ done
+
+ # The manifest is compared as JSON rather than as bytes: it is written
+ # with Environment.NewLine, so a byte compare would report drift for
+ # the operating system rather than for the shaders.
+ diff <(jq -S . "$committed/shaders.manifest.json") \
+ <(jq -S . "$fresh/shaders.manifest.json")
+
+ if [ "$drift" -ne 0 ]; then
+ echo "$drift .spv artifact(s) are stale."
+ echo "Run tools/compile-shaders.ps1 and commit the result."
+ exit 1
+ fi
+ echo "all committed .spv match a fresh compile"
+
+ - name: Upload Vulkan evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-vulkan-evidence
+ if-no-files-found: warn
+ path: |
+ artifacts/vulkaninfo-summary.txt
+ artifacts/vulkan-probe.log
+ artifacts/vulkan-forced.log
+ artifacts/vulkan-capabilities-pass.json
+ artifacts/vulkan-capabilities-forced.json
+ artifacts/vulkan-probe/*.png
diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 28761f41..3e17855d 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -619,7 +619,7 @@ tenth pair with no consumer at all; see the V6e report.
| **V6m** ✅ | **Portal space, and V7's instrument**, reported in §5.5.18. Two commits: **1** `PortalTunnelPresentation`'s RHI arm — the last raw-GL world-adjacent renderer — as a backbuffer pass published on `IWorldPassScope`, clearing to retail's opaque portal-space black rather than loading it, plus the deletion of `NullLocalPlayerTeleportPresentation` (`59c6b2ae`); **2** `tools/run-backend-differential-gate.ps1` and `connected-backend-differential.route.txt`, with MSAA forced off on both launches, the repeat gate's desktop-witness guards, and **an interior EnvCell stop** — §5.1's durable fix for the campaign's oldest coverage gap (`a99f517e`). One smoke pair was run and is reported in full. | GL pixel gate 4.97e-05 (28 px) vs `280f3b3f` against a 3.55e-05 (20 px) same-commit control, band 9–31; App tests 4,132/3 and complete Release suite 9,195/5; GL connected `-Runs 3` at 3/3 on both columns; one validation-layer Vulkan run at 0 errors / 0 warnings; connected portal-tunnel, creature-appraisal and interior-EnvCell captures on BOTH backends, inspected in §5.5.18 |
| **V7** ◐ **partially discharged — §5.5.19** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1` (**built at V6m**), strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** Starting distance, measured at V6m: 18.52% of the frame at the first stop. **What landed:** the world atlases' missing anisotropy (`ad5f8b68`, retail-anchored at `0x005a4230`), and two instrument pins the gate was silently missing — the cloud sheet's phase and, much larger, the Dereth clock, which had never actually been pinned by anything and was moving 22% of the frame between two captures 45 s apart *in the same run*. Offline GL-versus-Vulkan, both clocks pinned, is now **8.82e-04 below the tree band — inside the threshold**; the treeline is `AD-46`, proven not to be the depth class. **What did NOT land:** a passing connected stop (each carries a named phase exception), per-stop masks in the gate script, an aperture stop for the portal depth mask, the R6 soak on Vulkan, and the RenderDoc capture. | every differential checkpoint passes; both connected routes green on VK |
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
-| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |
+| **V9** ◐ **implemented, first CI run pending — §5.5.20** | Linux + CI: a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3+ software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). **What landed:** the eleven-step job; `ACDREAM_VULKAN_PROBE_FRAMES`, without which the harness cannot terminate unattended; `tools/compile-shaders.ps1` made path-portable; and the report's jq contract pinned by App tests so a rename fails locally rather than in CI. lavapipe clears every gate requirement by source inspection, including the `samplerAnisotropy` V7 made load-bearing. **Deferred:** the physical Linux GPU row, post-cutover, as for Slice L; and Wayland, which no runner offers. **Not attempted, with cause:** a GL-versus-Vulkan pixel comparison — the GL job asserts exit 4 and so has no frame, and the probe renders synthetic scenes rather than the DAT world CI cannot have. | CI green including the new job |
| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure |
@@ -2746,6 +2746,115 @@ zero warnings**. Final: complete Release suite **9,195 passed / 5 skipped, zero
failures** — no `AcDream.Content` flake this time; GL connected repeat gate
`-Runs 3` at **3/3 RENDERED** on both the desktop witness and the client capture.
+#### 5.5.20 V9 (2026-07-28): the first CI job that renders a frame
+
+**The whole slice rests on one fact §5.5.8 already bought and paid for.** When
+V6g cut set 0 from ten dynamic storage descriptors to four, it did so on the rule
+that a dynamic descriptor is for ring-fed data whose offset moves and nothing
+else — and four is not merely under the RX 9070 XT's eight, it is *Vulkan's
+guaranteed minimum*. That decision is what makes a lavapipe row possible at all.
+Every other requirement was then checked against Mesa's
+`src/gallium/frontends/lavapipe/lvp_device.c` rather than assumed, and **every
+one of the seventeen features the gate demands is `true`**, including the two
+that looked most likely to bite:
+
+- **`samplerAnisotropy`** — `.samplerAnisotropy = true`, an unconditional literal
+ since 2021, not a pipe-cap query. This was the real risk: V7 made anisotropy
+ load-bearing eight commits ago, and a software rasterizer declining to filter
+ anisotropically would have been entirely reasonable of it.
+- **`textureCompressionBC`** — `true`, so the DAT surfaces upload without
+ transcoding. (ETC2 and ASTC are `false`; the gate does not ask for them.)
+
+`maxDescriptorSetStorageBuffersDynamic` is 500,000, `maxBoundDescriptorSets` 8,
+`maxPushConstantsSize` 256, timestamps supported, and the X11 WSI has a software
+non-SHM present path, so it works under Xvfb without DRI3. Two notes for whoever
+touches this next: lavapipe offers **only 1x and 4x** MSAA — the harness asks for
+4, which is fine, and 2x or 8x would not be — and it reports **API 1.4** on the
+Mesa 25.2 that Ubuntu 24.04 now ships while `conformanceVersion` stays 1.3, so
+the job asserts `>= 1.3` and never `== 1.3`.
+
+**Three things had to be built before a job could exist.**
+
+**1. The harness could not stop.** `VulkanBringUpHost.Present()` presents until
+its window closes, which is exactly right for a developer answering "does this
+machine pass?" at a desk and impossible in CI, where nothing ever closes a
+window. `ACDREAM_VULKAN_PROBE_FRAMES` gives it a frame 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 so it is 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 composed its paths from
+embedded `'src\AcDream.App\Rendering\Shaders'` literals; a backslash is a path
+separator on Windows and an ordinary filename character everywhere else, so on
+Linux that is one long nonexistent file name. Now composed with
+`[System.IO.Path]::Combine`, and the `glslc` probe looks for the SDK's Linux
+layout as well as its Windows one.
+
+**3. The report's jq paths were invisible to the compiler.** The job reads its
+verdict out of `graphical-capabilities-vulkan.json` with `jq`. Renaming a record
+property or swapping the enum converter would have left every existing 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 — `"Cpu"`, `"X11"`, `SupportFailures`, `FunctionProbe.Failures`,
+`Features.TimelineSemaphore` — and pins the job's packed-version arithmetic
+against `VulkanApiVersion`'s own unpacking.
+
+**The job, eleven steps.** Install lavapipe + loader + Xvfb; record
+`vulkaninfo --summary` as evidence; publish `linux-x64`; run the `Gpu.Vk` tests
+on a second operating system; **(a)** run the probe under
+`xvfb-run -s "-screen 0 1920x1080x24"` — 24-bit explicitly, because xvfb's
+default screen is 8-bit and 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; **(b)** assert the captured PNG is a real frame by IHDR dimensions and
+byte count; **(c)** re-run with `ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore`
+and assert exit **4**, the forced feature recorded, a failure sentence naming it,
+and a refusal message that tells the operator where the report is; **(d)**
+recompile the shaders and compare. Every artifact uploads on `always()`, so a
+red run ships its own diagnosis.
+
+**On (d), what it adds over the existing test.** `VulkanShaderManifestTests`
+re-hashes the GLSL against the manifest, which catches "edited a shader, forgot
+to recompile". Nothing tied the committed **binaries** to those sources, so a
+stale or hand-edited `.spv` would have shipped silently. The job recompiles and
+compares each `.spv` byte-for-byte plus the file set; the manifest is compared as
+parsed JSON rather than as bytes, because it is written with
+`Environment.NewLine` and a byte compare would report drift for the operating
+system rather than for the shaders. **Measured on Windows before shipping: 18
+`.spv` plus the manifest, 19/19 byte-identical to a fresh compile, zero drift.**
+Whether Linux shaderc agrees byte-for-byte with Windows shaderc at the same
+pinned Silk.NET 2.23.0 is the one thing this slice asserts without having
+observed; the job is the instrument that answers it, and a mismatch there is a
+real finding about toolchain determinism, not a threshold to relax.
+
+**What was NOT done, and why — the pixel comparison.** The brief allowed a
+relaxed GL-versus-Vulkan smoke compare *if the GL job already produced a
+comparable frame*. It does not, for two independent reasons either of which is
+disqualifying. First, `linux-graphical` asserts **exit code 4**: llvmpipe-GL has
+no `GL_ARB_bindless_texture`, the gate refuses it, and there is no left-hand side
+to compare. Second, even given a GL frame, the probe harness renders the
+synthetic V6c/V6d verification scenes, not the world — and the world needs retail
+DATs that CI does not have and cannot be given. The real differential is V7's, on
+the developer machine, against the DATs, with both clocks pinned. 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.**
+
+**Also deferred.** The physical Linux GPU row, post-cutover, on the Slice L
+precedent — no hosted runner has a GPU, and Slice L's L1 checkpoint already
+records a supported physical AMD/NVIDIA driver row as its own first gate.
+Wayland, because no hosted runner offers a Wayland session; L1's immutable
+Win32/X11/Wayland selection is exercised on X11 here and on Windows natively.
+
+**Gates.** Release build green, zero warnings. App tests **4,152 / 3 skipped**
+against a **4,134 / 3** baseline measured at this worktree's base commit
+(`9b7f4343`) — eighteen new, all from this slice. Workflow validated by a real
+YAML parse (YamlDotNet) plus a GitHub-Actions schema check and `bash -n` over
+every extracted `run` block: 9/9 clean, no `actionlint` available locally and
+none downloaded. **The job itself has not run**: its first execution is the CI
+run this commit triggers, and the row stays ◐ until that is green.
+
### 5.4 The null-target `BeginPass` divergence (V4c) — ✅ DISCHARGED at V6k
> **Closed 2026-07-28 by V6k commit 2 (`eb7e6b4e`); see §5.5.16.** The answer is
@@ -2819,11 +2928,11 @@ indistinguishable from the fork option (B) permits.
| sRGB mismatch (global gamma shift) | Decided at V3 from the actual GL state; a mismatch fails every pixel at V7, so it cannot pass silently. |
| MSAA sample positions differ across backends | Strict gates run MSAA off; MSAA on gets a relaxed (0.01) visual smoke; a register row lands at V11. |
| ~15,000 lines of renderer churn destabilizing retail fidelity | CPU logic never forks; each port is self-differential on the still-shipping backend; V0 pins the contract so subagents never negotiate APIs; the architecture test prevents seam erosion. |
-| Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; lavapipe in CI as a second real implementation; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. |
+| Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; **lavapipe landed at V9 as a second real implementation** — §5.5.20 verified all seventeen required features against Mesa's source, and §5.5.8's four dynamic descriptors are Vulkan's guaranteed minimum; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. |
| Swapchain lifecycle (resize, minimize, RDP) | Owned explicitly at V5 and exercised by the connected lifecycle gate. |
| App tests breaking as renderers change signatures | `RecordingGpuDevice` ships at V0; each slice updates its renderers' test constructions in the same commit. |
| Hidden Chorizite consumers | V1 builds the device root without Chorizite inheritance; V4h audits the remainder; the package drops at V11 only if that audit is clean. |
-| `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, and a CI hash-freshness check. |
+| `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, an App test hashing the GLSL against the manifest, and — **landed at V9** — a CI step that recompiles and compares every `.spv` byte-for-byte, which is what ties the committed binaries to those sources rather than merely tying the manifest to them. |
---
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
index 23687b89..49a2543b 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
@@ -137,6 +137,13 @@ internal sealed class VulkanBringUpHost : IDisposable
///
/// The frame loop: record the verification scenes through the RHI, present,
/// and capture one screenshot once the scene has settled.
+ ///
+ /// Campaign V slice V9: when ACDREAM_VULKAN_PROBE_FRAMES 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.
///
private void Present()
{
@@ -197,12 +204,59 @@ internal sealed class VulkanBringUpHost : IDisposable
_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);
+
+ ///
+ /// Campaign V slice V9. The bounded-run decision, pure so it can be tested
+ /// without a window or a driver.
+ ///
+ ///
+ /// ACDREAM_VULKAN_PROBE_FRAMES. Zero or negative means the
+ /// interactive behaviour: run until the window closes.
+ ///
+ /// Frames presented so far.
+ ///
+ /// Whether an artifact directory was configured, and so whether this run owes
+ /// a PNG.
+ ///
+ /// Whether that capture has been attempted.
+ 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)
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 724654c9..f7e0af9a 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -62,7 +62,8 @@ public sealed record RuntimeOptions(
RenderBackendKind RenderBackend,
string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature,
- bool VulkanCapabilityProbe)
+ bool VulkanCapabilityProbe,
+ int VulkanCapabilityProbeFrames)
{
///
/// Build options from the process environment. Used by
@@ -165,7 +166,15 @@ public sealed record RuntimeOptions(
// diagnostic for "does this machine pass the Vulkan gate, and does
// the backend draw?"; ignored on OpenGL.
VulkanCapabilityProbe:
- IsExactlyOne(env("ACDREAM_VULKAN_PROBE")));
+ IsExactlyOne(env("ACDREAM_VULKAN_PROBE")),
+ // Campaign V slice V9: bound the probe harness to a frame budget so
+ // it can run unattended. The harness otherwise presents until its
+ // window closes, which is right for a developer answering "does this
+ // machine pass?" at a desk and impossible for CI, where nothing ever
+ // closes the window. Zero -- unset, unparseable, or an explicit 0 --
+ // keeps the interactive behaviour, so no existing invocation changes.
+ VulkanCapabilityProbeFrames:
+ TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0);
}
///
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBringUpBudgetTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBringUpBudgetTests.cs
new file mode 100644
index 00000000..c1bf3754
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBringUpBudgetTests.cs
@@ -0,0 +1,90 @@
+using AcDream.App.Rendering.Gpu.Vk;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V9: the probe harness's bounded-run decision.
+///
+/// The harness presents until its window closes, which is right at a desk
+/// and impossible in CI — nothing there ever closes a window.
+/// ACDREAM_VULKAN_PROBE_FRAMES 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.
+///
+///
+public sealed class VulkanBringUpBudgetTests
+{
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// With no artifact directory there is no PNG to wait for, so the budget is
+ /// the only term.
+ ///
+ [Fact]
+ public void ARunWithNoArtifactDirectoryRetiresOnTheBudgetAlone()
+ {
+ Assert.True(
+ VulkanBringUpHost.ShouldRetire(
+ frameBudget: 1,
+ presentedFrames: 1,
+ capturesScreenshot: false,
+ screenshotRequested: false));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityReportContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityReportContractTests.cs
new file mode 100644
index 00000000..3919e588
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityReportContractTests.cs
@@ -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;
+
+///
+/// Campaign V slice V9: the capability report is a CI contract, not just a
+/// diagnostic.
+///
+/// The linux-vulkan job in
+/// .github/workflows/headless-portability.yml asserts the lavapipe run's
+/// verdict by reading graphical-capabilities-vulkan.json with jq.
+/// Those jq 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.
+///
+/// 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.
+///
+///
+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;
+
+ ///
+ /// The passing run's assertions, one for one with the "Probe the Vulkan
+ /// capability gate on lavapipe" step.
+ ///
+ [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()));
+ }
+
+ ///
+ /// The job unpacks the API version out of DeviceApiVersionPacked 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// The report file name is what the workflow's VULKAN_REPORT path ends in.
+ ///
+ [Fact]
+ public void TheReportFileNameIsTheOneTheWorkflowPathNames()
+ {
+ Assert.Equal(
+ "graphical-capabilities-vulkan.json",
+ VulkanCapabilityGuard.ReportFileName);
+ }
+}
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index 641d2066..c15e1e85 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -556,4 +556,54 @@ public sealed class RuntimeOptionsTests
Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
.VulkanForcedUnsupportedFeature);
}
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
}
diff --git a/tools/compile-shaders.ps1 b/tools/compile-shaders.ps1
index 618da334..dad4b154 100644
--- a/tools/compile-shaders.ps1
+++ b/tools/compile-shaders.ps1
@@ -48,8 +48,14 @@ param(
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
+# Campaign V slice V9: every path below is composed one segment at a time rather
+# than from an embedded 'a\b\c' literal. A backslash is a path separator on
+# Windows and an ordinary filename character everywhere else, so the embedded
+# form silently produced one long nonexistent file name on the Linux CI runner
+# that this slice's lavapipe job introduced.
if (-not $ShadersDirectory) {
- $ShadersDirectory = Join-Path $repo 'src\AcDream.App\Rendering\Shaders'
+ $ShadersDirectory = [System.IO.Path]::Combine(
+ $repo, 'src', 'AcDream.App', 'Rendering', 'Shaders')
}
if (-not $OutputDirectory) {
$OutputDirectory = Join-Path $ShadersDirectory 'spv'
@@ -67,8 +73,14 @@ if ($PreferSdk) {
$glslc = $onPath.Source
}
elseif ($env:VULKAN_SDK) {
- $candidate = Join-Path $env:VULKAN_SDK 'Bin\glslc.exe'
- if (Test-Path $candidate) { $glslc = $candidate }
+ # 'Bin/glslc.exe' on Windows, 'bin/glslc' on the SDK's Linux layout.
+ $candidates = @(
+ [System.IO.Path]::Combine($env:VULKAN_SDK, 'Bin', 'glslc.exe'),
+ [System.IO.Path]::Combine($env:VULKAN_SDK, 'bin', 'glslc')
+ )
+ foreach ($candidate in $candidates) {
+ if (Test-Path $candidate) { $glslc = $candidate; break }
+ }
}
}
@@ -84,12 +96,15 @@ else {
Write-Step 'no Vulkan SDK glslc found; using the managed Silk.NET.Shaderc compiler'
}
-$tool = Join-Path $repo 'tools\ShaderCompiler\ShaderCompiler.csproj'
+$tool = [System.IO.Path]::Combine(
+ $repo, 'tools', 'ShaderCompiler', 'ShaderCompiler.csproj')
Write-Step 'building the shader compiler'
& dotnet build $tool -c Release --nologo -v q | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Shader compiler build failed with exit code $LASTEXITCODE." }
-$binary = Join-Path $repo 'tools\ShaderCompiler\bin\Release\net10.0\AcDream.Tools.ShaderCompiler.dll'
+$binary = [System.IO.Path]::Combine(
+ $repo, 'tools', 'ShaderCompiler', 'bin', 'Release', 'net10.0',
+ 'AcDream.Tools.ShaderCompiler.dll')
if (-not (Test-Path $binary)) { throw "Shader compiler not found at $binary." }
Write-Step "compiling $ShadersDirectory -> $OutputDirectory"