From 8491f758c953e4d5bca06ed2c4d9e5036941d3d4 Mon Sep 17 00:00:00 2001 From: stm <14291421+stephanmeesters@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:15:14 +0100 Subject: [PATCH 1/2] ci: Run D3D8 rendering tests under DXVK and lavapipe on Windows and compare them with the base commit Add a RenderingTest fixture that creates a real D3D8 device with an offscreen render target once for all rendering tests, and restores the default device state before each test. Its helpers clear the target, draw colored triangles, and capture the target as a PNG image with its pixels for checks. Each test lives in a file of its own; the temporary Clear and Triangle examples show how to write them. The CTest step of CI skips the RenderingTest suite; instead the plain win32 build uploads the Google Test executables and the new Rendering Check jobs in GenCI run them on a Windows runner under DXVK and Mesa lavapipe. The DXVK, Mesa and Vulkan loader downloads are pinned, so the rendering does not change with the runner image. The rendering check also runs the tests of the base commit, downloaded from its earlier GenCI run, under the same DXVK and Mesa. The base is the previous main commit for a push and the merge base with main otherwise, falling back to the nearest ancestor with a package. The check compares the PNG files and uploads one unzipped HTML page with the base, new and difference images inline, which opens in the browser. The message of the check and the job summary link to it. A changed or missing image fails the check of a pull request, so reviewers look at the report; on main it is a warning, because main becomes the base of later changes. No reference images are stored. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 ++- .../workflows/reusable-build-toolchain.yml | 15 ++ .../workflows/reusable-check-rendering.yml | 191 ++++++++++++++++++ Tests/Google/Core/CMakeLists.txt | 5 + .../Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp | 29 +++ .../EXAMPLE_TEMPORARY_TriangleTest.cpp | 35 ++++ .../Core/Rendering/RenderingTestFixture.cpp | 136 +++++++++++++ .../Core/Rendering/RenderingTestFixture.h | 65 ++++++ 8 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/reusable-check-rendering.yml create mode 100644 Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp create mode 100644 Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp create mode 100644 Tests/Google/Core/Rendering/RenderingTestFixture.cpp create mode 100644 Tests/Google/Core/Rendering/RenderingTestFixture.h 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') { "
$($image.Old)$($image.New)
" } + "$($image.Name)
$($image.State)$($image.Old)$($image.New)$diff" + } + @" + + + $env:GAME rendering $since + +

$env:GAME rendering $since

