Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
15 changes: 15 additions & 0 deletions .github/workflows/reusable-build-toolchain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
191 changes: 191 additions & 0 deletions .github/workflows/reusable-check-rendering.yml
Original file line number Diff line number Diff line change
@@ -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) { "<img src=`"data:image/png;base64,$([Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-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') { "<div class=diff>$($image.Old)$($image.New)</div>" }
"<tr><th>$($image.Name)<br>$($image.State)</th><td>$($image.Old)</td><td>$($image.New)</td><td>$diff</td></tr>"
}
@"
<!doctype html>
<meta charset="utf-8">
<title>$env:GAME rendering $since</title>
<style>
body { font-family: sans-serif } th, td { padding: 8px; text-align: left; vertical-align: top }
.diff { position: relative; filter: brightness(1000) } .diff img + img { position: absolute; top: 0; left: 0; mix-blend-mode: difference }
</style>
<h1>$env:GAME rendering $since</h1>
<table>
<tr><th></th><th>Base</th><th>This commit</th><th>Difference</th></tr>
$($rows -join "`n")
</table>
"@ | Set-Content "build/$env:GAME-rendering-report.html"

# A single file is uploaded unzipped, so its link opens it in the browser.
- name: Upload Rendering Report
id: report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
path: build/${{ inputs.game }}-rendering-report.html
archive: false
retention-days: 7
if-no-files-found: warn

# Comes after the upload, so the message that a failed check opens with links to the report.
# Changes fail pull requests, so reviewers check in the report whether they are intended.
# On main they are warnings, because main becomes the base of later changes.
- name: Check Rendering Changes
if: always()
env:
CHANGES: ${{ steps.compare.outputs.changes }}
MESSAGE: ${{ steps.compare.outputs.message }}
REPORT_URL: ${{ steps.report.outputs.artifact-url }}
run: |
"[Open the $env:GAME rendering report]($env:REPORT_URL)" >> $env:GITHUB_STEP_SUMMARY
$level = if (!$env:CHANGES) { 'notice' } elseif ($env:GITHUB_EVENT_NAME -eq 'pull_request') { 'error' } else { 'warning' }
"::$level title=$env:GAME rendering::$env:MESSAGE. Report: $env:REPORT_URL"
if ($level -eq 'error') { exit 1 }
5 changes: 5 additions & 0 deletions Tests/Google/Core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ set(COREI_GOOGLETEST_SRC
Libraries/WWVegas/WWMath/WWMathKeys.h
Libraries/WWVegas/WWMath/WWMathTest.cpp
Libraries/WWVegas/WWMath/WWMathTestCase.h
Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp
Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
Rendering/RenderingTestFixture.cpp
Rendering/RenderingTestFixture.h
)

add_library(corei_googletest INTERFACE)
Expand All @@ -26,6 +30,7 @@ target_link_libraries(corei_googletest INTERFACE
depsi_googletest
GTest::gtest
imm32
stb
vfw32
winmm
)
29 changes: 29 additions & 0 deletions Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2026 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include "RenderingTestFixture.h"

// Clearing the render target fills it with the clear color from corner to corner.
TEST_F(RenderingTest, Clear)
{
const DWORD color = 0x102030;
clear(color);
capture("clear.png");
EXPECT_EQ(pixel(0, 0), color);
EXPECT_EQ(pixel(Width - 1, Height - 1), color);
}
35 changes: 35 additions & 0 deletions Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2026 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include "RenderingTestFixture.h"

// A triangle with a red, a green and a blue corner blends the corner colors across its area.
TEST_F(RenderingTest, Triangle)
{
const DWORD background = 0x102030;
clear(background);
drawTriangles({
{128.0f, 80.0f, 0xff0000},
{192.0f, 200.0f, 0x00ff00},
{64.0f, 200.0f, 0x0000ff},
});
capture("triangle.png");
// GPUs round the blended colors differently, so the exact colors are left to the image comparison of CI.
EXPECT_EQ(pixel(8, 8), background);
EXPECT_NE(pixel(128, 160), background);
}
Loading
Loading