// instr.cpp — crash dump + periodic instance-count scanner #include "instr.h" #include "logging.h" #include "ac_addrs.h" #include #include #include #include #include #pragma comment(lib, "dbghelp.lib") namespace { // ===== Crash-dump ===== LPTOP_LEVEL_EXCEPTION_FILTER g_prev_filter = nullptr; // Returns directory of our DLL, ending with backslash. void get_dll_dir(char* out, size_t out_sz) { HMODULE h = nullptr; GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPCSTR)&get_dll_dir, &h); char path[MAX_PATH] = {0}; GetModuleFileNameA(h, path, MAX_PATH); size_t n = strlen(path); while (n > 0 && path[n-1] != '\\' && path[n-1] != '/') --n; if (n >= out_sz) n = out_sz - 1; memcpy(out, path, n); out[n] = '\0'; } LONG WINAPI top_level_handler(EXCEPTION_POINTERS* ep) { char dir[MAX_PATH]; get_dll_dir(dir, sizeof(dir)); SYSTEMTIME st; GetLocalTime(&st); char path[MAX_PATH]; std::snprintf(path, sizeof(path), "%sleakfix_crash_%lu_%04d%02d%02d_%02d%02d%02d.dmp", dir, GetCurrentProcessId(), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); leakfix::logf("UNHANDLED EXCEPTION code=0x%08lx addr=0x%p — writing %s", ep->ExceptionRecord->ExceptionCode, ep->ExceptionRecord->ExceptionAddress, path); HANDLE hf = CreateFileA(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (hf != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId = GetCurrentThreadId(); mei.ExceptionPointers = ep; mei.ClientPointers = FALSE; // MiniDumpNormal does NOT walk the heap — safer when crash is // heap-corruption. Includes thread stacks + module info which // is enough to identify the fault site. Previous attempts with // MiniDumpWithDataSegs produced 0-byte files because the // corrupted heap broke MiniDumpWriteDump mid-walk. BOOL ok = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hf, MiniDumpNormal, &mei, nullptr, nullptr); DWORD dump_err = ok ? 0 : GetLastError(); CloseHandle(hf); leakfix::logf("MiniDumpWriteDump: %s err=%lu", ok ? "ok" : "failed", dump_err); // If even MiniDumpNormal fails, write a tiny text file with the // bare minimum info — exception code, address, register state. if (!ok) { char fallback_path[MAX_PATH]; std::snprintf(fallback_path, sizeof(fallback_path), "%s.txt", path); HANDLE tf = CreateFileA(fallback_path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (tf != INVALID_HANDLE_VALUE) { CONTEXT* ctx = ep->ContextRecord; char body[512]; int n = std::snprintf(body, sizeof(body), "exception_code=0x%08lx\n" "exception_address=0x%p\n" "eax=0x%08lx ebx=0x%08lx ecx=0x%08lx edx=0x%08lx\n" "esi=0x%08lx edi=0x%08lx ebp=0x%08lx esp=0x%08lx\n" "eip=0x%08lx eflags=0x%08lx\n", ep->ExceptionRecord->ExceptionCode, ep->ExceptionRecord->ExceptionAddress, ctx->Eax, ctx->Ebx, ctx->Ecx, ctx->Edx, ctx->Esi, ctx->Edi, ctx->Ebp, ctx->Esp, ctx->Eip, ctx->EFlags); DWORD written = 0; WriteFile(tf, body, n, &written, nullptr); CloseHandle(tf); leakfix::logf("fallback text crash info -> %s", fallback_path); } } } else { leakfix::logf("CreateFile failed err=%lu", GetLastError()); } // Chain to previous filter (lets Windows do its thing too). if (g_prev_filter) return g_prev_filter(ep); return EXCEPTION_CONTINUE_SEARCH; } // ===== Periodic scan ===== struct vt_entry { const char* name; uintptr_t vt; }; // Mirrors what tools/snapshot_compare.py tracks. const vt_entry VTABLES[] = { {"uiitem", 0x007C0498}, {"palette", 0x007CAA08}, {"cphysicsobj", 0x007C78EC}, {"renderSurf", 0x0079A67C}, {"renderSurfD3D", 0x00801A94}, {"renderTexD3D", 0x00801A18}, {"csurface", 0x007CA4DC}, {"imgtex", 0x007CAB04}, {"cgfxobj", 0x007CA418}, {"d3dxmesh", 0x007ED3B0}, {"position", 0x00797910}, }; constexpr size_t VT_COUNT = sizeof(VTABLES) / sizeof(VTABLES[0]); HANDLE g_scan_thread = nullptr; HANDLE g_stop_event = nullptr; int g_prev_counts[VT_COUNT] = {0}; bool g_have_prev = false; DWORD g_scan_count = 0; // CPhysicsObj field offsets — VERIFIED against acclient.h + live sample // dumps from iter 2. LongHashData base = 12 bytes (vtable + // hash_next + id), then CPhysicsObj-specific fields follow. constexpr int CPHYS_VT_OFF = 0x00; // CPhysicsObj vt = 0x007C78EC constexpr int CPHYS_HASH_NEXT_OFF = 0x04; // LongHashData chain constexpr int CPHYS_ID_OFF = 0x08; // hash key constexpr int CPHYS_PARTARRAY_OFF = 0x10; // CPartArray* constexpr int CPHYS_PARENT_OFF = 0x40; // CPhysicsObj* parent constexpr int CPHYS_CHILDREN_OFF = 0x44; // CHILDLIST* constexpr int CPHYS_POSITION_OFF = 0x48; // Position (72 B) constexpr int CPHYS_CELL_OFF = 0x90; // CObjCell* constexpr int CPHYS_STATE_OFF = 0xA8; // unsigned int state constexpr int CPHYS_TRANSTATE_OFF = 0xAC; // unsigned int transient_state constexpr int CPHYS_MOVMGR_OFF = 0xC4; // MovementManager* constexpr int CPHYS_POSMGR_OFF = 0xC8; // PositionManager* constexpr int CPHYS_UPDATETIME_OFF= 0xD4; // long double (8 B at runtime) constexpr int CPHYS_HOOKS_OFF = 0x100; // PhysicsObjHook* constexpr int CPHYS_WEENIEOBJ_OFF = 0x12C; // CWeenieObject* // Iter-4 destroy target constexpr uintptr_t CPHYSICSOBJ_DESTROY_VA = 0x005145D0; // Engine global time pointer (TODO: verify offset/symbol) — used for // stale-update_time check. For now we just compare against a wall-time // estimate. constexpr int CPHYS_SAMPLE_BYTES = 384; // dump first 384 B per sample (incl weenie_obj at +0x12C) constexpr int CPHYS_SAMPLE_COUNT = 3; // samples per scan // Find up to `want` instances of vt 0x007C78EC; return how many recorded. int find_physobj_samples(uintptr_t* out_addrs, int want) { int found = 0; MEMORY_BASIC_INFORMATION mbi; uintptr_t addr = 0; while (found < want && VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) { bool committed = mbi.State == MEM_COMMIT; bool priv = mbi.Type == MEM_PRIVATE; DWORD prot = mbi.Protect & 0xFF; bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE); if (committed && priv && readable) { __try { const uint32_t* p = (const uint32_t*)mbi.BaseAddress; size_t n = mbi.RegionSize / 4; for (size_t i = 0; i < n && found < want; ++i) { if (p[i] == 0x007C78EC) { out_addrs[found++] = (uintptr_t)(p + i); } } } __except (EXCEPTION_EXECUTE_HANDLER) {} } uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (next <= addr) break; addr = next; if (addr >= 0x80000000) break; } return found; } void dump_physobj(uintptr_t obj_addr) { uint32_t buf[CPHYS_SAMPLE_BYTES / 4]; __try { memcpy(buf, (const void*)obj_addr, CPHYS_SAMPLE_BYTES); } __except (EXCEPTION_EXECUTE_HANDLER) { leakfix::logf("sample physobj @ 0x%08x: read failed", obj_addr); return; } // Log as 4 DWORDs per line. Annotate each DWORD with a hint: // '0' if null, 'V' if it equals the CPhysicsObj vt (suggests parent), // 'C' if it equals a known CObjCell-family vt (suggests cell), // 'i' if in image range (.text/.rdata), // 'h' if in typical heap range. leakfix::logf("sample physobj @ 0x%08x:", obj_addr); for (int row = 0; row < CPHYS_SAMPLE_BYTES / 16; ++row) { char line[256]; int n = std::snprintf(line, sizeof(line), " +0x%02x: ", row * 16); for (int col = 0; col < 4; ++col) { uint32_t v = buf[row * 4 + col]; char tag = ' '; if (v == 0) tag = '0'; else if (v == 0x007C78EC) tag = 'V'; // CPhysicsObj vt else if (v == 0x007ED3B0 || v == 0x007CA4F0) tag = 'C'; // CObjCell-family vt else if (v >= 0x00400000 && v < 0x00800000) tag = 'i'; // image range else if (v >= 0x01000000 && v < 0x40000000) tag = 'h'; // heap n += std::snprintf(line + n, sizeof(line) - n, "%08x%c ", v, tag); } leakfix::logf("%s", line); } } // ITER 3 — for each CPhysicsObj instance, evaluate "safe to destroy" // predicates. Read-only — no mutation. // // Predicates evaluated (all on the SAME instance): // P_no_parent = (parent == NULL) // P_no_cell = (cell == NULL) // P_orphan_hash = (hash_next == NULL) [not linked into any hash chain] // P_no_part_array = (part_array == NULL) // // Logged combinations: // n_total = all CPhysicsObj found // n_no_parent = count where parent==NULL // n_no_cell = count where cell==NULL // n_orphan_hash = count where hash_next==NULL // n_both = count where parent==NULL AND cell==NULL (STRICT) // n_triple = count where parent==NULL AND cell==NULL AND hash_next==NULL // // The "triple" set is the candidate set we'd target for sweep — physobjs // that are not in any hash chain, not in any cell, and have no parent. // They are by definition unreachable from the engine's active state. void evaluate_predicates_and_dump_candidates() { int n_total = 0; int n_no_parent = 0; int n_no_cell = 0; int n_orphan_hash = 0; int n_both = 0; int n_triple = 0; constexpr int CANDIDATE_DUMP_MAX = 3; uintptr_t candidates[CANDIDATE_DUMP_MAX] = {0}; int n_candidates_recorded = 0; MEMORY_BASIC_INFORMATION mbi; uintptr_t addr = 0; while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) { bool committed = mbi.State == MEM_COMMIT; bool priv = mbi.Type == MEM_PRIVATE; DWORD prot = mbi.Protect & 0xFF; bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE); if (committed && priv && readable) { __try { const uint32_t* p = (const uint32_t*)mbi.BaseAddress; size_t n = mbi.RegionSize / 4; // Walk DWORDs; need at least 0xA8/4 == 42 DWORDs of headroom // for the deepest field we read. size_t need = (CPHYS_STATE_OFF / 4) + 1; if (n < need) { addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (addr >= 0x80000000) break; continue; } for (size_t i = 0; i + need < n; ++i) { if (p[i] != 0x007C78EC) continue; // not CPhysicsObj ++n_total; const uint32_t hash_next = p[i + (CPHYS_HASH_NEXT_OFF / 4)]; const uint32_t parent = p[i + (CPHYS_PARENT_OFF / 4)]; const uint32_t cell = p[i + (CPHYS_CELL_OFF / 4)]; const bool no_parent = (parent == 0); const bool no_cell = (cell == 0); const bool orphan_hash = (hash_next == 0); if (no_parent) ++n_no_parent; if (no_cell) ++n_no_cell; if (orphan_hash) ++n_orphan_hash; if (no_parent && no_cell) ++n_both; if (no_parent && no_cell && orphan_hash) { ++n_triple; if (n_candidates_recorded < CANDIDATE_DUMP_MAX) { candidates[n_candidates_recorded++] = (uintptr_t)(p + i); } } } } __except (EXCEPTION_EXECUTE_HANDLER) {} } uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (next <= addr) break; addr = next; if (addr >= 0x80000000) break; } leakfix::logf("predicates: total=%d no_parent=%d no_cell=%d orphan_hash=%d " "both=%d triple=%d (candidates for sweep)", n_total, n_no_parent, n_no_cell, n_orphan_hash, n_both, n_triple); // Dump first few strict candidates so we can sanity-check they look // like genuinely abandoned objects (no weenie, no part_array, etc.). for (int i = 0; i < n_candidates_recorded; ++i) { leakfix::logf("--- strict candidate %d ---", i); dump_physobj(candidates[i]); } } // ===================================================================== // ITER 4 — STRICT PREDICATES + DESTRUCTION SWEEP // ===================================================================== // // Strict-candidate definition (all must hold): // - parent == NULL (+0x40) // - cell == NULL (+0x90) // - hash_next == NULL (+0x04) // - movement_manager == NULL (+0xC4) // - weenie_obj == NULL (+0x12C) // - state has no high bits set (high 16 bits == 0) // - transient_state == 0 (+0xAC) // // 4a: log candidates, no mutation // 4b: destroy 1 candidate per scan via CPhysicsObj::Destroy then // operator delete typedef void (__fastcall *physobj_destroy_fn)(void* self, void* edx); bool is_strict_abandoned(const uint32_t* p) { if (p[CPHYS_PARENT_OFF / 4] != 0) return false; if (p[CPHYS_CELL_OFF / 4] != 0) return false; if (p[CPHYS_HASH_NEXT_OFF / 4] != 0) return false; if (p[CPHYS_MOVMGR_OFF / 4] != 0) return false; if (p[CPHYS_WEENIEOBJ_OFF / 4] != 0) return false; const uint32_t state = p[CPHYS_STATE_OFF / 4]; if ((state & 0xFFFF0000) != 0) return false; if (p[CPHYS_TRANSTATE_OFF / 4] != 0) return false; return true; } DWORD g_total_destroyed = 0; bool g_sweep_armed = false; // becomes true after first 2 scans without // crashes — gives time to observe candidates // first, then mutate on subsequent scans void evaluate_strict_and_optionally_destroy() { int n_total = 0; int n_strict = 0; uintptr_t first_candidate = 0; uintptr_t first_dump_candidate = 0; uintptr_t second_dump_candidate = 0; MEMORY_BASIC_INFORMATION mbi; uintptr_t addr = 0; while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) { bool committed = mbi.State == MEM_COMMIT; bool priv = mbi.Type == MEM_PRIVATE; DWORD prot = mbi.Protect & 0xFF; bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE); if (committed && priv && readable) { __try { const uint32_t* p = (const uint32_t*)mbi.BaseAddress; size_t n = mbi.RegionSize / 4; // Need to read up to +0x12C / 4 + 1 = 76 DWORDs size_t need = (CPHYS_WEENIEOBJ_OFF / 4) + 1; if (n < need) { addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (addr >= 0x80000000) break; continue; } for (size_t i = 0; i + need < n; ++i) { if (p[i] != 0x007C78EC) continue; ++n_total; if (!is_strict_abandoned(p + i)) continue; ++n_strict; if (!first_candidate) first_candidate = (uintptr_t)(p + i); if (!first_dump_candidate) first_dump_candidate = (uintptr_t)(p + i); else if (!second_dump_candidate) second_dump_candidate = (uintptr_t)(p + i); } } __except (EXCEPTION_EXECUTE_HANDLER) {} } uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (next <= addr) break; addr = next; if (addr >= 0x80000000) break; } leakfix::logf("strict-predicates: total=%d strict_candidates=%d destroyed_so_far=%lu armed=%d", n_total, n_strict, g_total_destroyed, g_sweep_armed ? 1 : 0); // Always dump 2 candidates for forensic comparison if (first_dump_candidate) { leakfix::logf("--- strict-4 candidate #1 (would-destroy first) ---"); dump_physobj(first_dump_candidate); } if (second_dump_candidate) { leakfix::logf("--- strict-4 candidate #2 ---"); dump_physobj(second_dump_candidate); } // Iter 4b — actual destruction. Only after sweep is armed AND a // candidate is available. Throttled to 1 destruction per scan. // Kill switch: env LEAKFIX_NO_SWEEP=1 disables. if (!g_sweep_armed) { // arm on second consecutive scan with candidates static int warmup_scans = 0; if (n_strict > 0) ++warmup_scans; if (warmup_scans >= 2) { g_sweep_armed = true; leakfix::logf("SWEEP ARMED — next scan will destroy 1 candidate per cycle"); } return; } char no_sweep[8] = {0}; GetEnvironmentVariableA("LEAKFIX_NO_SWEEP", no_sweep, sizeof(no_sweep)); if (no_sweep[0] == '1') { leakfix::logf("LEAKFIX_NO_SWEEP=1 — destruction skipped this cycle"); return; } if (!first_candidate) { leakfix::logf("sweep: no candidate this cycle"); return; } leakfix::logf("SWEEP destroying CPhysicsObj @ 0x%p", (void*)first_candidate); __try { physobj_destroy_fn destroy = (physobj_destroy_fn)CPHYSICSOBJ_DESTROY_VA; destroy((void*)first_candidate, 0); // After Destroy, the object's owned state should be cleaned up. // We do NOT free the CPhysicsObj itself yet — Destroy may already // mark it dead and downstream code might still need to deref a // few fields (vtable, etc.). Conservative. ++g_total_destroyed; leakfix::logf("SWEEP ok — total destroyed=%lu", g_total_destroyed); } __except (EXCEPTION_EXECUTE_HANDLER) { leakfix::logf("SWEEP exception inside Destroy(); abandoning sweep this cycle. " "Set LEAKFIX_NO_SWEEP=1 in env to disable."); } } void scan_once() { int counts[VT_COUNT] = {0}; MEMORY_BASIC_INFORMATION mbi; uintptr_t addr = 0; int region_count = 0; while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) { bool committed = mbi.State == MEM_COMMIT; bool priv = mbi.Type == MEM_PRIVATE; DWORD prot = mbi.Protect & 0xFF; bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE); if (committed && priv && readable) { ++region_count; // Scan in DWORD steps. In-process so direct deref is fine, but // wrap with SEH in case of races / partial commits. __try { const uint32_t* p = (const uint32_t*)mbi.BaseAddress; size_t n = mbi.RegionSize / 4; for (size_t i = 0; i < n; ++i) { uint32_t v = p[i]; for (size_t k = 0; k < VT_COUNT; ++k) { if (v == VTABLES[k].vt) { ++counts[k]; break; } } } } __except (EXCEPTION_EXECUTE_HANDLER) { // skip this region — happens occasionally on volatile pages } } uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; if (next <= addr) break; addr = next; if (addr >= 0x80000000) break; // 32-bit user-space ceiling } ++g_scan_count; char buf[1024]; int n = std::snprintf(buf, sizeof(buf), "scan#%lu: regions=%d", g_scan_count, region_count); for (size_t k = 0; k < VT_COUNT && n < (int)sizeof(buf) - 16; ++k) { n += std::snprintf(buf + n, sizeof(buf) - n, " %s=%d", VTABLES[k].name, counts[k]); } leakfix::logf("%s", buf); // Deltas vs previous scan (5-min interval after first scan) if (g_have_prev) { char dbuf[1024]; int dn = std::snprintf(dbuf, sizeof(dbuf), "delta:"); bool any_nonzero = false; for (size_t k = 0; k < VT_COUNT && dn < (int)sizeof(dbuf) - 32; ++k) { int d = counts[k] - g_prev_counts[k]; if (d != 0) any_nonzero = true; dn += std::snprintf(dbuf + dn, sizeof(dbuf) - dn, " %s=%+d", VTABLES[k].name, d); } if (any_nonzero) leakfix::logf("%s", dbuf); } for (size_t k = 0; k < VT_COUNT; ++k) g_prev_counts[k] = counts[k]; g_have_prev = true; // Useful ratios (sanity-check our structural understanding): // position / cphysicsobj should be near 10 for active clients per v17 // diag (each CPhysicsPart has 2 Positions; ~5 parts per physobj) // cphysicsobj count is what the sweep would target if/when we add it if (counts[2] > 0) { // cphysicsobj index = 2 double pos_ratio = (double)counts[10] / (double)counts[2]; // position leakfix::logf("ratio: position/cphysicsobj=%.2f (idle ~7, active ~10)", pos_ratio); } // ITER 2 — sample CPhysicsObj field layouts (every-other scan). if (counts[2] > 0 && (g_scan_count % 2 == 1)) { uintptr_t samples[CPHYS_SAMPLE_COUNT] = {0}; int got = find_physobj_samples(samples, CPHYS_SAMPLE_COUNT); leakfix::logf("physobj-samples: got=%d of %d", got, CPHYS_SAMPLE_COUNT); for (int i = 0; i < got; ++i) dump_physobj(samples[i]); } // ITER 3 — predicate evaluation across ALL CPhysicsObj instances // (every scan; full walk reused from scan_once iteration is too // costly so we do a second pass dedicated to this). if (counts[2] > 0) { evaluate_predicates_and_dump_candidates(); } // ITER 4 — strict predicates + (after 2 warmup scans) destruction. if (counts[2] > 0) { evaluate_strict_and_optionally_destroy(); } } DWORD WINAPI scan_loop(LPVOID) { // First scan ~30s after start so the process is warmed up. if (WaitForSingleObject(g_stop_event, 30000) == WAIT_OBJECT_0) return 0; for (;;) { scan_once(); // Scan every 5 minutes thereafter. if (WaitForSingleObject(g_stop_event, 5 * 60 * 1000) == WAIT_OBJECT_0) return 0; } } } // anon namespace leakfix { void instr_install_crash_handler() { g_prev_filter = SetUnhandledExceptionFilter(top_level_handler); logf("instr: crash handler installed"); } void instr_start_periodic_scan() { if (g_scan_thread) return; g_stop_event = CreateEventA(nullptr, TRUE, FALSE, nullptr); g_scan_thread = CreateThread(nullptr, 0, scan_loop, nullptr, 0, nullptr); logf("instr: periodic scanner started (interval=5min)"); } void instr_stop_periodic_scan() { if (!g_scan_thread) return; if (g_stop_event) SetEvent(g_stop_event); WaitForSingleObject(g_scan_thread, 5000); CloseHandle(g_scan_thread); if (g_stop_event) CloseHandle(g_stop_event); g_scan_thread = nullptr; g_stop_event = nullptr; } } // namespace leakfix