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

196
dll/leakfix/src/ac_addrs.h Normal file
View file

@ -0,0 +1,196 @@
// 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[]
// ===== v18 — s_Resources sweep (free v5-purged shells) =====
// EoR GraphicsResource::s_Resources (SmartArray<GraphicsResource*, 1>):
// +0x0: m_data (GraphicsResource**)
// +0x4: m_sizeAndDeallocate (high bit = own-buffer flag)
// +0x8: m_num
constexpr uintptr_t V18_S_RESOURCES_VA = 0x008398C4; // &s_Resources
constexpr uintptr_t V18_S_RESOURCES_MNUM_VA = 0x008398CC; // &s_Resources.m_num
// GraphicsResource subobject layout (offsets from entry pointer):
// +0x00: vfptr (subobject vtable)
// +0x04: padding (zero)
// +0x08: m_bIsLost byte (1 = purged shell, eligible for sweep)
// +0x10: m_TimeUsed (8 B long double)
// +0x18: m_FrameUsed
// +0x1C: m_bIsThrashable + m_AutoRestore + pad
// +0x20: m_nResourceSize (0 after Destroy)
// +0x24: m_ListIndex (-1 sentinel after UnlinkResource)
// UnlinkResource (cdecl, arg1 = GraphicsResource*); 2013 0x00446B70 + 0x160
constexpr uintptr_t V18_UNLINK_RESOURCE_VA = 0x00446CD0;
// Whitelisted vfptrs eligible for sweep (only the v5-patched classes —
// their Destroy is known to leave the shell with NULL state fields).
constexpr uintptr_t V18_VTABLE_RENDERSURF = 0x0079A67C; // RenderSurface base
constexpr uintptr_t V18_VTABLE_RENDERTEX = 0x0079C198; // RenderTexture
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy redirect =====
// Mirror v5 for the D3D subclass that v5 correctly skipped.
//
// RenderSurfaceD3D is a GraphicsResource subclass whose own PurgeResource
// (0x00696D90) releases the D3D9 IUnknown but never destroys the 304-byte
// C++ shell. Result: 37-50% of s_Resources entries with vtable 0x00801A94
// flagged m_bIsLost=true accumulate indefinitely (v18-A1 evidence).
//
// Fix: replace slot 2 of the GR secondary vtable with a thunk that calls
// RenderSurfaceD3D::Destroy. CRITICAL: Destroy expects PRIMARY `this`
// (proven by its internal `lea ecx, [esi+0x30]` before MarkResourceAsNotLost),
// but the engine dispatches PurgeResource with GR-view `this`. The thunk
// must adjust ecx by -0x30 before calling Destroy.
constexpr uintptr_t V20_RSD3D_GR_VTABLE_SLOT_2 = 0x00801A9C; // 0x00801A94 + 0x08
constexpr uintptr_t V20_RSD3D_PURGE_VA = 0x00696D90; // expected current value
constexpr uintptr_t V20_RSD3D_DESTROY_VA = 0x00696EB0; // RenderSurfaceD3D::Destroy
// ===== v19 — feed iter-3-triple CPhysicsObj candidates to AC's safe
// destruction queue via CObjectMaint::AddObjectToBeDestroyed.
//
// Predicates (from project_iter3_predicate_data — ~9-15% of CPhysicsObjs
// pass the "triple" set):
// parent == NULL (+0x40)
// cell == NULL (+0x90)
// hash_next == NULL (+0x04 — not linked into any hash chain)
//
// Each candidate's id (+0x08) is pushed to AC's destruction queue with a
// 25s delay. AC's Tick processor (CObjectMaint at 2013 0x005089b0) drains
// the queue and calls vtable->RecvNotice_SetSelectedItem(id), which is
// AC's native destruction-by-id path. This uses AC's existing safe
// machinery; we just feed it data it wasn't seeing.
//
// Gated by env LEAKFIX_V19_FEED — default OFF (count-only).
constexpr uintptr_t V19_OBJ_MAINT_GLOBAL = 0x00844D64; // CPhysicsObj::obj_maint (CObjectMaint**)
constexpr uintptr_t V19_ADD_TO_DESTROY_VA = 0x00509A40; // CObjectMaint::AddObjectToBeDestroyed (__thiscall: ECX=this, stack=id)
// ===== v25 — D3D9 texture create tracker =====
//
// Hooks IDirect3DDevice9::CreateTexture (vtable slot 23) to log every
// texture allocation with the caller's return address. AC's d3d9.dll
// is dynamically loaded, so we find it at runtime via GetModuleHandle.
// The device vtable is identified by its slot count (119 valid d3d9
// pointers — only IDirect3DDevice9 is that large).
constexpr int V25_DEVICE_EVICT_MANAGED_SLOT = 5; // IDirect3DDevice9::EvictManagedResources
constexpr int V25_DEVICE_CREATE_TEXTURE_SLOT = 23; // IDirect3DDevice9::CreateTexture
constexpr int V25_DEVICE_CREATE_OFFSCREEN_SLOT = 36; // IDirect3DDevice9::CreateOffscreenPlainSurface
// This is the dominant 260KB-allocator path
// for AC's RenderSurfaceD3D::CreateD3DSurface
constexpr int V25_DEVICE_MIN_VTABLE_SLOTS = 110; // signature for "is the device" (119 expected; allow margin)
constexpr int V25_MAX_CALLER_BUCKETS = 64; // distinct caller VAs to track
// AC global pointer chain to the active IDirect3DDevice9.
// Per RenderSurfaceD3D::CreateD3DSurface decomp:
// (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0x90))(...)
// Resolves as: device = *(*( *(uint32_t*)0x00870340 ) + 0x468); vt = *device; fn = vt[36].
constexpr uintptr_t V25_AC_GLOBAL_VA = 0x00870340;
constexpr uintptr_t V25_AC_DEVICE_FIELD_OFFSET = 0x468;
// ===== v22 — unpacker stale-pointer SEH guard =====
//
// Small inline unpacker at 0x00526A50 (73 bytes, no Ghidra name).
// Pulls 4 consecutive DWORDs from arg1->buffer into this+4/+8/+C/+10,
// auto-advancing the buffer pointer. Crashes have repeatedly been
// observed at +0x3A (0x00526A8A: `mov edx, [edx]` — the 4th deref)
// when arg1->buffer points at freed/kernel memory. Server-driven —
// the 09:00 2026-05-21 incident hit 5 clients simultaneously.
//
// v12 (retired) tried entry validation but the bad pointer arrives
// mid-execution. v22 takes a different approach: copy the function
// body to executable memory, replace the original entry with a JMP
// to a C wrapper that runs the copy inside __try/__except. On any
// AV, returns 0 — which the engine already handles as the
// size-check-failure code path (line 1 of the original returns 0).
constexpr uintptr_t V22_UNPACKER_VA = 0x00526A50; // function entry
constexpr size_t V22_UNPACKER_LEN = 76; // 73-byte body + 3 NOP tail
// (round up to avoid clipping ret)
// ===== v23 — CPhysicsObj orphan-creation hook =====
//
// CObjectMaint::ReleaseObjCell (0x005086E0) filters cell-unload destruction
// with `(state & 1) == 0 AND parent == NULL` — children of CPhysicsObjs are
// silently skipped. Later, CPhysicsObj::unparent_children (0x00513FE0) nulls
// their parent without calling AddObjectToBeDestroyed. Result: orphan with
// parent=NULL, cell=NULL, hash_next=NULL still held by CObjectMaint::object_table
// and LongHash<CPhysicsObj>::Node. Exactly matches the iter-3 triple signature.
//
// v23 hooks the `mov dword ptr [esi+0x40], 0` instruction (the parent-NULL
// write inside unparent_children) and calls AddObjectToBeDestroyed on the
// child being orphaned. AC's 25-second deferred-destroy queue gives the
// engine time to settle any in-flight references — same safety property
// that made v19's feeder safe.
//
// Default: log-only mode (count would-be-enqueues, no FEED). Gated by env
// LEAKFIX_V23_ENQUEUE=1 OR file flag leakfix_v23_enqueue.flag.
//
// Patch site: at 0x00514043, replace 7 bytes `c7 46 40 00 00 00 00`
// (mov [esi+0x40], 0) with `e8 [rel32] 90 90` (5-byte CALL to thunk + 2 NOPs).
// Thunk preserves all regs/flags, calls log_enqueue_orphan_child(esi),
// performs the original mov, returns past the patched bytes.
constexpr uintptr_t V23_PATCH_SITE_VA = 0x00514043; // mov [esi+0x40], 0 inside unparent_children
constexpr uintptr_t V23_UNPARENT_CHILDREN_VA = 0x00513FE0;
// v23b — symmetric hook at the OTHER parent-NULL write site.
// CPhysicsObj::unset_parent (0x00513F70) is the single-object detach
// (also called transitively by both set_parent overloads). Same
// instruction (mov [esi+0x40], 0), same register convention. Catches
// mobile-class detach events that unparent_children misses.
constexpr uintptr_t V23B_PATCH_SITE_VA = 0x00513FAC;
constexpr uintptr_t V23B_UNSET_PARENT_VA = 0x00513F70;
// Field offsets (relative to CPhysicsObj primary this — same as v19/iter-3)
// id at +0x08
// parent at +0x40
// cell at +0x90
// state at +0xA8
// ===== v24 — RenderTextureD3D shell sweep =====
//
// RenderTextureD3D (vtable 0x00801A18 in s_Resources) has the same shape
// as RenderSurfaceD3D: pure-GPU class with no CPU buffers — PurgeResource
// releases the D3D9 IUnknown but leaves the 176-byte C++ shell linked.
// v5-style PurgeResource->Destroy redirect was proven inert by v20.
//
// Only way to recover the shells: invoke the scalar deleting destructor
// (which chains to ~RenderTextureD3D → ~GraphicsResource → UnlinkResource).
// Safety: only destroy entries that have been lost for >= AGE_THRESHOLD
// scans AND have all D3D refs NULL (engine has fully released them).
//
// Default: count-only mode. Gated by env LEAKFIX_V24_SWEEP=1 OR file flag
// leakfix_v24_sweep.flag.
constexpr uintptr_t V24_RTD3D_VTABLE_GR = 0x00801A18; // RenderTextureD3D GR-view vtable
constexpr uintptr_t V24_RTD3D_VTABLE_PRI = 0x00801A28; // RenderTextureD3D primary vtable
constexpr uintptr_t V24_RTD3D_DELETING_DTOR = 0x006969D0; // GR-view adjustor thunk → ~RenderTextureD3D + delete
// signature: __thiscall (this, int flag); flag=1 means operator delete
// Field offsets relative to GR-view this (= primary + 0x30) for predicate:
// m_p2DTextureD3D primary+0x98 => GR_view+0x68
// m_pCubeTextureD3D primary+0x9C => GR_view+0x6C
// m_D3DSurfaces.m_data primary+0xA0 => GR_view+0x70
} // namespace ac

