- Remove disabled patches from DLL source (v18/v19/v20/v23/v24 prototype paths and all v25-v38 d3d9 investigation tooling). Production DLL ships v3b/v5/v11/v14/v22 + crash handler only. - Strip repo to 16 files: README, .gitignore, DLL source (8 files), build.bat, prebuilt dist/leakfix.dll, two installer tools. - Rewrite README around per-patch pseudo-C showing each bug + fix. - Update dist/leakfix.dll to the cleaned-source build (SHA 99ab51fe).
199 lines
6 KiB
Markdown
199 lines
6 KiB
Markdown
# 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
|
|
```
|