Initial commit — leak-hunt project complete

Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,36 @@
// ac_addrs.h — EoR acclient.exe addresses used by leakfix.dll
// Verified against acclient.exe v0.0.11.6096 EoR (Jan 2017).
#pragma once
#include <cstdint>
namespace ac {
// ===== v3b — palette over-increment NOP =====
constexpr uintptr_t V3B_SITE_1 = 0x0053EFFE; // inc dword [ecx+24] -> NOP NOP NOP
constexpr uintptr_t V3B_SITE_2 = 0x0053F19C; // inc dword [esi+24] -> NOP NOP NOP
// ===== v5 — RenderSurface/Texture PurgeResource override =====
constexpr uintptr_t V5_RS_VTABLE_SLOT_2 = 0x0079A684; // RenderSurface vtable +0x08
constexpr uintptr_t V5_RT_VTABLE_SLOT_2 = 0x0079C1A0; // RenderTexture vtable +0x08
constexpr uintptr_t V5_NOOP_STUB_VA = 0x004154A0; // expected original (mov al,1; ret)
constexpr uintptr_t V5_RS_DESTROY_VA = 0x00444540; // RenderSurface::Destroy
constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
// ===== v11 — NULL-check guards =====
constexpr uintptr_t V11_SITE_1_VA = 0x00587126; // delete_contents JMP retarget
constexpr uintptr_t V11_SITE_2_VA = 0x005E565D; // ~GXTri3Mesh slot 0 NULL-check
// ===== v12 — unpacker validator + dispatch redirect =====
constexpr uintptr_t V12_VALIDATOR_VA = 0x00526A45; // overwrite 11-NOP pad + 18 bytes
constexpr uintptr_t V12_DISPATCH_VA = 0x007C92C8; // dispatch table entry (4 bytes)
constexpr uintptr_t V12_OLD_FUNC_VA = 0x00526A50; // original unpacker entry
// Dispatch points at validator (V12_VALIDATOR_VA) instead
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
constexpr uintptr_t V14_PATCH_SITE_VA = 0x0052E661; // 18-byte leak block
constexpr uintptr_t V14_RESUME_VA = 0x0052E673; // continue here after thunk
constexpr uintptr_t V14_CLIPPLANELIST_DTOR = 0x0053C760; // ClipPlaneList::~ClipPlaneList
constexpr uintptr_t V14_OPERATOR_DELETE = 0x005DF15E; // operator delete
constexpr uintptr_t V14_OPERATOR_DELETE_ARR = 0x005DF164; // operator delete[]
} // namespace ac

View file

@ -0,0 +1,63 @@
// dllmain.cpp — leakfix.dll entry point
#include <windows.h>
#include "logging.h"
#include "patches.h"
#include "instr.h"
namespace {
void on_attach() {
char dll_path[MAX_PATH] = {0};
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
// Log next to the DLL itself
char log_path[MAX_PATH] = {0};
char* slash = nullptr;
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
if (slash) {
size_t prefix = (size_t)(slash - dll_path) + 1;
memcpy(log_path, dll_path, prefix);
strcpy(log_path + prefix, "leakfix.log");
} else {
strcpy(log_path, "leakfix.log");
}
leakfix::log_init(log_path);
leakfix::logf("attach: dll=%s", dll_path);
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
// application. Instrumentation still runs. Used to bisect crashes:
// if the no-patches variant survives, the patches are the trigger.
char no_patches[8] = {0};
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
bool skip_patches = (no_patches[0] == '1');
leakfix::instr_install_crash_handler();
if (skip_patches) {
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
} else {
leakfix::apply_all_patches();
}
leakfix::instr_start_periodic_scan();
}
} // anon
// Exported stub so PE-import-table patching of acclient.exe can name
// a real symbol for the OS loader to resolve. Doing nothing is fine —
// just being present in the DLL is what makes the import valid.
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
return 0;
}
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
switch (reason) {
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(h);
on_attach();
break;
case DLL_PROCESS_DETACH:
leakfix::instr_stop_periodic_scan();
leakfix::logf("detach");
leakfix::log_close();
break;
}
return TRUE;
}

View file

@ -0,0 +1,418 @@
// instr.cpp — crash dump + periodic instance-count scanner
#include "instr.h"
#include "logging.h"
#include "ac_addrs.h"
#include <windows.h>
#include <dbghelp.h>
#include <cstdio>
#include <cstdint>
#include <cstring>
#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_UPDATETIME_OFF= 0xD4; // long double (8 B at runtime)
constexpr int CPHYS_SAMPLE_BYTES = 256; // dump first 256 B per sample
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]);
}
}
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();
}
}
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

View file

@ -0,0 +1,19 @@
// instr.h — instrumentation features for leakfix.dll
#pragma once
namespace leakfix {
// Install SetUnhandledExceptionFilter so any unhandled native exception
// writes a clean minidump to leakfix_crash_<pid>_<timestamp>.dmp next
// to the DLL, then chains to Windows' default handling.
void instr_install_crash_handler();
// Start a background thread that scans memory every 5 minutes,
// counts known leak-class vtable instances, and appends a one-line
// summary to leakfix.log.
void instr_start_periodic_scan();
// Stop the periodic scan thread (called from DLL_PROCESS_DETACH).
void instr_stop_periodic_scan();
} // namespace leakfix

View file

