From b135bc12a185d2b7fa3769d333184377ebbca8ec Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:50:51 +0200 Subject: [PATCH 1/5] build(cmake): Add deps_config and depsi_always targets for settings shared by Dependencies and Core and move setups to Dependencies/CMakeLists.txt (#3405) --- CMakeLists.txt | 21 +++++---------------- Dependencies/Bink/CMakeLists.txt | 2 +- Dependencies/CMakeLists.txt | 25 +++++++++++++++++++++++++ Dependencies/DbgHelp/CMakeLists.txt | 2 +- Dependencies/Miles/CMakeLists.txt | 2 +- Dependencies/Usp10/CMakeLists.txt | 2 +- Dependencies/Utility/CMakeLists.txt | 2 +- cmake/config-build.cmake | 23 +++++++++-------------- cmake/config.cmake | 6 ++++++ 9 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 Dependencies/CMakeLists.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 39509160788..cab997057c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,7 +57,6 @@ if((WIN32 OR "${CMAKE_SYSTEM}" MATCHES "Windows") AND ${CMAKE_SIZEOF_VOID_P} EQU include(cmake/dx8.cmake) endif() -# Define a dummy stlport target when not on VC6. if (IS_VS6_BUILD) include(cmake/stlport.cmake) else() @@ -69,24 +68,14 @@ include(cmake/gamespy.cmake) include(cmake/lzhl.cmake) include(cmake/stb.cmake) -if (IS_VS6_BUILD) - # The original max sdk does not compile against a modern compiler. - # If there is a desire to make this work, then a fixed max sdk needs to be created. - add_subdirectory(Dependencies/MaxSDK) -endif() - -add_subdirectory(Dependencies/Precompiled) -add_subdirectory(Dependencies/Utility) -add_subdirectory(Dependencies/Bink) -add_subdirectory(Dependencies/Miles) -if (WIN32) - add_subdirectory(Dependencies/DbgHelp) - add_subdirectory(Dependencies/Usp10) +if(RTS_BUILD_OPTION_PROFILE_TRACY) + include(cmake/tracy.cmake) else() - add_library(deps_dbghelp INTERFACE) - add_library(deps_usp10 INTERFACE) + add_library(core_profile_tracy INTERFACE) endif() +add_subdirectory(Dependencies) + add_subdirectory(resources) add_subdirectory(Core) diff --git a/Dependencies/Bink/CMakeLists.txt b/Dependencies/Bink/CMakeLists.txt index 937ad7eba9c..05136c294ae 100644 --- a/Dependencies/Bink/CMakeLists.txt +++ b/Dependencies/Bink/CMakeLists.txt @@ -13,6 +13,6 @@ target_include_directories(deps_bink PUBLIC ) target_link_libraries(deps_bink PRIVATE - deps_precompiled # Gives force included headers. Link early. + depsi_always # Gives force included headers. Link early. deps_utility ) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt new file mode 100644 index 00000000000..9bb1ba36f46 --- /dev/null +++ b/Dependencies/CMakeLists.txt @@ -0,0 +1,25 @@ +add_subdirectory(Precompiled) + +add_library(depsi_always INTERFACE) +target_link_libraries(depsi_always INTERFACE + deps_precompiled # Gives force included headers. Link early. + deps_config +) + +if (IS_VS6_BUILD) + # The original max sdk does not compile against a modern compiler. + # If there is a desire to make this work, then a fixed max sdk needs to be created. + add_subdirectory(MaxSDK) +endif() + +add_subdirectory(Utility) +add_subdirectory(Bink) +add_subdirectory(Miles) + +if (WIN32) + add_subdirectory(DbgHelp) + add_subdirectory(Usp10) +else() + add_library(deps_dbghelp INTERFACE) + add_library(deps_usp10 INTERFACE) +endif() diff --git a/Dependencies/DbgHelp/CMakeLists.txt b/Dependencies/DbgHelp/CMakeLists.txt index 9199e37969a..267b32cbed0 100644 --- a/Dependencies/DbgHelp/CMakeLists.txt +++ b/Dependencies/DbgHelp/CMakeLists.txt @@ -16,7 +16,7 @@ target_include_directories(deps_dbghelp PUBLIC ) target_link_libraries(deps_dbghelp PRIVATE - deps_precompiled # Gives force included headers. Link early. + depsi_always # Gives force included headers. Link early. deps_utility stlport ) diff --git a/Dependencies/Miles/CMakeLists.txt b/Dependencies/Miles/CMakeLists.txt index d93a276b74d..d147a20faa7 100644 --- a/Dependencies/Miles/CMakeLists.txt +++ b/Dependencies/Miles/CMakeLists.txt @@ -14,6 +14,6 @@ target_include_directories(deps_miles PUBLIC ) target_link_libraries(deps_miles PRIVATE - deps_precompiled # Gives force included headers. Link early. + depsi_always # Gives force included headers. Link early. deps_utility ) diff --git a/Dependencies/Usp10/CMakeLists.txt b/Dependencies/Usp10/CMakeLists.txt index 9e6f2c00c28..922c376864a 100644 --- a/Dependencies/Usp10/CMakeLists.txt +++ b/Dependencies/Usp10/CMakeLists.txt @@ -14,6 +14,6 @@ target_include_directories(deps_usp10 PUBLIC ) target_link_libraries(deps_usp10 PRIVATE - deps_precompiled # Gives force included headers. Link early. + depsi_always # Gives force included headers. Link early. deps_utility ) diff --git a/Dependencies/Utility/CMakeLists.txt b/Dependencies/Utility/CMakeLists.txt index 34f1ceb951f..bbc8d87eb1a 100644 --- a/Dependencies/Utility/CMakeLists.txt +++ b/Dependencies/Utility/CMakeLists.txt @@ -31,5 +31,5 @@ target_include_directories(deps_utility INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) target_sources(deps_utility PRIVATE ${UTILITY_SRC}) target_link_libraries(deps_utility INTERFACE - deps_precompiled # Gives force included headers. Link early. + depsi_always # Gives force included headers. Link early. ) diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake index d0201bed604..3a9f65c2ef0 100644 --- a/cmake/config-build.cmake +++ b/cmake/config-build.cmake @@ -46,26 +46,28 @@ if(RTS_BUILD_GENERALS) add_feature_info(GeneralsDocs RTS_BUILD_GENERALS_DOCS "Build Generals Documentation") endif() + if(NOT IS_VS6_BUILD) # Because we set CMAKE_CXX_STANDARD_REQUIRED and CMAKE_CXX_EXTENSIONS in the compilers.cmake this should be enforced. - target_compile_features(core_config INTERFACE cxx_std_20) + target_compile_features(deps_config INTERFACE cxx_std_20) endif() if(IS_VS6_BUILD AND RTS_BUILD_OPTION_VC6_FULL_DEBUG) - target_compile_options(core_config INTERFACE ${RTS_FLAGS} /Zi) + target_compile_options(deps_config INTERFACE ${RTS_FLAGS} /Zi) else() - target_compile_options(core_config INTERFACE ${RTS_FLAGS}) + target_compile_options(deps_config INTERFACE ${RTS_FLAGS}) +endif() + +if(UNIX) + target_compile_definitions(deps_config INTERFACE _UNIX) endif() + # This disables a lot of warnings steering developers to use windows only functions/function names. if(MSVC) target_compile_definitions(core_config INTERFACE _CRT_NONSTDC_NO_WARNINGS _CRT_SECURE_NO_WARNINGS $<$:_DEBUG_CRT>) endif() -if(UNIX) - target_compile_definitions(core_config INTERFACE _UNIX) -endif() - if(RTS_BUILD_OPTION_DEBUG) target_compile_definitions(core_config INTERFACE RTS_DEBUG WWDEBUG DEBUG) else() @@ -75,10 +77,3 @@ endif() if(RTS_BUILD_OPTION_PROFILE) target_compile_definitions(core_config INTERFACE RTS_PROFILE_LEGACY) endif() - -# Define a dummy Tracy target when the build option is disabled. -if(RTS_BUILD_OPTION_PROFILE_TRACY) - include(cmake/tracy.cmake) -else() - add_library(core_profile_tracy INTERFACE) -endif() diff --git a/cmake/config.cmake b/cmake/config.cmake index c29dd6656ca..cf4ddbb1729 100644 --- a/cmake/config.cmake +++ b/cmake/config.cmake @@ -1,5 +1,11 @@ +# Contains the build settings that the Dependencies and Core targets share. +# Settings that are specific to the game belong to core_config instead. +add_library(deps_config INTERFACE) + add_library(core_config INTERFACE) +target_link_libraries(core_config INTERFACE deps_config) + include(${CMAKE_CURRENT_LIST_DIR}/config-build.cmake) include(${CMAKE_CURRENT_LIST_DIR}/config-macros.cmake) include(${CMAKE_CURRENT_LIST_DIR}/config-retail.cmake) From b3a41f9532c47ebac05c5bd8c2f94343a3e6ca99 Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:01:32 +0200 Subject: [PATCH 2/5] feat(debug): Add DebugSetCrashHandler to pass debug and release crashes to a host (#3405) --- Core/GameEngine/Include/Common/Debug.h | 5 +++ .../GameEngine/Source/Common/System/Debug.cpp | 36 +++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Core/GameEngine/Include/Common/Debug.h b/Core/GameEngine/Include/Common/Debug.h index 7288a753cda..786dae328ab 100644 --- a/Core/GameEngine/Include/Common/Debug.h +++ b/Core/GameEngine/Include/Common/Debug.h @@ -217,6 +217,11 @@ DEBUG_EXTERN_C void ReleaseCrashLocalized(const AsciiString& p, const AsciiStrin #define RELEASE_CRASH(m) do { ReleaseCrash(m); } while (0) #define RELEASE_CRASHLOCALIZED(p, m) do { ReleaseCrashLocalized(p, m); } while (0) +// Debug and release crashes go to the crash handler instead of a crash box, +// for example to fail a unit test. A debug crash then continues, a release crash still exits. +typedef void (*DebugCrashHandler)(const char *message); +DEBUG_EXTERN_C void DebugSetCrashHandler(DebugCrashHandler handler); + #ifdef DEBUG_PROFILE diff --git a/Core/GameEngine/Source/Common/System/Debug.cpp b/Core/GameEngine/Source/Common/System/Debug.cpp index 815ec558ea8..deb7f604832 100644 --- a/Core/GameEngine/Source/Common/System/Debug.cpp +++ b/Core/GameEngine/Source/Common/System/Debug.cpp @@ -114,6 +114,7 @@ static char theLogFileNamePrev[ _MAX_PATH ]; static char theBuffer[ LARGE_BUFFER ]; // make it big to avoid weird overflow bugs in debug mode static int theDebugFlags = 0; static DWORD theMainThreadID = 0; +static DebugCrashHandler theCrashHandler = nullptr; // ---------------------------------------------------------------------------- // PUBLIC DATA // ---------------------------------------------------------------------------- @@ -548,6 +549,13 @@ void DebugCrash(const char *format, ...) #endif } + if (theCrashHandler != nullptr) + { + // Every crash reaches the handler, because the ignore option of the crash box does not apply. + theCrashHandler(theCrashBuffer); + return; + } + strlcat(theCrashBuffer, "\n\nAbort->exception; Retry->debugger; Ignore->continue", ARRAY_SIZE(theCrashBuffer)); const int result = doCrashBox(theCrashBuffer, useLogging); @@ -624,6 +632,18 @@ void DebugSetFlags(int flags) #endif // ALLOW_DEBUG_UTILS +// ---------------------------------------------------------------------------- +// DebugSetCrashHandler +// ---------------------------------------------------------------------------- +/** + Set the handler that receives debug and release crashes instead of the crash box. + Pass nullptr to show the crash box again. +*/ +void DebugSetCrashHandler(DebugCrashHandler handler) +{ + theCrashHandler = handler; +} + #ifdef DEBUG_PROFILE // ---------------------------------------------------------------------------- SimpleProfiler::SimpleProfiler() @@ -747,6 +767,11 @@ static void TriggerMiniDump() void ReleaseCrash(const char *reason) { + if (theCrashHandler != nullptr) + { + theCrashHandler(reason); + } + /// do additional reporting on the crash, if possible if (!DX8Wrapper_IsWindowed) { @@ -808,7 +833,7 @@ void ReleaseCrash(const char *reason) #if defined(RTS_DEBUG) /* static */ char buff[8192]; // not so static so we can be threadsafe snprintf(buff, 8192, "Sorry, a serious error occurred. (%s)", reason); - if (!(TheGlobalData && TheGlobalData->m_headless)) + if (theCrashHandler == nullptr && !(TheGlobalData && TheGlobalData->m_headless)) { ::MessageBox(nullptr, buff, "Technical Difficulties...", MB_OK|MB_SYSTEMMODAL|MB_ICONERROR); } @@ -818,7 +843,7 @@ void ReleaseCrash(const char *reason) // ::MessageBox(nullptr, "You have encountered a serious error. Serious errors can be caused by many things including viruses, overheated hardware and hardware that does not meet the minimum specifications for the game. Please visit the forums at www.generals.ea.com for suggested courses of action or consult your manual for Technical Support contact information.", "Technical Difficulties...", MB_OK|MB_TASKMODAL|MB_ICONERROR); // crash error message changed again 8/22/03 M Lorenzen... made this message box modal to the system so it will appear on top of any task-modal windows, splash-screen, etc. - if (!(TheGlobalData && TheGlobalData->m_headless)) + if (theCrashHandler == nullptr && !(TheGlobalData && TheGlobalData->m_headless)) { ::MessageBox(nullptr, "You have encountered a serious error. Serious errors can be caused by many things including viruses, overheated hardware and hardware that does not meet the minimum specifications for the game. Please visit the forums at www.generals.ea.com for suggested courses of action or consult your manual for Technical Support contact information.", "Technical Difficulties...", @@ -839,6 +864,11 @@ void ReleaseCrashLocalized(const AsciiString& p, const AsciiString& m) return; } + if (theCrashHandler != nullptr) + { + theCrashHandler(m.str()); + } + TriggerMiniDump(); UnicodeString prompt = TheGameText->fetch(p); @@ -853,7 +883,7 @@ void ReleaseCrashLocalized(const AsciiString& p, const AsciiString& m) } } - if (!(TheGlobalData && TheGlobalData->m_headless)) + if (theCrashHandler == nullptr && !(TheGlobalData && TheGlobalData->m_headless)) { ::MessageBoxW(nullptr, mesg.str(), prompt.str(), MB_OK | MB_SYSTEMMODAL | MB_ICONERROR); } From f70e35a5b7987a5a7cadfe5c4a7de12bdcdd0f87 Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:20:05 +0200 Subject: [PATCH 3/5] test: Add Google Test unit tests for Dependencies, Core, Generals and Zero Hour (#3405) --- CMakeLists.txt | 6 ++ CMakePresets.json | 62 +++++++++++++++++ Core/CMakeLists.txt | 4 ++ Core/GoogleTest/CMakeLists.txt | 19 ++++++ .../GameEngine/Common/AsciiStringTest.cpp | 30 +++++++++ Core/GoogleTest/GoogleTestMain.cpp | 67 +++++++++++++++++++ .../Compression/CompressionManagerTest.cpp | 45 +++++++++++++ Dependencies/CMakeLists.txt | 4 ++ Dependencies/GoogleTest/CMakeLists.txt | 12 ++++ .../GoogleTest/Utility/stringex_test.cpp | 28 ++++++++ Generals/Code/CMakeLists.txt | 4 ++ Generals/Code/GoogleTest/CMakeLists.txt | 13 ++++ GeneralsMD/Code/CMakeLists.txt | 4 ++ GeneralsMD/Code/GoogleTest/CMakeLists.txt | 13 ++++ TESTING.md | 29 +++++++- cmake/config-build.cmake | 2 + cmake/googletest.cmake | 13 ++++ 17 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 Core/GoogleTest/CMakeLists.txt create mode 100644 Core/GoogleTest/GameEngine/Common/AsciiStringTest.cpp create mode 100644 Core/GoogleTest/GoogleTestMain.cpp create mode 100644 Core/GoogleTest/Libraries/Compression/CompressionManagerTest.cpp create mode 100644 Dependencies/GoogleTest/CMakeLists.txt create mode 100644 Dependencies/GoogleTest/Utility/stringex_test.cpp create mode 100644 Generals/Code/GoogleTest/CMakeLists.txt create mode 100644 GeneralsMD/Code/GoogleTest/CMakeLists.txt create mode 100644 cmake/googletest.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index cab997057c5..6a38fc92da3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,12 @@ else() add_library(core_profile_tracy INTERFACE) endif() +if(RTS_BUILD_OPTION_TESTS) + # Must come before the subdirectories that add tests. + enable_testing() + include(cmake/googletest.cmake) +endif() + add_subdirectory(Dependencies) add_subdirectory(resources) diff --git a/CMakePresets.json b/CMakePresets.json index e5ac089fe72..a361827a1e2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -292,6 +292,68 @@ "description": "Build MinGW-w64 32-bit (i686) Profile" } ], + "testPresets": [ + { + "name": "default", + "displayName": "Default Test (don't use directly!)", + "hidden": true, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error", + "timeout": 300 + } + }, + { + "name": "win32", + "inherits": "default", + "configurePreset": "win32", + "displayName": "Test Windows 32bit Release", + "description": "Test Windows 32bit Release", + "configuration": "Release" + }, + { + "name": "win32-profile", + "inherits": "default", + "configurePreset": "win32-profile", + "displayName": "Test Windows 32bit Profile", + "description": "Test Windows 32bit Profile", + "configuration": "Release" + }, + { + "name": "win32-debug", + "inherits": "default", + "configurePreset": "win32-debug", + "displayName": "Test Windows 32bit Debug", + "description": "Test Windows 32bit Debug", + "configuration": "Debug" + }, + { + "name": "win32-vcpkg", + "inherits": "default", + "configurePreset": "win32-vcpkg", + "displayName": "Test Windows 32bit VCPKG Release", + "description": "Test Windows 32bit VCPKG Release", + "configuration": "Release" + }, + { + "name": "win32-vcpkg-profile", + "inherits": "default", + "configurePreset": "win32-vcpkg-profile", + "displayName": "Test Windows 32bit VCPKG Profile", + "description": "Test Windows 32bit VCPKG Profile", + "configuration": "Release" + }, + { + "name": "win32-vcpkg-debug", + "inherits": "default", + "configurePreset": "win32-vcpkg-debug", + "displayName": "Test Windows 32bit VCPKG Debug", + "description": "Test Windows 32bit VCPKG Debug", + "configuration": "Debug" + } + ], "workflowPresets": [ { "name": "vc6", diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 4377b5db7d3..2757a06100b 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -36,3 +36,7 @@ add_subdirectory(GameEngine) add_subdirectory(GameEngineDevice) add_subdirectory(Tools) + +if(RTS_BUILD_OPTION_TESTS) + add_subdirectory(GoogleTest) +endif() diff --git a/Core/GoogleTest/CMakeLists.txt b/Core/GoogleTest/CMakeLists.txt new file mode 100644 index 00000000000..f4a850622cf --- /dev/null +++ b/Core/GoogleTest/CMakeLists.txt @@ -0,0 +1,19 @@ +# Core GameEngine code needs a game to compile, so g_googletest and z_googletest compile these sources. +set(COREI_GOOGLETEST_SRC + GoogleTestMain.cpp + GameEngine/Common/AsciiStringTest.cpp + Libraries/Compression/CompressionManagerTest.cpp +) + +add_library(corei_googletest INTERFACE) + +target_sources(corei_googletest INTERFACE ${COREI_GOOGLETEST_SRC}) + +target_link_libraries(corei_googletest INTERFACE + comctl32 + depsi_googletest + GTest::gtest + imm32 + vfw32 + winmm +) diff --git a/Core/GoogleTest/GameEngine/Common/AsciiStringTest.cpp b/Core/GoogleTest/GameEngine/Common/AsciiStringTest.cpp new file mode 100644 index 00000000000..49b99ada928 --- /dev/null +++ b/Core/GoogleTest/GameEngine/Common/AsciiStringTest.cpp @@ -0,0 +1,30 @@ +/* +** 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 + +#include "Common/AsciiString.h" + +TEST(AsciiString, FormatAndConcat) +{ + AsciiString text; + text.format("%s %d", "Generals", 2003); + text.concat("!"); + EXPECT_STREQ(text.str(), "Generals 2003!"); + EXPECT_EQ(text.getLength(), 14); +} diff --git a/Core/GoogleTest/GoogleTestMain.cpp b/Core/GoogleTest/GoogleTestMain.cpp new file mode 100644 index 00000000000..f4bb9c8f4a6 --- /dev/null +++ b/Core/GoogleTest/GoogleTestMain.cpp @@ -0,0 +1,67 @@ +/* +** 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 +#include + +#include + +#include "Common/CommandLine.h" +#include "Common/Debug.h" +#include "Common/GameMemory.h" +#include "GameClient/ClientInstance.h" + +// The game engine libraries expect the executable to define these. +HINSTANCE ApplicationHInstance = nullptr; +HWND ApplicationHWnd = nullptr; +const char *gAppPrefix = "GoogleTest_"; +const Char *g_strFile = "data\\Generals.str"; +const Char *g_csfFile = "data\\%s\\Generals.csf"; + +static void failTestOnCrash(const char *message) +{ + ADD_FAILURE() << message; +} + +static int runTests(int argc, char **argv) +{ + // Google Test removes its flags from the argument array it gets, but the engine reads the process + // arguments through __argv and __argc, so Google Test gets a copy that includes the terminating null. + std::vector testArgs(argv, argv + argc + 1); + ::testing::InitGoogleTest(&argc, testArgs.data()); + DebugSetCrashHandler(failTestOnCrash); + + // Same startup as DebugInit does in logging builds, so that the tests run alike in every build. + initMemoryManager(); + CommandLine::parseCommandLineForStartup(); + rts::ClientInstance::initialize(); + + // The memory manager is not shut down, because Google Test frees its objects through it after main returns. + return RUN_ALL_TESTS(); +} + +int main(int argc, char **argv) +{ + return runTests(argc, argv); +} + +// The engine refers to WinMain when it dumps exception info, so the executable must define it. +Int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, Int nCmdShow) +{ + return runTests(__argc, __argv); +} diff --git a/Core/GoogleTest/Libraries/Compression/CompressionManagerTest.cpp b/Core/GoogleTest/Libraries/Compression/CompressionManagerTest.cpp new file mode 100644 index 00000000000..24549e9c634 --- /dev/null +++ b/Core/GoogleTest/Libraries/Compression/CompressionManagerTest.cpp @@ -0,0 +1,45 @@ +/* +** 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 +#include + +#include "Compression.h" + +// The game compresses map and save data chunks and transferred map previews with the preferred compression. +TEST(CompressionManager, PreferredCompressionRoundTrips) +{ + std::vector source(4096); + for (size_t i = 0; i < source.size(); ++i) + { + source[i] = static_cast(i % 61); + } + const Int sourceSize = static_cast(source.size()); + + const CompressionType type = CompressionManager::getPreferredCompression(); + std::vector compressed(CompressionManager::getMaxCompressedSize(sourceSize, type)); + const Int compressedSize = CompressionManager::compressData(type, source.data(), sourceSize, compressed.data(), static_cast(compressed.size())); + ASSERT_GT(compressedSize, 0); + EXPECT_LT(compressedSize, sourceSize); + EXPECT_TRUE(CompressionManager::isDataCompressed(compressed.data(), compressedSize)); + EXPECT_EQ(CompressionManager::getUncompressedSize(compressed.data(), compressedSize), sourceSize); + + std::vector decompressed(source.size()); + EXPECT_EQ(CompressionManager::decompressData(compressed.data(), compressedSize, decompressed.data(), sourceSize), sourceSize); + EXPECT_EQ(decompressed, source); +} diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 9bb1ba36f46..5f652aaf3ba 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -23,3 +23,7 @@ else() add_library(deps_dbghelp INTERFACE) add_library(deps_usp10 INTERFACE) endif() + +if(RTS_BUILD_OPTION_TESTS) + add_subdirectory(GoogleTest) +endif() diff --git a/Dependencies/GoogleTest/CMakeLists.txt b/Dependencies/GoogleTest/CMakeLists.txt new file mode 100644 index 00000000000..7d2e167adb1 --- /dev/null +++ b/Dependencies/GoogleTest/CMakeLists.txt @@ -0,0 +1,12 @@ +# g_googletest and z_googletest compile these sources together with the Core tests. +set(DEPSI_GOOGLETEST_SRC + Utility/stringex_test.cpp +) + +add_library(depsi_googletest INTERFACE) + +target_sources(depsi_googletest INTERFACE ${DEPSI_GOOGLETEST_SRC}) + +target_link_libraries(depsi_googletest INTERFACE + deps_utility +) diff --git a/Dependencies/GoogleTest/Utility/stringex_test.cpp b/Dependencies/GoogleTest/Utility/stringex_test.cpp new file mode 100644 index 00000000000..91af8c11125 --- /dev/null +++ b/Dependencies/GoogleTest/Utility/stringex_test.cpp @@ -0,0 +1,28 @@ +/* +** 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 + +#include "Utility/stringex.h" + +TEST(StringEx, StrlcpyTruncatesAndTerminates) +{ + char buffer[4]; + EXPECT_EQ(strlcpy_t(buffer, "hello"), 5u); + EXPECT_STREQ(buffer, "hel"); +} diff --git a/Generals/Code/CMakeLists.txt b/Generals/Code/CMakeLists.txt index 85838bb94d6..c96af005b33 100644 --- a/Generals/Code/CMakeLists.txt +++ b/Generals/Code/CMakeLists.txt @@ -40,3 +40,7 @@ add_subdirectory(Main) if (RTS_BUILD_GENERALS_TOOLS OR RTS_BUILD_GENERALS_EXTRAS) add_subdirectory(Tools) endif() + +if (RTS_BUILD_OPTION_TESTS) + add_subdirectory(GoogleTest) +endif() diff --git a/Generals/Code/GoogleTest/CMakeLists.txt b/Generals/Code/GoogleTest/CMakeLists.txt new file mode 100644 index 00000000000..0f3ae34ea58 --- /dev/null +++ b/Generals/Code/GoogleTest/CMakeLists.txt @@ -0,0 +1,13 @@ +# Keep the test executable out of the Generals output folder, which CI collects into the build artifacts. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +add_executable(g_googletest) + +target_link_libraries(g_googletest PRIVATE + corei_googletest + g_gameengine + g_gameenginedevice + gi_always +) + +add_test(NAME g_googletest COMMAND g_googletest) diff --git a/GeneralsMD/Code/CMakeLists.txt b/GeneralsMD/Code/CMakeLists.txt index a713dcc7c2b..3a72fc51eaa 100644 --- a/GeneralsMD/Code/CMakeLists.txt +++ b/GeneralsMD/Code/CMakeLists.txt @@ -40,3 +40,7 @@ add_subdirectory(Main) if (RTS_BUILD_ZEROHOUR_TOOLS OR RTS_BUILD_ZEROHOUR_EXTRAS) add_subdirectory(Tools) endif() + +if (RTS_BUILD_OPTION_TESTS) + add_subdirectory(GoogleTest) +endif() diff --git a/GeneralsMD/Code/GoogleTest/CMakeLists.txt b/GeneralsMD/Code/GoogleTest/CMakeLists.txt new file mode 100644 index 00000000000..1d09d3ab004 --- /dev/null +++ b/GeneralsMD/Code/GoogleTest/CMakeLists.txt @@ -0,0 +1,13 @@ +# Keep the test executable out of the GeneralsMD output folder, which CI collects into the build artifacts. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +add_executable(z_googletest) + +target_link_libraries(z_googletest PRIVATE + corei_googletest + z_gameengine + z_gameenginedevice + zi_always +) + +add_test(NAME z_googletest COMMAND z_googletest) diff --git a/TESTING.md b/TESTING.md index bcfed391427..bace9501786 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,4 +11,31 @@ START /B /W generalszh.exe -jobs 4 -headless -replay subfolder/*.rep > replay_ch echo %errorlevel% PAUSE ``` -It will run the game in the background and check that each replay is compatible. You need to use a VC6 build with optimizations and RTS_BUILD_OPTION_DEBUG = OFF, otherwise the game won't be compatible. \ No newline at end of file +It will run the game in the background and check that each replay is compatible. You need to use a VC6 build with optimizations and RTS_BUILD_OPTION_DEBUG = OFF, otherwise the game won't be compatible. + +# Unit Tests + +Unit tests use [Google Test](https://github.com/TheSuperHackers/google-test). They need a C++17 compiler, so they are not available with VC6. Enable them with `RTS_BUILD_OPTION_TESTS` and run them with CTest: +``` +cmake --preset win32 -DRTS_BUILD_OPTION_TESTS=ON +cmake --build --preset win32 +ctest --preset win32 +``` +Visual Studio also lists the tests in its Test Explorer. A test executable can be run directly as well, for example with `--gtest_filter=AsciiString.*` or with `--gtest_break_on_failure` to stop in the debugger. + +Each game has one test executable, g_googletest for Generals and z_googletest for Zero Hour. It compiles the tests of all layers: + +| Sources | Tests | +|---|---| +| Dependencies/GoogleTest | Dependencies code. It must not use Core. | +| Core/GoogleTest | Core game engine and libraries. | +| Generals/GoogleTest | Generals specific code. Prefer adding to Core/GoogleTest when possible. | +| GeneralsMD/GoogleTest | GeneralsMD specific code. Prefer adding to Core/GoogleTest when possible. | + +A test file is named after the file it tests and follows its naming style, for example stringex_test.cpp for stringex.h and AsciiStringTest.cpp for AsciiString.h. + +A failed `DEBUG_ASSERTCRASH` fails the running test in builds with debug crashing, which are the debug presets and builds with `RTS_DEBUG_CRASHING=ON`. Release builds compile these asserts out, so a test must not rely on them alone. + +A `RELEASE_CRASH` reports its reason as a failure of the running test and then exits the test executable. Expected release crashes can be tested with `EXPECT_EXIT(..., ::testing::ExitedWithCode(1), "")`. + +The test executables use the regular user data folder of the game, so a `RELEASE_CRASH` also writes its ReleaseCrashInfo.txt there. diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake index 3a9f65c2ef0..ccffcbf0373 100644 --- a/cmake/config-build.cmake +++ b/cmake/config-build.cmake @@ -8,6 +8,7 @@ option(RTS_BUILD_OPTION_PROFILE_TRACY "Build code with Tracy profiling enabled." option(RTS_BUILD_OPTION_DEBUG "Build code with the \"Debug\" configuration." OFF) option(RTS_BUILD_OPTION_ASAN "Build code with Address Sanitizer." OFF) option(RTS_BUILD_OPTION_VC6_FULL_DEBUG "Build VC6 with full debug info." OFF) +cmake_dependent_option(RTS_BUILD_OPTION_TESTS "Build the unit tests." OFF "NOT IS_VS6_BUILD" OFF) if(NOT RTS_BUILD_ZEROHOUR AND NOT RTS_BUILD_GENERALS) set(RTS_BUILD_ZEROHOUR TRUE) @@ -23,6 +24,7 @@ add_feature_info(DebugBuild RTS_BUILD_OPTION_DEBUG "Building as a \"Debug\" buil add_feature_info(AddressSanitizer RTS_BUILD_OPTION_ASAN "Building with address sanitizer") add_feature_info(Vc6FullDebug RTS_BUILD_OPTION_VC6_FULL_DEBUG "Building VC6 with full debug info") add_feature_info(FFmpegSupport RTS_BUILD_OPTION_FFMPEG "Building with FFmpeg support") +add_feature_info(Tests RTS_BUILD_OPTION_TESTS "Building the unit tests") set(RTS_BUILD_OUTPUT_SUFFIX "" CACHE STRING "Suffix appended to output names of installable targets") diff --git a/cmake/googletest.cmake b/cmake/googletest.cmake new file mode 100644 index 00000000000..b51172bc42b --- /dev/null +++ b/cmake/googletest.cmake @@ -0,0 +1,13 @@ +# Fetch Google Test for the unit test executables. +# It is always built from source, so that it uses the same compiler, architecture and runtime library as the game. + +set(BUILD_GMOCK OFF) + +FetchContent_Declare( + googletest + EXCLUDE_FROM_ALL # Exclude Google Test's development files from the INSTALL target. + GIT_REPOSITORY https://github.com/TheSuperHackers/google-test + GIT_TAG 063de7e9578f82b369302001269680b4b1553359 # 1.18.0 +) + +FetchContent_MakeAvailable(googletest) From b15b92b17980ac7d40e1bb87a44f3a44332cd9df Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:27:28 +0200 Subject: [PATCH 4/5] test: Add Google Benchmark benchmarks for Dependencies, Core, Generals and Zero Hour (#3405) --- CMakeLists.txt | 4 + Core/CMakeLists.txt | 4 + Core/GoogleBenchmark/CMakeLists.txt | 19 +++++ .../Common/AsciiStringBenchmark.cpp | 32 ++++++++ Core/GoogleBenchmark/GoogleBenchmarkMain.cpp | 73 +++++++++++++++++++ .../CompressionManagerBenchmark.cpp | 66 +++++++++++++++++ Dependencies/CMakeLists.txt | 4 + Dependencies/GoogleBenchmark/CMakeLists.txt | 12 +++ .../Utility/stringex_benchmark.cpp | 49 +++++++++++++ Generals/Code/CMakeLists.txt | 4 + Generals/Code/GoogleBenchmark/CMakeLists.txt | 11 +++ GeneralsMD/Code/CMakeLists.txt | 4 + .../Code/GoogleBenchmark/CMakeLists.txt | 11 +++ TESTING.md | 6 ++ cmake/config-build.cmake | 2 + cmake/googlebenchmark.cmake | 16 ++++ 16 files changed, 317 insertions(+) create mode 100644 Core/GoogleBenchmark/CMakeLists.txt create mode 100644 Core/GoogleBenchmark/GameEngine/Common/AsciiStringBenchmark.cpp create mode 100644 Core/GoogleBenchmark/GoogleBenchmarkMain.cpp create mode 100644 Core/GoogleBenchmark/Libraries/Compression/CompressionManagerBenchmark.cpp create mode 100644 Dependencies/GoogleBenchmark/CMakeLists.txt create mode 100644 Dependencies/GoogleBenchmark/Utility/stringex_benchmark.cpp create mode 100644 Generals/Code/GoogleBenchmark/CMakeLists.txt create mode 100644 GeneralsMD/Code/GoogleBenchmark/CMakeLists.txt create mode 100644 cmake/googlebenchmark.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a38fc92da3..706044b164b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,10 @@ if(RTS_BUILD_OPTION_TESTS) include(cmake/googletest.cmake) endif() +if(RTS_BUILD_OPTION_BENCHMARKS) + include(cmake/googlebenchmark.cmake) +endif() + add_subdirectory(Dependencies) add_subdirectory(resources) diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 2757a06100b..9d8937e1fcf 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -40,3 +40,7 @@ add_subdirectory(Tools) if(RTS_BUILD_OPTION_TESTS) add_subdirectory(GoogleTest) endif() + +if(RTS_BUILD_OPTION_BENCHMARKS) + add_subdirectory(GoogleBenchmark) +endif() diff --git a/Core/GoogleBenchmark/CMakeLists.txt b/Core/GoogleBenchmark/CMakeLists.txt new file mode 100644 index 00000000000..d18c2ea05fa --- /dev/null +++ b/Core/GoogleBenchmark/CMakeLists.txt @@ -0,0 +1,19 @@ +# Core GameEngine code needs a game to compile, so g_googlebenchmark and z_googlebenchmark compile these sources. +set(COREI_GOOGLEBENCHMARK_SRC + GoogleBenchmarkMain.cpp + GameEngine/Common/AsciiStringBenchmark.cpp + Libraries/Compression/CompressionManagerBenchmark.cpp +) + +add_library(corei_googlebenchmark INTERFACE) + +target_sources(corei_googlebenchmark INTERFACE ${COREI_GOOGLEBENCHMARK_SRC}) + +target_link_libraries(corei_googlebenchmark INTERFACE + benchmark::benchmark + comctl32 + depsi_googlebenchmark + imm32 + vfw32 + winmm +) diff --git a/Core/GoogleBenchmark/GameEngine/Common/AsciiStringBenchmark.cpp b/Core/GoogleBenchmark/GameEngine/Common/AsciiStringBenchmark.cpp new file mode 100644 index 00000000000..b831ae2bd0a --- /dev/null +++ b/Core/GoogleBenchmark/GameEngine/Common/AsciiStringBenchmark.cpp @@ -0,0 +1,32 @@ +/* +** 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 + +#include "Common/AsciiString.h" + +static void BM_AsciiStringFormat(benchmark::State &state) +{ + for (auto _ : state) + { + AsciiString text; + text.format("%s %d", "Generals", 2003); + benchmark::DoNotOptimize(text.str()); + } +} +BENCHMARK(BM_AsciiStringFormat); diff --git a/Core/GoogleBenchmark/GoogleBenchmarkMain.cpp b/Core/GoogleBenchmark/GoogleBenchmarkMain.cpp new file mode 100644 index 00000000000..7d10e1639b6 --- /dev/null +++ b/Core/GoogleBenchmark/GoogleBenchmarkMain.cpp @@ -0,0 +1,73 @@ +/* +** 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 +#include +#include + +#include + +#include "Common/CommandLine.h" +#include "Common/Debug.h" +#include "Common/GameMemory.h" +#include "GameClient/ClientInstance.h" + +// The game engine libraries expect the executable to define these. +HINSTANCE ApplicationHInstance = nullptr; +HWND ApplicationHWnd = nullptr; +const char *gAppPrefix = "GoogleBenchmark_"; +const Char *g_strFile = "data\\Generals.str"; +const Char *g_csfFile = "data\\%s\\Generals.csf"; + +static void printCrash(const char *message) +{ + fprintf(stderr, "%s\n", message); +} + +static int runBenchmarks(int argc, char **argv) +{ + DebugSetCrashHandler(printCrash); + + // Same startup as DebugInit does in logging builds, so that the benchmarks run alike in every build. + initMemoryManager(); + CommandLine::parseCommandLineForStartup(); + rts::ClientInstance::initialize(); + + // Google Benchmark removes its flags from the argument array it gets, but the engine reads the process + // arguments through __argv and __argc, so Google Benchmark gets a copy that includes the terminating null. + std::vector benchmarkArgs(argv, argv + argc + 1); + ::benchmark::Initialize(&argc, benchmarkArgs.data()); + if (::benchmark::ReportUnrecognizedArguments(argc, benchmarkArgs.data())) + return 1; + ::benchmark::RunSpecifiedBenchmarks(); + ::benchmark::Shutdown(); + + // The memory manager is not shut down, because Google Benchmark frees its objects through it after main returns. + return 0; +} + +int main(int argc, char **argv) +{ + return runBenchmarks(argc, argv); +} + +// The engine refers to WinMain when it dumps exception info, so the executable must define it. +Int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, Int nCmdShow) +{ + return runBenchmarks(__argc, __argv); +} diff --git a/Core/GoogleBenchmark/Libraries/Compression/CompressionManagerBenchmark.cpp b/Core/GoogleBenchmark/Libraries/Compression/CompressionManagerBenchmark.cpp new file mode 100644 index 00000000000..20a581cb3d2 --- /dev/null +++ b/Core/GoogleBenchmark/Libraries/Compression/CompressionManagerBenchmark.cpp @@ -0,0 +1,66 @@ +/* +** 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 +#include + +#include "Compression.h" + +// The game compresses map and save data chunks and transferred map previews with the preferred compression. + +static std::vector makeSource() +{ + std::vector source(64 * 1024); + for (size_t i = 0; i < source.size(); ++i) + { + source[i] = static_cast((i * 7) % 251); + } + return source; +} + +static void BM_CompressData(benchmark::State &state) +{ + std::vector source = makeSource(); + const Int sourceSize = static_cast(source.size()); + const CompressionType type = CompressionManager::getPreferredCompression(); + std::vector compressed(CompressionManager::getMaxCompressedSize(sourceSize, type)); + + for (auto _ : state) + { + benchmark::DoNotOptimize(CompressionManager::compressData(type, source.data(), sourceSize, compressed.data(), static_cast(compressed.size()))); + } + state.SetBytesProcessed(state.iterations() * sourceSize); +} +BENCHMARK(BM_CompressData); + +static void BM_DecompressData(benchmark::State &state) +{ + std::vector source = makeSource(); + const Int sourceSize = static_cast(source.size()); + const CompressionType type = CompressionManager::getPreferredCompression(); + std::vector compressed(CompressionManager::getMaxCompressedSize(sourceSize, type)); + const Int compressedSize = CompressionManager::compressData(type, source.data(), sourceSize, compressed.data(), static_cast(compressed.size())); + std::vector decompressed(source.size()); + + for (auto _ : state) + { + benchmark::DoNotOptimize(CompressionManager::decompressData(compressed.data(), compressedSize, decompressed.data(), sourceSize)); + } + state.SetBytesProcessed(state.iterations() * sourceSize); +} +BENCHMARK(BM_DecompressData); diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 5f652aaf3ba..8899178e0ef 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -27,3 +27,7 @@ endif() if(RTS_BUILD_OPTION_TESTS) add_subdirectory(GoogleTest) endif() + +if(RTS_BUILD_OPTION_BENCHMARKS) + add_subdirectory(GoogleBenchmark) +endif() diff --git a/Dependencies/GoogleBenchmark/CMakeLists.txt b/Dependencies/GoogleBenchmark/CMakeLists.txt new file mode 100644 index 00000000000..91974cf8a5c --- /dev/null +++ b/Dependencies/GoogleBenchmark/CMakeLists.txt @@ -0,0 +1,12 @@ +# g_googlebenchmark and z_googlebenchmark compile these sources together with the Core benchmarks. +set(DEPSI_GOOGLEBENCHMARK_SRC + Utility/stringex_benchmark.cpp +) + +add_library(depsi_googlebenchmark INTERFACE) + +target_sources(depsi_googlebenchmark INTERFACE ${DEPSI_GOOGLEBENCHMARK_SRC}) + +target_link_libraries(depsi_googlebenchmark INTERFACE + deps_utility +) diff --git a/Dependencies/GoogleBenchmark/Utility/stringex_benchmark.cpp b/Dependencies/GoogleBenchmark/Utility/stringex_benchmark.cpp new file mode 100644 index 00000000000..5daabafcaf3 --- /dev/null +++ b/Dependencies/GoogleBenchmark/Utility/stringex_benchmark.cpp @@ -0,0 +1,49 @@ +/* +** 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 +#include + +#include "Utility/stringex.h" + +// The source text is created at runtime, so that the compiler cannot precompute its length and the copy. +static const std::string SourceText("The quick brown fox jumps over the lazy dog"); + +static void BM_strlcpy_t(benchmark::State &state) +{ + char buffer[16]; + for (auto _ : state) + { + strlcpy_t(buffer, SourceText.c_str()); + benchmark::DoNotOptimize(buffer); + } +} +BENCHMARK(BM_strlcpy_t); + +// The truncating copy that strlcpy_t replaces. +static void BM_strncpy(benchmark::State &state) +{ + char buffer[16]; + for (auto _ : state) + { + strncpy(buffer, SourceText.c_str(), sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; + benchmark::DoNotOptimize(buffer); + } +} +BENCHMARK(BM_strncpy); diff --git a/Generals/Code/CMakeLists.txt b/Generals/Code/CMakeLists.txt index c96af005b33..5682dd7bc1f 100644 --- a/Generals/Code/CMakeLists.txt +++ b/Generals/Code/CMakeLists.txt @@ -44,3 +44,7 @@ endif() if (RTS_BUILD_OPTION_TESTS) add_subdirectory(GoogleTest) endif() + +if (RTS_BUILD_OPTION_BENCHMARKS) + add_subdirectory(GoogleBenchmark) +endif() diff --git a/Generals/Code/GoogleBenchmark/CMakeLists.txt b/Generals/Code/GoogleBenchmark/CMakeLists.txt new file mode 100644 index 00000000000..70c0919b653 --- /dev/null +++ b/Generals/Code/GoogleBenchmark/CMakeLists.txt @@ -0,0 +1,11 @@ +# Keep the benchmark executable out of the Generals output folder, which CI collects into the build artifacts. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +add_executable(g_googlebenchmark) + +target_link_libraries(g_googlebenchmark PRIVATE + corei_googlebenchmark + g_gameengine + g_gameenginedevice + gi_always +) diff --git a/GeneralsMD/Code/CMakeLists.txt b/GeneralsMD/Code/CMakeLists.txt index 3a72fc51eaa..17a51e5d45e 100644 --- a/GeneralsMD/Code/CMakeLists.txt +++ b/GeneralsMD/Code/CMakeLists.txt @@ -44,3 +44,7 @@ endif() if (RTS_BUILD_OPTION_TESTS) add_subdirectory(GoogleTest) endif() + +if (RTS_BUILD_OPTION_BENCHMARKS) + add_subdirectory(GoogleBenchmark) +endif() diff --git a/GeneralsMD/Code/GoogleBenchmark/CMakeLists.txt b/GeneralsMD/Code/GoogleBenchmark/CMakeLists.txt new file mode 100644 index 00000000000..743ba1a10c6 --- /dev/null +++ b/GeneralsMD/Code/GoogleBenchmark/CMakeLists.txt @@ -0,0 +1,11 @@ +# Keep the benchmark executable out of the GeneralsMD output folder, which CI collects into the build artifacts. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +add_executable(z_googlebenchmark) + +target_link_libraries(z_googlebenchmark PRIVATE + corei_googlebenchmark + z_gameengine + z_gameenginedevice + zi_always +) diff --git a/TESTING.md b/TESTING.md index bace9501786..cb866b1d7c4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,3 +39,9 @@ A failed `DEBUG_ASSERTCRASH` fails the running test in builds with debug crashin A `RELEASE_CRASH` reports its reason as a failure of the running test and then exits the test executable. Expected release crashes can be tested with `EXPECT_EXIT(..., ::testing::ExitedWithCode(1), "")`. The test executables use the regular user data folder of the game, so a `RELEASE_CRASH` also writes its ReleaseCrashInfo.txt there. + +# Benchmarks + +Benchmarks use [Google Benchmark](https://github.com/TheSuperHackers/google-benchmark). Enable them with `RTS_BUILD_OPTION_BENCHMARKS`. They are organized like the unit tests in GoogleBenchmark folders, with the executables g_googlebenchmark and z_googlebenchmark. + +Run benchmarks from a Release build. Select benchmarks with `--benchmark_filter=`, and use `--benchmark_out=.json` to compare the results before and after a change. diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake index ccffcbf0373..10473848213 100644 --- a/cmake/config-build.cmake +++ b/cmake/config-build.cmake @@ -9,6 +9,7 @@ option(RTS_BUILD_OPTION_DEBUG "Build code with the \"Debug\" configuration." OFF option(RTS_BUILD_OPTION_ASAN "Build code with Address Sanitizer." OFF) option(RTS_BUILD_OPTION_VC6_FULL_DEBUG "Build VC6 with full debug info." OFF) cmake_dependent_option(RTS_BUILD_OPTION_TESTS "Build the unit tests." OFF "NOT IS_VS6_BUILD" OFF) +cmake_dependent_option(RTS_BUILD_OPTION_BENCHMARKS "Build the benchmarks." OFF "NOT IS_VS6_BUILD" OFF) if(NOT RTS_BUILD_ZEROHOUR AND NOT RTS_BUILD_GENERALS) set(RTS_BUILD_ZEROHOUR TRUE) @@ -25,6 +26,7 @@ add_feature_info(AddressSanitizer RTS_BUILD_OPTION_ASAN "Building with address s add_feature_info(Vc6FullDebug RTS_BUILD_OPTION_VC6_FULL_DEBUG "Building VC6 with full debug info") add_feature_info(FFmpegSupport RTS_BUILD_OPTION_FFMPEG "Building with FFmpeg support") add_feature_info(Tests RTS_BUILD_OPTION_TESTS "Building the unit tests") +add_feature_info(Benchmarks RTS_BUILD_OPTION_BENCHMARKS "Building the benchmarks") set(RTS_BUILD_OUTPUT_SUFFIX "" CACHE STRING "Suffix appended to output names of installable targets") diff --git a/cmake/googlebenchmark.cmake b/cmake/googlebenchmark.cmake new file mode 100644 index 00000000000..413fe4eb99e --- /dev/null +++ b/cmake/googlebenchmark.cmake @@ -0,0 +1,16 @@ +# Fetch Google Benchmark for the benchmark executables. +# It is always built from source, so that it uses the same compiler, architecture and runtime library as the game. + +# Its own tests would need Google Test and would register with our CTest. +set(BENCHMARK_ENABLE_TESTING OFF) +# Its MSVC warning level comes with -WX, and its upstream does not build with 32-bit MSVC. +set(BENCHMARK_ENABLE_WERROR OFF) + +FetchContent_Declare( + googlebenchmark + EXCLUDE_FROM_ALL # Exclude Google Benchmark's development files from the INSTALL target. + GIT_REPOSITORY https://github.com/TheSuperHackers/google-benchmark + GIT_TAG 192ef10025eb2c4cdd392bc502f0c852196baa48 # 1.9.5 +) + +FetchContent_MakeAvailable(googlebenchmark) From 70e5c65d7087f69f4418107c198742190b27d0d2 Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:28:12 +0200 Subject: [PATCH 5/5] ci: Build tests and benchmarks and run the tests in win32 jobs (#3405) --- .github/workflows/ci.yml | 4 ++++ .../workflows/reusable-build-toolchain.yml | 20 +++++++++++++++++++ TESTING.md | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b300a469c7f..808633b0939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: preset: ${{ matrix.preset }} tools: ${{ matrix.tools }} extras: ${{ matrix.extras }} + tests: ${{ startsWith(matrix.preset, 'win32') }} + benchmarks: ${{ matrix.preset == 'win32' }} secrets: inherit # Note build-generalsmd is split into two jobs for vc6 and win32 because replaycheck-generalsmd @@ -170,6 +172,8 @@ jobs: tools: ${{ matrix.tools }} extras: ${{ matrix.extras }} ffmpeg: ${{ matrix.ffmpeg || false}} + tests: true + benchmarks: ${{ matrix.preset == 'win32' }} secrets: inherit replaycheck-generalsmd: diff --git a/.github/workflows/reusable-build-toolchain.yml b/.github/workflows/reusable-build-toolchain.yml index 7e2f84e4ca7..40edcc2d01d 100644 --- a/.github/workflows/reusable-build-toolchain.yml +++ b/.github/workflows/reusable-build-toolchain.yml @@ -31,6 +31,16 @@ on: default: false type: boolean description: "Build with FFmpeg support" + tests: + required: false + default: false + type: boolean + description: "Build and run the tests" + benchmarks: + required: false + default: false + type: boolean + description: "Build the benchmarks" jobs: build: @@ -152,6 +162,8 @@ jobs: $buildFlags += "-DRTS_BUILD_CORE_EXTRAS=${{ inputs.extras && 'ON' || 'OFF' }}" $buildFlags += "-DRTS_BUILD_${gamePrefix}_EXTRAS=${{ inputs.extras && 'ON' || 'OFF' }}" $buildFlags += "-DRTS_BUILD_OPTION_FFMPEG=${{ inputs.ffmpeg && 'ON' || 'OFF' }}" + $buildFlags += "-DRTS_BUILD_OPTION_TESTS=${{ inputs.tests && 'ON' || 'OFF' }}" + $buildFlags += "-DRTS_BUILD_OPTION_BENCHMARKS=${{ inputs.benchmarks && 'ON' || 'OFF' }}" Write-Host "Build flags: $($buildFlags -join ' | ')" cmake --preset ${{ inputs.preset }} $buildFlags @@ -161,6 +173,14 @@ jobs: run: | cmake --build --preset ${{ inputs.preset }} + # 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. + - name: Test ${{ inputs.game }} with CTest Using ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Preset + if: ${{ inputs.tests }} + shell: pwsh + run: | + ctest --preset ${{ inputs.preset }} --verbose + - name: Collect ${{ inputs.game }} ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Artifact shell: pwsh run: | diff --git a/TESTING.md b/TESTING.md index cb866b1d7c4..67a791a83f5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -34,7 +34,7 @@ Each game has one test executable, g_googletest for Generals and z_googletest fo A test file is named after the file it tests and follows its naming style, for example stringex_test.cpp for stringex.h and AsciiStringTest.cpp for AsciiString.h. -A failed `DEBUG_ASSERTCRASH` fails the running test in builds with debug crashing, which are the debug presets and builds with `RTS_DEBUG_CRASHING=ON`. Release builds compile these asserts out, so a test must not rely on them alone. +A failed `DEBUG_ASSERTCRASH` fails the running test in builds with debug crashing, which are the debug presets and builds with `RTS_DEBUG_CRASHING=ON`. Release builds compile these asserts out, so a test must not rely on them alone. CI runs the tests in release, profile and debug builds. A `RELEASE_CRASH` reports its reason as a failure of the running test and then exits the test executable. Expected release crashes can be tested with `EXPECT_EXIT(..., ::testing::ExitedWithCode(1), "")`. @@ -42,6 +42,6 @@ The test executables use the regular user data folder of the game, so a `RELEASE # Benchmarks -Benchmarks use [Google Benchmark](https://github.com/TheSuperHackers/google-benchmark). Enable them with `RTS_BUILD_OPTION_BENCHMARKS`. They are organized like the unit tests in GoogleBenchmark folders, with the executables g_googlebenchmark and z_googlebenchmark. +Benchmarks use [Google Benchmark](https://github.com/TheSuperHackers/google-benchmark). Enable them with `RTS_BUILD_OPTION_BENCHMARKS`. They are organized like the unit tests in GoogleBenchmark folders, with the executables g_googlebenchmark and z_googlebenchmark. CI builds them, but does not run them. Run benchmarks from a Release build. Select benchmarks with `--benchmark_filter=`, and use `--benchmark_out=.json` to compare the results before and after a change.