+ + + $($rows -join "`n") +
BaseThis commitDifference
+ "@ | 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 } diff --git a/Tests/Google/Core/CMakeLists.txt b/Tests/Google/Core/CMakeLists.txt index 68c37d4b96d..775d16d4126 100644 --- a/Tests/Google/Core/CMakeLists.txt +++ b/Tests/Google/Core/CMakeLists.txt @@ -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) @@ -26,6 +30,7 @@ target_link_libraries(corei_googletest INTERFACE depsi_googletest GTest::gtest imm32 + stb vfw32 winmm ) diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp new file mode 100644 index 00000000000..1f67e37de62 --- /dev/null +++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp @@ -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 . +*/ + +#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); +} diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp new file mode 100644 index 00000000000..9e39fda6166 --- /dev/null +++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp @@ -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 . +*/ + +#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, 32.0f, 0xff0000}, + {224.0f, 224.0f, 0x00ff00}, + {32.0f, 224.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); +} diff --git a/Tests/Google/Core/Rendering/RenderingTestFixture.cpp b/Tests/Google/Core/Rendering/RenderingTestFixture.cpp new file mode 100644 index 00000000000..a18dcd89b6a --- /dev/null +++ b/Tests/Google/Core/Rendering/RenderingTestFixture.cpp @@ -0,0 +1,136 @@ +/* +** 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 . +*/ + +#include "RenderingTestFixture.h" +#include + +IDirect3DDevice8 *RenderingTest::s_device = nullptr; +HWND RenderingTest::s_window = nullptr; +IDirect3D8 *RenderingTest::s_d3d = nullptr; +IDirect3DSurface8 *RenderingTest::s_target = nullptr; +IDirect3DSurface8 *RenderingTest::s_readback = nullptr; +DWORD RenderingTest::s_defaultState = 0; + +namespace +{ +template +void release(T *&object) +{ + if (object != nullptr) + { + object->Release(); + object = nullptr; + } +} +} // namespace + +// Runs once before the first test of the suite. When it fails, Google Test skips the tests of the suite. +void RenderingTest::SetUpTestSuite() +{ + s_window = CreateWindowA("STATIC", "RenderingTest", WS_OVERLAPPEDWINDOW, 0, 0, Width, Height, nullptr, nullptr, GetModuleHandle(nullptr), nullptr); + ASSERT_NE(s_window, nullptr); + s_d3d = Direct3DCreate8(D3D_SDK_VERSION); + ASSERT_NE(s_d3d, nullptr); + + D3DPRESENT_PARAMETERS params = {}; + params.BackBufferWidth = Width; + params.BackBufferHeight = Height; + params.BackBufferFormat = D3DFMT_X8R8G8B8; + params.BackBufferCount = 1; + params.SwapEffect = D3DSWAPEFFECT_DISCARD; + params.hDeviceWindow = s_window; + params.Windowed = TRUE; + ASSERT_HRESULT_SUCCEEDED(s_d3d->CreateDevice(0, D3DDEVTYPE_HAL, s_window, D3DCREATE_SOFTWARE_VERTEXPROCESSING, ¶ms, &s_device)); + ASSERT_HRESULT_SUCCEEDED(s_device->CreateRenderTarget(Width, Height, D3DFMT_X8R8G8B8, D3DMULTISAMPLE_NONE, FALSE, &s_target)); + ASSERT_HRESULT_SUCCEEDED(s_device->SetRenderTarget(s_target, nullptr)); + ASSERT_HRESULT_SUCCEEDED(s_device->CreateImageSurface(Width, Height, D3DFMT_X8R8G8B8, &s_readback)); + // No test has run yet, so this captures the default state of the device. + ASSERT_HRESULT_SUCCEEDED(s_device->CreateStateBlock(D3DSBT_ALL, &s_defaultState)); +} + +void RenderingTest::TearDownTestSuite() +{ + if (s_defaultState != 0) + { + s_device->DeleteStateBlock(s_defaultState); + s_defaultState = 0; + } + release(s_readback); + release(s_target); + release(s_device); + release(s_d3d); + if (s_window != nullptr) + { + DestroyWindow(s_window); + s_window = nullptr; + } +} + +// Undoes what the previous test changed. The state comes first, because it sets the viewport that Clear fills. +void RenderingTest::SetUp() +{ + ASSERT_HRESULT_SUCCEEDED(s_device->SetRenderTarget(s_target, nullptr)); + ASSERT_HRESULT_SUCCEEDED(s_device->ApplyStateBlock(s_defaultState)); + clear(0x000000); +} + +void RenderingTest::clear(DWORD color) +{ + EXPECT_HRESULT_SUCCEEDED(s_device->Clear(0, nullptr, D3DCLEAR_TARGET, color, 1.0f, 0)); +} + +void RenderingTest::drawTriangles(std::initializer_list vertices) +{ + struct TransformedVertex + { + float x, y, z, rhw; + DWORD color; + }; + std::vector transformed; + for (const Vertex &vertex : vertices) + { + transformed.push_back({vertex.x, vertex.y, 0.0f, 1.0f, vertex.color}); + } + ASSERT_HRESULT_SUCCEEDED(s_device->SetVertexShader(D3DFVF_XYZRHW | D3DFVF_DIFFUSE)); + ASSERT_HRESULT_SUCCEEDED(s_device->BeginScene()); + EXPECT_HRESULT_SUCCEEDED(s_device->DrawPrimitiveUP(D3DPT_TRIANGLELIST, transformed.size() / 3, transformed.data(), sizeof(TransformedVertex))); + ASSERT_HRESULT_SUCCEEDED(s_device->EndScene()); +} + +void RenderingTest::capture(const char *imageName) +{ + ASSERT_HRESULT_SUCCEEDED(s_device->CopyRects(s_target, nullptr, 0, s_readback, nullptr)); + D3DLOCKED_RECT locked = {}; + ASSERT_HRESULT_SUCCEEDED(s_readback->LockRect(&locked, nullptr, D3DLOCK_READONLY)); + std::vector rgb; + rgb.reserve(Width * Height * 3); + for (UINT y = 0; y < Height; ++y) + { + const DWORD *row = reinterpret_cast(static_cast(locked.pBits) + y * locked.Pitch); + for (UINT x = 0; x < Width; ++x) + { + const DWORD color = row[x] & 0x00ffffff; + m_pixels[y * Width + x] = color; + rgb.push_back(static_cast(color >> 16)); + rgb.push_back(static_cast(color >> 8)); + rgb.push_back(static_cast(color)); + } + } + ASSERT_HRESULT_SUCCEEDED(s_readback->UnlockRect()); + EXPECT_NE(stbi_write_png(imageName, Width, Height, 3, rgb.data(), Width * 3), 0) << imageName; +} diff --git a/Tests/Google/Core/Rendering/RenderingTestFixture.h b/Tests/Google/Core/Rendering/RenderingTestFixture.h new file mode 100644 index 00000000000..9e97802b0f2 --- /dev/null +++ b/Tests/Google/Core/Rendering/RenderingTestFixture.h @@ -0,0 +1,65 @@ +/* +** 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 . +*/ + +#pragma once + +#include +#include +#include +#include +#include + +// Each test renders with a real D3D8 device into an offscreen render target. The CTest step of CI skips these tests; +// its rendering check runs them on Windows under DXVK and Mesa lavapipe and compares the saved images with the base commit. +// Write each test as TEST_F(RenderingTest, Name) in a file of its own. The tests share one device, which is created +// once for the suite, and each test starts with the default device state and a black render target. +class RenderingTest : public ::testing::Test +{ +protected: + static constexpr UINT Width = 256; + static constexpr UINT Height = 256; + + // A position on the render target in pixels, and its color as 0xRRGGBB. + struct Vertex + { + float x, y; + DWORD color; + }; + + static void SetUpTestSuite(); + static void TearDownTestSuite(); + virtual void SetUp() override; + + static void clear(DWORD color); + // Draws a triangle list and blends the vertex colors across each triangle. + static void drawTriangles(std::initializer_list vertices); + // Saves the render target as a PNG for the CI report and keeps its pixels for pixel(). + void capture(const char *imageName); + // The color of a captured pixel as 0xRRGGBB. + DWORD pixel(UINT x, UINT y) const { return m_pixels[y * Width + x]; } + + static IDirect3DDevice8 *s_device; + +private: + std::vector m_pixels = std::vector(Width * Height); + static HWND s_window; + static IDirect3D8 *s_d3d; + static IDirect3DSurface8 *s_target; + static IDirect3DSurface8 *s_readback; + static DWORD s_defaultState; +}; From 6c0447cb6c3a2e88489a9e0c3f3e4d799ef082fc Mon Sep 17 00:00:00 2001 From: stm <14291421+stephanmeesters@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:55:14 +0100 Subject: [PATCH 2/2] test: Shrink the example triangle to demonstrate the rendering comparison The triangle keeps its centroid, so the test still passes while its image changes. Co-Authored-By: Claude Opus 5.5 --- .../Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp index 9e39fda6166..c00478777a8 100644 --- a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp +++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp @@ -24,9 +24,9 @@ TEST_F(RenderingTest, Triangle) const DWORD background = 0x102030; clear(background); drawTriangles({ - {128.0f, 32.0f, 0xff0000}, - {224.0f, 224.0f, 0x00ff00}, - {32.0f, 224.0f, 0x0000ff}, + {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.