diff --git a/Engine/Core/Debug/Test/crash_report_tests.cpp b/Engine/Core/Debug/Test/crash_report_tests.cpp index 97b31ed..0de33ec 100644 --- a/Engine/Core/Debug/Test/crash_report_tests.cpp +++ b/Engine/Core/Debug/Test/crash_report_tests.cpp @@ -8,6 +8,7 @@ #include "Engine/Core/Debug/trace.h" #include "crash_report_config.h" #include "Engine/Core/Log/logging.h" +#include #include #include #include @@ -30,6 +31,19 @@ LONG WINAPI native_child_handler(EXCEPTION_POINTERS* pointers) { CopyFileW(path.c_str(), (native_fixture_directory / L"duplicate.dmp").c_str(), TRUE); ExitProcess(42); } +// A heap block only the faulting stack points at: present in a dump only with the memory the +// stacks reference. Made at run time, so its bytes are nowhere else in the process. +// (Filled in place: a copy left on a stack would be in every dump.) +__declspec(noinline) void fill_marker(std::array& bytes) { + for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast((i * 37 + 11) ^ 0xa5); +} +// The game's own fatal handler: ReSkate's import hook asks for its dump first, then the game +// writes its own (game_fatal_dump.cpp). +LONG WINAPI fatal_child_handler(EXCEPTION_POINTERS* pointers) { + capture_game_fatal(GetCurrentThreadId(), pointers); + return native_child_handler(pointers); +} +enum class Mode { direct, native, fatal }; std::string read(const std::filesystem::path& path) { std::ifstream file(path, std::ios::binary); return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; @@ -190,7 +204,8 @@ void queue_checks(const std::filesystem::path& root) { upload_pending(L"test", queue, failure); check(!std::filesystem::exists(expired) && !std::filesystem::exists(log_attachment_path(expired)), "Expired report or attachment retained"); } -void crash_check(const std::filesystem::path& root, bool native) { +void crash_check(const std::filesystem::path& root, Mode mode) { + const bool native = mode != Mode::direct; std::filesystem::create_directories(root); std::array executable{}; GetModuleFileNameW(nullptr, executable.data(), static_cast(executable.size())); @@ -200,7 +215,8 @@ void crash_check(const std::filesystem::path& root, bool native) { std::filesystem::copy_file(executable.data(), child_path); std::filesystem::copy_file(std::filesystem::path(executable.data()).parent_path() / L"ReSkateLauncher.exe", root / L"ReSkateLauncher.exe"); } - auto command = std::format(L"\"{}\" {} \"{}\"", child_path.wstring(), native ? L"--native-crash" : L"--crash", root.wstring()); + auto command = std::format(L"\"{}\" {} \"{}\"", child_path.wstring(), + mode == Mode::fatal ? L"--fatal-crash" : native ? L"--native-crash" : L"--crash", root.wstring()); STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; check(CreateProcessW(child_path.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process) != FALSE, "Cannot start crash fixture"); @@ -226,7 +242,13 @@ void crash_check(const std::filesystem::path& root, bool native) { check(MiniDumpReadDumpStream(bytes.data(), ModuleListStream, &stream, &data, &size) != FALSE, "Minidump module list missing"); check(Json::parse(read(dump.wstring() + L".json")).at("application").string() == "ReSkate", "Crash attributes missing"); check(Json::parse(read(dump.wstring() + L".json")).at("capture.source").string() == - (native ? "game-native" : "unhandled-exception"), "Wrong capture source"); + (mode == Mode::fatal ? "game-fatal-handler" : native ? "game-native" : "unhandled-exception"), "Wrong capture source"); + if (mode != Mode::native) { + std::array marker{}; + fill_marker(marker); + check(bytes.find(std::string_view(marker.data(), marker.size())) != std::string::npos, + "Dump lacks the heap memory the faulting stack points at"); + } check(Json::parse(read(dump.wstring() + L".json")).at("attachment.log").string() == "complete" && read(log_attachment_path(dump)) == log, "Crash did not snapshot its session log"); const auto tags = Json::parse(read(dump.wstring() + L".json")); @@ -234,7 +256,7 @@ void crash_check(const std::filesystem::path& root, bool native) { tags.at("game.destination").string() == "Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint" && tags.at("gpu.name").string() == "Fixture GPU" && tags.at("gpu.driver").string() == "32.0.21025.1", "Crash was not tagged with its level and graphics card"); - if (native) { + if (mode == Mode::native) { const auto source = root / L"local" / L"ReSkate" / L"Game" / L"Skate" / L"CrashDumps"; const auto pid = GetProcessId(child.value), created = process_creation_seconds(child.value); const auto selected = find_native_dump(source, pid, created); @@ -289,8 +311,10 @@ int wmain(int argc, wchar_t** argv) { check(result.accepted, "Sentry did not confirm test report"); return 0; } - if (argc == 3 && (std::wstring_view(argv[1]) == L"--crash" || std::wstring_view(argv[1]) == L"--native-crash")) { - const bool native = std::wstring_view(argv[1]) == L"--native-crash"; + if (argc == 3 && (std::wstring_view(argv[1]) == L"--crash" || std::wstring_view(argv[1]) == L"--native-crash" || + std::wstring_view(argv[1]) == L"--fatal-crash")) { + const bool fatal = std::wstring_view(argv[1]) == L"--fatal-crash"; + const bool native = fatal || std::wstring_view(argv[1]) == L"--native-crash"; if (native) { const auto local = std::filesystem::path(argv[2]) / L"local"; SetEnvironmentVariableW(L"LOCALAPPDATA", local.c_str()); @@ -302,10 +326,13 @@ int wmain(int argc, wchar_t** argv) { SetUnhandledExceptionFilter(finish_child); logging::Options options; options.directory = argv[2]; options.truncate_file = true; check(logging::initialize(options), "Fixture logging failed"); - if (native) SetUnhandledExceptionFilter(native_child_handler); // Reproduce the game's later handler registration. + if (native) SetUnhandledExceptionFilter(fatal ? fatal_child_handler : native_child_handler); // Reproduce the game's later handler registration. set_destination(L"Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint"); set_gpu(L"Fixture GPU", L"32.0.21025.1"); - RaiseException(test_exception, EXCEPTION_NONCONTINUABLE, 0, nullptr); + auto* const volatile marker = new std::array; + fill_marker(*marker); + const ULONG_PTR arguments[]{reinterpret_cast(marker)}; + RaiseException(test_exception, EXCEPTION_NONCONTINUABLE, 1, arguments); return 3; } check(argc == 2, "Pass a fixture root"); @@ -320,8 +347,9 @@ int wmain(int argc, wchar_t** argv) { endpoint_checks(); attachment_checks(root); queue_checks(root); - crash_check(root / L"direct", false); - crash_check(root / L"native", true); - std::cout << "Timing envelopes, endpoint, receipts, multipart attachments, log snapshots, retries, retention, opt-out, exception chaining, direct capture and native-handler fallback checks passed.\n"; + crash_check(root / L"direct", Mode::direct); + crash_check(root / L"native", Mode::native); + crash_check(root / L"fatal", Mode::fatal); + std::cout << "Timing envelopes, endpoint, receipts, multipart attachments, log snapshots, retries, retention, opt-out, exception chaining, direct capture, native-handler fallback and game fatal-handler capture checks passed.\n"; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } } diff --git a/Engine/Core/Debug/client.cpp b/Engine/Core/Debug/client.cpp index 8f368fd..5f2d0c8 100644 --- a/Engine/Core/Debug/client.cpp +++ b/Engine/Core/Debug/client.cpp @@ -104,16 +104,25 @@ StartResult start(const std::filesystem::path& log_directory) noexcept { return StartResult::ready; } catch (...) { return StartResult::failed; } } -void capture(EXCEPTION_POINTERS* exception) noexcept { - if (!exception || InterlockedCompareExchange(&capturing, 1, 0) != 0) return; - report->thread_id = GetCurrentThreadId(); +namespace { +void capture_on(DWORD thread_id, EXCEPTION_POINTERS* exception, CaptureSource source) noexcept { + if (!exception || !thread_id || InterlockedCompareExchange(&capturing, 1, 0) != 0) return; + report->thread_id = thread_id; report->exception = exception; + report->source = source; MemoryBarrier(); if (SetEvent(handles[requested])) { const HANDLE wait_for[]{handles[captured], helper}; WaitForMultipleObjects(2, wait_for, FALSE, capture_timeout_ms); } } +} +void capture(EXCEPTION_POINTERS* exception) noexcept { + capture_on(GetCurrentThreadId(), exception, CaptureSource::unhandled_exception); +} +void capture_game_fatal(DWORD thread_id, EXCEPTION_POINTERS* exception) noexcept { + capture_on(thread_id, exception, CaptureSource::game_fatal_handler); +} void set_destination(std::wstring_view destination) noexcept { if (auto* const shared = report) copy_cut(shared->destination, destination); } diff --git a/Engine/Core/Debug/crash_report.h b/Engine/Core/Debug/crash_report.h index a5828dd..231f4ca 100644 --- a/Engine/Core/Debug/crash_report.h +++ b/Engine/Core/Debug/crash_report.h @@ -10,6 +10,9 @@ StartResult start(const std::filesystem::path& log_directory) noexcept; void stop() noexcept; // Last-chance path: only signals the prestarted helper and waits at most 15 seconds. void capture(EXCEPTION_POINTERS* exception) noexcept; +// The same, from the game's own fatal handler just before it writes its dump: thread_id is the +// thread that faulted, which need not be the calling one. The first capture of either kind wins. +void capture_game_fatal(DWORD thread_id, EXCEPTION_POINTERS* exception) noexcept; // The level being loaded ("root + sublevel @ start point") and the graphics card the game renders // on, for a report's tags: game.map, game.destination, gpu.name and gpu.driver. Nothing while // reporting is off; too long a value is cut short. diff --git a/Engine/Core/Debug/protocol.h b/Engine/Core/Debug/protocol.h index fb2d49a..902d2a2 100644 --- a/Engine/Core/Debug/protocol.h +++ b/Engine/Core/Debug/protocol.h @@ -4,7 +4,10 @@ #include namespace dingosdk::crash_report { -inline constexpr DWORD protocol_version = 3; +inline constexpr DWORD protocol_version = 4; +// Who asked for the dump: ReSkate's last-chance filter, or the game's own fatal handler about to +// write its dump (which it does without ever reaching that filter). +enum class CaptureSource : DWORD { unhandled_exception, game_fatal_handler }; inline constexpr DWORD capture_timeout_ms = 15000; struct SharedReport { DWORD version{protocol_version}; @@ -20,6 +23,7 @@ struct SharedReport { std::array destination{}; std::array gpu_name{}; std::array gpu_driver{}; + CaptureSource source{}; }; // Explicitly inherited unnamed handles; no token or paths on the command line. enum HandleIndex : std::size_t { mapping, requested, captured, stopping, ready, parent, handle_count }; diff --git a/Engine/Game/Build/20260929/game_fatal_dump.h b/Engine/Game/Build/20260929/game_fatal_dump.h new file mode 100644 index 0000000..ffcd989 --- /dev/null +++ b/Engine/Game/Build/20260929/game_fatal_dump.h @@ -0,0 +1,15 @@ +// Reviewed native contract for supported_build::game_sha256 (September 29 2026). +// The game's fatal handlers catch most crashes themselves (ReSkate's last-chance filter never +// sees them) and write their own minidump, of thread stacks only, in-process: three callers +// (0x18e3ea1 "Frostbite MiniDump", 0x2d28c9d, 0x2d2bfee) through one import thunk. +#pragma once +#include +#include + +namespace dingosdk::game::build::v20260929::game_fatal_dump { +// MiniDumpWriteDump's import slot (api-ms-win-core-debug-minidump-l1-1-0.dll). +inline constexpr std::uintptr_t import_slot = 0x05feb2e0; +// The thunk every caller goes through: jmp qword ptr [import_slot]. +inline constexpr std::uintptr_t thunk = 0x0546dbc6; +inline constexpr std::array thunk_bytes{0xff, 0x25, 0x14, 0xd7, 0xb7, 0x00}; +} // namespace dingosdk::game::build::v20260929::game_fatal_dump diff --git a/Extension/Boot/game_fatal_dump.cpp b/Extension/Boot/game_fatal_dump.cpp new file mode 100644 index 0000000..bcf487f --- /dev/null +++ b/Extension/Boot/game_fatal_dump.cpp @@ -0,0 +1,71 @@ +#include "game_fatal_dump.h" + +#include "Engine/Core/Debug/crash_report.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/game_fatal_dump.h" + +#include +#include +#include +#include +#include + +namespace dingosdk { +namespace { +namespace contract = addr::game_fatal_dump; +using WriteDump = BOOL(WINAPI*)(HANDLE, DWORD, HANDLE, MINIDUMP_TYPE, PMINIDUMP_EXCEPTION_INFORMATION, + PMINIDUMP_USER_STREAM_INFORMATION, PMINIDUMP_CALLBACK_INFORMATION); +std::atomic original_write{}; +std::uintptr_t installed_base{}; +std::mutex install_mutex; + +// Only a dump of this process with an exception to show is a crash; the game's dump follows +// whether or not the reporter took one. (Its pointers are this process's either way.) +BOOL WINAPI write_dump(HANDLE process, DWORD process_id, HANDLE file, MINIDUMP_TYPE type, + PMINIDUMP_EXCEPTION_INFORMATION exception, PMINIDUMP_USER_STREAM_INFORMATION streams, + PMINIDUMP_CALLBACK_INFORMATION callback) { + if (exception && exception->ExceptionPointers && process_id == GetCurrentProcessId()) + crash_report::capture_game_fatal(exception->ThreadId, exception->ExceptionPointers); + return original_write.load(std::memory_order_acquire)(process, process_id, file, type, exception, streams, callback); +} + +// The slot holds the export it was bound to: MiniDumpWriteDump of the module that address is in. +bool bound_to_minidump(std::uintptr_t value) noexcept { + HMODULE module{}; + if (!value || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(value), &module)) return false; + return reinterpret_cast(GetProcAddress(module, "MiniDumpWriteDump")) == value; +} +} // namespace + +bool start_game_fatal_dump(std::uintptr_t base, std::string& error) { + std::lock_guard lock(install_mutex); + error.clear(); + if (installed_base) { + if (installed_base == base) return true; + error = "already installed on another image"; + return false; + } + std::array thunk{}; + std::uintptr_t bound{}; + if (!memory::read_bytes(base + contract::thunk, thunk.data(), thunk.size()) || + !std::equal(thunk.begin(), thunk.end(), contract::thunk_bytes.begin()) || + !memory::read(base + contract::import_slot, bound) || !bound_to_minidump(bound)) { + error = "the game's MiniDumpWriteDump import does not match the supported game"; + return false; + } + original_write.store(reinterpret_cast(bound), std::memory_order_release); + auto* const slot = reinterpret_cast(base + contract::import_slot); + DWORD previous{}; + if (!VirtualProtect(slot, sizeof(void*), PAGE_READWRITE, &previous)) { + error = "its import slot could not be made writable"; + return false; + } + InterlockedExchangePointer(slot, reinterpret_cast(&write_dump)); + DWORD ignored{}; + VirtualProtect(slot, sizeof(void*), previous, &ignored); + installed_base = base; + return true; +} +} // namespace dingosdk diff --git a/Extension/Boot/game_fatal_dump.h b/Extension/Boot/game_fatal_dump.h new file mode 100644 index 0000000..e359f07 --- /dev/null +++ b/Extension/Boot/game_fatal_dump.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace dingosdk { +// When one of the game's fatal handlers is about to write its minidump, the crash reporter first +// takes its own of the still-running game (crash_report::capture_game_fatal), with the memory the +// stacks point at. Points the game's MiniDumpWriteDump import at ReSkate; the game's dump is +// still written after. Install after crash reporting has started, before the game runs. +bool start_game_fatal_dump(std::uintptr_t base, std::string& error); +} // namespace dingosdk diff --git a/Launcher/crash_reporter.cpp b/Launcher/crash_reporter.cpp index 59eff6d..27c9375 100644 --- a/Launcher/crash_reporter.cpp +++ b/Launcher/crash_reporter.cpp @@ -75,12 +75,16 @@ bool save_dump(HANDLE process, const SharedReport& report, const std::filesystem Handle file(CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)); if (file.value == INVALID_HANDLE_VALUE) return false; MINIDUMP_EXCEPTION_INFORMATION exception{report.thread_id, report.exception, TRUE}; - constexpr auto flags = static_cast(MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules); + // With the memory the stacks point at, so a crash on a freed or overwritten object shows that + // object and the list it was found in, not just the registers (a few MB more a dump). + constexpr auto flags = static_cast(MiniDumpNormal | MiniDumpWithThreadInfo | + MiniDumpWithUnloadedModules | MiniDumpWithIndirectlyReferencedMemory); const auto written = MiniDumpWriteDump(process, GetProcessId(process), file.value, flags, &exception, nullptr, nullptr); const auto durable = written && FlushFileBuffers(file.value); CloseHandle(file.release()); if (!durable) { DeleteFileW(temporary.c_str()); return false; } - return publish_dump(temporary, report, "unhandled-exception"); + return publish_dump(temporary, report, + report.source == CaptureSource::game_fatal_handler ? "game-fatal-handler" : "unhandled-exception"); } bool collect_native_dump(HANDLE process, const SharedReport& report, const std::filesystem::path& directory) { const auto native = find_native_dump(report.native_directory.data(), GetProcessId(process), process_creation_seconds(process)); diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index 20b3a99..76ec466 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -9,6 +9,7 @@ #include "Extension/Boot/ea_app_block.h" #include "Extension/Boot/ea_service_block.h" #include "Extension/Boot/descriptor_heap_size.h" +#include "Extension/Boot/game_fatal_dump.h" #include "Extension/Boot/eadp_errors_arena.h" #include "Extension/Assets/native_patch_support.h" #include "Extension/Assets/mod_layers.h" @@ -48,6 +49,9 @@ bool initialize_bootstrap(std::uintptr_t base) { // The memory ledger, when RESKATE_MEMORY_TRACE asks for it: here, while the game is still // suspended and before the pools below are enlarged, so it sees who makes all of them. console::start_memory_trace_from_environment(); + // (Not needed for the game to run: a mismatch leaves crash reports at what the game writes.) + error.clear(); ready = start_game_fatal_dump(base, error); + (void)stage(Channel::runtime, "Crash dumps from the game's own fatal handlers", ready, error); error.clear(); ready = start_native_patch_support(base, error); if (!stage(Channel::assets, "Native InitFS/package support", ready, error)) return false; error.clear(); ready = start_mod_layers(base, error); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 27a069f..0417485 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -231,7 +231,8 @@ add_library(dingosdk_runtime SHARED Extension/Boot/ea_app_block.cpp Extension/Boot/ea_service_block.cpp Extension/Boot/eadp_errors_arena.cpp - Extension/Boot/descriptor_heap_size.cpp) + Extension/Boot/descriptor_heap_size.cpp + Extension/Boot/game_fatal_dump.cpp) target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_mod_list dingosdk_launcher_support dingosdk_miniz shell32 dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt