# leakfix.dll — Asheron's Call retail client patches Five memory-leak and crash-stability patches for `acclient.exe` v0.0.11.6096 (End of Retail, January 2017), delivered as a DLL that's loaded via a static PE-import added to acclient.exe. Unpatched control client dies in ~26 hours from palette state exhaustion. With these patches applied, clients reach 5+ days of uptime. ## Install ``` python tools/install_leakfix.py "C:\Turbine\Asheron's Call" ``` The installer adds a `.limport` PE section to `acclient.exe` that imports `leakfix.dll`. Idempotent. Verifies with: ``` python tools/install_leakfix.py "C:\Turbine\Asheron's Call" verify ``` `leakfix.dll` must already be in the same directory as `acclient.exe`. ## What gets patched All five patches are applied 30 seconds after process start (deferred so Decal / UtilityBelt finish their own hooking first). ### v3b — palette over-increment The palette allocator increments a refcount twice when applying a modification but only decrements once. Result: every modified palette leaks one refcount, palettes never reach 0, the cache grows unbounded. On heavy-loot clients this caused the dominant leak class (446 MB / 56k instances at ~26 h). ```c // Before: void makeModifiedPalette(...) { cache_entry->refcount += 1; // first increment — correct ... cache_entry->refcount += 1; // <-- bug: second increment ... } // After: both `inc dword ptr [reg+0x24]` instructions NOP'd out. // Refcount now matches release-side decrements; entries reach 0 and free. ``` Sites: `0x0053EFFE` (3 bytes → `90 90 90`), `0x0053F19C` (3 bytes → `90 90 90`). ### v5 — RenderSurface / RenderTexture PurgeResource override `GraphicsResource::PurgeResource` is a virtual that returns 1 (the "purged" indicator) without actually doing anything in the base class: ```c // Before — slot 2 of RenderSurface and RenderTexture vtables both // pointed at this no-op stub: int GraphicsResource::PurgeResource() { return 1; } ``` The engine then deletes the entry from its purge list, marking the resource "freed" while the D3D handle and shell still exist. Over time these accumulate. ```c // After — vtable slot redirected to our thunk: int our_purge_thunk(this) { this->Destroy(); // actually releases the D3D resource + shell state return 1; } ``` Vtable slot patches at `0x0079A684` (RenderSurface) and `0x0079C1A0` (RenderTexture); thunks call the existing `RenderSurface::Destroy` at `0x00444540` and `RenderTexture::Destroy` at `0x0044C4F0`. ### v11 — dangling-pointer crash guards Two sites that dereference pointers that can already be freed. The engine's existing code has the right "NULL check then skip" structure nearby, but the wrong branch target / instruction order: **Site 1 (`0x00587126`) — hash walk in `delete_contents`:** ```c // Before — when slot has been freed, jumps to the wrong "done" label // and re-dereferences the freed pointer: if (slot == NULL) goto continue_walk; // intent // actually compiled as: goto deref_slot; // bug // After: JMP offset patched (EB 07 -> EB 42) so the NULL case skips // the deref entirely and continues to the next hash bucket. ``` **Site 2 (`0x005E565D`) — `~GXTri3Mesh` vtable slot 0:** ```c // Before — the destructor calls vtable[0] before nulling the field: mov ecx, [eax] // load vtable push eax call [ecx + 8] // dispatch — AVs if eax was already freed mov [esi+8], ebx // then clears the field // After — reorder so the field is cleared first; the now-redundant // virtual call is NOP'd: mov [esi+8], ebx nop nop nop nop nop nop ``` ### v14 — `CEnvCell::Destroy` ClipPlaneList leak When a cell unloads, the engine zeroes out the count of clip planes but never frees the heap-allocated `ClipPlaneList` it points to: ```c // Before — pseudo-C of the 18-byte buggy block at 0x0052E661: ClipPlaneList** outer = this->cplanes; // [esi+0xDC] if (outer) { ClipPlaneList* inner = *outer; if (inner) *outer = 0; // !! leaks `inner` and `outer` } ``` ```c // After — JMP rel32 replaces the 18 bytes; thunk does the real free: if (this->cplanes) { if (*this->cplanes) { (*this->cplanes)->~ClipPlaneList(); operator delete(*this->cplanes); } operator delete[](this->cplanes); this->cplanes = nullptr; } ``` ### v22 — unpacker stale-pointer SEH guard A small inline message-unpacker function dereferences a pointer that the server can cause to be freed mid-execution. On 2026-05-21 a server message simultaneously crashed 5 clients with the same AV. ```c // The vulnerable function at 0x00526A50 (73 bytes): int unpack(this, src, count) { if (count < 0x10) return 0; this->buf[0] = *src->ptr++; this->buf[1] = *src->ptr++; this->buf[2] = *src->ptr++; this->buf[3] = *src->ptr++; // <-- AVs here when src->ptr is stale return 1; } ``` The fix copies the 73-byte body to executable memory, then patches the original entry with a JMP into a wrapper that runs the copy inside `__try / __except`: ```c int unpacker_wrapper(this, src, count) { __try { return original_copy_in_heap(this, src, count); } __except (EXCEPTION_EXECUTE_HANDLER) { // engine's existing "count < 0x10" failure path return 0; } } ``` ## Build from source Requires Visual Studio 2022 Build Tools (x86 toolchain). ``` dll\leakfix\build.bat ``` Output: `dll\leakfix\build\leakfix.dll` (~117 KB). ## Layout ``` README.md dll/leakfix/ build.bat # one-line MSVC build dist/leakfix.dll # prebuilt artifact (SHA 99ab51fe...) src/ dllmain.cpp # DllMain + 30s deferred-patch thread patches.cpp # apply_v3b/v5/v11/v14/v22 thunks.cpp # runtime thunks called by patched code ac_addrs.h # EoR addresses instr.cpp # crash handler (writes minidump on AV) logging.cpp # log file writer tools/ install_leakfix.py # patch acclient.exe to import leakfix.dll check_acclient_imports.py # verify the import is present ```