diff --git a/docs/crash_handling.md b/docs/crash_handling.md new file mode 100644 index 0000000..9729f31 --- /dev/null +++ b/docs/crash_handling.md @@ -0,0 +1,193 @@ +# Py4GW Crash Handler + +Forensic crash capture for Py4GW. When Guild Wars or `Py4GW.dll` faults, it leaves a +per-client artifact set that answers: **what native instruction crashed**, **what GW itself +said about it**, and **which Python script was last active**. + +Status: **implemented, built, deployed, and verified live.** One enhancement is pending — see +[Pending: Python breadcrumb](#pending-python-breadcrumb). + +Source: `include/CrashHandler.h`, `src/CrashHandler.cpp`, `include/Breadcrumbs.h` (native); +`Py4GWCoreLib/py4gwcorelib_src/CrashLog.py` (Python). Bindings: `Py4GW.crashlog` / `Py4GW.debug` +in `src/Py4GW.cpp`. + +--- + +## Architecture + +Four capture paths feed one crash sink (`CrashHandler::OnException`), which writes a minidump + +sidecars under a single re-entrancy guard, then lets the process die. + +| Path | Trigger | Mechanism | +|---|---|---| +| **A — SEH** | any unhandled native fault (AV, illegal instr, stack overflow) | `SetUnhandledExceptionFilter` | +| **B — GWCA panic** | a GWCA `FatalAssert` (e.g. a `Scanner::Find` pattern rotted after a GW update) | `GW::RegisterPanicHandler`; self-dumps via `RtlCaptureContext` (GWCA `abort()`s, so it can't rely on Path A) | +| **C — GW engine** | GW's own engine hits a fatal (assertion, packet/state error) — **captures the exact crashing GW function** | detours GW's internal crash-message builder | +| **D — Python** | uncaught Python exception | `sys.excepthook` + `threading.excepthook` write a full traceback | + +The native side cannot read the live Python stack (the GIL/interpreter may be inconsistent at +fault time), so Python context comes from two places: a **pre-captured last-frame breadcrumb** +(for native faults) and the **excepthook tracebacks** (for Python-level exceptions). + +### Install / teardown (`src/dllmain.cpp`) +- `CrashHandler::Instance().Initialize()` right after `InitializeGWCA()` succeeds (`dllmain.cpp:71`) + — Paths B/C need GWCA live; the SEH filter and the process-exception-policy change happen here. +- `CrashHandler::Instance().Terminate()` before `Py4GW::Instance().Terminate()` (`dllmain.cpp:131`) + — disables/removes the Path C hook, restores the SEH filter + exception policy. + +### Process exception policy +`Initialize()` clears `PROCESS_CALLBACK_FILTER_ENABLED` (via dynamically-resolved +`Get/SetProcessUserModeExceptionPolicy`) and saves the prior value to restore at teardown. Without +this, exceptions raised inside kernel-mode callbacks (window procs / D3D present) are silently +swallowed and never reach the SEH filter — essential for a GUI overlay injected into a game. + +--- + +## Path C — GW's internal crash-message builder (verified) + +This is the path that names the exact crashing GW function. When GW's engine hits a fatal, it +builds a crash report (assertion text, registers, loaded-module list, and a stack trace) into an +internal debug-info buffer. Py4GW resolves that builder **at runtime by a stable format-string +anchor** and detours it, capturing GW's own report + a CONTEXT whose `Eip` is the faulting +instruction. + +Verified against the live client (`Gw.exe`, 2026-06-02 build) — the target function and ABI: + +| Fact | Value | +|---|---| +| Resolution | `GW::Scanner::FindUseOfString("%p %08x %08x %08x %08x ")` -> `ToFunctionStart(use, 0xfff)` | +| Target (this build) | `0x00488fe0` | +| Calling convention | `__cdecl`, **7 args** `(debug_info*, u32, u32, u32, CONTEXT* ctx, u32, u32)` | +| Debug-info text offset | `+0x20c` (length-counted; bound `< 0x80000`) | +| `CONTEXT.Eip` | `0xB8` | +| Report-section delimiter | `*-->
<--*` | + +The detour forwards all 7 args to the trampoline, lets GW fill its report, captures it, synthesizes +an `EXCEPTION_RECORD` (`EXCEPTION_BREAKPOINT`, address = `ctx->Eip`), and dumps. The `0x20c` read is +guarded by `__try/__except` and gated on the scanner hit; on a scan miss it **logs and continues** +so Paths A/B stay functional. The address is re-resolved per process, so **re-anchor (not +re-address) after a GW patch** — the convention and offsets are stable; only the address moves. + +Boot-time smoke test: `Py4GW_injection_log.txt` logs `[CrashHandler] Path C attached (GW +crash-message hook).` when the anchor resolves. + +--- + +## Crash-time safety invariants + +The SEH writer path runs while the process is dying, so it is: +- **allocation-free** — static buffers only, no `new`/STL allocation; +- **lock-free** — no `std::mutex`; the breadcrumb ring is a lock-free MPSC with release/acquire seq; +- **Python-API-free** — never calls any `Py*` function; +- **non-recursive** — one `InterlockedCompareExchange` guard; a fault during dumping terminates + rather than recursing; `MiniDumpWriteDump` is wrapped in `__try/__except`; +- **x86 only** — `static_assert(sizeof(void*) == 4)` guards the `CONTEXT.Eip` assumption. + +The C++ `Logger` (heap + mutex) is used only at install/teardown, never on the crash path. The +crash path appends one line to `Py4GW_injection_log.txt` via a raw `CreateFileW(FILE_APPEND_DATA)`. + +--- + +## Output + +Per-client. The crash directory is `/crashes/` — the process working directory captured +at `Initialize()`, which (before Py4GW's first-frame `ChangeWorkingDirectory`) is the GW client's own +folder, so each multibox instance gets an isolated `crashes/` next to its own +`Py4GW_injection_log.txt`. The Python side fetches the same path via `Py4GW.crashlog.get_crash_dir()`. + +Per crash, sharing a `py4gw----` stem: + +| File | Contents | +|---|---| +| `....dmp` | minidump, flags `0x1041` (`DataSegs \| IndirectlyReferencedMemory \| ThreadInfo`), exception info + a `CommentStreamA` summary | +| `....json` | sidecar (below) | +| `...-gwtext.txt` | GW's **full** crash report verbatim (Path C only) | +| `...--pytrace.txt` | full Python `traceback.format_exc()` (Python-uncaught only) | + +Plus one `CRASH ...` line appended to `Py4GW_injection_log.txt`. + +### Sidecar JSON +```json +{ + "version": "0.0.0-dev", + "source": "gw_engine", // seh | wndproc | gwca_assert | gw_engine + "crash_class": "breakpoint", // derived from the real ExceptionCode + "exception_code": "0x80000003", + "fault_address": "0x00EA7A9B", + "faulting_tid": 48864, + "dump_file": "py4gw-20260614-011912-13084-48864.dmp", + "gw_text_file": "py4gw-20260614-011912-13084-48864-gwtext.txt", + "python_last_frame": { "file": "", "line": 0, "func": "" }, + "assert": "", + "gw_text": "*--> Crash <--*\r\nAssertion: ...\r\nP:\\Code\\Gw\\...(NNN)\r\n...", + "breadcrumbs": ["gw_text: *--> Crash <--* ..."] +} +``` +All interpolated strings are JSON-escaped. `gw_text` is a ~1 KB preview; the full report is in +`gw_text_file`. + +### Live-verified example +A real crash captured at `...\Guild Wars alt 7\crashes\` (Path C): +`source: gw_engine`, and `gw_text`: +`Assertion: !(manualAgentId && !ManagerFindAgent(manualAgentId)) P:\Code\Gw\AgentView\AvSelect.cpp(780)` +— i.e. a change-target on a stale/despawned agent id. The handler named the exact GW function, file, +and line. + +--- + +## Python side (`CrashLog.py`) + +`install_crash_hooks()` (called once from `Py4GWCoreLib/__init__.py` after the stdout/stderr redirect): +- installs `sys.excepthook` + `threading.excepthook` -> full `traceback.format_exc()` to a + `-pytrace.txt` for uncaught exceptions on the render thread and worker threads; +- exposes `set_breadcrumb(widget, phase)` which snapshots the current Python frame into the native + TLS slot (via `Py4GW.crashlog.set_last_frame`) so a native crash can name the last Python frame. + +`faulthandler` is **off by default**: Py4GW routinely raises+swallows native exceptions, and +`faulthandler`'s vectored handler logs every one process-wide (huge file + overhead). Pass +`install_crash_hooks(enable_faulthandler=True)` only for quiet single-client interpreter-fault +debugging. + +--- + +## Bindings (`Py4GW.crashlog` / `Py4GW.debug`) +- `Py4GW.crashlog.set_last_frame(file, line, func)` — record the last Python frame (TLS). +- `Py4GW.crashlog.breadcrumb(text)` — append to the crash ring. +- `Py4GW.crashlog.get_crash_dir()` — the native crash dir (so Python writes beside the dump). +- `Py4GW.debug.crash()` — deliberate access violation to exercise the handler. + **Gated behind `PY4GW_DEBUG_BUILD`** — remove that define in `CMakeLists.txt` for production. + +--- + +## Build / deploy + +- `CMakeLists.txt` links `dbghelp` and defines `PY4GW_DEBUG_BUILD` (testing). `src/*.cpp` is + glob-included; new sources need a CMake re-configure. +- Build: `cmake --build build --config RelWithDebInfo --target Py4GW` (VS 2022, Win32, Python 3.13-32). +- Deploy: copy `bin/RelWithDebInfo/Py4GW.dll` into the runtime tree (the DLL is locked while clients + run — close them first). Running clients keep their old code in memory until relaunched. +- Test: a fresh client -> `Py4GW.debug.crash()` -> expect one dump in that client's own `crashes/` with + `source:"seh"`, `exception_code:"0xC0000005"`. + +--- + +## Pending: Python breadcrumb + +`python_last_frame` is currently empty — the breadcrumb infrastructure exists but nothing calls +`set_last_frame`/`set_breadcrumb` yet. Two call sites are needed (per-faulting-thread TLS): +1. **Game-thread enqueue runner** (`src/Py4GW.cpp`, the `GW::GameThread::Enqueue` lambda) — record + the enqueued callback's `__code__` before running it. Covers deferred-call crashes like the + AvSelect example. (C++ — needs a rebuild.) +2. **WidgetManager per-widget dispatch** (`Py4GWCoreLib/py4gwcorelib_src/WidgetManager.py`) — call + `CrashLog.set_breadcrumb(widget, phase)` before each widget callback. Covers crashes during a + widget's draw on the render thread. (Python only — no rebuild.) + +--- + +## Known limitations +- The `0x20c` text offset and the Path C target address are build-specific; re-anchor on GW patches. +- Pre-existing `__except (EXCEPTION_EXECUTE_HANDLER)` swallow blocks (`GwDatTextureManager`, + `py_dialog`, `dllmain` `SafeWndProc`) pre-empt the SEH filter by design; a missing dump there does + not imply no fault. +- `WndProcFilter` exists but is intentionally **not wired** — GW's window proc raises survivable + exceptions routinely, so its `__except` is left as a silent swallow. diff --git a/include/Breadcrumbs.h b/include/Breadcrumbs.h new file mode 100644 index 0000000..7cded52 --- /dev/null +++ b/include/Breadcrumbs.h @@ -0,0 +1,95 @@ +#pragma once +// Crash breadcrumbs: per-thread last-Python-frame POD + lock-free MPSC ring. +// Read by the SEH filter, so EVERYTHING here is allocation-free, lock-free, and Python-API-free. +// +// Why a breadcrumb instead of a live Python frame walk: +// At native-crash time the GIL/interpreter may be inconsistent (the faulting callback +// held the GIL, Py4GW.cpp:1146). We therefore CANNOT call any Py* API from the filter. +// Instead the Python side snapshots its current frame into per-thread static storage +// (under the GIL, on the executing thread) during normal execution; the filter reads +// only that static copy on the FAULTING thread. + +#include +#include +#include +#include +#include + +namespace bc { + +// ---- last Python frame (per-thread) ---------------------------------------- +// Fixed-size POD; strings are memcpy'd at capture time (NOT PyUnicode_AsUTF8 ptrs, +// whose lifetime/heap is exactly what a crash corrupts). +struct LastPyFrame { + char file[260]; + char func[128]; + int line; +}; + +// One slot per thread. Written by whichever thread runs the Python callback (game thread AND +// update thread); read by the filter on the faulting thread. POD `{}` => constant zero-init, +// so there is NO thread_local dynamic-init guard for the crash-time reader to trip over. +inline thread_local LastPyFrame t_last_py_frame{}; + +// Capture: called from the pybind binding with the GIL held. Copies into TLS (no heap). +inline void set_last_py_frame(const char* file, int line, const char* func) { + LastPyFrame& f = t_last_py_frame; + if (file) { strncpy_s(f.file, file, _TRUNCATE); } else { f.file[0] = 0; } + if (func) { strncpy_s(f.func, func, _TRUNCATE); } else { f.func[0] = 0; } + f.line = line; +} + +// Read: called from the SEH filter. Returns a by-value POD copy of THIS thread's slot. +inline LastPyFrame read_last_py_frame() { return t_last_py_frame; } + +// ---- lock-free MPSC breadcrumb ring ---------------------------------------- +// Fixed ring of fixed-size messages. MULTIPLE producers (game + update threads call +// breadcrumb()); single consumer (the SEH filter draining at crash time). No locks. +// +// Publish protocol (fixes the missing release/acquire the review flagged): reserve a +// monotonic index, write the message, then RELEASE-store a per-slot seq == index+1. The +// consumer ACQUIRE-loads next, then ACQUIRE-loads each slot's seq and only reads a slot +// whose seq matches the index it expects -> never reads a half-written or recycled slot. +// Residual: two producers landing on the same physical slot (indices kRingSize apart) at the +// same instant can tear one message; tolerated (best-effort, last-few-crumbs-only). +constexpr uint32_t kRingSize = 64; // power of two for cheap masking +constexpr uint32_t kMsgLen = 160; + +struct Slot { + std::atomic seq; // 0 = empty; otherwise (producer_index + 1) + char msg[kMsgLen]; +}; + +struct Ring { + Slot slots[kRingSize]; + std::atomic next; // next index to hand out (monotonic) +}; + +inline Ring g_ring{}; + +// Producer: append "tag: text" (truncated). Call from normal execution context only. +inline void breadcrumb_copy(const char* tag, const char* text) { + uint32_t idx = g_ring.next.fetch_add(1, std::memory_order_relaxed); + Slot& s = g_ring.slots[idx & (kRingSize - 1)]; + _snprintf_s(s.msg, kMsgLen, _TRUNCATE, "%s: %s", tag ? tag : "?", text ? text : ""); + s.seq.store(idx + 1, std::memory_order_release); // publish AFTER the message is written +} + +// Convenience one-arg breadcrumb. +inline void breadcrumb(const char* text) { breadcrumb_copy("bc", text); } + +// Consumer (SEH filter). Visit the most-recent up-to-N entries oldest->newest, skipping any +// slot a producer has since recycled or not finished writing. +template +inline void drain_recent(uint32_t max_entries, Fn&& visit) { + uint32_t end = g_ring.next.load(std::memory_order_acquire); // one past the newest index + uint32_t count = (end < max_entries) ? end : max_entries; + for (uint32_t k = count; k > 0; --k) { + uint32_t idx = end - k; // oldest of the window first + Slot& s = g_ring.slots[idx & (kRingSize - 1)]; + if (s.seq.load(std::memory_order_acquire) == idx + 1) // slot still holds THIS entry + visit(s.msg); + } +} + +} // namespace bc diff --git a/include/CrashHandler.h b/include/CrashHandler.h new file mode 100644 index 0000000..7e0efc0 --- /dev/null +++ b/include/CrashHandler.h @@ -0,0 +1,76 @@ +#pragma once +// Py4GW crash handler: native minidump (Paths A/B/C) + Python last-frame breadcrumb. +// +// Crash-time contract (design-safety invariants enforced in the .cpp): +// - The SEH writer path is allocation-free, lock-free, and Python-API-free. +// - A single re-entrancy guard serializes Paths A/B/C. +// - Path B self-dumps via RtlCaptureContext (GWCA FatalAssert -> abort() may not reach Path A). +// - Path C detours GW's own internal crash-message builder. Verified on the live build +// (Gw.exe 2026-06-02, 0x00488fe0): target is __cdecl, 7 args, debug-info text @ +0x20c, +// arg5 = CONTEXT*, Eip @ 0xB8. Resolved at runtime by string anchor (re-anchors per patch). +// - x86 only (CONTEXT.Eip). static_assert guards the assumption. +// - Python full stack is NOT available here; we emit a pre-captured last-frame breadcrumb +// only. Full tracebacks come from the Python excepthooks. + +#include +#include +#include + +// Build-time version stamp. No version macro exists in the project; override via +// target_compile_definitions(Py4GW PRIVATE PY4GW_VERSION="x.y.z"). +#ifndef PY4GW_VERSION +#define PY4GW_VERSION "0.0.0-dev" +#endif + +class CrashHandler { +public: + static CrashHandler& Instance(); + + // Install Paths A/B (+ C if scanner hits). Idempotent. Call AFTER GW::Initialize() + // succeeds (dllmain.cpp:70) and BEFORE AttachRenderHook (dllmain.cpp:88). + void Initialize(); + + // Disable+remove Path C, restore SEH filter + exception policy, null panic handler. + // Call at dllmain.cpp:131 BEFORE Py4GW::Terminate() (tears down GW Scanner/Hook). Idempotent. + void Terminate(); + + // Shared crash sink. recoverable=false (fatal A/B/C) latches the guard; recoverable=true + // (WndProc) writes a dump then resets the guard so the game can resume. Returns true if + // it wrote (acquired the guard), false if another fault is already being handled. + bool OnException(EXCEPTION_POINTERS* info, const char* source, bool recoverable); + + // Optional recoverable-fault sink. NOT currently wired: SafeWndProc's __except is left as a + // silent swallow on purpose (GW raises survivable WndProc exceptions routinely). Kept for + // future use -- writes a dump then returns EXCEPTION_EXECUTE_HANDLER to fall through. + LONG WndProcFilter(EXCEPTION_POINTERS* info); + + // Resolved crash directory as UTF-8. Safe at NON-crash time only (e.g. the Python + // binding at startup). Empty until Initialize() has run. Lets the Python half write its + // *-pytrace.txt next to the .dmp/.json so one crash = one correlated set. + std::string CrashDirUtf8() const; + +private: + CrashHandler() = default; + CrashHandler(const CrashHandler&) = delete; + CrashHandler& operator=(const CrashHandler&) = delete; + + static LONG WINAPI TopLevelFilter(EXCEPTION_POINTERS* info); // Path A + static void GwcaPanic(void* ctx, const char* expr, const char* file, // Path B (5-arg) + unsigned int line, const char* func); + // Path C detour over GW's internal crash-message builder (verified __cdecl, 7-arg signature). + static uintptr_t __cdecl AppendStackDetour(void* debug_info, uint32_t a2, uint32_t a3, + uint32_t a4, CONTEXT* ctx, uint32_t a6, + uint32_t a7); + + void InstallPathA(); + void InstallPathB(); + void InstallPathC(); + void ClearCallbackFilterPolicy(); // clear PROCESS_CALLBACK_FILTER_ENABLED (bit 0) + void RestoreCallbackFilterPolicy(); // restore the saved policy at teardown + + // Crash-time-safe writers (no heap, no locks, no Py API). Paths are pre-built stems. + void WriteSidecar(EXCEPTION_POINTERS* info, const wchar_t* json_path, + const wchar_t* dmp_name, const wchar_t* gwtext_name, const char* source); + void WriteDump(EXCEPTION_POINTERS* info, const wchar_t* dmp_path, const char* comment); + bool EnsureCrashDir(); // GetCurrentDirectoryW base + "\\crashes"; init-time only +}; diff --git a/src/CrashHandler.cpp b/src/CrashHandler.cpp new file mode 100644 index 0000000..9a102a0 --- /dev/null +++ b/src/CrashHandler.cpp @@ -0,0 +1,460 @@ +// Py4GW crash handler implementation. +// Paths: A = SetUnhandledExceptionFilter, B = GWCA panic handler, C = GW crash-message detour. +// +// Design-safety invariants enforced here: +// * Crash-time writers use a pre-opened path + static buffers + raw WriteFile. No +// std::mutex, no heap, no MessageBox, no Py* calls. MiniDumpWriteDump is __try-guarded +// (it HeapAllocs internally and can fault on a heap-corruption crash). +// * A single re-entrancy guard (s_handling) prevents recursive dumps; fatal paths latch it +// (process ends), the WndProc path resets it and is rate-capped. +// * Path B self-dumps via RtlCaptureContext; it never relies on abort() reaching Path A. +// * Path C uses the VERIFIED 7-arg __cdecl signature and forwards ALL args to the trampoline. +// * The crash dir is resolved ONCE at Initialize() (non-crash time) from the process CWD, +// so it sits next to Py4GW_injection_log.txt and the SEH path never touches a std::string. + +#include "CrashHandler.h" +#include "Breadcrumbs.h" + +#include // MiniDumpWriteDump; needs target_link_libraries(... dbghelp) +#include // _vsnprintf_s / _snwprintf_s into static buffers only +#include + +#include // GW::RegisterPanicHandler +#include // GW::Scanner::FindUseOfString / ToFunctionStart +#include // GW::HookBase::CreateHook / EnableHooks / RemoveHook + +// The Logger (GWCA-declared, project-implemented) is heap+mutex based; only safe to call +// OUTSIDE the crash path (install / teardown). Never from the SEH filter. +#include + +// Project global: directory containing Py4GW.dll. Used ONLY as an init-time fallback. +extern std::string dllDirectory; + +namespace { + +// ---- re-entrancy guard ------------------------------------------------------ +volatile LONG s_handling = 0; // 0 = idle, 1 = a fault is being handled +volatile LONG s_wndproc_dumps = 0; // rate-cap for the recoverable WndProc path +constexpr LONG kMaxWndProcDumps = 5; + +// ---- install/teardown state ------------------------------------------------- +bool s_installed = false; +LPTOP_LEVEL_EXCEPTION_FILTER s_prev_filter = nullptr; +uintptr_t s_append_stack_fn = 0; // resolved GW function (Path C) +void* s_append_stack_orig = nullptr; // Path C trampoline +DWORD s_prev_policy = 0; // saved PROCESS_CALLBACK_FILTER policy +bool s_policy_changed = false; + +// ---- pre-resolved crash dir (wide + utf8) ---------------------------------- +wchar_t s_crash_dir[MAX_PATH] = {0}; // \crashes (next to Py4GW_injection_log.txt) +bool s_crash_dir_ready = false; +std::string s_crash_dir_utf8; + +// ---- crash text channels (static; filled before the dump) ------------------- +char s_assert_text[512] = {0}; // Path B assertion text +char s_gw_text[32768] = {0}; // Path C: GW's full crash report (Crash/System/DllList/Stack) + +// Map a Windows exception code to a stable label for the sidecar. +const char* exception_label(DWORD code) { + switch (code) { + case EXCEPTION_ACCESS_VIOLATION: return "access_violation"; + case EXCEPTION_STACK_OVERFLOW: return "stack_overflow"; + case EXCEPTION_ILLEGAL_INSTRUCTION: return "illegal_instruction"; + case EXCEPTION_INT_DIVIDE_BY_ZERO: return "int_divide_by_zero"; + case EXCEPTION_PRIV_INSTRUCTION: return "priv_instruction"; + case EXCEPTION_IN_PAGE_ERROR: return "in_page_error"; + case 0xC0000409: return "stack_buffer_overrun"; // FAST_FAIL + case 0xE06D7363: return "cpp_exception"; + case 0x80000003: return "breakpoint"; + case 0xE0000001: return "gwca_assert"; + default: return "exception"; + } +} + +// Append-into-bounded-buffer helper. On truncation it stops cleanly (no overrun, no -1 math). +struct JBuf { char* p; char* const e; }; +void jappend(JBuf& b, const char* fmt, ...) { + if (b.p >= b.e) return; + va_list ap; va_start(ap, fmt); + int n = _vsnprintf_s(b.p, static_cast(b.e - b.p), _TRUNCATE, fmt, ap); + va_end(ap); + b.p = (n >= 0) ? b.p + n : b.e; // n<0 => truncated => stop (n==0 is a valid write) +} + +// Escape a UTF-8 string into dst for JSON embedding (\\, ", control chars). Static, no heap. +void json_escape(char* dst, size_t cap, const char* src) { + if (cap == 0) return; + size_t j = 0; + for (size_t i = 0; src && src[i] && j + 2 < cap; ++i) { + unsigned char c = static_cast(src[i]); + if (c == '\\' || c == '"') { dst[j++] = '\\'; dst[j++] = c; } + else if (c == '\n') { dst[j++] = '\\'; dst[j++] = 'n'; } + else if (c == '\r') { dst[j++] = '\\'; dst[j++] = 'r'; } + else if (c == '\t') { dst[j++] = '\\'; dst[j++] = 't'; } + else if (c >= 0x20) { dst[j++] = c; } + // other control chars are dropped + } + dst[j] = 0; +} + +// Recursive CreateDirectory for a wide path (no error if it exists). Init-time only. +void make_dir_tree(const wchar_t* path) { + wchar_t tmp[MAX_PATH]; + wcsncpy_s(tmp, path, _TRUNCATE); + for (wchar_t* p = tmp + 3; *p; ++p) { // skip drive "C:\" + if (*p == L'\\') { *p = 0; CreateDirectoryW(tmp, nullptr); *p = L'\\'; } + } + CreateDirectoryW(tmp, nullptr); +} + +// Build "\py4gw-YYYYMMDD-HHMMSS-pid-tid" (no extension). Crash-safe (no heap). +void build_stem(wchar_t* out, size_t cap) { + SYSTEMTIME st; GetLocalTime(&st); + _snwprintf_s(out, cap, _TRUNCATE, + L"%s\\py4gw-%04u%02u%02u-%02u%02u%02u-%lu-%lu", + s_crash_dir, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, + GetCurrentProcessId(), GetCurrentThreadId()); +} + +// Append one CRASH line to Py4GW_injection_log.txt (relative -> CWD, same place the Logger +// writes). Crash-safe: CreateFileW(FILE_APPEND_DATA) + WriteFile, no heap, shared access. +void append_injection_log(const char* line) { + HANDLE h = CreateFileW(L"Py4GW_injection_log.txt", FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + DWORD w = 0; WriteFile(h, line, static_cast(strlen(line)), &w, nullptr); + CloseHandle(h); +} + +// Write GW's full crash report (s_gw_text) to a human-readable sidecar. Crash-safe: raw WriteFile, +// no heap/escaping (the JSON keeps only a preview, this file keeps the whole thing verbatim). +void write_gwtext(const wchar_t* path, const char* text) { + HANDLE h = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + DWORD w = 0; WriteFile(h, text, static_cast(strlen(text)), &w, nullptr); + CloseHandle(h); +} + +} // namespace + +CrashHandler& CrashHandler::Instance() { + static CrashHandler inst; + return inst; +} + +std::string CrashHandler::CrashDirUtf8() const { return s_crash_dir_utf8; } + +bool CrashHandler::EnsureCrashDir() { + if (s_crash_dir_ready) return true; + wchar_t base[MAX_PATH] = {0}; + // Match the Logger's PER-CLIENT behavior. The injection log is written via a RELATIVE path, + // so it lands in the process CWD. Initialize() runs during DLL init -- BEFORE Py4GW's + // first-frame ChangeWorkingDirectory(dllDirectory) (Py4GW.cpp:1987) -- so the CWD here is + // still the GW client's OWN directory. Capturing it now puts crashes/ inside each client's + // directory, next to that client's Py4GW_injection_log.txt. (The native get_crash_dir() + // binding hands this same per-client path to the Python side so all artifacts stay together, + // even though Python runs after the CWD flips to the shared DLL folder.) + DWORD n = GetCurrentDirectoryW(MAX_PATH, base); + if (n == 0 || n >= MAX_PATH) { // fallback: the Py4GW.dll directory + int m = MultiByteToWideChar(CP_UTF8, 0, dllDirectory.c_str(), -1, base, MAX_PATH); + if (m <= 0 || base[0] == 0) return false; + } + _snwprintf_s(s_crash_dir, MAX_PATH, _TRUNCATE, L"%s\\crashes", base); + make_dir_tree(s_crash_dir); + s_crash_dir_ready = true; + char u8[MAX_PATH * 2]; + if (WideCharToMultiByte(CP_UTF8, 0, s_crash_dir, -1, u8, sizeof(u8), nullptr, nullptr) > 0) + s_crash_dir_utf8 = u8; + return true; +} + +// --------------------------------------------------------------------------- +// Install / teardown (runs at DLL init/term, NOT crash time -> Logger ok here) +// --------------------------------------------------------------------------- + +void CrashHandler::Initialize() { + if (s_installed) return; // idempotent: Initialize() runs more than once + s_installed = true; + EnsureCrashDir(); // pre-cache wide + utf8 dir at NON-crash time + ClearCallbackFilterPolicy(); + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + InstallPathA(); + InstallPathB(); + InstallPathC(); // best-effort; logs and continues on miss + Logger::Instance().LogInfo("[CrashHandler] installed (A=SEH, B=panic, C=scanner)."); +} + +void CrashHandler::Terminate() { + if (!s_installed) return; + s_installed = false; + if (s_append_stack_fn) { // Path C: disable before remove (MinHook rule) + GW::HookBase::DisableHooks(reinterpret_cast(s_append_stack_fn)); + GW::HookBase::RemoveHook(reinterpret_cast(s_append_stack_fn)); + s_append_stack_fn = 0; + s_append_stack_orig = nullptr; // null trampoline after removal (no stale calls) + } + SetUnhandledExceptionFilter(s_prev_filter); // restore Path A + GW::RegisterPanicHandler(nullptr, nullptr); // null Path B + RestoreCallbackFilterPolicy(); + InterlockedExchange(&s_handling, 0); // reset guards so a re-Initialize() is clean + InterlockedExchange(&s_wndproc_dumps, 0); + Logger::Instance().LogInfo("[CrashHandler] torn down."); +} + +void CrashHandler::ClearCallbackFilterPolicy() { + // Clear PROCESS_CALLBACK_FILTER_ENABLED (bit 0) so WoW64 kernel-callback faults (WndProc, + // D3D Present) propagate to the user-mode filter. Resolved dynamically; saved for restore. + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + using GetFn = BOOL(WINAPI*)(LPDWORD); + using SetFn = BOOL(WINAPI*)(DWORD); + auto get = reinterpret_cast(GetProcAddress(k32, "GetProcessUserModeExceptionPolicy")); + auto set = reinterpret_cast(GetProcAddress(k32, "SetProcessUserModeExceptionPolicy")); + if (!get || !set) return; + DWORD policy = 0; + if (!get(&policy)) return; + s_prev_policy = policy; + if (set(policy & 0xFFFFFFFEu)) s_policy_changed = true; +} + +void CrashHandler::RestoreCallbackFilterPolicy() { + if (!s_policy_changed) return; + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + using SetFn = BOOL(WINAPI*)(DWORD); + auto set = reinterpret_cast(GetProcAddress(k32, "SetProcessUserModeExceptionPolicy")); + if (set) set(s_prev_policy); + s_policy_changed = false; +} + +void CrashHandler::InstallPathA() { + s_prev_filter = SetUnhandledExceptionFilter(&CrashHandler::TopLevelFilter); +} + +void CrashHandler::InstallPathB() { + GW::RegisterPanicHandler(&CrashHandler::GwcaPanic, nullptr); +} + +void CrashHandler::InstallPathC() { + // Resolve GW's crash-message builder by its (stable) format-string anchor, then detour it. + // VERIFIED live: anchor lives inside the target; ToFunctionStart lands on its prologue. + uintptr_t use = GW::Scanner::FindUseOfString("%p %08x %08x %08x %08x "); + if (!use) { Logger::Instance().LogWarning("[CrashHandler] Path C anchor miss; A/B only."); return; } + s_append_stack_fn = GW::Scanner::ToFunctionStart(use, 0xfff); + if (!s_append_stack_fn) { Logger::Instance().LogWarning("[CrashHandler] Path C prologue miss."); return; } + int status = GW::HookBase::CreateHook( + reinterpret_cast(&s_append_stack_fn), + reinterpret_cast(&CrashHandler::AppendStackDetour), + &s_append_stack_orig); + if (status != 0 || !s_append_stack_orig) { + Logger::Instance().LogWarning("[CrashHandler] Path C CreateHook failed."); + s_append_stack_fn = 0; s_append_stack_orig = nullptr; return; + } + GW::HookBase::EnableHooks(reinterpret_cast(s_append_stack_fn)); + Logger::Instance().LogInfo("[CrashHandler] Path C attached (GW crash-message hook)."); +} + +// --------------------------------------------------------------------------- +// Path A / WndProc filters +// --------------------------------------------------------------------------- + +LONG WINAPI CrashHandler::TopLevelFilter(EXCEPTION_POINTERS* info) { + Instance().OnException(info, "seh", /*recoverable=*/false); + return EXCEPTION_EXECUTE_HANDLER; +} + +LONG CrashHandler::WndProcFilter(EXCEPTION_POINTERS* info) { + // Recoverable: historically these faults were swallowed and GW survived via CallWindowProc. + // Dump (rate-capped so a per-frame fault can't spam dumps), then fall through as before. + if (InterlockedIncrement(&s_wndproc_dumps) <= kMaxWndProcDumps) + OnException(info, "wndproc", /*recoverable=*/true); + return EXCEPTION_EXECUTE_HANDLER; +} + +// --------------------------------------------------------------------------- +// Path B: GWCA panic handler (5-arg). Self-dumps; abort() may never reach Path A. +// --------------------------------------------------------------------------- + +void CrashHandler::GwcaPanic(void* /*ctx*/, const char* expr, const char* file, + unsigned int line, const char* func) { + static_assert(sizeof(void*) == 4, "Py4GW crash handler is x86 (CONTEXT.Eip) only"); + _snprintf_s(s_assert_text, sizeof(s_assert_text), _TRUNCATE, + "GWCA_ASSERT(%s) at %s:%u in %s", expr ? expr : "?", + file ? file : "?", line, func ? func : "?"); + CONTEXT ctx; RtlCaptureContext(&ctx); // accurate panic-site context (not a throw site) + EXCEPTION_RECORD rec = {0}; + rec.ExceptionCode = 0xE0000001; // synthetic: GWCA assertion + rec.ExceptionFlags = EXCEPTION_NONCONTINUABLE; + rec.ExceptionAddress = reinterpret_cast(ctx.Eip); + EXCEPTION_POINTERS info = { &rec, &ctx }; + Instance().OnException(&info, "gwca_assert", /*recoverable=*/false); + // returns to FatalAssert -> abort(); the dump is already written. +} + +// --------------------------------------------------------------------------- +// Path C: detour GW's internal crash-message builder (verified __cdecl, 7 args). +// Forward ALL args to the trampoline, capture GW's text, then self-dump from its CONTEXT. +// --------------------------------------------------------------------------- + +uintptr_t __cdecl CrashHandler::AppendStackDetour(void* debug_info, uint32_t a2, uint32_t a3, + uint32_t a4, CONTEXT* ctx, uint32_t a6, + uint32_t a7) { + static_assert(sizeof(void*) == 4, "Py4GW crash handler is x86 (CONTEXT.Eip) only"); + using Fn = uintptr_t(__cdecl*)(void*, uint32_t, uint32_t, uint32_t, CONTEXT*, uint32_t, uint32_t); + // Defensive: the detour only runs while the hook is enabled (orig is set then, nulled only + // after RemoveHook), but guard against a teardown race rather than call through null. + if (!s_append_stack_orig) return 0; + // Let GW fill its own crash text first (caller cleans the stack; we forward all 7 args). + uintptr_t ret = reinterpret_cast(s_append_stack_orig)(debug_info, a2, a3, a4, ctx, a6, a7); + + // Capture GW's text (VERIFIED: text region at buf+0x20c, len-counted). Guard the read. + __try { + const char* gw = reinterpret_cast( + reinterpret_cast(debug_info) + 0x20c); + if (gw && *gw) { + strncpy_s(s_gw_text, gw, _TRUNCATE); + bc::breadcrumb_copy("gw_text", s_gw_text); + } + } __except (EXCEPTION_EXECUTE_HANDLER) { /* enrichment only; ignore */ } + + // GW is fatally crashing: synthesize an EXCEPTION from its CONTEXT and dump it. + if (ctx) { + EXCEPTION_RECORD rec = {0}; + rec.ExceptionCode = 0x80000003; // EXCEPTION_BREAKPOINT + rec.ExceptionFlags = EXCEPTION_NONCONTINUABLE; + rec.ExceptionAddress = reinterpret_cast(ctx->Eip); + EXCEPTION_POINTERS info = { &rec, ctx }; + Instance().OnException(&info, "gw_engine", /*recoverable=*/false); + } + return ret; +} + +// --------------------------------------------------------------------------- +// Shared crash sink + crash-time-safe writers +// --------------------------------------------------------------------------- + +bool CrashHandler::OnException(EXCEPTION_POINTERS* info, const char* source, bool recoverable) { + if (InterlockedCompareExchange(&s_handling, 1, 0) != 0) { + // A fault is already being handled (e.g. our writer itself faulted). Don't recurse. + if (!recoverable) TerminateProcess(GetCurrentProcess(), 1); + return false; + } + if (s_crash_dir_ready) { + wchar_t stem[MAX_PATH], dmp[MAX_PATH], json[MAX_PATH]; + build_stem(stem, MAX_PATH); + _snwprintf_s(dmp, MAX_PATH, _TRUNCATE, L"%s.dmp", stem); + _snwprintf_s(json, MAX_PATH, _TRUNCATE, L"%s.json", stem); + const wchar_t* slash = wcsrchr(dmp, L'\\'); + const wchar_t* dmp_name = slash ? slash + 1 : dmp; + + // Path C populates GW's full crash report -> give it its own -gwtext.txt sidecar. + wchar_t gwt[MAX_PATH] = {0}; + const wchar_t* gwt_name = L""; + if (s_gw_text[0]) { + _snwprintf_s(gwt, MAX_PATH, _TRUNCATE, L"%s-gwtext.txt", stem); + const wchar_t* gslash = wcsrchr(gwt, L'\\'); + gwt_name = gslash ? gslash + 1 : gwt; + } + + bc::LastPyFrame f = bc::read_last_py_frame(); // POD copy, no Py API + char comment[768]; + _snprintf_s(comment, sizeof(comment), _TRUNCATE, + "Py4GW %s | %s | py:%s:%d %s | %s", + PY4GW_VERSION, source, f.file[0] ? f.file : "?", f.line, + f.func[0] ? f.func : "?", s_assert_text[0] ? s_assert_text : ""); + + WriteSidecar(info, json, dmp_name, gwt_name, source); // JSON first: survives a dump failure + WriteDump(info, dmp, comment); + if (gwt[0]) write_gwtext(gwt, s_gw_text); // full GW report, verbatim + + char dmp_name_u8[MAX_PATH]; // narrow copy: avoid locale-dependent %S in printf + if (WideCharToMultiByte(CP_UTF8, 0, dmp_name, -1, dmp_name_u8, sizeof(dmp_name_u8), nullptr, nullptr) <= 0) + dmp_name_u8[0] = 0; + char log_line[320]; + DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0; + _snprintf_s(log_line, sizeof(log_line), _TRUNCATE, + "CRASH %s 0x%08lX py:%s:%d -> see crashes\\%s\r\n", + source, static_cast(code), f.file[0] ? f.file : "?", f.line, dmp_name_u8); + append_injection_log(log_line); + } + if (recoverable) InterlockedExchange(&s_handling, 0); // allow future WndProc dumps + return true; +} + +void CrashHandler::WriteSidecar(EXCEPTION_POINTERS* info, const wchar_t* json_path, + const wchar_t* dmp_name, const wchar_t* gwtext_name, + const char* source) { + HANDLE file = CreateFileW(json_path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return; + + bc::LastPyFrame f = bc::read_last_py_frame(); + DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0; + uintptr_t addr = (info && info->ExceptionRecord) + ? reinterpret_cast(info->ExceptionRecord->ExceptionAddress) : 0; + + char efile[300], efunc[160], eassert[600], egw[1100], edmp[160]; + json_escape(efile, sizeof(efile), f.file); + json_escape(efunc, sizeof(efunc), f.func); + json_escape(eassert, sizeof(eassert), s_assert_text); + json_escape(egw, sizeof(egw), s_gw_text); // preview only; full report -> gw_text_file + char dmp_u8[MAX_PATH]; + if (WideCharToMultiByte(CP_UTF8, 0, dmp_name, -1, dmp_u8, sizeof(dmp_u8), nullptr, nullptr) <= 0) + dmp_u8[0] = 0; + json_escape(edmp, sizeof(edmp), dmp_u8); + char egwt[MAX_PATH] = {0}; // -gwtext.txt name (empty unless Path C) + if (gwtext_name && gwtext_name[0]) { + char gwt_u8[MAX_PATH]; + if (WideCharToMultiByte(CP_UTF8, 0, gwtext_name, -1, gwt_u8, sizeof(gwt_u8), nullptr, nullptr) <= 0) + gwt_u8[0] = 0; + json_escape(egwt, sizeof(egwt), gwt_u8); + } + + char buf[8192]; // generous: worst-case gw_text (~1.1K) + 16 escaped breadcrumbs (~4K) must fit + JBuf b { buf, buf + sizeof(buf) }; + jappend(b, "{\"version\":\"%s\",\"source\":\"%s\",\"crash_class\":\"%s\",", + PY4GW_VERSION, source, exception_label(code)); + jappend(b, "\"exception_code\":\"0x%08lX\",\"fault_address\":\"0x%08lX\",\"faulting_tid\":%lu,", + static_cast(code), static_cast(addr), GetCurrentThreadId()); + jappend(b, "\"dump_file\":\"%s\",", edmp); + if (egwt[0]) jappend(b, "\"gw_text_file\":\"%s\",", egwt); + jappend(b, "\"python_last_frame\":{\"file\":\"%s\",\"line\":%d,\"func\":\"%s\"},", + efile, f.line, efunc); + jappend(b, "\"assert\":\"%s\",\"gw_text\":\"%s\",", eassert, egw); + jappend(b, "\"breadcrumbs\":["); + bool first = true; + bc::drain_recent(16, [&](const char* m) { + char em[256]; json_escape(em, sizeof(em), m); + jappend(b, "%s\"%s\"", first ? "" : ",", em); + first = false; + }); + jappend(b, "]}\n"); + + DWORD written = 0; + WriteFile(file, buf, static_cast(b.p - buf), &written, nullptr); + CloseHandle(file); +} + +void CrashHandler::WriteDump(EXCEPTION_POINTERS* info, const wchar_t* dmp_path, const char* comment) { + HANDLE file = CreateFileW(dmp_path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return; + MINIDUMP_EXCEPTION_INFORMATION mei = {0}; + mei.ThreadId = GetCurrentThreadId(); + mei.ExceptionPointers = info; + mei.ClientPointers = FALSE; + MINIDUMP_USER_STREAM us = {0}; + us.Type = CommentStreamA; // == 10 (MINIDUMP_STREAM_TYPE in dbghelp.h) + us.BufferSize = static_cast(strlen(comment) + 1); + us.Buffer = const_cast(comment); + MINIDUMP_USER_STREAM_INFORMATION usi = { 1, &us }; + const MINIDUMP_TYPE flags = static_cast(0x1041); // DataSegs|IndirectRefd|ThreadInfo + __try { + // dbghelp HeapAllocs internally; guard against a fault on heap-corruption crashes. + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, flags, + info ? &mei : nullptr, &usi, nullptr); + } __except (EXCEPTION_EXECUTE_HANDLER) { /* partial/absent dump; sidecar already written */ } + CloseHandle(file); +} diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 102ac76..c15ba41 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -4,6 +4,7 @@ #include "Py4GW.h" #include "Headers.h" +#include "CrashHandler.h" #include "WinUser.h" #include "hidusage.h" #include @@ -68,6 +69,9 @@ bool DLLMain::Initialize() { Logger::Instance().LogError("[DLLMain] Failed to initialize GWCA"); return false; } + + // Install the crash handler now that GWCA is live (Paths B/C need it). Idempotent. + CrashHandler::Instance().Initialize(); // Get Guild Wars window handle if (!initialized) Logger::Instance().LogInfo("[DLLMain] Attempting to get GW window handle..."); @@ -128,6 +132,7 @@ void DLLMain::Terminate() { GW::GameThread::RemoveGameThreadCallback(&Update_Entry); dat_texture_update_attached = false; Logger::Instance().LogInfo("Terminating DLL..."); + CrashHandler::Instance().Terminate(); Py4GW::Instance().Terminate(); // Clean up ImGui @@ -490,6 +495,9 @@ LRESULT CALLBACK DLLMain::SafeWndProc(const HWND hWnd, const UINT Message, const return WndProc(hWnd, Message, wParam, lParam); } __except (EXCEPTION_EXECUTE_HANDLER) { + // Routine swallow (NOT a crash sink): GW's WndProc raises survivable software + // exceptions; dumping them produced useless code-0 dumps. Real crashes are caught by + // Path A (SetUnhandledExceptionFilter). Left as the original silent fallback. return CallWindowProc(OldWndProc, hWnd, Message, wParam, lParam); } } diff --git a/vendor/gwca/Source/ItemMgr.cpp b/vendor/gwca/Source/ItemMgr.cpp index b970a7c..61824eb 100644 --- a/vendor/gwca/Source/ItemMgr.cpp +++ b/vendor/gwca/Source/ItemMgr.cpp @@ -282,8 +282,12 @@ namespace { IdentifyItem_Func = (IdentifyItem_pt)Scanner::ToFunctionStart(Scanner::FindAssertion("ItCliApi.cpp", "context->itemTable.Get(srcItemId)", 0, 0)); Logger::AssertAddress("IdentifyItem_Func", (uintptr_t)IdentifyItem_Func, "Item Module"); - address = Scanner::Find("\x83\xc4\x40\x6a\x00\x6a\x19", "xxxxxxx", -0x4e); - DropItem_Func = (DropItem_pt)Scanner::FunctionFromNearCall(address); + // CharMsgSendOrderDrop(item_id, quantity): builds CtoS drop packet {0x2C, item_id, quantity}. + // Old pattern "\x83\xc4\x40\x6a\x00\x6a\x19" -0x4e rotted to 2 matches; + address = Scanner::Find( + "\x8B\x45\x08\x89\x45\xF4\x8B\x45\x0C\x89\x45\xF8\x8D\x45\xF0\x50\x6A\x0C\xC7\x45\xF0\x2C\x00\x00\x00", + "xxxxxxxxxxxxxxxxxxxxxxxxx"); + DropItem_Func = (DropItem_pt)Scanner::ToFunctionStart(address); //commented address is from exe 28-nov-2025 //address = Scanner::Find("\x83\x78\x08\x0a\x75\x10", "xxxxxx", 0xe);