@ -0,0 +1,74 @@
#include "logging.h"
#include <windows.h>
#include <cstdio>
#include <cstdarg>
#include <cstring>
namespace {
HANDLE g_log = INVALID_HANDLE_VALUE;
CRITICAL_SECTION g_cs;
bool g_cs_inited = false;
void ensure_cs() {
if (!g_cs_inited) {
InitializeCriticalSection(&g_cs);
g_cs_inited = true;
}
}
void write_line(const char* s, size_t len) {
if (g_log == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(g_log, s, (DWORD)len, &written, nullptr);
}
}
namespace leakfix {
void log_init(const char* path) {
ensure_cs();
EnterCriticalSection(&g_cs);
if (g_log != INVALID_HANDLE_VALUE) { LeaveCriticalSection(&g_cs); return; }
g_log = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
SetFilePointer(g_log, 0, nullptr, FILE_END);
LeaveCriticalSection(&g_cs);
logf("===== leakfix.dll loaded (pid=%lu) =====", GetCurrentProcessId());
}
void log_close() {
ensure_cs();
EnterCriticalSection(&g_cs);
if (g_log != INVALID_HANDLE_VALUE) {
CloseHandle(g_log);
g_log = INVALID_HANDLE_VALUE;
}
LeaveCriticalSection(&g_cs);
}
void logf(const char* fmt, ...) {
ensure_cs();
char buf[1024];
SYSTEMTIME st;
GetLocalTime(&st);
int n = std::snprintf(buf, sizeof(buf),
"[%04d-%02d-%02d %02d:%02d:%02d.%03d] ",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
va_list ap; va_start(ap, fmt);
int m = std::vsnprintf(buf + n, sizeof(buf) - n - 2, fmt, ap);
va_end(ap);
if (m < 0) m = 0;
int total = n + m;
if (total >= (int)sizeof(buf) - 1) total = sizeof(buf) - 2;
buf[total] = '\n';
buf[total + 1] = '\0';
EnterCriticalSection(&g_cs);
write_line(buf, (size_t)total + 1);
LeaveCriticalSection(&g_cs);
// Also forward to debugger if attached
OutputDebugStringA(buf);
}
} // namespace leakfix

View file

@ -0,0 +1,8 @@
// logging.h — minimal file-based logging for leakfix.dll
#pragma once
namespace leakfix {
void log_init(const char* path); // open log file (relative to acclient.exe dir if not absolute)
void log_close();
void logf(const char* fmt, ...); // appends a timestamped line
} // namespace leakfix

View file

@ -0,0 +1,195 @@
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
#include "patches.h"
#include "logging.h"
#include "thunks.h"
#include "ac_addrs.h"
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <cstdio>
using namespace leakfix;
namespace {
// Copy `data` to absolute address `addr`, flipping page protection.
bool write_memory(uintptr_t addr, const void* data, size_t len) {
DWORD old = 0;
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
return false;
}
std::memcpy((void*)addr, data, len);
DWORD restored = 0;
VirtualProtect((void*)addr, len, old, &restored);
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
return true;
}
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
return std::memcmp((void*)addr, expected, len) == 0;
}
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
const uint8_t* p = (const uint8_t*)addr;
size_t used = 0;
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
}
}
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
// up to `total_replace` with 0x90 NOPs.
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
uint8_t buf[64];
if (total_replace > sizeof(buf)) return false;
int32_t rel = (int32_t)(target - (at + 5));
buf[0] = 0xE9;
std::memcpy(buf + 1, &rel, 4);
std::memset(buf + 5, 0x90, total_replace - 5);
return write_memory(at, buf, total_replace);
}
} // anon namespace
namespace leakfix {
// ===== v3b =====
bool apply_v3b() {
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
logf("v3b: already applied");
return true;
}
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
logf("v3b: site1 unexpected bytes %s — refusing", h);
return false;
}
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
logf("v3b: site2 unexpected bytes %s — refusing", h);
return false;
}
write_memory(ac::V3B_SITE_1, nops, 3);
write_memory(ac::V3B_SITE_2, nops, 3);
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
return true;
}
// ===== v5 =====
bool apply_v5() {
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
if (!rs_done) {
if (rs_cur != ac::V5_NOOP_STUB_VA) {
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
} else {
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
logf("v5: RS vtable slot -> 0x%08x", rs_new);
}
} else {
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
}
if (!rt_done) {
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
logf("v5: RT vtable slot -> 0x%08x", rt_new);
} else {
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
}
return true;
}
// ===== v11 =====
bool apply_v11() {
// Site 1: 2-byte rewrite of a JMP target
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
logf("v11: site1 already patched");
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
logf("v11: site1 patched");
} else {
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
logf("v11: site1 unexpected %s — skipping", h);
}
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
logf("v11: site2 already patched");
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
logf("v11: site2 patched");
} else {
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
logf("v11: site2 unexpected %s — skipping", h);
}
return true;
}
// ===== v12 RETIRED =====
// v12 was designed against post-Decal in-memory bytes that don't match
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
// Decal init), it sees the truly-original bytes and v12 would refuse.
// When the Python patcher ran later against a running PID, it saw
// Decal-modified bytes that happened to match its expected pattern and
// applied a duplicate range check — adding no protection beyond what
// Decal already provides. Neither variant prevented the Shadow/Frank
// stale-heap-pointer crashes. v12 removed.
// ===== v14 =====
bool apply_v14() {
static const uint8_t ORIG[18] = {
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x08, // jz +8
0x8B, 0x00, // mov eax, [eax]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x02, // jz +2
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
};
// If already patched, the first byte is 0xE9 (our JMP).
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
if (cur == 0xE9) {
logf("v14: already applied");
return true;
}
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
logf("v14: site unexpected bytes %s — refusing", h);
return false;
}
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
return true;
}
bool apply_all_patches() {
bool ok = true;
ok &= apply_v3b();
ok &= apply_v11();
ok &= apply_v5();
ok &= apply_v14();
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
return ok;
}
} // namespace leakfix

View file

@ -0,0 +1,16 @@
#pragma once
namespace leakfix {
// Returns true if all patches applied (or were already in place).
bool apply_all_patches();
bool apply_v3b();
bool apply_v5();
bool apply_v11();
bool apply_v14();
// v12 retired: it was a duplicate of Decal's built-in unpacker range
// check and didn't address the actual Shadow/Frank crash class
// (stale-heap-pointer in cursor). See memory.
} // namespace leakfix

View file

