diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3fd5b0b952d..8fb05c29c1f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,7 +59,8 @@ jobs:
echo "- Shared: ${{ steps.filter.outputs.shared == 'true' && '✅' || '❌' }}" >> $GITHUB_STEP_SUMMARY
# Note the build jobs are split into two jobs for vc6 and win32 because the replay checks
- # only require the vc6 build and compiling vc6 is much faster than win32
+ # only require the vc6 build and compiling vc6 is much faster than win32,
+ # and the rendering checks only require the win32 build.
build-generals-vc6:
name: Build Generals${{ matrix.preset && '' }}
needs: detect-changes
@@ -237,3 +238,25 @@ jobs:
userdata: "GeneralsReplays/GeneralsZH/1.04"
preset: ${{ matrix.preset }}
secrets: inherit
+
+ renderingcheck-generals:
+ name: Rendering Check Generals
+ needs: [detect-changes, build-generals-win32]
+ if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generals == 'true' || needs.detect-changes.outputs.shared == 'true' }}
+ permissions:
+ contents: read
+ actions: read
+ uses: ./.github/workflows/reusable-check-rendering.yml
+ with:
+ game: "Generals"
+
+ renderingcheck-generalsmd:
+ name: Rendering Check GeneralsMD
+ needs: [detect-changes, build-generalsmd-win32]
+ if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generalsmd == 'true' || needs.detect-changes.outputs.shared == 'true' }}
+ permissions:
+ contents: read
+ actions: read
+ uses: ./.github/workflows/reusable-check-rendering.yml
+ with:
+ game: "GeneralsMD"
diff --git a/.github/workflows/reusable-build-toolchain.yml b/.github/workflows/reusable-build-toolchain.yml
index 40edcc2d01d..bedf78d8955 100644
--- a/.github/workflows/reusable-build-toolchain.yml
+++ b/.github/workflows/reusable-build-toolchain.yml
@@ -175,12 +175,27 @@ jobs:
# The benchmarks are only built, because running them takes too long here.
# Verbose prints the Google Test output, which lists the test cases of each executable.
+ # The rendering tests run in the rendering check of ci.yml instead.
- name: Test ${{ inputs.game }} with CTest Using ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Preset
if: ${{ inputs.tests }}
shell: pwsh
+ env:
+ GTEST_FILTER: -RenderingTest.*
run: |
ctest --preset ${{ inputs.preset }} --verbose
+ # Keep this package separate from the game release artifacts. Only the plain
+ # win32 Release build is needed for the rendering check in ci.yml, whose runner has the CRT.
+ - name: Upload Rendering Test Executable
+ if: ${{ inputs.tests && inputs.preset == 'win32' }}
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ name: ${{ inputs.game }}-win32-rendering-tests
+ path: build/${{ inputs.preset }}/Tests/Google/Release/${{ inputs.game == 'Generals' && 'g' || 'z' }}_googletest.exe
+ # Kept longer on main, where pull requests download it to compare their rendering with.
+ retention-days: ${{ github.ref == 'refs/heads/main' && 90 || 7 }}
+ if-no-files-found: error
+
- name: Collect ${{ inputs.game }} ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Artifact
shell: pwsh
run: |
diff --git a/.github/workflows/reusable-check-rendering.yml b/.github/workflows/reusable-check-rendering.yml
new file mode 100644
index 00000000000..9320e2f4ee6
--- /dev/null
+++ b/.github/workflows/reusable-check-rendering.yml
@@ -0,0 +1,191 @@
+name: Check Rendering
+
+# WARNING: Do not declare permissions in this reusable workflow.
+# Declare the required permissions in each calling job:
+# contents: read - required for rendering checks.
+# actions: read - required to download the rendering tests of the base commit.
+
+on:
+ workflow_call:
+ inputs:
+ game:
+ required: true
+ type: string
+ description: "Game to check (Generals, GeneralsMD)"
+
+jobs:
+ rendering:
+ name: DXVK and lavapipe
+ runs-on: windows-2022
+ timeout-minutes: 15
+ env:
+ GAME: ${{ inputs.game }}
+ TEST_NAME: ${{ inputs.game == 'Generals' && 'g_googletest' || 'z_googletest' }}
+ # Google Test reads its flags from these as well.
+ GTEST_FILTER: RenderingTest.*
+ GTEST_FAIL_IF_NO_TEST_SELECTED: 1
+ GTEST_OUTPUT: xml:results.xml
+ # The pinned Mesa version decides the rendering, not the runner image.
+ DXVK_VERSION: 2.6.2
+ DXVK_SHA256: 17761876556afd55736cb895d184f5a1c55d43350f1b1e3b129f8d28706d7992
+ MESA_VERSION: 26.2.4
+ MESA_SHA256: 351fc8c8b695878ffb3eaa044b3ead08672a48b1a045e3c3e3975811df0f6695
+ VULKAN_RUNTIME_VERSION: 1.4.363.0
+ VULKAN_RUNTIME_SHA256: a25a927aa8b9f0371048f1861cf88ac3b9bc9b1fb332c42d897c8ab32695769a
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Download MSVC Rendering Tests
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
+ with:
+ name: ${{ inputs.game }}-win32-rendering-tests
+ path: build/rendering-tests
+
+ - name: Install DXVK and Lavapipe
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $true
+ function Get-Checked($url, $out, $sha) {
+ curl.exe --fail --location --retry 3 --output $out $url
+ if ((Get-FileHash $out SHA256).Hash -ne $sha) { throw "SHA256 mismatch for $url" }
+ }
+ $dxvk = "dxvk-$env:DXVK_VERSION"
+ Get-Checked "https://github.com/doitsujin/dxvk/releases/download/v$env:DXVK_VERSION/$dxvk.tar.gz" build/dxvk.tar.gz $env:DXVK_SHA256
+ Get-Checked "https://github.com/pal1000/mesa-dist-win/releases/download/$env:MESA_VERSION/mesa3d-$env:MESA_VERSION-release-msvc.7z" build/mesa.7z $env:MESA_SHA256
+ Get-Checked "https://sdk.lunarg.com/sdk/download/$env:VULKAN_RUNTIME_VERSION/windows/vulkan-runtime-components.zip" build/vulkan.zip $env:VULKAN_RUNTIME_SHA256
+ # The 32-bit DLLs that go beside each test exe.
+ New-Item -ItemType Directory build/runtime | Out-Null
+ tar -C build/runtime -xf build/dxvk.tar.gz --strip-components=2 "$dxvk/x32/d3d8.dll" "$dxvk/x32/d3d9.dll"
+ 7z e build/vulkan.zip -obuild/runtime "VulkanRT-X64-$env:VULKAN_RUNTIME_VERSION-Components/x86/vulkan-1.dll"
+ 7z e build/mesa.7z -obuild/lavapipe x86/vulkan_lvp.dll x86/lvp_icd.x86.json
+ # The runner is elevated, so the Vulkan loader ignores VK_DRIVER_FILES. Register 32-bit lavapipe instead.
+ reg add HKLM\SOFTWARE\WOW6432Node\Khronos\Vulkan\Drivers /v "$PWD\build\lavapipe\lvp_icd.x86.json" /t REG_DWORD /d 0 /f
+
+ - name: Run Rendering Google Tests
+ working-directory: build/rendering-tests
+ timeout-minutes: 5
+ run: |
+ Copy-Item ../runtime/* .
+ & "./$env:TEST_NAME.exe"
+
+ - name: Download Base Rendering Tests
+ id: base
+ if: always()
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PUSH_BEFORE: ${{ github.event.before }}
+ PR_BASE: ${{ github.event.pull_request.base.sha }}
+ PR_HEAD: ${{ github.event.pull_request.head.sha }}
+ PR_HEAD_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
+ run: |
+ # Compare with the commit this change is based on: the previous main commit for a push,
+ # otherwise the merge base with main. Its GenCI run already built the rendering tests.
+ # A failed gh api call prints the error to stdout, so each call checks its exit code.
+ $repo = "repos/$env:GITHUB_REPOSITORY"
+ $base = switch ($env:GITHUB_EVENT_NAME) {
+ 'push' { $env:PUSH_BEFORE }
+ 'pull_request' { gh api "$repo/compare/${env:PR_BASE}...${env:PR_HEAD_OWNER}:${env:PR_HEAD}" --jq .merge_base_commit.sha }
+ default { gh api "$repo/compare/main...$env:GITHUB_SHA" --jq .merge_base_commit.sha }
+ }
+ if ($LASTEXITCODE) { $base = $null }
+ # A commit has no package when it was pushed together with later commits, or when its run
+ # skipped this game's build because nothing it builds from changed. The nearest ancestor
+ # with a package then renders the same.
+ $commits = @()
+ if ($base) {
+ $commits = gh api "$repo/commits?sha=$base&per_page=20" --jq '.[].sha'
+ if ($LASTEXITCODE) { $commits = @() }
+ }
+ foreach ($commit in $commits) {
+ $runs = gh api "$repo/actions/runs?head_sha=$commit" --jq ".workflow_runs[] | select(.id != $env:GITHUB_RUN_ID) | .id"
+ if ($LASTEXITCODE) { continue }
+ foreach ($run in $runs) {
+ gh run download $run --repo $env:GITHUB_REPOSITORY --name "${env:GAME}-win32-rendering-tests" --dir build/rendering-tests-base
+ if ($LASTEXITCODE -eq 0) {
+ "Using the rendering tests of run $run for commit $commit (base commit $base)"
+ "sha=$commit" >> $env:GITHUB_OUTPUT
+ exit 0
+ }
+ Remove-Item -Recurse -Force -ErrorAction Ignore build/rendering-tests-base
+ }
+ }
+ "::notice title=Rendering::No rendering tests found for base commit $($base ?? 'unknown'), so the images are not compared"
+ exit 0
+
+ - name: Run Base Rendering Google Tests
+ if: ${{ always() && steps.base.outputs.sha }}
+ working-directory: build/rendering-tests-base
+ timeout-minutes: 5
+ run: |
+ Copy-Item ../runtime/* .
+ & "./$env:TEST_NAME.exe"
+ if ($LASTEXITCODE) { "::warning title=Rendering::The base rendering tests failed, so only the images they wrote are compared" }
+ exit 0
+
+ - name: Compare Rendering with Base
+ id: compare
+ if: always()
+ env:
+ BASE_SHA: ${{ steps.base.outputs.sha }}
+ run: |
+ $since = if ($env:BASE_SHA) { "since base $($env:BASE_SHA.Substring(0, 10))" } else { '(no base found)' }
+ function Get-Image($path) {
+ if (Test-Path $path) { "" }
+ }
+ # The same PNG encoder wrote both images, so equal pixels give equal files.
+ $images = foreach ($name in (Get-ChildItem build/rendering-tests*/*.png).Name | Sort-Object -Unique) {
+ $old = Get-Image build/rendering-tests-base/$name
+ $new = Get-Image build/rendering-tests/$name
+ $state = if (!$old) { 'new' } elseif (!$new) { 'removed' } elseif ($old -eq $new) { 'unchanged' } else { 'changed' }
+ [pscustomobject]@{ Name = $name; State = $state; Old = $old; New = $new }
+ }
+ $changes = ($images | Where-Object State -in 'changed', 'removed' | ForEach-Object { "$($_.Name) $($_.State)" }) -join ', '
+ "changes=$changes" >> $env:GITHUB_OUTPUT
+ "message=$(if ($changes) { $changes } else { 'No changes' }) $since" >> $env:GITHUB_OUTPUT
+ "### $env:GAME Rendering`n`n$(($images | Group-Object State | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ') $since.`n" >> $env:GITHUB_STEP_SUMMARY
+ # One page with the images inline, so the browser opens the artifact without unzipping. The difference
+ # blends the new image over the base and brightens the result, so only changed pixels show.
+ $rows = foreach ($image in $images | Sort-Object State, Name) {
+ $diff = if ($image.State -eq 'changed') { "
| Base | This commit | Difference |
|---|