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"