@ -0,0 +1,222 @@
# Iter 4 — CPhysicsObj sweep design (DRAFT, NOT YET IMPLEMENTED)
## Goal
Periodically destroy abandoned CPhysicsObj instances to recover the
residual leak documented in §6.1 of REPORT.md. **Highest-risk patch
class** (physics-state mutation, same risk profile as v13 which
killed Larsson at 98 min). Long soak per change is mandatory.
## What iter 3 told us
After 13 minutes on Unkle Leo (PID 16044), a typical scan shows:
```
total=971 no_parent=546 no_cell=278 orphan_hash=697 both=234 triple=111
```
So ~11% of all CPhysicsObj instances pass the strict triple predicate.
On a fresh client triple count is ~100 (startup residual). Growth is
+1-2 candidates per minute during normal play.
Strict-candidate sample dumps confirm:
- `parent`, `cell`, `hash_next` all NULL ✓
- `part_array` non-NULL (heap allocation that should be freed)
- `shadow_objects.data` non-NULL (heap allocation that should be freed)
- `state` has small bits set (e.g., 0x00000414 — normal active flags)
This matches the v17 owner-vtable diagnostic's "abandoned but heap state
still allocated" pattern.
## Candidate destruction call
The engine already has correct teardown:
```c
// EoR 0x005145D0 — CPhysicsObj::Destroy
void __thiscall CPhysicsObj::Destroy(CPhysicsObj* this);
```
Per the v17 owner-diag, `CPhysicsObj::Destroy` correctly tears down
all owned heap state (`CPartArray::DestroyParts`, etc.). The leak is
that it's never **called** on these abandoned objects.
After Destroy, the CPhysicsObj itself (~408 bytes) needs to be freed
via `operator delete`.
## Predicate hardening (BEFORE we destroy anything)
The triple predicate may not be conservative enough. Additional
checks before destroy:
1. **`update_time` is stale** — field at +0xD4 is a long double
(timestamp). If less than `now() - 60s`, the object hasn't been
touched in a minute. Compare via TimeGetTime() or similar global.
2. **`state` is not "currently active"** — need to identify which
bits indicate "being processed." For now, skip if state has any
high bit set.
3. **`weenie_obj == NULL`** — at +0x?? (need to verify offset).
If a weenie-object still owns this physobj, the engine considers
it alive even if other tracking is gone.
4. **`movement_manager == NULL`** — at +0xC4 per acclient.h
(LongHashData base 12 + ... + 0xB8 should be it). If there's an
active mover, the object is in flight.
5. **`hooks == NULL`** — at +0xE? — animation hooks pending.
The candidate must pass ALL these AND the iter-3 triple predicate.
Stricter than iter 3.
## Safety protocol
1. **Throttle:** max 1 destruction per scan cycle (5 min). Even if 100
candidates qualify, destroy ONE per scan. Surface latent bugs slowly.
2. **Sample-first:** for the first 2 hours, LOG candidate addresses
but do NOT destroy. Verify the candidates stay candidates over
multiple scans (i.e., they're not transient).
3. **Per-scan budget:** if a destruction succeeds, log address +
pre-destroy field dump. If process crashes after, we have the last
destroyed object for forensics.
4. **Kill switch:** check `LEAKFIX_NO_SWEEP=1` env var at scan start.
If set, skip destruction. Default ON (=destroy) once code lands.
5. **Initial test target:** Unkle Leo (current designated guinea pig
per CLAUDE.md). One client only. 4-hour soak before declaring safe.
6. **Failure recovery:** if Unkle Leo crashes within 1 hour of
destruction logic enabling, set the env var, restart with sweep
disabled, mark iter-4 as failed in memory, do not retry without
redesign.
## Implementation outline (when ready)
```cpp
struct CPhysicsObj {
void* vtable; // +0x00
void* hash_next; // +0x04
uint32_t id; // +0x08
void* netblob_list; // +0x0C
void* part_array; // +0x10
// ... 12 bytes of player_vector/distance/CYpt
void* sound_table; // +0x28
uint32_t pad_exam; // +0x2C
void* script_manager; // +0x30
void* physics_script; // +0x34
uint32_t default_script; // +0x38
float script_intensity;// +0x3C
void* parent; // +0x40
void* children; // +0x44
char position[72]; // +0x48
void* cell; // +0x90
uint32_t num_shadow; // +0x94
char shadow_arr[16]; // +0x98 — DArray
uint32_t state; // +0xA8
uint32_t transient_state; // +0xAC
// ... floats
void* movement_manager;// +0xC4
void* position_manager;// +0xC8
int last_move_auto; // +0xCC
int jumped_frame; // +0xD0
double update_time; // +0xD4 (8 bytes)
// ...
void* weenie_obj; // +0x?? TBD
};
typedef void (__fastcall *destroy_fn_t)(CPhysicsObj* self, void* edx);
constexpr destroy_fn_t CPHYSICSOBJ_DESTROY = (destroy_fn_t)0x005145D0;
constexpr void* OP_DELETE = (void*)0x005DF15E;
bool is_truly_abandoned(CPhysicsObj* p) {
if (p->parent) return false;
if (p->cell) return false;
if (p->hash_next) return false;
if (p->movement_manager) return false;
// state mask: bits 0..15 are flags we tolerate; high bits suggest
// active processing
if ((p->state & 0xFFFF0000) != 0) return false;
if (p->weenie_obj) return false; // need offset verified
// update_time stale check
double now = get_engine_time(); // need to find this — e.g., 0x????
if (now - p->update_time < 60.0) return false;
return true;
}
void sweep_once() {
if (env_skip_sweep()) return;
// Walk all CPhysicsObj instances...
CPhysicsObj* victim = nullptr;
for (each CPhysicsObj p) {
if (is_truly_abandoned(p)) { victim = p; break; } // ONLY ONE
}
if (!victim) return;
logf("SWEEP destroying CPhysicsObj @ 0x%p (state=0x%08x)", victim, victim->state);
dump_physobj((uintptr_t)victim); // pre-destroy forensics
__try {
CPHYSICSOBJ_DESTROY(victim, 0);
((void(__fastcall*)(void*, void*))OP_DELETE)(victim, 0);
logf("SWEEP ok");
} __except (EXCEPTION_EXECUTE_HANDLER) {
logf("SWEEP exception — abandoning sweep this scan");
}
}
```
## Known unknowns to resolve before coding
1. **Engine time global address** — for the stale-`update_time` check
2. **`weenie_obj` offset** — need to read acclient.h carefully or sample dumps
3. **State-bit meanings** — which bits indicate "in active processing"
4. **Does `operator delete` of a CPhysicsObj that already had Destroy() called work?**
Destroy probably tears down state but may not free `this`.
5. **What if the object is mid-iteration in some other code?**
destroying it would leave dangling iterators. Need to check the
render loop / update loop doesn't have outstanding refs.
These are NOT minor — getting any wrong = v13-class crash.
## Recommended path
1. **Iter 4a (logging-only):** add the harder predicates (`movement_manager`,
`weenie_obj`, `update_time` stale, state mask). Log candidate count
passing the harder set. Compare to iter-3 triple count. If much
smaller, predicates are stricter and we have higher confidence.
2. **Iter 4b (sample-first):** dump 3 candidates that pass the hard
set every scan. Verify they look genuinely abandoned across multiple
scans.
3. **Iter 4c (destroy 1 per hour, not per scan):** initial mutation
test at the slowest possible rate. Soak 8h+ before declaring safe.
4. **Iter 4d (destroy N per scan, where N = current candidate count):**
only after 4c passes 24h soak.
This is a 3-day minimum process if everything goes right. If a v13-class
crash happens anywhere, restart from 4a with a redesigned predicate.
## Decision gate
Per the soak data on Unkle Leo:
- triple candidate growth: ~5/5min = 1/min
- After 1 hour without sweep: ~60 abandoned physobjs added
- After 24h: ~1440 abandoned
- At ~1KB heap state per physobj: ~1.4 MB/day from this exact predicate
Compare to the agent's CObjCell-family estimate of 7-8 MB/hr. The
triple subset is much smaller than the agent's total. The harder
predicates will be smaller still.
**Question for the decision-maker (the human):** is recovering
~1-2 MB/day per active client worth a v13-class risk? Given the
project's 5-day soak target is already met without iter 4, **the
honest answer is probably NO** — iter 4 buys marginal improvement
at meaningful risk.
If the goal is 10-day uptime for heavy looters, iter 4 might help
but the residual is dominated by other classes (CObjCell, gm*UI
recycle pool, palette outside v3b's scope).
## Recommendation
**Defer iter 4 indefinitely.** Iter 3 instrumentation gives us data
to argue for or against. The DLL form's basic patches (v3b/v5/v11/v14)
are what produces the soak win. Adding sweep is high-risk,
low-marginal-reward.
Keep this document for future reference if a future analyst decides
the residual leak warrants the risk.

View file

@ -0,0 +1,72 @@
// thunks.cpp — runtime replacements called by AC into our DLL
#include "thunks.h"
#include "ac_addrs.h"
// ===== v5 — replacement PurgeResource for RenderSurface / RenderTexture =====
//
// Vtable slots use thiscall (ECX = this). MSVC __fastcall(arg1, arg2)
// receives arg1 in ECX and arg2 in EDX. EDX is scratch from the caller
// and isn't used, so we make it an unused parameter.
//
// Effect: instead of the no-op stub `mov al,1; ret`, we now actually
// call Destroy() on the resource (frees its D3D handle + heap state)
// then return 1 so PurgeOldResources marks it cleanly purged.
typedef void (__fastcall *destroy_fn_t)(void* self, void* edx_unused);
extern "C" int __fastcall purge_rendersurface_thunk(void* self, void* /*edx*/) {
((destroy_fn_t)ac::V5_RS_DESTROY_VA)(self, 0);
return 1;
}
extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
((destroy_fn_t)ac::V5_RT_DESTROY_VA)(self, 0);
return 1;
}
// ===== v14 — CEnvCell::Destroy ClipPlaneList cleanup =====
//
// EoR's CEnvCell::Destroy contains an 18-byte cleanup block at
// 0x0052E661 that only zeros cplane_num without freeing the underlying
// ClipPlaneList object. We replace those 18 bytes with a 5-byte
// JMP rel32 into the naked thunk below + 13 NOPs.
//
// Register context at entry (preserved from caller):
// esi = `this` (CEnvCell)
// ebx = 0 (cleared earlier in Destroy — relied on by the original
// buggy `mov [eax], ebx`)
// edi/ebp = live in surrounding loop
//
// On exit, we JMP to V14_RESUME_VA (the instruction immediately after
// the 18-byte block).
extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
__asm {
pushad ; preserve everything
mov edi, [esi + 0xDC] ; outer ClipPlaneList wrapper ptr
test edi, edi
jz done
mov ecx, [edi] ; inner ClipPlaneList ptr
test ecx, ecx
jz free_outer
// Free the inner ClipPlaneList properly
push ecx
mov eax, ac::V14_CLIPPLANELIST_DTOR
call eax ; ClipPlaneList::~ClipPlaneList (thiscall)
pop ecx
push ecx
mov eax, ac::V14_OPERATOR_DELETE
call eax ; operator delete(inner)
add esp, 4
free_outer:
push edi
mov eax, ac::V14_OPERATOR_DELETE_ARR
call eax ; operator delete[](outer)
add esp, 4
mov dword ptr [esi + 0xDC], 0 ; clear back-pointer
done:
popad
push ac::V14_RESUME_VA ; jmp to resume point
ret
}
}

