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