103
dll/leakfix/src/dllmain.cpp Normal file
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;
}

85
dll/leakfix/src/instr.cpp Normal file
View file

@ -0,0 +1,85 @@
// instr.cpp — crash handler only (production build)
//
// Earlier revisions of this file also contained periodic scan code,
// D3D9 lifecycle tracking, region/orphan/heap diagnostics (v25-v38).
// All of that was investigation tooling and has been stripped from the
// production DLL — they did not change runtime behavior, only emitted
// log lines, but they added code surface for no benefit once the
// d3d9-internal-pool conclusion was reached (see REPORT.md §10).
//
// What remains:
// - instr_install_crash_handler() — installs SetUnhandledExceptionFilter
// so any unhandled native exception writes a minidump next to the DLL.
// - instr_start_periodic_scan() / instr_stop_periodic_scan() — stubs
// kept for source-compatibility with dllmain.cpp. No-op.
//
// Everything else from the investigation phase is in git history.
#include "instr.h"
#include "logging.h"
#include <windows.h>
#include <dbghelp.h>
#include <cstdio>
#include <cstdint>
#include <cstring>
#pragma comment(lib, "dbghelp.lib")
namespace {
LPTOP_LEVEL_EXCEPTION_FILTER g_prev_filter = nullptr;
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 = { GetCurrentThreadId(), ep, FALSE };
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
hf, MiniDumpNormal, &mei, nullptr, nullptr);
CloseHandle(hf);
}
if (g_prev_filter) return g_prev_filter(ep);
return EXCEPTION_CONTINUE_SEARCH;
}
} // anon
namespace leakfix {
void instr_install_crash_handler() {
g_prev_filter = SetUnhandledExceptionFilter(top_level_handler);
logf("instr: crash handler installed");
}
// Stubs — kept for source-compat with dllmain.cpp.
void instr_start_periodic_scan() { /* no-op in production */ }
void instr_stop_periodic_scan() { /* no-op in production */ }
} // namespace leakfix