View file

@ -0,0 +1,15 @@
// thunks.h — replacement functions called by patched code paths
#pragma once
extern "C" {
// v5 replacement vtable slot 2 functions. __thiscall so vtable call ABI matches.
int __fastcall purge_rendersurface_thunk(void* self, void* /*edx_unused*/);
int __fastcall purge_rendertexture_thunk(void* self, void* /*edx_unused*/);
// v14 — naked thunk JMPed to from 0x0052E661.
// Saves regs, frees inner ClipPlaneList, frees outer wrapper, clears the
// back-pointer at [esi+0xDC], restores regs, jumps to V14_RESUME_VA.
void v14_clipplane_cleanup_thunk();
} // extern "C"

View file

@ -0,0 +1,36 @@
// ac_addrs.h — EoR acclient.exe addresses used by leakfix.dll
// Verified against acclient.exe v0.0.11.6096 EoR (Jan 2017).
#pragma once
#include <cstdint>
namespace ac {
// ===== v3b — palette over-increment NOP =====
constexpr uintptr_t V3B_SITE_1 = 0x0053EFFE; // inc dword [ecx+24] -> NOP NOP NOP
constexpr uintptr_t V3B_SITE_2 = 0x0053F19C; // inc dword [esi+24] -> NOP NOP NOP
// ===== v5 — RenderSurface/Texture PurgeResource override =====
constexpr uintptr_t V5_RS_VTABLE_SLOT_2 = 0x0079A684; // RenderSurface vtable +0x08
constexpr uintptr_t V5_RT_VTABLE_SLOT_2 = 0x0079C1A0; // RenderTexture vtable +0x08
constexpr uintptr_t V5_NOOP_STUB_VA = 0x004154A0; // expected original (mov al,1; ret)
constexpr uintptr_t V5_RS_DESTROY_VA = 0x00444540; // RenderSurface::Destroy
constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
// ===== v11 — NULL-check guards =====
constexpr uintptr_t V11_SITE_1_VA = 0x00587126; // delete_contents JMP retarget
constexpr uintptr_t V11_SITE_2_VA = 0x005E565D; // ~GXTri3Mesh slot 0 NULL-check
// ===== v12 — unpacker validator + dispatch redirect =====
constexpr uintptr_t V12_VALIDATOR_VA = 0x00526A45; // overwrite 11-NOP pad + 18 bytes
constexpr uintptr_t V12_DISPATCH_VA = 0x007C92C8; // dispatch table entry (4 bytes)
constexpr uintptr_t V12_OLD_FUNC_VA = 0x00526A50; // original unpacker entry
// Dispatch points at validator (V12_VALIDATOR_VA) instead
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
constexpr uintptr_t V14_PATCH_SITE_VA = 0x0052E661; // 18-byte leak block
constexpr uintptr_t V14_RESUME_VA = 0x0052E673; // continue here after thunk
constexpr uintptr_t V14_CLIPPLANELIST_DTOR = 0x0053C760; // ClipPlaneList::~ClipPlaneList
constexpr uintptr_t V14_OPERATOR_DELETE = 0x005DF15E; // operator delete
constexpr uintptr_t V14_OPERATOR_DELETE_ARR = 0x005DF164; // operator delete[]
} // namespace ac

