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,73 @@
"""dump_260k_content.py <pid>
For a few 260KB regions, dump first 128 bytes + mid + tail to characterize
whether they're zeroed (free pool), structured (live d3d9 surface), or
texture data."""
import ctypes, ctypes.wintypes as wt, sys, struct
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
class MBI(ctypes.Structure):
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t),
("State", wt.DWORD), ("Protect", wt.DWORD), ("Type", wt.DWORD)]
def rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(0x410, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
# Find 260KB regions
candidates = []
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
candidates.append(base)
next_addr = base + mbi.RegionSize
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
print(f"260KB regions: {len(candidates)}")
# Histogram of first DWORD across ALL of them
from collections import Counter
first_dwords = []
for b in candidates:
d = rd(h, b, 4)
if d: first_dwords.append(struct.unpack('<I', d)[0])
c = Counter(first_dwords)
print("Top first-DWORD values (offset 0):")
for v, n in c.most_common(8):
print(f" 0x{v:08x}: {n} ({n*100/len(first_dwords):.1f}%)")
# Sample a few in detail
import random
random.seed(42)
sample_count = 4
sample = random.sample(candidates, sample_count)
for b in sample:
print(f"\n=== Region @0x{b:08x} (260KB) ===")
for off, label in [(0, "first 64B"), (256, "+0x100"),
(4096, "+0x1000 (start of pixel data?)"),
(133120, "midpoint"),
(262144, "+0x40000 (256KB mark)"),
(266112, "last 128 bytes")]:
d = rd(h, b + off, 64 if 'first' in label or 'last' not in label else 128)
if not d: continue
# Count zero bytes
zeros = d.count(0)
non_zero = len(d) - zeros
print(f" +0x{off:06x} ({label}, {zeros}/{len(d)} zero): "
f"{d[:32].hex(' ')}{'...' if len(d)>32 else ''}")
k.CloseHandle(h)