diff --git a/.github/workflows/reusable-build-toolchain.yml b/.github/workflows/reusable-build-toolchain.yml index 7e2f84e4ca7..bb7db873219 100644 --- a/.github/workflows/reusable-build-toolchain.yml +++ b/.github/workflows/reusable-build-toolchain.yml @@ -120,6 +120,8 @@ jobs: if: contains(inputs.preset, 'vcpkg') uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11.6 with: + # The optional Crashpad package has its own separate manifest. + vcpkgJsonGlob: 'vcpkg.json' runVcpkgInstall: false doNotCache: true @@ -176,6 +178,15 @@ jobs: Where-Object { $_.Extension -in @(".exe", ".dll", ".pdb") } -Verbose } + # Preserve dependency notices alongside the optional Crashpad runtime. + $gameOutput = if ("${{ inputs.preset }}" -like "win32*") { + "$buildDir\${{ inputs.game }}\$configToUse" + } else { + "$buildDir\${{ inputs.game }}" + } + if (Test-Path "$gameOutput\crashpad-notices") { + Copy-Item -Path "$gameOutput\crashpad-notices" -Destination $artifactsDir -Recurse -Force + } $files | Move-Item -Destination $artifactsDir -Verbose -Force - name: Upload ${{ inputs.game }} ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Artifact diff --git a/CMakeLists.txt b/CMakeLists.txt index 39509160788..d414d7df3db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,8 @@ endif() add_subdirectory(resources) +include(cmake/crashpad.cmake) + add_subdirectory(Core) # Add main build targets diff --git a/Core/GameEngine/CMakeLists.txt b/Core/GameEngine/CMakeLists.txt index e29b9f93290..90b9620fada 100644 --- a/Core/GameEngine/CMakeLists.txt +++ b/Core/GameEngine/CMakeLists.txt @@ -72,6 +72,7 @@ set(GAMEENGINE_SRC Include/Common/MapObject.h Include/Common/MapReaderWriterInfo.h Include/Common/MessageStream.h + Include/Common/CrashReporting.h Include/Common/MiniDumper.h Include/Common/MiniLog.h Include/Common/MiscAudio.h @@ -674,6 +675,7 @@ set(GAMEENGINE_SRC # Source/Common/System/List.cpp Source/Common/System/LocalFile.cpp Source/Common/System/LocalFileSystem.cpp + Source/Common/System/CrashReporting.cpp Source/Common/System/MiniDumper.cpp Source/Common/System/ObjectStatusTypes.cpp # Source/Common/System/QuotedPrintable.cpp diff --git a/Core/GameEngine/Include/Common/CrashReporting.h b/Core/GameEngine/Include/Common/CrashReporting.h new file mode 100644 index 00000000000..d40cdb95755 --- /dev/null +++ b/Core/GameEngine/Include/Common/CrashReporting.h @@ -0,0 +1,31 @@ +/* +** 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 + +class AsciiString; + +// Keep this interface compatible with VC6 and tools that only use MiniDumper. +namespace CrashReporting +{ + void initialize(const AsciiString& userDirectory, int major, int minor, int build); + void userDirectoryReady(const AsciiString& userDirectory); + const char* backendName(); + void captureFatal(); + void shutdown(); +} diff --git a/Core/GameEngine/Source/Common/System/CrashReporting.cpp b/Core/GameEngine/Source/Common/System/CrashReporting.cpp new file mode 100644 index 00000000000..377f2e56bfe --- /dev/null +++ b/Core/GameEngine/Source/Common/System/CrashReporting.cpp @@ -0,0 +1,178 @@ +/* +** 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 . +*/ + +// TheSuperHackers @feature Codex 01/10/2026 Select optional local Crashpad reporting with legacy MiniDumper fallback. +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine +#include "Common/CrashReporting.h" +#include "Common/MiniDumper.h" + +#ifdef RTS_USE_CRASHPAD +#include "CrashpadBridge.h" +#include "gitinfo.h" + +namespace +{ + RtsCrashpadCaptureFatalFunction capture; + RtsCrashpadShutdownFunction stop; + bool awaitingUserDirectory; + int savedMajor; + int savedMinor; + int savedBuild; + + bool StartCrashpad(const AsciiString& userDirectory, int major, int minor, int build) + { + wchar_t path[32768]; + const DWORD count = GetModuleFileNameW(nullptr, path, ARRAY_SIZE(path)); + if (!count || count >= ARRAY_SIZE(path)) + { + return false; + } + + wchar_t* leaf = wcsrchr(path, L'\\'); + const wchar_t name[] = L"rts_crashpad.dll"; + if (!leaf || (leaf + 1 - path) + ARRAY_SIZE(name) > ARRAY_SIZE(path)) + { + return false; + } + + memcpy(leaf + 1, name, sizeof(name)); + HMODULE module = LoadLibraryExW(path, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); + if (!module) + { + return false; + } + + RtsCrashpadInitializeFunction start = reinterpret_cast( + GetProcAddress(module, "RtsCrashpadInitialize")); + capture = reinterpret_cast(GetProcAddress(module, "RtsCrashpadCaptureFatal")); + stop = reinterpret_cast(GetProcAddress(module, "RtsCrashpadShutdown")); + char version[64]; + snprintf(version, ARRAY_SIZE(version), "%d.%d.%d", major, minor, build); +#if RTS_ZEROHOUR + const char* game = "Zero Hour"; +#else + const char* game = "Generals"; +#endif + if (start && capture && stop && start(userDirectory.str(), game, version, GitSHA1, GitUncommittedChanges)) + { + return true; + } + + // Crashpad retains process-lifetime pointers even after failed startup. + // Keep the module loaded; its initializer restores the previous filter. + capture = nullptr; + stop = nullptr; + return false; + } +} +#endif + +void CrashReporting::initialize(const AsciiString& userDirectory, int major, int minor, int build) +{ +#ifdef RTS_USE_CRASHPAD + // Generals resolves UserDataLeafName from GameData.ini during engine init. + // Zero Hour already has its registry-derived path at WinMain startup. + awaitingUserDirectory = userDirectory.isEmpty(); + savedMajor = major; + savedMinor = minor; + savedBuild = build; + if (!awaitingUserDirectory && StartCrashpad(userDirectory, major, minor, build)) + { + OutputDebugStringA("Crash reporting: Crashpad (local only)\n"); + DEBUG_LOG(("Crash reporting: Crashpad (local only)\n")); + return; + } + + if (!awaitingUserDirectory) + { + OutputDebugStringA("Crashpad startup failed; trying MiniDumper\n"); + DEBUG_LOG(("Crashpad startup failed; trying MiniDumper\n")); + } +#endif +#ifdef RTS_ENABLE_CRASHDUMP + MiniDumper::initMiniDumper(userDirectory); + DEBUG_LOG(("Crash reporting: %s\n", backendName())); +#endif +} + +void CrashReporting::userDirectoryReady(const AsciiString& userDirectory) +{ +#ifdef RTS_USE_CRASHPAD + // Tools never call initialize(), so this cannot start Crashpad in a tool. + if (awaitingUserDirectory && !userDirectory.isEmpty()) + { + MiniDumper::shutdownMiniDumper(); + initialize(userDirectory, savedMajor, savedMinor, savedBuild); + } +#endif +} + +const char* CrashReporting::backendName() +{ +#ifdef RTS_USE_CRASHPAD + if (capture) + { + return "crashpad"; + } +#endif +#ifdef RTS_ENABLE_CRASHDUMP + if (TheMiniDumper && TheMiniDumper->IsInitialized()) + { + return "minidumper"; + } +#endif + + return "unavailable"; +} + +void CrashReporting::captureFatal() +{ +#ifdef RTS_USE_CRASHPAD + if (capture) + { + capture(); + return; + } +#endif +#ifdef RTS_ENABLE_CRASHDUMP + if (TheMiniDumper && TheMiniDumper->IsInitialized()) + { + // Preserve both minimal and full memory dumps for the legacy backend. + TheMiniDumper->TriggerMiniDump(DumpType_Minimal); + TheMiniDumper->TriggerMiniDump(DumpType_Full); + } + + MiniDumper::shutdownMiniDumper(); +#endif +} + +void CrashReporting::shutdown() +{ +#ifdef RTS_USE_CRASHPAD + awaitingUserDirectory = false; + if (stop) + { + stop(); + stop = nullptr; + capture = nullptr; + } +#endif +#ifdef RTS_ENABLE_CRASHDUMP + MiniDumper::shutdownMiniDumper(); +#endif +} diff --git a/Core/GameEngine/Source/Common/System/Debug.cpp b/Core/GameEngine/Source/Common/System/Debug.cpp index 815ec558ea8..27e212f2c5d 100644 --- a/Core/GameEngine/Source/Common/System/Debug.cpp +++ b/Core/GameEngine/Source/Common/System/Debug.cpp @@ -69,9 +69,7 @@ #if defined(DEBUG_STACKTRACE) || defined(IG_DEBUG_STACKTRACE) #include "Common/StackDump.h" #endif -#ifdef RTS_ENABLE_CRASHDUMP -#include "Common/MiniDumper.h" -#endif +#include "Common/CrashReporting.h" // Horrible reference, but we really, really need to know if we are windowed. extern bool DX8Wrapper_IsWindowed; @@ -732,21 +730,20 @@ double SimpleProfiler::getAverageTime() static void TriggerMiniDump() { -#ifdef RTS_ENABLE_CRASHDUMP - if (TheMiniDumper && TheMiniDumper->IsInitialized()) - { - // Create both minimal and full memory dumps - TheMiniDumper->TriggerMiniDump(DumpType_Minimal); - TheMiniDumper->TriggerMiniDump(DumpType_Full); - } - - MiniDumper::shutdownMiniDumper(); -#endif + CrashReporting::captureFatal(); } void ReleaseCrash(const char *reason) { + TriggerMiniDump(); + + // We are shutting down, and TheGlobalData has been freed. jba. [4/15/2003] + if (TheGlobalData == nullptr) + { + return; + } + /// do additional reporting on the crash, if possible if (!DX8Wrapper_IsWindowed) { @@ -755,15 +752,9 @@ void ReleaseCrash(const char *reason) } } - TriggerMiniDump(); - char prevbuf[ _MAX_PATH ]; char curbuf[ _MAX_PATH ]; - if (TheGlobalData==nullptr) { - return; // We are shutting down, and TheGlobalData has been freed. jba. [4/15/2003] - } - strlcpy(prevbuf, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(prevbuf)); strlcat(prevbuf, RELEASECRASH_FILE_NAME_PREV, ARRAY_SIZE(prevbuf)); strlcpy(curbuf, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(curbuf)); @@ -833,6 +824,13 @@ void ReleaseCrash(const char *reason) void ReleaseCrashLocalized(const AsciiString& p, const AsciiString& m) { + // TheSuperHackers @bugfix Codex 01/10/2026 Match ReleaseCrash during shutdown instead of dereferencing freed global data. + if (TheGlobalData == nullptr) + { + TriggerMiniDump(); + return; + } + if (!TheGameText) { ReleaseCrash(m.str()); // This won't ever return diff --git a/Core/GameEngine/Source/Common/System/MiniDumper.cpp b/Core/GameEngine/Source/Common/System/MiniDumper.cpp index 53a36ebf627..38a1f794a61 100644 --- a/Core/GameEngine/Source/Common/System/MiniDumper.cpp +++ b/Core/GameEngine/Source/Common/System/MiniDumper.cpp @@ -43,7 +43,14 @@ void MiniDumper::initMiniDumper(const AsciiString& userDirPath) // Use placement new on the process heap so TheMiniDumper is placed outside the MemoryPoolFactory managed area. // If the crash is due to corrupted MemoryPoolFactory structures, try to mitigate the chances of MiniDumper memory also being corrupted - TheMiniDumper = new (::HeapAlloc(::GetProcessHeap(), HEAP_GENERATE_EXCEPTIONS, sizeof(MiniDumper))) MiniDumper; + // TheSuperHackers @bugfix Codex 01/10/2026 Reporting initialization must not crash the game when allocation fails. + void* storage = ::HeapAlloc(::GetProcessHeap(), 0, sizeof(MiniDumper)); + if (!storage) + { + return; + } + + TheMiniDumper = new (storage) MiniDumper; TheMiniDumper->Initialize(userDirPath); } diff --git a/Dependencies/Crashpad/CMakeLists.txt b/Dependencies/Crashpad/CMakeLists.txt new file mode 100644 index 00000000000..77cf22030da --- /dev/null +++ b/Dependencies/Crashpad/CMakeLists.txt @@ -0,0 +1,64 @@ +# Acquire separately with this directory's manifest. Disabled builds never run +# vcpkg or look for Crashpad. A copied, pinned installed prefix works without a +# vcpkg toolchain in the game build. +set(RTS_CRASHPAD_PACKAGE_ROOT "" CACHE PATH "Pinned x86-windows-static-md Crashpad installed prefix") +set(RTS_CRASHPAD_REVISION "7e0af1d4d45b526f01677e74a56f4a951b70517d") +set(RTS_CRASHPAD_MINI_CHROMIUM_REVISION "dce72d97d1c2e9beb5e206c6a05a702269794ca3") +set(package "${RTS_CRASHPAD_PACKAGE_ROOT}") +if(NOT EXISTS "${package}/share/crashpad/vcpkg.spdx.json") + message(FATAL_ERROR "Set RTS_CRASHPAD_PACKAGE_ROOT to the x86-windows-static-md prefix built with Dependencies/Crashpad/vcpkg.json. See docs/crashpad.md.") +endif() +file(READ "${package}/share/crashpad/vcpkg.spdx.json" provenance) +foreach(required "crashpad:x86-windows-static-md@2024-04-11#7" + "${RTS_CRASHPAD_REVISION}" "${RTS_CRASHPAD_MINI_CHROMIUM_REVISION}") + string(FIND "${provenance}" "${required}" position) + if(position EQUAL -1) + message(FATAL_ERROR "Crashpad package does not match the pinned x86 package: missing ${required}") + endif() +endforeach() + +# The baseline port's config uses these two variables even outside a vcpkg +# toolchain. Keep the workaround scoped to this dependency directory. +get_filename_component(_VCPKG_INSTALLED_DIR "${package}" DIRECTORY) +get_filename_component(VCPKG_TARGET_TRIPLET "${package}" NAME) +list(PREPEND CMAKE_PREFIX_PATH "${package}") +find_package(crashpad CONFIG REQUIRED PATHS "${package}/share/crashpad" NO_DEFAULT_PATH) +set(handler "${package}/tools/crashpad/crashpad_handler.exe") +if(NOT EXISTS "${handler}") + message(FATAL_ERROR "The pinned package is missing its matching crashpad_handler.exe") +endif() +set(RTS_CRASHPAD_HANDLER "${handler}" PARENT_SCOPE) + +# Deploy the redistributable x86 VC runtime from the selected Visual Studio. +# The Windows 10+ Universal CRT is supplied by the OS. Debug builds still need +# a developer installation of the non-redistributable debug CRT. +set(CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP TRUE) +include(InstallRequiredSystemLibraries) +if(NOT CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS) + message(FATAL_ERROR "The Visual Studio x86 redistributable runtime was not found") +endif() +set(RTS_CRASHPAD_RUNTIME_FILES "${CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS}" PARENT_SCOPE) + +add_library(rts_crashpad SHARED CrashpadBridge.cpp CrashpadBridge.h CrashpadBridge.def) +target_compile_features(rts_crashpad PRIVATE cxx_std_20) +target_link_libraries(rts_crashpad PRIVATE crashpad::crashpad) +set_target_properties(rts_crashpad PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL" + DISABLE_PRECOMPILE_HEADERS ON) +target_compile_options(rts_crashpad PRIVATE /Zi) +target_link_options(rts_crashpad PRIVATE /DEBUG) +target_compile_definitions(rts_crashpad PRIVATE + "RTS_CRASHPAD_COMPILER=\"${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}\"" + "RTS_CRASHPAD_CONFIGURATION=\"$\"" + "RTS_CRASHPAD_REVISION=\"${RTS_CRASHPAD_REVISION}\"") + +set(notices "${CMAKE_CURRENT_BINARY_DIR}/notices") +file(MAKE_DIRECTORY "${notices}") +configure_file("${package}/share/crashpad/copyright" "${notices}/crashpad-LICENSE" COPYONLY) +configure_file("${package}/share/zlib/copyright" "${notices}/zlib-LICENSE" COPYONLY) +configure_file("${package}/share/crashpad/vcpkg.spdx.json" "${notices}/crashpad-package.spdx.json" COPYONLY) +configure_file("${package}/share/zlib/vcpkg.spdx.json" "${notices}/zlib-package.spdx.json" COPYONLY) +configure_file("${package}/share/crashpad/vcpkg_abi_info.txt" "${notices}/crashpad-package-abi.txt" COPYONLY) +configure_file(getopt-LICENSE "${notices}/getopt-LICENSE" COPYONLY) +configure_file(mini-chromium-LICENSE "${notices}/mini-chromium-LICENSE" COPYONLY) +set(RTS_CRASHPAD_NOTICES "${notices}" PARENT_SCOPE) diff --git a/Dependencies/Crashpad/CrashpadBridge.cpp b/Dependencies/Crashpad/CrashpadBridge.cpp new file mode 100644 index 00000000000..fe6620c8d81 --- /dev/null +++ b/Dependencies/Crashpad/CrashpadBridge.cpp @@ -0,0 +1,254 @@ +/* +** 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 . +*/ + +// Deliberately compiled outside the game PCH, forced includes and allocator. +#include +#include +#include +#include +#include + +#include "CrashpadBridge.h" +#include "client/crashpad_client.h" +#include "client/crash_report_database.h" +#include "client/prune_crash_reports.h" +#include "client/settings.h" +#include "util/misc/capture_context.h" + +namespace +{ + std::unique_ptr client; + LPTOP_LEVEL_EXCEPTION_FILTER previousFilter; + void (*previousAbortHandler)(int); + HANDLE stopEvent; + HANDLE captureEvent; + HANDLE completedEvent; + HANDLE watchdogThread; + volatile LONG capturedFatal; + bool attempted; + bool active; + + // DumpWithoutCrash waits indefinitely in the pinned Windows client. Keep + // the request on the faulting thread, and bound fatal capture with a thread + // created while the process is healthy. Do not terminate a reporting thread + // and then let the game reuse memory still referenced by the handler. + DWORD WINAPI Watchdog(void*) + { + const HANDLE events[] = {stopEvent, captureEvent}; + if (WaitForMultipleObjects(2, events, FALSE, INFINITE) == WAIT_OBJECT_0 + 1) + { + if (WaitForSingleObject(completedEvent, 15000) != WAIT_OBJECT_0) + { + TerminateProcess(GetCurrentProcess(), 1); + } + } + + return 0; + } + + LONG WINAPI AlreadyCaptured(EXCEPTION_POINTERS*) + { + // A failure in best-effort fatal diagnostics must not create a second + // report that hides the original explicit fatal capture. + TerminateProcess(GetCurrentProcess(), 1); + return EXCEPTION_EXECUTE_HANDLER; + } + + void StopWatchdog() + { + if (watchdogThread) + { + SetEvent(stopEvent); + WaitForSingleObject(watchdogThread, INFINITE); + CloseHandle(watchdogThread); + watchdogThread = nullptr; + } + + if (stopEvent) + { + CloseHandle(stopEvent); + stopEvent = nullptr; + } + + if (captureEvent) + { + CloseHandle(captureEvent); + captureEvent = nullptr; + } + + if (completedEvent) + { + CloseHandle(completedEvent); + completedEvent = nullptr; + } + } + + bool Initialize(const char* userDirectory, const char* game, + const char* version, const char* revision, int dirty) + { + const int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + userDirectory, -1, nullptr, 0); + if (count <= 1) + { + return false; + } + + std::vector userPath(count); + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, userDirectory, + -1, userPath.data(), count)) + { + return false; + } + + const base::FilePath user(userPath.data()); + if (!user.IsAbsolute()) + { + return false; + } + + std::vector executable(32768); + const DWORD length = GetModuleFileNameW(nullptr, executable.data(), + static_cast(executable.size())); + if (!length || length >= executable.size()) + { + return false; + } + + const base::FilePath handler = base::FilePath(executable.data()).DirName() + .Append(L"crashpad_handler.exe"); + if (GetFileAttributesW(handler.value().c_str()) == INVALID_FILE_ATTRIBUTES) + { + return false; + } + + const base::FilePath dumps = user.Append(L"CrashDumps"); + if (!CreateDirectoryW(dumps.value().c_str(), nullptr) && GetLastError() != ERROR_ALREADY_EXISTS) + { + return false; + } + + const base::FilePath databasePath = dumps.Append(L"Crashpad"); + auto database = crashpad::CrashReportDatabase::Initialize(databasePath); + if (!database || !database->GetSettings()->SetUploadsEnabled(false)) + { + return false; + } + + // Prune through the database so active/locked reports stay protected. + // Disable the handler's different default policy below. The size limit + // applies at startup; reports from concurrent runs can exceed it until + // the next startup. + crashpad::BinaryPruneCondition retention(crashpad::BinaryPruneCondition::OR, + new crashpad::AgePruneCondition(30), + new crashpad::DatabaseSizePruneCondition(500 * 1024)); + crashpad::PruneCrashReportDatabase(database.get(), &retention); + + stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + captureEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + completedEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!stopEvent || !captureEvent || !completedEvent) + { + return false; + } + + watchdogThread = CreateThread(nullptr, 0, Watchdog, nullptr, 0, nullptr); + if (!watchdogThread) + { + return false; + } + + const std::map annotations = { + {"game", game}, {"version", version}, {"revision", revision}, + {"dirty", dirty ? "true" : "false"}, {"architecture", "x86"}, + {"compiler", RTS_CRASHPAD_COMPILER}, {"configuration", RTS_CRASHPAD_CONFIGURATION}, + {"backend", "crashpad"}, {"crashpad_revision", RTS_CRASHPAD_REVISION} + }; + client = std::make_unique(); + // Wait for the IPC ping before claiming readiness, but do not block game + // startup forever if a valid executable never services the pipe. On a + // timeout the DLL must stay loaded for the upstream background thread. + // Windows has no automatic restart here. No URL, metrics or attachments. + return client->StartHandler(handler, databasePath, base::FilePath(), "", + annotations, {"--no-periodic-tasks"}, false, true) + && client->WaitForHandlerStart(5000); + } +} + +extern "C" int __cdecl RtsCrashpadInitialize(const char* userDirectory, + const char* game, const char* version, const char* revision, int dirty) +{ + if (attempted) + { + return active; + } + + attempted = true; + previousFilter = SetUnhandledExceptionFilter(nullptr); + SetUnhandledExceptionFilter(previousFilter); + previousAbortHandler = std::signal(SIGABRT, SIG_DFL); + std::signal(SIGABRT, previousAbortHandler); + try + { + active = Initialize(userDirectory, game, version, revision, dirty); + } + catch (...) + { + active = false; + } + + if (!active) + { + RtsCrashpadShutdown(); + } + + return active; +} + +extern "C" void __cdecl RtsCrashpadCaptureFatal() +{ + if (!active || InterlockedCompareExchange(&capturedFatal, 1, 0) != 0) + { + return; + } + + CONTEXT context; + crashpad::CaptureContext(&context); + if (!SetEvent(captureEvent)) + { + TerminateProcess(GetCurrentProcess(), 1); + } + + crashpad::CrashpadClient::DumpWithoutCrash(context); + SetEvent(completedEvent); + SetUnhandledExceptionFilter(AlreadyCaptured); + // Removes the heap-corruption vectored handler too. The client leaves its + // IPC state alive for process lifetime; never unload this DLL. + client.reset(); + std::signal(SIGABRT, SIG_DFL); +} + +extern "C" void __cdecl RtsCrashpadShutdown() +{ + active = false; + SetUnhandledExceptionFilter(previousFilter); + std::signal(SIGABRT, previousAbortHandler); + client.reset(); + StopWatchdog(); + // The handler observes process exit and exits itself. Crashpad's Windows + // API intentionally retains its IPC handles until then. +} diff --git a/Dependencies/Crashpad/CrashpadBridge.def b/Dependencies/Crashpad/CrashpadBridge.def new file mode 100644 index 00000000000..01786e581ae --- /dev/null +++ b/Dependencies/Crashpad/CrashpadBridge.def @@ -0,0 +1,4 @@ +EXPORTS + RtsCrashpadInitialize + RtsCrashpadCaptureFatal + RtsCrashpadShutdown diff --git a/Dependencies/Crashpad/CrashpadBridge.h b/Dependencies/Crashpad/CrashpadBridge.h new file mode 100644 index 00000000000..88b23577793 --- /dev/null +++ b/Dependencies/Crashpad/CrashpadBridge.h @@ -0,0 +1,31 @@ +/* +** 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 + +// C ABI only. No allocation or C++ object crosses the game/DLL boundary. +// The game loads these exports explicitly so a missing DLL permits fallback. +typedef int (__cdecl* RtsCrashpadInitializeFunction)(const char* userDirectory, + const char* game, const char* version, const char* revision, int dirty); +typedef void (__cdecl* RtsCrashpadCaptureFatalFunction)(); +typedef void (__cdecl* RtsCrashpadShutdownFunction)(); + +extern "C" int __cdecl RtsCrashpadInitialize(const char* userDirectory, + const char* game, const char* version, const char* revision, int dirty); +extern "C" void __cdecl RtsCrashpadCaptureFatal(); +extern "C" void __cdecl RtsCrashpadShutdown(); diff --git a/Dependencies/Crashpad/getopt-LICENSE b/Dependencies/Crashpad/getopt-LICENSE new file mode 100644 index 00000000000..4444b1201e3 --- /dev/null +++ b/Dependencies/Crashpad/getopt-LICENSE @@ -0,0 +1,5 @@ +Copyright (C) 1997 Gregory Pietsch + +[These files] are hereby placed in the public domain without restrictions. Just +give the author credit, don't claim you wrote it or prevent anyone else from +using it. diff --git a/Dependencies/Crashpad/mini-chromium-LICENSE b/Dependencies/Crashpad/mini-chromium-LICENSE new file mode 100644 index 00000000000..179bf344522 --- /dev/null +++ b/Dependencies/Crashpad/mini-chromium-LICENSE @@ -0,0 +1,27 @@ +// Copyright 2006-2008 The Chromium Authors +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Dependencies/Crashpad/vcpkg.json b/Dependencies/Crashpad/vcpkg.json new file mode 100644 index 00000000000..e25fbd62a52 --- /dev/null +++ b/Dependencies/Crashpad/vcpkg.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "builtin-baseline": "b02e341c927f16d991edbd915d8ea43eac52096c", + "dependencies": ["crashpad"] +} diff --git a/Generals/CMakeLists.txt b/Generals/CMakeLists.txt index a226d1d46a5..3b047c099ce 100644 --- a/Generals/CMakeLists.txt +++ b/Generals/CMakeLists.txt @@ -38,6 +38,7 @@ if("${CMAKE_HOST_SYSTEM}" MATCHES "Windows" AND "${CMAKE_SYSTEM}" MATCHES "Windo endif() if(RTS_INSTALL_PREFIX_GENERALS) + rts_install_crashpad(g_generals "${RTS_INSTALL_PREFIX_GENERALS}") install(TARGETS g_generals RUNTIME DESTINATION "${RTS_INSTALL_PREFIX_GENERALS}") install(FILES $ DESTINATION "${RTS_INSTALL_PREFIX_GENERALS}" OPTIONAL) diff --git a/Generals/Code/GameEngine/Source/Common/GameEngine.cpp b/Generals/Code/GameEngine/Source/Common/GameEngine.cpp index 039321096d0..b317e67f120 100644 --- a/Generals/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/Generals/Code/GameEngine/Source/Common/GameEngine.cpp @@ -27,6 +27,7 @@ // Author: Michael S. Booth, April 2001 #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine +#include "Common/CrashReporting.h" #include "Common/ActionManager.h" #include "Common/AudioAffect.h" @@ -402,6 +403,9 @@ void GameEngine::init() // special-case: parse command-line parameters after loading global data CommandLine::parseCommandLineForEngineInit(); + // Generals obtains its user-data path from the GameData INI files above. + CrashReporting::userDirectoryReady(TheGlobalData->getPath_UserData()); + TheArchiveFileSystem->loadMods(); // doesn't require resets so just create a single instance here. diff --git a/Generals/Code/Main/CMakeLists.txt b/Generals/Code/Main/CMakeLists.txt index 8df79b91bb7..3cc5214fcf8 100644 --- a/Generals/Code/Main/CMakeLists.txt +++ b/Generals/Code/Main/CMakeLists.txt @@ -91,3 +91,5 @@ endif() if(MINGW AND COMMAND add_debug_strip_target) add_debug_strip_target(g_generals) endif() + +rts_enable_crashpad(g_generals g_gameengine) diff --git a/Generals/Code/Main/WinMain.cpp b/Generals/Code/Main/WinMain.cpp index a2a4caa2241..c29dc9b89ef 100644 --- a/Generals/Code/Main/WinMain.cpp +++ b/Generals/Code/Main/WinMain.cpp @@ -67,6 +67,7 @@ #ifdef RTS_ENABLE_CRASHDUMP #include "Common/MiniDumper.h" #endif +#include "Common/CrashReporting.h" // GLOBALS //////////////////////////////////////////////////////////////////// @@ -838,12 +839,13 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, gLoadScreenBitmap = (HBITMAP)LoadImage(hInstance, "Install_Final.bmp", IMAGE_BITMAP, 0, 0, LR_SHARED|LR_LOADFROMFILE); #ifdef RTS_ENABLE_CRASHDUMP - // Initialize minidump facilities - requires TheGlobalData so performed after parseCommandLineForStartup - MiniDumper::initMiniDumper(TheGlobalData->getPath_UserData()); + // Begin reporting with TheGlobalData available; the INIs provide the user-data path later. + CrashReporting::initialize(TheGlobalData->getPath_UserData(), VERSION_MAJOR, VERSION_MINOR, VERSION_BUILDNUM); #endif // register windows class and create application window if(!TheGlobalData->m_headless && initializeAppWindows(hInstance, nCmdShow, TheGlobalData->m_windowed) == false) { + CrashReporting::shutdown(); return exitcode; } @@ -881,6 +883,7 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, DEBUG_LOG(("Generals is already running...Bail!")); delete TheVersion; TheVersion = nullptr; + CrashReporting::shutdown(); shutdownMemoryManager(); return exitcode; } @@ -911,7 +914,7 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, } #ifdef RTS_ENABLE_CRASHDUMP - MiniDumper::shutdownMiniDumper(); + CrashReporting::shutdown(); #endif TheAsciiStringCriticalSection = nullptr; TheUnicodeStringCriticalSection = nullptr; diff --git a/GeneralsMD/CMakeLists.txt b/GeneralsMD/CMakeLists.txt index 311a5a0d409..6484aff13be 100644 --- a/GeneralsMD/CMakeLists.txt +++ b/GeneralsMD/CMakeLists.txt @@ -38,6 +38,7 @@ if("${CMAKE_HOST_SYSTEM}" MATCHES "Windows" AND "${CMAKE_SYSTEM}" MATCHES "Windo endif() if(RTS_INSTALL_PREFIX_ZEROHOUR) + rts_install_crashpad(z_generals "${RTS_INSTALL_PREFIX_ZEROHOUR}") install(TARGETS z_generals RUNTIME DESTINATION "${RTS_INSTALL_PREFIX_ZEROHOUR}") install(FILES $ DESTINATION "${RTS_INSTALL_PREFIX_ZEROHOUR}" OPTIONAL) diff --git a/GeneralsMD/Code/Main/CMakeLists.txt b/GeneralsMD/Code/Main/CMakeLists.txt index d1f4dc06683..0ee8660f8e0 100644 --- a/GeneralsMD/Code/Main/CMakeLists.txt +++ b/GeneralsMD/Code/Main/CMakeLists.txt @@ -78,3 +78,5 @@ endif() if(MINGW AND COMMAND add_debug_strip_target) add_debug_strip_target(z_generals) endif() + +rts_enable_crashpad(z_generals z_gameengine) diff --git a/GeneralsMD/Code/Main/WinMain.cpp b/GeneralsMD/Code/Main/WinMain.cpp index d5040bc1dd7..e2be5260924 100644 --- a/GeneralsMD/Code/Main/WinMain.cpp +++ b/GeneralsMD/Code/Main/WinMain.cpp @@ -69,6 +69,7 @@ #ifdef RTS_ENABLE_CRASHDUMP #include "Common/MiniDumper.h" #endif +#include "Common/CrashReporting.h" // GLOBALS //////////////////////////////////////////////////////////////////// @@ -865,13 +866,14 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, #endif #ifdef RTS_ENABLE_CRASHDUMP - // Initialize minidump facilities - requires TheGlobalData so performed after parseCommandLineForStartup - MiniDumper::initMiniDumper(TheGlobalData->getPath_UserData()); + // Initialize reporting after parseCommandLineForStartup provides the user-data path. + CrashReporting::initialize(TheGlobalData->getPath_UserData(), VERSION_MAJOR, VERSION_MINOR, VERSION_BUILDNUM); #endif // register windows class and create application window if(!TheGlobalData->m_headless && initializeAppWindows(hInstance, nCmdShow, TheGlobalData->m_windowed) == false) { + CrashReporting::shutdown(); return exitcode; } @@ -909,6 +911,7 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, DEBUG_LOG(("Generals is already running...Bail!")); delete TheVersion; TheVersion = nullptr; + CrashReporting::shutdown(); shutdownMemoryManager(); return exitcode; } @@ -940,7 +943,7 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, } #ifdef RTS_ENABLE_CRASHDUMP - MiniDumper::shutdownMiniDumper(); + CrashReporting::shutdown(); #endif TheUnicodeStringCriticalSection = nullptr; TheDmaCriticalSection = nullptr; diff --git a/cmake/crashpad.cmake b/cmake/crashpad.cmake new file mode 100644 index 00000000000..d0f42eea25b --- /dev/null +++ b/cmake/crashpad.cmake @@ -0,0 +1,49 @@ +option(RTS_BUILD_OPTION_CRASHPAD "Use optional local-only Crashpad reporting in the games" OFF) +add_feature_info(Crashpad RTS_BUILD_OPTION_CRASHPAD "Local-only out-of-process game crash reports") + +if(RTS_BUILD_OPTION_CRASHPAD) + if(NOT RTS_CRASHDUMP_ENABLE) + message(FATAL_ERROR "RTS_BUILD_OPTION_CRASHPAD requires RTS_CRASHDUMP_ENABLE=ON") + endif() + if(NOT WIN32 OR NOT MSVC OR IS_VS6_BUILD OR NOT CMAKE_SIZEOF_VOID_P EQUAL 4 + OR MSVC_VERSION LESS 1920) + message(FATAL_ERROR "Crashpad requires modern MSVC or ClangCL targeting Win32 (x86)") + endif() + if(RTS_BUILD_OPTION_ASAN OR RTS_BUILD_CORE_FUZZ) + message(FATAL_ERROR "Keep Crashpad disabled in sanitizer and fuzzing builds") + endif() + add_subdirectory(Dependencies/Crashpad) +endif() + +function(rts_enable_crashpad game engine) + if(NOT RTS_BUILD_OPTION_CRASHPAD) + return() + endif() + # Tools may link the same engine, but never initialize this optional backend. + # Neither the engine nor tools link the Crashpad client or DLL import library. + target_compile_definitions(${engine} PRIVATE RTS_USE_CRASHPAD=1) + target_include_directories(${engine} PRIVATE "${CMAKE_SOURCE_DIR}/Dependencies/Crashpad") + # Refresh runtime files before linking, even when only the DLL changed. + add_custom_target(${game}_crashpad_runtime + COMMAND ${CMAKE_COMMAND} -E make_directory "$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "$" + "${RTS_CRASHPAD_HANDLER}" + ${RTS_CRASHPAD_RUNTIME_FILES} "$" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${RTS_CRASHPAD_NOTICES}" "$/crashpad-notices" + DEPENDS rts_crashpad + VERBATIM) + add_dependencies(${game} ${game}_crashpad_runtime) +endfunction() + +function(rts_install_crashpad game destination) + if(RTS_BUILD_OPTION_CRASHPAD) + install(FILES "$" "$" + "${RTS_CRASHPAD_HANDLER}" + DESTINATION "${destination}") + install(DIRECTORY "${RTS_CRASHPAD_NOTICES}/" DESTINATION "${destination}/crashpad-notices") + install(FILES ${RTS_CRASHPAD_RUNTIME_FILES} DESTINATION "${destination}" + CONFIGURATIONS Release RelWithDebInfo MinSizeRel) + endif() +endfunction() diff --git a/docs/crashpad.md b/docs/crashpad.md new file mode 100644 index 00000000000..85a1c1c5868 --- /dev/null +++ b/docs/crashpad.md @@ -0,0 +1,83 @@ +# Optional local Crashpad reports + +`RTS_BUILD_OPTION_CRASHPAD` defaults to `OFF`. It enables local crash reporting +through a separate handler process for modern MSVC and ClangCL Win32 game builds. +WorldBuilder and other tools keep their existing reporting behavior. Disabled +builds do not acquire or link Crashpad. Unsupported configurations, including +VC6, non-Windows, x64, sanitizer/fuzzing builds and `RTS_CRASHDUMP_ENABLE=OFF`, +reject an explicit request to enable it. + +The optional backend expects Windows 10 or newer for the Universal CRT. It does +not change the OS requirements of default builds. A failure to load or start +Crashpad falls back to MiniDumper; the game can start if neither is available. + +## Building + +Use the separate pinned dependency manifest so ordinary builds keep their +existing dependencies. From the repository root in Git Bash, with `VCPKG_ROOT` +pointing to vcpkg: + +```bash +"$VCPKG_ROOT/vcpkg.exe" install \ + --x-manifest-root=Dependencies/Crashpad \ + --x-install-root=build/crashpad-package \ + --triplet=x86-windows-static-md --host-triplet=x86-windows-static-md + +cmake -S . -B build/crashpad -G "Visual Studio 18 2026" -A Win32 -T ClangCL \ + -DRTS_BUILD_OPTION_CRASHPAD=ON \ + -DRTS_CRASHPAD_PACKAGE_ROOT="$PWD/build/crashpad-package/x86-windows-static-md" \ + -DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded +cmake --build build/crashpad --config Release --target g_generals z_generals --parallel 4 +``` + +Omit `-T ClangCL` to build the games and adapter with MSVC. The dependency uses +vcpkg's MSVC/GN build. Both triplets must be native x86 for the pinned port's +Python helper. Configure checks the package's SPDX record against the pinned +port version, architecture and source revisions. Use the handler from that same +package. Debug builds need the Visual Studio debug CRT. + +## Capture and fallback + +The game loads `rts_crashpad.dll` by absolute path beside its executable. The DLL +isolates Crashpad's C++ requirements and CRT allocations from the game's memory +allocator. Zero Hour initializes it in WinMain. Generals uses MiniDumper until +its user-data path is available after GameData loading and startup options. + +Startup waits up to five seconds for handler readiness. Failure restores the +previous exception handlers and selects MiniDumper. A handler that dies later +is not restarted and does not trigger automatic fallback. The DLL stays loaded +because upstream retains process-lifetime IPC pointers; the handler exits when +the game process exits. + +Crashpad handles unhandled exceptions and both explicit fatal-error paths. +Explicit fatal errors capture once, before legacy diagnostics. A watchdog +terminates the game if that capture stalls for 15 seconds, provided the process +remains schedulable. Headless mode adds no dialogs. Crashpad reports omit the +legacy unhandled-exception text and do not provide the legacy full-memory dump. + +## Reports and deployment + +Reports stay in `/CrashDumps/Crashpad/`. Uploads are disabled, with no +upload URL or attachments. Each report is a binary minidump with build annotations +including the game version, Git revision, dirty state, compiler and configuration. +Crashpad does not generate a readable diagnosis of the crash. + +At startup, the database API prunes reports older than 30 days and older reports +beyond a 500 MiB report-size budget. Active reports remain under Crashpad's +control. This is not a hard disk quota; new reports can exceed it until the next +startup. The handler's default pruning policy is disabled. + +After the game exits, copy the desired `.dmp` from the database's `reports` +directory to share it. Keep the original database files in place. Dumps contain +process memory; sharing is a separate user action. + +Enabled game outputs and CMake installs include the matching handler, adapter +DLL/PDB, redistributable VC runtime DLLs and dependency notices. Set +`RTS_INSTALL_PREFIX_GENERALS` and `RTS_INSTALL_PREFIX_ZEROHOUR` to explicit staging +paths when testing installation. Runtime paths do not depend on the build tree. + +Keep the exact game executable/PDB, adapter DLL/PDB and handler for each +distributed build, together with its source revision. Preserve the source +snapshot for dirty builds. Open the dump as a crash dump in WinDbg or Visual +Studio and load those matching PDBs to inspect the exception and call stack. +CI artifact retention remains 30 days; preserve release symbols separately.