View file

@ -0,0 +1,103 @@
// dllmain.cpp — leakfix.dll entry point
//
// iter-5 (2026-05-20): patch application is deferred to a worker
// thread that sleeps ~30 seconds before applying. This matches the
// timing of the Python runtime patcher (tools/fleet_monitor.sh),
// which lands its patches well after Decal init is complete. The
// PE-import-load → DllMain → immediate apply_all_patches sequence
// used in iter-1..iter-4 lost the race with Decal's own hook
// installation and crashed some accounts (Unkle Leo most reliably).
// See feedback_dll_load_order_conflict.md.
//
// The SEH crash handler is still installed immediately so any
// crashes during the 30s window (including ones caused by Decal)
// are captured.
#include <windows.h>
#include "logging.h"
#include "patches.h"
#include "instr.h"
namespace {
bool g_skip_patches = false;
DWORD WINAPI deferred_patch_thread(LPVOID) {
// Give Decal / UtilityBelt time to finish their own hook
// installation before we lay our patches on top. 30s matches
// the Python cascade's observed-good timing.
Sleep(30000);
leakfix::logf("deferred-patch thread: woke after 30s, applying patches");
if (g_skip_patches) {
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
} else {
leakfix::apply_all_patches();
}
leakfix::instr_start_periodic_scan();
return 0;
}
void on_attach() {
char dll_path[MAX_PATH] = {0};
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
// Log next to the DLL itself
char log_path[MAX_PATH] = {0};
char* slash = nullptr;
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
if (slash) {
size_t prefix = (size_t)(slash - dll_path) + 1;
memcpy(log_path, dll_path, prefix);
strcpy(log_path + prefix, "leakfix.log");
} else {
strcpy(log_path, "leakfix.log");
}
leakfix::log_init(log_path);
leakfix::logf("attach: dll=%s (iter-5 deferred-patch)", dll_path);
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
// application. Instrumentation still runs. Used to bisect crashes:
// if the no-patches variant survives, the patches are the trigger.
char no_patches[8] = {0};
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
g_skip_patches = (no_patches[0] == '1');
// Crash handler installed immediately so the 30s pre-patch window
// is still observable if Decal/UB crashes the process.
leakfix::instr_install_crash_handler();
HANDLE h = CreateThread(nullptr, 0, deferred_patch_thread, nullptr, 0, nullptr);
if (h) {
CloseHandle(h);
leakfix::logf("deferred-patch thread spawned");
} else {
// CreateThread failure is extraordinary — fall back to the
// old in-DllMain apply so we at least get patches eventually.
leakfix::logf("CreateThread failed (err=%lu) — falling back to in-DllMain apply",
GetLastError());
if (!g_skip_patches) leakfix::apply_all_patches();
leakfix::instr_start_periodic_scan();
}
}
} // anon
// Exported stub so PE-import-table patching of acclient.exe can name
// a real symbol for the OS loader to resolve. Doing nothing is fine —
// just being present in the DLL is what makes the import valid.
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
return 0;
}
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
switch (reason) {
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(h);
on_attach();
break;
case DLL_PROCESS_DETACH:
leakfix::instr_stop_periodic_scan();
leakfix::logf("detach");
leakfix::log_close();
break;
}
return TRUE;
}

View file

@ -0,0 +1,568 @@
// instr.cpp — crash dump + periodic instance-count scanner
#include "instr.h"
#include "logging.h"
#include "ac_addrs.h"
#include <windows.h>
#include <dbghelp.h>
#include <cstdio>
#include <cstdint>
#include <cstring>
#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

View file

@ -0,0 +1,19 @@
// instr.h — instrumentation features for leakfix.dll
#pragma once
namespace leakfix {
// Install SetUnhandledExceptionFilter so any unhandled native exception
// writes a clean minidump to leakfix_crash_<pid>_<timestamp>.dmp next
// to the DLL, then chains to Windows' default handling.
void instr_install_crash_handler();
// Start a background thread that scans memory every 5 minutes,
// counts known leak-class vtable instances, and appends a one-line
// summary to leakfix.log.
void instr_start_periodic_scan();
// Stop the periodic scan thread (called from DLL_PROCESS_DETACH).
void instr_stop_periodic_scan();
} // namespace leakfix

View file

