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

53
tools/inspect_vtable.py Normal file
View file

@ -0,0 +1,53 @@
"""inspect_vtable.py <dump.dmp> <vtable_va> [num_slots]
Read N dwords starting at vtable_va. For each, mark whether it is in
code memory (image, executable). A valid vtable is a row of >= 4
contiguous code pointers.
"""
import struct, sys
from minidump.minidumpfile import MinidumpFile
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def main():
md = MinidumpFile.parse(sys.argv[1])
vt = int(sys.argv[2], 16)
n = int(sys.argv[3]) if len(sys.argv) > 3 else 32
# Module map
mods = []
for m in md.modules.modules:
mods.append((m.baseaddress, m.size, m.name.split("\\")[-1]))
def mod_of(a):
for b, s, nm in mods:
if b <= a < b + s: return nm
return None
# Executable image-region cache
exec_ranges = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st == 0x1000 and ty == 0x1000000 and pr in (0x20, 0x80):
exec_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
def is_exec(a):
for lo, hi in exec_ranges:
if lo <= a < hi: return True
return False
rdr = md.get_reader().get_buffered_reader()
rdr.move(vt)
buf = rdr.read(n * 4)
print(f"vtable @ 0x{vt:08x} ({mod_of(vt) or '?'}):")
for i in range(n):
v = struct.unpack_from("<I", buf, i*4)[0]
owner = mod_of(v) or "?"
exe = "CODE" if is_exec(v) else " "
print(f" [{i:2d}] +0x{i*4:02x} 0x{v:08x} {exe} ({owner})")
if __name__ == "__main__":
main()