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

34
dll/leakfix/build.bat Normal file
View file

@ -0,0 +1,34 @@
@echo off
setlocal
pushd "%~dp0"
set "VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars32.bat"
if not exist "%VCVARS%" (
echo ERROR: vcvars32.bat not found at "%VCVARS%"
exit /b 1
)
call "%VCVARS%" >nul
if not exist build mkdir build
cl /LD /nologo /O2 /MT /EHsc /std:c++17 /W3 ^
/D_CRT_SECURE_NO_WARNINGS /D_WIN32_WINNT=0x0601 ^
/Fo"build\\" /Fd"build\\" ^
/Fe"build\leakfix.dll" ^
src\dllmain.cpp src\patches.cpp src\thunks.cpp src\logging.cpp src\instr.cpp ^
/link /SUBSYSTEM:WINDOWS kernel32.lib user32.lib dbghelp.lib /OUT:"build\leakfix.dll"
set RC=%ERRORLEVEL%
if %RC% NEQ 0 (
echo BUILD FAILED rc=%RC%
popd
exit /b %RC%
)
echo.
echo Built: %CD%\build\leakfix.dll
echo.
popd
endlocal

BIN
dll/leakfix/dist/leakfix.dll vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,66 @@
// inject.cpp — load leakfix.dll into a running acclient.exe PID.
//
// Usage: inject.exe <pid> <abs_path_to_leakfix.dll>
//
// Mechanism: OpenProcess + VirtualAllocEx + WriteProcessMemory +
// CreateRemoteThread(LoadLibraryA). Standard Win32 DLL injection.
#include <windows.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
int main(int argc, char** argv) {
if (argc != 3) {
std::printf("usage: %s <pid> <dll_path>\n", argv[0]);
return 1;
}
DWORD pid = (DWORD)std::strtoul(argv[1], nullptr, 10);
const char* dll = argv[2];
HANDLE h = OpenProcess(
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, pid);
if (!h) {
std::printf("OpenProcess(%lu) failed err=%lu\n", pid, GetLastError());
return 2;
}
size_t path_len = std::strlen(dll) + 1;
void* remote = VirtualAllocEx(h, nullptr, path_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remote) {
std::printf("VirtualAllocEx failed err=%lu\n", GetLastError());
CloseHandle(h); return 3;
}
SIZE_T written = 0;
if (!WriteProcessMemory(h, remote, dll, path_len, &written)) {
std::printf("WriteProcessMemory failed err=%lu\n", GetLastError());
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 4;
}
HMODULE k32 = GetModuleHandleA("kernel32.dll");
LPTHREAD_START_ROUTINE loadlib = (LPTHREAD_START_ROUTINE)GetProcAddress(k32, "LoadLibraryA");
if (!loadlib) {
std::printf("GetProcAddress(LoadLibraryA) failed err=%lu\n", GetLastError());
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 5;
}
DWORD tid = 0;
HANDLE thr = CreateRemoteThread(h, nullptr, 0, loadlib, remote, 0, &tid);
if (!thr) {
std::printf("CreateRemoteThread failed err=%lu\n", GetLastError());
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 6;
}
std::printf("injected; remote tid=%lu, waiting for LoadLibraryA to return...\n", tid);
WaitForSingleObject(thr, 30000);
DWORD exit_code = 0;
GetExitCodeThread(thr, &exit_code);
std::printf("LoadLibraryA returned 0x%08lx (non-zero = HMODULE)\n", exit_code);
CloseHandle(thr);
VirtualFreeEx(h, remote, 0, MEM_RELEASE);
CloseHandle(h);
return exit_code ? 0 : 7;
}

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"

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"

View file