@ -0,0 +1,74 @@
#include "logging.h"
#include <windows.h>
#include <cstdio>
#include <cstdarg>
#include <cstring>
namespace {
HANDLE g_log = INVALID_HANDLE_VALUE;
CRITICAL_SECTION g_cs;
bool g_cs_inited = false;
void ensure_cs() {
if (!g_cs_inited) {
InitializeCriticalSection(&g_cs);
g_cs_inited = true;
}
}
void write_line(const char* s, size_t len) {
if (g_log == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(g_log, s, (DWORD)len, &written, nullptr);
}
}
namespace leakfix {
void log_init(const char* path) {
ensure_cs();
EnterCriticalSection(&g_cs);
if (g_log != INVALID_HANDLE_VALUE) { LeaveCriticalSection(&g_cs); return; }
g_log = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
SetFilePointer(g_log, 0, nullptr, FILE_END);
LeaveCriticalSection(&g_cs);
logf("===== leakfix.dll loaded (pid=%lu) =====", GetCurrentProcessId());
}
void log_close() {
ensure_cs();
EnterCriticalSection(&g_cs);
if (g_log != INVALID_HANDLE_VALUE) {
CloseHandle(g_log);
g_log = INVALID_HANDLE_VALUE;
}
LeaveCriticalSection(&g_cs);
}
void logf(const char* fmt, ...) {
ensure_cs();
char buf[1024];
SYSTEMTIME st;
GetLocalTime(&st);
int n = std::snprintf(buf, sizeof(buf),
"[%04d-%02d-%02d %02d:%02d:%02d.%03d] ",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
va_list ap; va_start(ap, fmt);
int m = std::vsnprintf(buf + n, sizeof(buf) - n - 2, fmt, ap);
va_end(ap);
if (m < 0) m = 0;
int total = n + m;
if (total >= (int)sizeof(buf) - 1) total = sizeof(buf) - 2;
buf[total] = '\n';
buf[total + 1] = '\0';
EnterCriticalSection(&g_cs);
write_line(buf, (size_t)total + 1);
LeaveCriticalSection(&g_cs);
// Also forward to debugger if attached
OutputDebugStringA(buf);
}
} // namespace leakfix

View file

@ -0,0 +1,8 @@
// logging.h — minimal file-based logging for leakfix.dll
#pragma once
namespace leakfix {
void log_init(const char* path); // open log file (relative to acclient.exe dir if not absolute)
void log_close();
void logf(const char* fmt, ...); // appends a timestamped line
} // namespace leakfix

View file

@ -0,0 +1,195 @@
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
#include "patches.h"
#include "logging.h"
#include "thunks.h"
#include "ac_addrs.h"
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <cstdio>
using namespace leakfix;
namespace {
// Copy `data` to absolute address `addr`, flipping page protection.
bool write_memory(uintptr_t addr, const void* data, size_t len) {
DWORD old = 0;
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
return false;
}
std::memcpy((void*)addr, data, len);
DWORD restored = 0;
VirtualProtect((void*)addr, len, old, &restored);
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
return true;
}
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
return std::memcmp((void*)addr, expected, len) == 0;
}
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
const uint8_t* p = (const uint8_t*)addr;
size_t used = 0;
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
}
}
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
// up to `total_replace` with 0x90 NOPs.
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
uint8_t buf[64];
if (total_replace > sizeof(buf)) return false;
int32_t rel = (int32_t)(target - (at + 5));
buf[0] = 0xE9;
std::memcpy(buf + 1, &rel, 4);
std::memset(buf + 5, 0x90, total_replace - 5);
return write_memory(at, buf, total_replace);
}
} // anon namespace
namespace leakfix {
// ===== v3b =====
bool apply_v3b() {
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
logf("v3b: already applied");
return true;
}
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
logf("v3b: site1 unexpected bytes %s — refusing", h);
return false;
}
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
logf("v3b: site2 unexpected bytes %s — refusing", h);
return false;
}
write_memory(ac::V3B_SITE_1, nops, 3);
write_memory(ac::V3B_SITE_2, nops, 3);
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
return true;
}
// ===== v5 =====
bool apply_v5() {
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
if (!rs_done) {
if (rs_cur != ac::V5_NOOP_STUB_VA) {
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
} else {
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
logf("v5: RS vtable slot -> 0x%08x", rs_new);
}
} else {
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
}
if (!rt_done) {
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
logf("v5: RT vtable slot -> 0x%08x", rt_new);
} else {
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
}
return true;
}
// ===== v11 =====
bool apply_v11() {
// Site 1: 2-byte rewrite of a JMP target
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
logf("v11: site1 already patched");
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
logf("v11: site1 patched");
} else {
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
logf("v11: site1 unexpected %s — skipping", h);
}
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
logf("v11: site2 already patched");
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
logf("v11: site2 patched");
} else {
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
logf("v11: site2 unexpected %s — skipping", h);
}
return true;
}
// ===== v12 RETIRED =====
// v12 was designed against post-Decal in-memory bytes that don't match
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
// Decal init), it sees the truly-original bytes and v12 would refuse.
// When the Python patcher ran later against a running PID, it saw
// Decal-modified bytes that happened to match its expected pattern and
// applied a duplicate range check — adding no protection beyond what
// Decal already provides. Neither variant prevented the Shadow/Frank
// stale-heap-pointer crashes. v12 removed.
// ===== v14 =====
bool apply_v14() {
static const uint8_t ORIG[18] = {
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x08, // jz +8
0x8B, 0x00, // mov eax, [eax]
0x3B, 0xC3, // cmp eax, ebx
0x74, 0x02, // jz +2
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
};
// If already patched, the first byte is 0xE9 (our JMP).
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
if (cur == 0xE9) {
logf("v14: already applied");
return true;
}
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
logf("v14: site unexpected bytes %s — refusing", h);
return false;
}
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
return true;
}
bool apply_all_patches() {
bool ok = true;
ok &= apply_v3b();
ok &= apply_v11();
ok &= apply_v5();
ok &= apply_v14();
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
return ok;
}
} // namespace leakfix

View file

@ -0,0 +1,16 @@
#pragma once
namespace leakfix {
// Returns true if all patches applied (or were already in place).
bool apply_all_patches();
bool apply_v3b();
bool apply_v5();
bool apply_v11();
bool apply_v14();
// v12 retired: it was a duplicate of Decal's built-in unpacker range
// check and didn't address the actual Shadow/Frank crash class
// (stale-heap-pointer in cursor). See memory.
} // namespace leakfix

