mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 11:57:01 +02:00
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:
1 parent
ea3f081367
commit
3249f747dc
10 files changed
+169
-18
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user