Asheron Call EoR memory leak hunt � fixes + investigation tooling
Find a file
acbot 92ec4e5ecf Clean source + repo down to deliverables only
- 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).
2026-05-26 20:50:57 +02:00
dll/leakfix Clean source + repo down to deliverables only 2026-05-26 20:50:57 +02:00
tools Clean source + repo down to deliverables only 2026-05-26 20:50:57 +02:00
.gitignore Initial commit — leak-hunt project complete 2026-05-23 21:07:58 +02:00
README.md Clean source + repo down to deliverables only 2026-05-26 20:50:57 +02:00

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).

// 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:

// 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.

// 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:

// 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:

// 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:

// 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`
}
// 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.

// 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:

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