View file

@ -0,0 +1,222 @@
# Iter 4 — CPhysicsObj sweep design (DRAFT, NOT YET IMPLEMENTED)
## Goal
Periodically destroy abandoned CPhysicsObj instances to recover the
residual leak documented in §6.1 of REPORT.md. **Highest-risk patch
class** (physics-state mutation, same risk profile as v13 which
killed Larsson at 98 min). Long soak per change is mandatory.
## What iter 3 told us
After 13 minutes on Unkle Leo (PID 16044), a typical scan shows:
```
total=971 no_parent=546 no_cell=278 orphan_hash=697 both=234 triple=111
```
So ~11% of all CPhysicsObj instances pass the strict triple predicate.
On a fresh client triple count is ~100 (startup residual). Growth is
+1-2 candidates per minute during normal play.
Strict-candidate sample dumps confirm:
- `parent`, `cell`, `hash_next` all NULL ✓
- `part_array` non-NULL (heap allocation that should be freed)
- `shadow_objects.data` non-NULL (heap allocation that should be freed)
- `state` has small bits set (e.g., 0x00000414 — normal active flags)
This matches the v17 owner-vtable diagnostic's "abandoned but heap state
still allocated" pattern.
## Candidate destruction call
The engine already has correct teardown:
```c
// EoR 0x005145D0 — CPhysicsObj::Destroy
void __thiscall CPhysicsObj::Destroy(CPhysicsObj* this);
```
Per the v17 owner-diag, `CPhysicsObj::Destroy` correctly tears down
all owned heap state (`CPartArray::DestroyParts`, etc.). The leak is
that it's never **called** on these abandoned objects.
After Destroy, the CPhysicsObj itself (~408 bytes) needs to be freed
via `operator delete`.
## Predicate hardening (BEFORE we destroy anything)
The triple predicate may not be conservative enough. Additional
checks before destroy:
1. **`update_time` is stale** — field at +0xD4 is a long double
(timestamp). If less than `now() - 60s`, the object hasn't been
touched in a minute. Compare via TimeGetTime() or similar global.
2. **`state` is not "currently active"** — need to identify which
bits indicate "being processed." For now, skip if state has any
high bit set.
3. **`weenie_obj == NULL`** — at +0x?? (need to verify offset).
If a weenie-object still owns this physobj, the engine considers
it alive even if other tracking is gone.
4. **`movement_manager == NULL`** — at +0xC4 per acclient.h
(LongHashData base 12 + ... + 0xB8 should be it). If there's an
active mover, the object is in flight.
5. **`hooks == NULL`** — at +0xE? — animation hooks pending.
The candidate must pass ALL these AND the iter-3 triple predicate.
Stricter than iter 3.
## Safety protocol
1. **Throttle:** max 1 destruction per scan cycle (5 min). Even if 100
candidates qualify, destroy ONE per scan. Surface latent bugs slowly.
2. **Sample-first:** for the first 2 hours, LOG candidate addresses
but do NOT destroy. Verify the candidates stay candidates over
multiple scans (i.e., they're not transient).
3. **Per-scan budget:** if a destruction succeeds, log address +
pre-destroy field dump. If process crashes after, we have the last
destroyed object for forensics.
4. **Kill switch:** check `LEAKFIX_NO_SWEEP=1` env var at scan start.
If set, skip destruction. Default ON (=destroy) once code lands.
5. **Initial test target:** Unkle Leo (current designated guinea pig
per CLAUDE.md). One client only. 4-hour soak before declaring safe.
6. **Failure recovery:** if Unkle Leo crashes within 1 hour of
destruction logic enabling, set the env var, restart with sweep
disabled, mark iter-4 as failed in memory, do not retry without
redesign.
## Implementation outline (when ready)
```cpp
struct CPhysicsObj {
void* vtable; // +0x00
void* hash_next; // +0x04
uint32_t id; // +0x08
void* netblob_list; // +0x0C
void* part_array; // +0x10
// ... 12 bytes of player_vector/distance/CYpt
void* sound_table; // +0x28
uint32_t pad_exam; // +0x2C
void* script_manager; // +0x30
void* physics_script; // +0x34
uint32_t default_script; // +0x38
float script_intensity;// +0x3C
void* parent; // +0x40
void* children; // +0x44
char position[72]; // +0x48
void* cell; // +0x90
uint32_t num_shadow; // +0x94
char shadow_arr[16]; // +0x98 — DArray
uint32_t state; // +0xA8
uint32_t transient_state; // +0xAC
// ... floats
void* movement_manager;// +0xC4
void* position_manager;// +0xC8
int last_move_auto; // +0xCC
int jumped_frame; // +0xD0
double update_time; // +0xD4 (8 bytes)
// ...
void* weenie_obj; // +0x?? TBD
};
typedef void (__fastcall *destroy_fn_t)(CPhysicsObj* self, void* edx);
constexpr destroy_fn_t CPHYSICSOBJ_DESTROY = (destroy_fn_t)0x005145D0;
constexpr void* OP_DELETE = (void*)0x005DF15E;
bool is_truly_abandoned(CPhysicsObj* p) {
if (p->parent) return false;
if (p->cell) return false;
if (p->hash_next) return false;
if (p->movement_manager) return false;
// state mask: bits 0..15 are flags we tolerate; high bits suggest
// active processing
if ((p->state & 0xFFFF0000) != 0) return false;
if (p->weenie_obj) return false; // need offset verified
// update_time stale check
double now = get_engine_time(); // need to find this — e.g., 0x????
if (now - p->update_time < 60.0) return false;
return true;
}
void sweep_once() {
if (env_skip_sweep()) return;
// Walk all CPhysicsObj instances...
CPhysicsObj* victim = nullptr;
for (each CPhysicsObj p) {
if (is_truly_abandoned(p)) { victim = p; break; } // ONLY ONE
}
if (!victim) return;
logf("SWEEP destroying CPhysicsObj @ 0x%p (state=0x%08x)", victim, victim->state);
dump_physobj((uintptr_t)victim); // pre-destroy forensics
__try {
CPHYSICSOBJ_DESTROY(victim, 0);
((void(__fastcall*)(void*, void*))OP_DELETE)(victim, 0);
logf("SWEEP ok");
} __except (EXCEPTION_EXECUTE_HANDLER) {
logf("SWEEP exception — abandoning sweep this scan");
}
}
```
## Known unknowns to resolve before coding
1. **Engine time global address** — for the stale-`update_time` check
2. **`weenie_obj` offset** — need to read acclient.h carefully or sample dumps
3. **State-bit meanings** — which bits indicate "in active processing"
4. **Does `operator delete` of a CPhysicsObj that already had Destroy() called work?**
Destroy probably tears down state but may not free `this`.
5. **What if the object is mid-iteration in some other code?**
destroying it would leave dangling iterators. Need to check the
render loop / update loop doesn't have outstanding refs.
These are NOT minor — getting any wrong = v13-class crash.
## Recommended path
1. **Iter 4a (logging-only):** add the harder predicates (`movement_manager`,
`weenie_obj`, `update_time` stale, state mask). Log candidate count
passing the harder set. Compare to iter-3 triple count. If much
smaller, predicates are stricter and we have higher confidence.
2. **Iter 4b (sample-first):** dump 3 candidates that pass the hard
set every scan. Verify they look genuinely abandoned across multiple
scans.
3. **Iter 4c (destroy 1 per hour, not per scan):** initial mutation
test at the slowest possible rate. Soak 8h+ before declaring safe.
4. **Iter 4d (destroy N per scan, where N = current candidate count):**
only after 4c passes 24h soak.
This is a 3-day minimum process if everything goes right. If a v13-class
crash happens anywhere, restart from 4a with a redesigned predicate.
## Decision gate
Per the soak data on Unkle Leo:
- triple candidate growth: ~5/5min = 1/min
- After 1 hour without sweep: ~60 abandoned physobjs added
- After 24h: ~1440 abandoned
- At ~1KB heap state per physobj: ~1.4 MB/day from this exact predicate
Compare to the agent's CObjCell-family estimate of 7-8 MB/hr. The
triple subset is much smaller than the agent's total. The harder
predicates will be smaller still.
**Question for the decision-maker (the human):** is recovering
~1-2 MB/day per active client worth a v13-class risk? Given the
project's 5-day soak target is already met without iter 4, **the
honest answer is probably NO** — iter 4 buys marginal improvement
at meaningful risk.
If the goal is 10-day uptime for heavy looters, iter 4 might help
but the residual is dominated by other classes (CObjCell, gm*UI
recycle pool, palette outside v3b's scope).
## Recommendation
**Defer iter 4 indefinitely.** Iter 3 instrumentation gives us data
to argue for or against. The DLL form's basic patches (v3b/v5/v11/v14)
are what produces the soak win. Adding sweep is high-risk,
low-marginal-reward.
Keep this document for future reference if a future analyst decides
the residual leak warrants the risk.

View file

@ -0,0 +1,72 @@
// thunks.cpp — runtime replacements called by AC into our DLL
#include "thunks.h"
#include "ac_addrs.h"
// ===== v5 — replacement PurgeResource for RenderSurface / RenderTexture =====
//
// Vtable slots use thiscall (ECX = this). MSVC __fastcall(arg1, arg2)
// receives arg1 in ECX and arg2 in EDX. EDX is scratch from the caller
// and isn't used, so we make it an unused parameter.
//
// Effect: instead of the no-op stub `mov al,1; ret`, we now actually
// call Destroy() on the resource (frees its D3D handle + heap state)
// then return 1 so PurgeOldResources marks it cleanly purged.
typedef void (__fastcall *destroy_fn_t)(void* self, void* edx_unused);
extern "C" int __fastcall purge_rendersurface_thunk(void* self, void* /*edx*/) {
((destroy_fn_t)ac::V5_RS_DESTROY_VA)(self, 0);
return 1;
}
extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
((destroy_fn_t)ac::V5_RT_DESTROY_VA)(self, 0);
return 1;
}
// ===== v14 — CEnvCell::Destroy ClipPlaneList cleanup =====
//
// EoR's CEnvCell::Destroy contains an 18-byte cleanup block at
// 0x0052E661 that only zeros cplane_num without freeing the underlying
// ClipPlaneList object. We replace those 18 bytes with a 5-byte
// JMP rel32 into the naked thunk below + 13 NOPs.
//
// Register context at entry (preserved from caller):
// esi = `this` (CEnvCell)
// ebx = 0 (cleared earlier in Destroy — relied on by the original
// buggy `mov [eax], ebx`)
// edi/ebp = live in surrounding loop
//
// On exit, we JMP to V14_RESUME_VA (the instruction immediately after
// the 18-byte block).
extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
__asm {
pushad ; preserve everything
mov edi, [esi + 0xDC] ; outer ClipPlaneList wrapper ptr
test edi, edi
jz done
mov ecx, [edi] ; inner ClipPlaneList ptr
test ecx, ecx
jz free_outer
// Free the inner ClipPlaneList properly
push ecx
mov eax, ac::V14_CLIPPLANELIST_DTOR
call eax ; ClipPlaneList::~ClipPlaneList (thiscall)
pop ecx
push ecx
mov eax, ac::V14_OPERATOR_DELETE
call eax ; operator delete(inner)
add esp, 4
free_outer:
push edi
mov eax, ac::V14_OPERATOR_DELETE_ARR
call eax ; operator delete[](outer)
add esp, 4
mov dword ptr [esi + 0xDC], 0 ; clear back-pointer
done:
popad
push ac::V14_RESUME_VA ; jmp to resume point
ret
}
}

View file

@ -0,0 +1,15 @@
// thunks.h — replacement functions called by patched code paths
#pragma once
extern "C" {
// v5 replacement vtable slot 2 functions. __thiscall so vtable call ABI matches.
int __fastcall purge_rendersurface_thunk(void* self, void* /*edx_unused*/);
int __fastcall purge_rendertexture_thunk(void* self, void* /*edx_unused*/);
// v14 — naked thunk JMPed to from 0x0052E661.
// Saves regs, frees inner ClipPlaneList, frees outer wrapper, clears the
// back-pointer at [esi+0xDC], restores regs, jumps to V14_RESUME_VA.
void v14_clipplane_cleanup_thunk();
} // extern "C"