Crash reports: the game's own fatal crashes come with the memory their stacks point at

Most crashes are caught by the game's own fatal handlers, which write a
dump of thread stacks only; the launcher could only copy it after the
game had exited. A crash on a freed or overwritten object then showed
the faulting registers but not the object or the list it came from.

- game_fatal_dump: the game's MiniDumpWriteDump import points at
  ReSkate. Before the game writes its dump, the crash reporter takes its
  own of the still-running game for the faulting thread; the game's dump
  follows as before. Reports say capture.source "game-fatal-handler".
- The reporter's dumps include the memory the stacks point at
  (MiniDumpWithIndirectlyReferencedMemory), a few MB more a report.
- The shared report says who asked for the dump (protocol version 4).
- Tests: a game-handler crash is captured with a heap block only its
  stack points at; ReSkate's own capture keeps it too.
This commit is contained in:
AyeZeeBB committed 2026-10-15 20:01:55 -05:00
1 parent ea3f081367
commit 3249f747dc
10 files changed
+169 -18

No files matched your search

+39 -11
View File
@@ -8,6 +8,7 @@
#include "Engine/Core/Debug/trace.h"
#include "crash_report_config.h"
#include "Engine/Core/Log/logging.h"
#include <array>
#include <fstream>
#include <format>
#include <iostream>
@@ -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<char, 64>& bytes) {
for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<char>((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<char>(file), std::istreambuf_iterator<char>()};
@@ -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<wchar_t, 32768> executable{};
GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(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<char, 64> 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<char, 64>;
fill_marker(*marker);
const ULONG_PTR arguments[]{reinterpret_cast<ULONG_PTR>(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; }
}
+12 -3
View File
@@ -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);
}
+3
View File
@@ -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.
+5 -1
View File
@@ -4,7 +4,10 @@
#include <string>
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<wchar_t, 512> destination{};
std::array<wchar_t, 128> gpu_name{};
std::array<wchar_t, 32> 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 };
@@ -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 <array>
#include <cstdint>
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<std::uint8_t, 6> thunk_bytes{0xff, 0x25, 0x14, 0xd7, 0xb7, 0x00};
} // namespace dingosdk::game::build::v20260929::game_fatal_dump
+71
View File
@@ -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 <Windows.h>
#include <DbgHelp.h>
#include <algorithm>
#include <atomic>
#include <mutex>
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<WriteDump> 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<LPCWSTR>(value), &module)) return false;
return reinterpret_cast<std::uintptr_t>(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<std::uint8_t, contract::thunk_bytes.size()> 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<WriteDump>(bound), std::memory_order_release);
auto* const slot = reinterpret_cast<void**>(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<void*>(&write_dump));
DWORD ignored{};
VirtualProtect(slot, sizeof(void*), previous, &ignored);
installed_base = base;
return true;
}
} // namespace dingosdk
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <cstdint>
#include <string>
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
+6 -2
View File
@@ -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<MINIDUMP_TYPE>(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<MINIDUMP_TYPE>(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));
+4
View File
@@ -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);
+2 -1
View File
@@ -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