@ -0,0 +1,219 @@
"""add_import.py <input.exe> <output.exe> <dll_name>
Patch a PE EXE's import table to add a new DLL import.
The OS loader will pull <dll_name> into the process before the EXE's
entry point runs exactly what we want for leakfix.dll.
Mechanism:
1. Read the PE file.
2. Add a new section called ".limport" at the end with:
- new IMAGE_IMPORT_DESCRIPTOR array (existing entries + ours + null)
- ILT (Import Lookup Table) and IAT for our DLL both pointing
at a single hint/name "LeakfixStub" (any name; doesn't have to
exist since loading the DLL is enough to trigger its DllMain).
- The DLL name string.
- Hint/name table for our exported function.
3. Update OptionalHeader.DataDirectory[1] (IMPORT) to point at our
new array, with the size covering all entries.
4. Write the new file.
We must pick an export name that exists in leakfix.dll for the loader
to resolve at load time, OR we can use ordinal #1 if we export by
ordinal. Simplest: have leakfix.dll export a stub function named
"leakfix_init" via __declspec(dllexport), and reference that here.
"""
import struct, sys, os
PE_MACHINE_I386 = 0x014c
def u8(b, o): return b[o]
def u16(b, o): return struct.unpack_from("<H", b, o)[0]
def u32(b, o): return struct.unpack_from("<I", b, o)[0]
def main():
if len(sys.argv) != 4:
print(__doc__); sys.exit(1)
inp, outp, dll_name = sys.argv[1], sys.argv[2], sys.argv[3]
with open(inp, "rb") as f:
data = bytearray(f.read())
# 1. Locate headers
if data[:2] != b"MZ":
print("not a PE file"); sys.exit(2)
pe_off = u32(data, 0x3c)
if data[pe_off:pe_off+4] != b"PE\0\0":
print("PE signature not found"); sys.exit(2)
machine = u16(data, pe_off + 4)
if machine != PE_MACHINE_I386:
print(f"unexpected machine 0x{machine:04x} (want 0x14c = i386)"); sys.exit(2)
num_sections = u16(data, pe_off + 6)
size_of_optional = u16(data, pe_off + 20)
optional_off = pe_off + 24
section_table_off = optional_off + size_of_optional
# PE32 (not PE32+); confirm magic 0x10b
if u16(data, optional_off) != 0x010b:
print("not PE32 (32-bit) optional header magic"); sys.exit(2)
image_base = u32(data, optional_off + 28)
section_align = u32(data, optional_off + 32)
file_align = u32(data, optional_off + 36)
size_of_image = u32(data, optional_off + 56)
size_of_headers = u32(data, optional_off + 60)
data_dir_off = optional_off + 96 # for PE32
# Existing IMPORT directory
imp_rva = u32(data, data_dir_off + 1*8)
imp_size = u32(data, data_dir_off + 1*8 + 4)
print(f"PE32 image_base=0x{image_base:08x}, sectionAlign=0x{section_align:x}, fileAlign=0x{file_align:x}")
print(f"existing IMPORT dir: rva=0x{imp_rva:08x} size={imp_size}")
# 2. Read all sections
sections = []
for i in range(num_sections):
sh = section_table_off + i * 40
name = bytes(data[sh:sh+8]).rstrip(b"\0").decode("ascii", "replace")
vsize = u32(data, sh+8)
vaddr = u32(data, sh+12)
rsize = u32(data, sh+16)
roff = u32(data, sh+20)
chars = u32(data, sh+36)
sections.append({"name":name, "vsize":vsize, "vaddr":vaddr, "rsize":rsize, "roff":roff, "chars":chars, "sh_off":sh})
# find rva-to-file mapping for IMPORT
def rva_to_off(rva):
for s in sections:
if s["vaddr"] <= rva < s["vaddr"] + max(s["vsize"], s["rsize"]):
return s["roff"] + (rva - s["vaddr"])
return None
imp_off = rva_to_off(imp_rva)
if imp_off is None: print("can't map import RVA"); sys.exit(2)
# 3. Count existing import descriptors (each is 20 bytes; terminated by zero descriptor)
DESC_SZ = 20
existing_descs = bytearray()
pos = imp_off
while True:
d = bytes(data[pos:pos+DESC_SZ])
if d == b"\0"*DESC_SZ: break
existing_descs += d
pos += DESC_SZ
n_existing = len(existing_descs) // DESC_SZ
print(f"existing imports: {n_existing}")
# 4. Build new section
# Section layout (offsets within section start):
# 0x00 new descriptor array: existing descs + our desc + zero terminator
# then ILT: one DWORD pointing at name-table; one DWORD zero (terminator)
# then IAT: same shape
# then name-table: hint(2) + "leakfix_init\0"
# then dll-name: "leakfix.dll\0"
new_section_align = section_align
new_section = bytearray()
# We don't know final RVAs yet; lay out, then patch RVAs at the end.
n_descs = n_existing + 2 # existing + ours + terminator
desc_table_size = n_descs * DESC_SZ
ilt_off = desc_table_size # 2 DWORDs (1 hint+name RVA, 1 terminator)
iat_off = ilt_off + 8
name_table_off = iat_off + 8
func_name = b"leakfix_init\0"
# IMAGE_IMPORT_BY_NAME = WORD hint + name
name_entry = b"\x00\x00" + func_name
if len(name_entry) & 1: name_entry += b"\0"
dll_name_off = name_table_off + len(name_entry)
dll_name_b = dll_name.encode("ascii") + b"\0"
if len(dll_name_b) & 1: dll_name_b += b"\0"
total_data_size = dll_name_off + len(dll_name_b)
# Round section size up to fileAlign for raw, sectionAlign for virtual
def round_up(v, a): return (v + a - 1) & ~(a - 1)
raw_size = round_up(total_data_size, file_align)
virt_size = round_up(total_data_size, section_align)
# Determine new section's RVA: at end of image
last_vend = max((s["vaddr"] + round_up(max(s["vsize"], s["rsize"]), section_align)) for s in sections)
new_vaddr = round_up(last_vend, section_align)
new_roff = len(data) # append to end of file
new_roff = round_up(new_roff, file_align)
# Pad file up to new_roff
if new_roff > len(data):
data += b"\0" * (new_roff - len(data))
# Now we know RVAs. Build section bytes.
sec = bytearray(raw_size)
# Copy existing descriptors verbatim, then append our descriptor, then zero
sec[0:len(existing_descs)] = existing_descs
our_desc_off = len(existing_descs)
# Our descriptor: ILT_RVA, TimeStamp, ForwarderChain, Name_RVA, IAT_RVA
our_ilt_rva = new_vaddr + ilt_off
our_iat_rva = new_vaddr + iat_off
our_name_rva = new_vaddr + dll_name_off
name_entry_rva = new_vaddr + name_table_off
struct.pack_into("<IIIII", sec, our_desc_off,
our_ilt_rva, 0, 0, our_name_rva, our_iat_rva)
# Zero terminator after our descriptor — sec is already zeroed
# ILT/IAT entries (both point at the hint/name)
struct.pack_into("<II", sec, ilt_off, name_entry_rva, 0)
struct.pack_into("<II", sec, iat_off, name_entry_rva, 0)
# Name table
sec[name_table_off:name_table_off + len(name_entry)] = name_entry
# DLL name
sec[dll_name_off:dll_name_off + len(dll_name_b)] = dll_name_b
# Append section bytes to file
data += sec
# 5. Update section table
if num_sections + 1 > (size_of_headers - section_table_off + pe_off) // 40:
# Not enough room in headers for another section entry. Bail.
print("ERROR: no room in PE headers for an additional section entry"); sys.exit(3)
new_sh = section_table_off + num_sections * 40
name_bytes = b".limport"[:8].ljust(8, b"\0")
data[new_sh:new_sh+8] = name_bytes
struct.pack_into("<I", data, new_sh + 8, total_data_size) # VirtualSize
struct.pack_into("<I", data, new_sh + 12, new_vaddr) # VirtualAddress
struct.pack_into("<I", data, new_sh + 16, raw_size) # SizeOfRawData
struct.pack_into("<I", data, new_sh + 20, new_roff) # PointerToRawData
struct.pack_into("<I", data, new_sh + 24, 0) # PointerToRelocations
struct.pack_into("<I", data, new_sh + 28, 0) # PointerToLinenumbers
struct.pack_into("<H", data, new_sh + 32, 0) # NumberOfRelocations
struct.pack_into("<H", data, new_sh + 34, 0) # NumberOfLinenumbers
# Characteristics: CODE? DATA. Use 0x40000040 = INITIALIZED_DATA | READ
# We need WRITE on the IAT but for simple loaders read-only is fine
# because the loader rewrites IAT to actual addresses (writable while loading).
# Use IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE | INITIALIZED_DATA = 0xC0000040
struct.pack_into("<I", data, new_sh + 36, 0xC0000040)
# Bump section count
struct.pack_into("<H", data, pe_off + 6, num_sections + 1)
# 6. Update OptionalHeader: SizeOfImage, IMPORT data directory
new_size_of_image = new_vaddr + virt_size
struct.pack_into("<I", data, optional_off + 56, new_size_of_image)
new_imp_rva = new_vaddr + 0 # descriptor table at start of our section
new_imp_size = (n_existing + 1) * DESC_SZ # not including null terminator per MS spec... but include for safety
struct.pack_into("<II", data, data_dir_off + 1*8, new_imp_rva, new_imp_size + DESC_SZ)
# IAT data directory (index 12) might also need updating — point at our IAT.
# For loaders, IMPORT is what matters; IAT directory is optional. Leave alone.
with open(outp, "wb") as f:
f.write(data)
print(f"wrote {outp} ({len(data)} bytes)")
print(f" new section @ rva 0x{new_vaddr:08x} (file 0x{new_roff:x}), size {raw_size}")
print(f" new IMPORT dir @ rva 0x{new_imp_rva:08x}, descriptors: {n_existing} existing + 1 ours")
fn = func_name.rstrip(b"\0").decode()
print(f" added import: {dll_name} (resolves '{fn}')")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,47 @@
"""list_imports.py <pe.exe> — list all DLLs imported by a PE file."""
import struct, sys
with open(sys.argv[1], "rb") as f:
data = f.read()
pe_off = struct.unpack_from("<I", data, 0x3c)[0]
opt_off = pe_off + 24
num_sec = struct.unpack_from("<H", data, pe_off + 6)[0]
size_opt = struct.unpack_from("<H", data, pe_off + 20)[0]
sec_off = opt_off + size_opt
imp_rva, imp_size = struct.unpack_from("<II", data, opt_off + 96 + 1*8)
# Build RVA->offset map
secs = []
for i in range(num_sec):
sh = sec_off + i*40
vaddr = struct.unpack_from("<I", data, sh+12)[0]
vsize = struct.unpack_from("<I", data, sh+8)[0]
rsize = struct.unpack_from("<I", data, sh+16)[0]
roff = struct.unpack_from("<I", data, sh+20)[0]
secs.append((vaddr, max(vsize, rsize), roff, bytes(data[sh:sh+8]).rstrip(b"\0").decode()))
def rva2off(rva):
for vaddr, vsize, roff, _ in secs:
if vaddr <= rva < vaddr + vsize:
return roff + (rva - vaddr)
return None
print(f"IMPORT dir RVA=0x{imp_rva:08x} size={imp_size}")
print(f"sections:")
for v, sz, r, n in secs:
print(f" {n:>10} vaddr=0x{v:08x} vsize={sz:>8} roff=0x{r:x}")
print("imports:")
pos = rva2off(imp_rva)
i = 0
while True:
desc = data[pos:pos+20]
if desc == b"\0"*20:
print(f" [{i}] (null terminator)")
break
ilt_rva, ts, fwc, name_rva, iat_rva = struct.unpack("<IIIII", desc)
name_off = rva2off(name_rva)
name = bytes(data[name_off:data.index(0, name_off)]).decode("ascii", "replace") if name_off else "?"
print(f" [{i}] {name} (ILT=0x{ilt_rva:08x} IAT=0x{iat_rva:08x})")
pos += 20
i += 1