19
dll/leakfix/src/instr.h Normal file
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

362
dll/leakfix/src/patches.cpp Normal file
View file

@ -0,0 +1,362 @@
// 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;
}
// ===== v22 — unpacker stale-pointer SEH guard =====
//
// Function pointer type matching the unpacker's ABI:
// __thiscall (this=ecx, arg1=stack, count=stack), ret 8.
// We declare as __fastcall with a dummy edx param so MSVC emits the
// right calling convention.
typedef int (__fastcall *v22_unpacker_fn_t)(void* self, void* edx_unused, void* arg1, int count);
// Pointer to the relocated copy of the original 73 bytes.
static v22_unpacker_fn_t g_v22_original_copy = nullptr;
// Wrapper that runs the original (via the relocated copy) inside SEH.
// On AV: log + return 0 (engine's existing failure path).
extern "C" int __fastcall v22_unpacker_wrapper(void* self, void* edx,
void* arg1, int count) {
__try {
return g_v22_original_copy(self, edx, arg1, count);
} __except (EXCEPTION_EXECUTE_HANDLER) {
static volatile LONG s_caught = 0;
LONG n = InterlockedIncrement(&s_caught);
// Throttle logging: first 5, then every 256th
if (n <= 5 || (n & 0xFF) == 0) {
logf("v22: caught AV in unpacker (total caught=%ld) — returning 0", n);
}
return 0;
}
}
bool apply_v22() {
// If already patched (first byte = 0xE9 JMP), bail.
uint8_t cur = *(uint8_t*)ac::V22_UNPACKER_VA;
if (cur == 0xE9) {
logf("v22: already applied");
return true;
}
// Expected original first byte: 0x83 (cmp dword ptr [esp+8], 0x10)
if (cur != 0x83) {
char h[16]; hexdump_short(ac::V22_UNPACKER_VA, 5, h, sizeof(h));
logf("v22: site unexpected bytes %s — refusing", h);
return false;
}
// Allocate executable memory for the copy.
void* copy = VirtualAlloc(NULL, ac::V22_UNPACKER_LEN,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!copy) {
logf("v22: VirtualAlloc failed err=%lu", GetLastError());
return false;
}
std::memcpy(copy, (void*)ac::V22_UNPACKER_VA, ac::V22_UNPACKER_LEN);
FlushInstructionCache(GetCurrentProcess(), copy, ac::V22_UNPACKER_LEN);
g_v22_original_copy = (v22_unpacker_fn_t)copy;
// Patch the original entry with JMP rel32 to wrapper.
uintptr_t wrapper_va = (uintptr_t)&v22_unpacker_wrapper;
uint8_t patch[5];
int32_t rel = (int32_t)(wrapper_va - (ac::V22_UNPACKER_VA + 5));
patch[0] = 0xE9;
std::memcpy(patch + 1, &rel, 4);
if (!write_memory(ac::V22_UNPACKER_VA, patch, 5)) {
logf("v22: write_memory failed");
return false;
}
logf("v22: applied (JMP rel32 -> 0x%08x, copy at 0x%p)", wrapper_va, copy);
return true;
}
// ===== v23 — CPhysicsObj orphan-creation hook =====
//
// At 0x00514043 inside CPhysicsObj::unparent_children, the engine has a
// 7-byte instruction `mov dword ptr [esi+0x40], 0` that nulls a child's
// parent pointer. esi at that point holds the child CPhysicsObj* (primary
// view). We replace those 7 bytes with `e8 [rel32 to thunk] 90 90` — a
// 5-byte CALL to our v23_orphan_hook_thunk + 2 NOPs.
//
// The thunk preserves all regs/flags, invokes log_enqueue_orphan_child(esi),
// performs the original `mov [esi+0x40], 0` write, and returns. The thunk
// gates actual destruction-queue enqueue on a file flag — default off.
bool apply_v23() {
// Expected original bytes: c7 46 40 00 00 00 00 (mov dword ptr [esi+0x40], 0)
static const uint8_t ORIG[7] = { 0xC7, 0x46, 0x40, 0x00, 0x00, 0x00, 0x00 };
// If first byte is 0xE8 (CALL), we already applied this patch.
uint8_t cur = *(uint8_t*)ac::V23_PATCH_SITE_VA;
if (cur == 0xE8) {
logf("v23: already applied");
return true;
}
if (!bytes_equal(ac::V23_PATCH_SITE_VA, ORIG, 7)) {
char h[24]; hexdump_short(ac::V23_PATCH_SITE_VA, 7, h, sizeof(h));
logf("v23: site unexpected bytes %s — refusing", h);
return false;
}
uintptr_t thunk_va = (uintptr_t)&v23_orphan_hook_thunk;
// Build: e8 [rel32] 90 90 (7 bytes total)
uint8_t buf[7];
int32_t rel = (int32_t)(thunk_va - (ac::V23_PATCH_SITE_VA + 5));
buf[0] = 0xE8;
std::memcpy(buf + 1, &rel, 4);
buf[5] = 0x90;
buf[6] = 0x90;
if (!write_memory(ac::V23_PATCH_SITE_VA, buf, 7)) return false;
logf("v23: applied (CALL rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
// === v23b — same hook on unset_parent ===
// unset_parent's parent-NULL write is at 0x00513FAC with identical
// 7-byte instruction `mov [esi+0x40], 0` and esi=child. Reuse the
// same thunk; rel32 is recomputed for the new call site.
uint8_t cur_b = *(uint8_t*)ac::V23B_PATCH_SITE_VA;
if (cur_b == 0xE8) {
logf("v23b: already applied");
} else if (!bytes_equal(ac::V23B_PATCH_SITE_VA, ORIG, 7)) {
char h[24]; hexdump_short(ac::V23B_PATCH_SITE_VA, 7, h, sizeof(h));
logf("v23b: site unexpected bytes %s — leaving alone (v23 already applied)", h);
} else {
uint8_t bufB[7];
int32_t relB = (int32_t)(thunk_va - (ac::V23B_PATCH_SITE_VA + 5));
bufB[0] = 0xE8;
std::memcpy(bufB + 1, &relB, 4);
bufB[5] = 0x90;
bufB[6] = 0x90;
if (write_memory(ac::V23B_PATCH_SITE_VA, bufB, 7)) {
logf("v23b: applied (CALL rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
}
}
return true;
}
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy =====
bool apply_v20() {
uintptr_t cur = *(uintptr_t*)ac::V20_RSD3D_GR_VTABLE_SLOT_2;
if (cur != ac::V20_RSD3D_PURGE_VA) {
// Could be already-redirected (re-apply) or unexpected — log and skip
uintptr_t thunk_va = (uintptr_t)&purge_rendersurfaced3d_thunk;
if (cur == thunk_va) {
logf("v20: already redirected to our thunk (0x%08x)", cur);
} else {
logf("v20: slot has unexpected value (0x%08x, expected 0x%08x) — skipping",
cur, ac::V20_RSD3D_PURGE_VA);
}
return true;
}
uintptr_t thunk_va = (uintptr_t)&purge_rendersurfaced3d_thunk;
if (!write_memory(ac::V20_RSD3D_GR_VTABLE_SLOT_2, &thunk_va, 4)) return false;
logf("v20: RSD3D slot 2 -> 0x%08x (was 0x%08x = RenderSurfaceD3D::PurgeResource)",
thunk_va, cur);
return true;
}
bool apply_all_patches() {
bool ok = true;
ok &= apply_v3b();
ok &= apply_v11();
ok &= apply_v5();
ok &= apply_v14();
ok &= apply_v22();
// v23/v23b — CPhysicsObj orphan-creation hook — DISABLED.
// Built and probed 2026-05-21. Captured 240K+ events in soak but
// probe of 50h heavy-looter (Elliot/3872) found only 2 instances
// would actually pass the safe-destroy predicates — the rest are
// inventory items at rest. The "CPhysicsObj-family leak" was a
// misreading of normal inventory state. Same outcome as v20.
// ok &= apply_v23();
// v20 — RenderSurfaceD3D PurgeResource->Destroy redirect — DISABLED.
// Shipped 2026-05-21 and proved inert: RSD3D is pure-GPU and never
// allocates the CPU-side buffers (m_pSurfaceBits, sourceData inner ptr)
// that RS::Destroy would free. v20's added work over the original
// PurgeResource is zero bytes. Code retained (apply_v20 / thunk / addrs)
// for revival if a non-buffer-based cleanup approach is designed.
// See: project_v20_inert_outcome.md
// ok &= apply_v20();
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
return ok;
}
} // namespace leakfix

19
dll/leakfix/src/patches.h Normal file
View file

@ -0,0 +1,19 @@
#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();
bool apply_v20();
bool apply_v22();
bool apply_v23();
// 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.

121
dll/leakfix/src/thunks.cpp Normal file
View file

@ -0,0 +1,121 @@
// thunks.cpp — runtime replacements called by AC into our DLL
//
// Production build: only the v5 / v14 / v20 / v23 thunks remain.
// v25 / v27 / v29 D3D9 instrumentation wrappers were removed once the
// d3d9-internal-pool investigation concluded (see REPORT.md §10).
#include "thunks.h"
#include "ac_addrs.h"
#include <windows.h>
#include <excpt.h>
#include <intrin.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;
}
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy =====
// Engine dispatches via 0x00801A94 slot 2 with GR-view this (s_Resources
// stores GR-view pointers — vfptr at offset 0x00 of the entry IS the GR
// vtable). RenderSurfaceD3D::Destroy at 0x00696EB0 was compiled expecting
// PRIMARY this, so we adjust ecx by -0x30 before calling Destroy.
// Currently disabled in apply_all_patches — retained for revival.
extern "C" int __fastcall purge_rendersurfaced3d_thunk(void* gr_view, void* /*edx*/) {
void* primary = (char*)gr_view - 0x30;
((destroy_fn_t)ac::V20_RSD3D_DESTROY_VA)(primary, 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
}
}
// ===== v23 — CPhysicsObj orphan-creation hook =====
//
// Currently disabled in apply_all_patches. Kept for source completeness.
// Logs every orphan-creation event; would call log_enqueue_orphan_child
// to feed AC's safe destruction queue if re-enabled with a stricter
// predicate (see feedback_v19_inventory_destruction_bug.md for why the
// current iter-3 triple is unsafe).
extern "C" void __cdecl log_enqueue_orphan_child(void* /*child*/) {
// No-op in production build. Earlier versions counted events into
// a histogram + optionally invoked AC's AddObjectToBeDestroyed.
}
extern "C" __declspec(naked) void v23_orphan_hook_thunk() {
__asm {
pushad
pushfd
push esi // arg = child CPhysicsObj* (primary view)
mov eax, log_enqueue_orphan_child
call eax
add esp, 4
popfd
popad
// Now perform the original instruction we displaced:
// mov dword ptr [esi+0x40], 0
mov dword ptr [esi+0x40], 0
ret
}
}

28
dll/leakfix/src/thunks.h Normal file
View file

@ -0,0 +1,28 @@
// 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*/);
// v20 replacement for RenderSurfaceD3D PurgeResource (slot 2 of 0x00801A94).
// Adjusts ecx from GR-view to primary before calling Destroy. (Currently
// disabled in apply_all_patches — kept for source completeness.)
int __fastcall purge_rendersurfaced3d_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();
// v23 — naked thunk CALLed from 0x00514043 (currently disabled in
// apply_all_patches — kept for source completeness).
void v23_orphan_hook_thunk();
// v23 C entrypoint (cdecl). Called from the naked thunk with the child
// CPhysicsObj* (primary view) as its single argument.
void __cdecl log_enqueue_orphan_child(void* child);
} // extern "C"