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

View file

@ -0,0 +1,121 @@
"""dump_leaked_objects.py <dump.dmp>
For each object with primary vtable 0x007caa08, dump full state.
Also dump the first 256 bytes of the buffer it points to (if any).
"""
import struct, sys
from collections import Counter
from minidump.minidumpfile import MinidumpFile
VTABLE_PRIMARY = 0x007caa08
VTABLE_SECONDARY = 0x007ca9f4
SECONDARY_OFFSET = 0x30
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])
reader = md.get_reader().get_buffered_reader()
# Leaked regions
leaked = []
leaked_set = set()
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 == 0x20000 and pr in (0x04, 0x40) \
and 256*1024 <= r.RegionSize < 512*1024:
leaked.append((r.BaseAddress, r.RegionSize))
leaked_set.add(r.BaseAddress)
def in_leaked(p):
if p in leaked_set: return True
for b, s in leaked:
if b <= p < b + s: return (b, s)
return False
# Find all instances of vtable 0x007caa08 at offset 0
scan = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
scan.append((r.BaseAddress, r.RegionSize))
objs = []
for base, size in scan:
try:
reader.move(base); buf = reader.read(size)
except Exception: continue
if not buf: continue
end = (len(buf) // 4) * 4
for off in range(0, end - 0x48, 4):
if struct.unpack_from("<I", buf, off)[0] != VTABLE_PRIMARY: continue
# Verify secondary vtable at +0x30
sec = struct.unpack_from("<I", buf, off + SECONDARY_OFFSET)[0]
if sec != VTABLE_SECONDARY: continue
obj_addr = base + off
fields = [struct.unpack_from("<I", buf, off + i*4)[0] for i in range(18)]
objs.append((obj_addr, fields))
print(f"objects with primary vtable 0x{VTABLE_PRIMARY:08x}: {len(objs)}")
print()
# Histogram +0x38 (size field) and +0x40 (buffer) leaked-in
size_hist = Counter()
buf_leaked = 0
buf_null = 0
refcount_hist = Counter()
for addr, f in objs:
# +0x10 = refcount per DBObj (slot 4 of 0x007caa08 is AddRef at refcount +0x10 ... actually
# DBObj refcount per DBObj::AddRef decompile is at +0x24)
rc = f[9] # +0x24
sz_field = f[0xe] # +0x38
buf_ptr = f[0x10] # +0x40
refcount_hist[rc] += 1
size_hist[sz_field] += 1
if buf_ptr == 0:
buf_null += 1
else:
r = in_leaked(buf_ptr)
if r:
buf_leaked += 1
print(f"+0x40 buffer pointer: null={buf_null}, in_leaked_256-512KB={buf_leaked}")
print(f"+0x38 size field top 10:")
for sz, n in size_hist.most_common(10):
print(f" size=0x{sz:x} ({sz}) count={n}")
print(f"+0x24 refcount top 10:")
for rc, n in refcount_hist.most_common(10):
print(f" rc={rc} count={n}")
# Show first 10 examples
print()
print("first 10 objects (addr, vtbl, +4..+0x44):")
for addr, f in objs[:10]:
flds = " ".join(f"{v:08x}" for v in f)
print(f" 0x{addr:08x} {flds}")
# For objects with non-null +0x40, dump first bytes of that buffer
print()
print("=== sample buffers at +0x40 (first 64 bytes) ===")
n_dumped = 0
for addr, f in objs:
if n_dumped >= 8: break
buf_ptr = f[0x10]
if buf_ptr == 0: continue
try:
reader.move(buf_ptr); raw = reader.read(64)
except Exception:
continue
if not raw: continue
h = " ".join(f"{b:02x}" for b in raw)
print(f" obj 0x{addr:08x} +0x40 -> 0x{buf_ptr:08x}: {h}")
n_dumped += 1
if __name__ == "__main__":
main()