// Crash report: on an unhandled exception, writes sacred_re_crash.txt (code, // address as module + offset, registers, return addresses found on the stack) // and sacred_re_crash.dmp next to the DLL. The game may install its own // filter later; SetUnhandledExceptionFilter is hooked so ours stays first and // passes the crash on to the game's afterwards. // // Hangs: a watchdog thread writes sacred_re_hang.dmp (all threads) when the // engine tick (re::EngineHeartbeat) has stopped for kHangMs while the game // window is in front; once per hang. The game freezing on F2 while walking // (2026-10-18) left no report. #include "re/crash.h" #include #include #include #include #include "re/hook.h" #include "re/log.h" namespace re { namespace { std::wstring g_dir; LPTOP_LEVEL_EXCEPTION_FILTER g_gameFilter = nullptr; volatile LONG g_reported = 0; // "module+offset" for a code address. void DescribeAddress(uintptr_t address, char* out, size_t size) { HMODULE module = nullptr; char path[MAX_PATH] = "?"; if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) { GetModuleFileNameA(module, path, MAX_PATH); const char* name = strrchr(path, '\\'); snprintf(out, size, "%08x %s+%x", static_cast(address), name ? name + 1 : path, static_cast(address - reinterpret_cast(module))); } else { snprintf(out, size, "%08x", static_cast(address)); } } bool IsCode(uintptr_t address) { MEMORY_BASIC_INFORMATION info; if (!VirtualQuery(reinterpret_cast(address), &info, sizeof(info)) || info.State != MEM_COMMIT) { return false; } return (info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE)) != 0; } void WriteReport(EXCEPTION_POINTERS* info) { FILE* file = _wfopen((g_dir + L"sacred_re_crash.txt").c_str(), L"w"); if (!file) { return; } const EXCEPTION_RECORD& record = *info->ExceptionRecord; const CONTEXT& c = *info->ContextRecord; char where[MAX_PATH + 32]; DescribeAddress(reinterpret_cast(record.ExceptionAddress), where, sizeof(where)); fprintf(file, "exception %08lx at %s, thread %lu\n", record.ExceptionCode, where, GetCurrentThreadId()); if (record.ExceptionCode == EXCEPTION_ACCESS_VIOLATION && record.NumberParameters >= 2) { fprintf(file, "%s address %08x\n", record.ExceptionInformation[0] ? "writing" : "reading", static_cast(record.ExceptionInformation[1])); } fprintf(file, "eax %08lx ebx %08lx ecx %08lx edx %08lx esi %08lx edi %08lx ebp %08lx esp %08lx\n", c.Eax, c.Ebx, c.Ecx, c.Edx, c.Esi, c.Edi, c.Ebp, c.Esp); // Frame pointer chain (our debug build and most of sacred.exe keep ebp). fprintf(file, "frames (ebp chain):\n"); uintptr_t frame = c.Ebp; for (int i = 0; i < 64 && frame; ++i) { MEMORY_BASIC_INFORMATION mem; if (!VirtualQuery(reinterpret_cast(frame), &mem, sizeof(mem)) || mem.State != MEM_COMMIT) { break; } const auto* slots = reinterpret_cast(frame); DescribeAddress(slots[1], where, sizeof(where)); fprintf(file, " %s\n", where); if (slots[0] <= frame) { break; } frame = slots[0]; } fprintf(file, "code addresses on the stack (innermost first; may include data):\n"); const auto* stack = reinterpret_cast(c.Esp); int found = 0; for (int i = 0; i < 4096 && found < 48; ++i) { MEMORY_BASIC_INFORMATION mem; if (!VirtualQuery(stack + i, &mem, sizeof(mem)) || mem.State != MEM_COMMIT) { break; } const uintptr_t value = stack[i]; if (IsCode(value)) { DescribeAddress(value, where, sizeof(where)); fprintf(file, " [esp+%04x] %s\n", i * 4, where); ++found; } } fclose(file); HANDLE dump = CreateFileW((g_dir + L"sacred_re_crash.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (dump != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION exception = {GetCurrentThreadId(), info, FALSE}; MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump, MiniDumpWithIndirectlyReferencedMemory, &exception, nullptr, nullptr); CloseHandle(dump); } } constexpr DWORD kHangMs = 10000; volatile LONG g_lastBeat = 0; // GetTickCount of the last engine tick, 0: no game running bool GameInFront() { DWORD process = 0; GetWindowThreadProcessId(GetForegroundWindow(), &process); return process == GetCurrentProcessId(); } void WriteHangDump() { HANDLE dump = CreateFileW((g_dir + L"sacred_re_hang.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (dump != INVALID_HANDLE_VALUE) { MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump, static_cast(MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo), nullptr, nullptr, nullptr); CloseHandle(dump); } } DWORD WINAPI Watchdog(void*) { bool reported = false; for (;;) { Sleep(1000); const DWORD beat = static_cast(g_lastBeat); // Signed: a tick may land between reading the beat and the clock. if (beat == 0 || static_cast(GetTickCount() - beat) < static_cast(kHangMs)) { reported = false; continue; } if (!reported && GameInFront()) { reported = true; Log("hang: no engine tick for %lu ms, writing sacred_re_hang.dmp", GetTickCount() - beat); WriteHangDump(); Log("hang: dump written"); } } } LONG WINAPI Filter(EXCEPTION_POINTERS* info) { if (InterlockedExchange(&g_reported, 1) == 0) { WriteReport(info); } return g_gameFilter ? g_gameFilter(info) : EXCEPTION_CONTINUE_SEARCH; } } // namespace void InstallCrashHandler(const std::wstring& dir) { g_dir = dir; g_gameFilter = SetUnhandledExceptionFilter(&Filter); if (HANDLE thread = CreateThread(nullptr, 0, &Watchdog, nullptr, 0, nullptr)) { CloseHandle(thread); } } void EngineHeartbeat(bool running) { InterlockedExchange(&g_lastBeat, running ? static_cast(GetTickCount() | 1) : 0); } } // namespace re // The game's own filter goes behind ours. LPTOP_LEVEL_EXCEPTION_FILTER WINAPI Hooked_SetUnhandledExceptionFilter(LPTOP_LEVEL_EXCEPTION_FILTER filter); RE_HOOK_API(SetUnhandledExceptionFilter, Hooked_SetUnhandledExceptionFilter); LPTOP_LEVEL_EXCEPTION_FILTER WINAPI Hooked_SetUnhandledExceptionFilter(LPTOP_LEVEL_EXCEPTION_FILTER filter) { if (filter == &re::Filter) { return Hooked_SetUnhandledExceptionFilter_original(filter); } LPTOP_LEVEL_EXCEPTION_FILTER previous = re::g_gameFilter; re::g_gameFilter = filter; return previous; }