acdream/.github/workflows/headless-portability.yml
Erik 5852bdb877 feat(render): Vulkan campaign V11 step 4 — retire GL from CI and gate scripts
Closes out the GL deletion by fixing the CI workflow and developer gate
scripts that still assumed a GL arm existed to compare against, build,
or select via ACDREAM_RENDER_BACKEND.

.github/workflows/headless-portability.yml: linux-graphical's "Verify
actionable unsupported-driver gate" step is deleted outright — it ran
the deleted `ui-studio` CLI verb (Studio was removed at Commit 1) to
prove the GL capability gate rejects Mesa's llvmpipe driver, and there
is no more GL capability gate for any driver to pass or fail. Its test
filter dropped two dead entries (GraphicalCapabilityRequirementsTests,
deleted at Commit 2; StudioWindowTests, already gone). Its package
contract check dropped the libcimgui.so assertion (ImGui's native
bridge, deleted at Commit 1). linux-vulkan's explanatory comment, which
described GL's rejection as the reason no cross-backend pixel diff runs
in CI, is rewritten to explain there is no GL arm left at all. Two dead
src/AcDream.UI.ImGui/** path triggers (that project no longer exists)
are removed from both the pull_request and push filters.

tools/run-backend-differential-gate.ps1 and its dedicated route file
tools/connected-backend-differential.route.txt are deleted: the whole
script's purpose was comparing a GL launch against a Vulkan launch of
the same route, and there is no second arm left to compare. Single-arm
regression checking already exists via run-offline-pixel-gate.ps1's
-Baseline mechanism.

tools/run-portal-churn-soak.ps1 is simplified rather than deleted: its
repeated-portal-churn methodology (within-arm capture comparison,
memory/entity/GPU trend analysis) has value independent of the
GL-versus-Vulkan question it was built to answer for issues #256/#257
before V11. -Backends now defaults to @('vulkan') alone; the doc
comments are rewritten from "step 0 discriminator, run before V11" to
an ongoing single-arm regression soak.

tools/run-offline-pixel-gate.ps1 drops its now-nonfunctional -Backend
parameter (ACDREAM_RENDER_BACKEND has read zero call sites since
RuntimeOptions.RenderBackend was removed at Commit 2 — passing -Backend
gl silently launched Vulkan anyway) along with its GL-escape-hatch
example and every comment that referenced the now-deleted differential
gate. tools/run-connected-world-lifecycle-gate.ps1,
tools/run-offline-vulkan-capture.ps1, and
tools/run-repeat-connected-gate.ps1 keep their (harmless, already
no-op) ACDREAM_RENDER_BACKEND set/clear lines but have their
now-inaccurate "escape hatch" / "GL run" comments corrected to state
plainly that the variable is unread and the line is kept only for the
historical record.

No .cs files touched; `dotnet build AcDream.slnx -c Release` unaffected
(0 warnings, 0 errors, matching the prior commit's build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:06:45 +02:00

438 lines
19 KiB
YAML

name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "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"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:
contents: read
jobs:
portable-headless:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Plugin.Abstractions, Core,
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
# the Headless CLI. Nothing in it opens a display, links GL, or calls
# xvfb-run, so an "install the graphical smoke dependencies" step here was
# both unnecessary and, being unconditional `sudo apt-get` in a
# two-operating-system matrix, fatal on the windows-latest leg (exit 127,
# `sudo: command not found`). The graphical jobs that do use xvfb-run and
# jq run only on ubuntu-latest and take both from the runner image.
- name: Build presentation-free closure
shell: pwsh
run: |
$projects = @(
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
"src/AcDream.Content/AcDream.Content.csproj",
"src/AcDream.Runtime/AcDream.Runtime.csproj",
"src/AcDream.Headless/AcDream.Headless.csproj"
)
foreach ($project in $projects) {
dotnet build $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# AcDream.Core.Tests still contains historical App integration fixtures,
# so it is deliberately not a member of this no-App restore lane. Core is
# built directly above and exercised through every portable downstream
# test project below; the complete solution lane retains Core.Tests.
- name: Test presentation-free closure
shell: pwsh
run: |
$projects = @(
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
"tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
)
foreach ($project in $projects) {
dotnet test $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Verify CLI without connecting
shell: pwsh
run: |
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
linux-graphical:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build and publish Linux graphical client
shell: pwsh
run: |
dotnet build src/AcDream.App/AcDream.App.csproj -c Release -r linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
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 portable graphical boundary
shell: pwsh
run: |
dotnet test `
tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj `
-c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
--filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests|FullyQualifiedName~GraphicalWindowBackendSelectionTests"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify Linux package contract
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
test -x "$root/AcDream.App"
test -f "$root/AcDream.App.dll"
test -f "$root/libglfw.so.3"
test -f "$root/libopenal.so"
test -f "$root/Rendering/Shaders/mesh_modern.vert"
test -f "$root/plugins/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.dll"
test -f "$root/plugins/AcDream.Plugins.Smoke/plugin.json"
test "$(grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App | wc -l)" -eq 1
grep -q 'WindowsHighResolutionFramePacingWaiter.cs' < <(
grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App)
! grep -RIn --include='*.cs' 'LocalApplicationData' \
src/AcDream.App src/AcDream.UI.Abstractions
! grep -RIn --include='*.cs' \
'WindowsHighResolutionFramePacingWaiter.Create()' \
src/AcDream.App \
--exclude='FramePacingWaiterFactory.cs' \
--exclude='WindowsHighResolutionFramePacingWaiter.cs'
# Campaign V slice V9 built this job to prove lavapipe (Mesa's software
# Vulkan) passes the capability gate: every Vulkan feature acdream requires
# is core 1.3 or a descriptor-indexing feature lavapipe implements. That made
# it the first CI job in the project's history to render a frame.
#
# Until Campaign V slice V11, linux-graphical (above) carried the mirror
# case: llvmpipe (Mesa's software OpenGL) FAILING the capability gate,
# because mandatory GL_ARB_bindless_texture has no llvmpipe implementation.
# V11 deleted the GL backend entirely, so there is no more GL capability
# gate for any driver to pass or fail — that job's "Verify actionable
# unsupported-driver gate" step went with it. "Verify the forced-unsupported
# gate exits 4" below is what now proves the exit-code-4 contract still
# fires, forcing an unsupported VULKAN feature instead.
#
# NOT DONE HERE, deliberately: a GL-versus-Vulkan pixel comparison. It was
# never viable in CI even before V11 — 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 was V7's, on the developer machine, against the DATs, with
# both clocks pinned, before V11 deleted the GL arm it depended on.
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