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

179
tools/byte_accounting.py Normal file
View file

@ -0,0 +1,179 @@
"""byte_accounting.py <pid>
Walk all committed memory in a target process and categorize bytes by:
- VAD region type (private vs mapped vs image)
- Protection (RW vs RX vs RWX)
- Size bucket
- Known-class signature scan (vtable bytes within the region)
Output: per-category totals so we can see where the working set lives."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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),
]
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
# Known class vtables from instr.cpp
KNOWN_VTABLES = {
0x007C78EC: "CPhysicsObj",
0x0079A67C: "RenderSurface",
0x0079C198: "RenderTexture",
0x00801A94: "RenderSurfaceD3D",
0x00801A18: "RenderTextureD3D",
0x007CA4DC: "CSurface(GR)",
0x007CAB04: "ImgTex(GR)",
0x007CA418: "CGfxObj",
0x007ED3B0: "GXTri3Mesh",
0x007E4F70: "ACCWeenieObject",
0x007E4ED8: "CWeenieObject",
}
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(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
# Totals by region type
total_committed = 0
by_type = {"private_rw": 0, "private_rx": 0, "private_rwx": 0,
"mapped": 0, "image": 0, "other": 0}
# Bytes attributed to each known class (rough estimate: vtable_count × likely class size)
class_size_estimate = {
"CPhysicsObj": 376, # +0x178 from constructor allocation
"RenderSurface": 288,
"RenderTexture": 152,
"RenderSurfaceD3D": 304,
"RenderTextureD3D": 176,
"CSurface(GR)": 144,
"ImgTex(GR)": 136,
"CGfxObj": 200, # estimate
"GXTri3Mesh": 1000, # estimate; large mesh class
"ACCWeenieObject": 336,
"CWeenieObject": 200, # estimate
}
class_instance_count = {name: 0 for name in KNOWN_VTABLES.values()}
# Histogram of private-RW region sizes
size_buckets = [
(0, 4096, "<4K"),
(4096, 65536, "4K-64K"),
(65536, 262144, "64K-256K"),
(262144, 1048576, "256K-1M"),
(1048576, 4194304, "1M-4M"),
(4194304, 16777216, "4M-16M"),
(16777216, 67108864, "16M-64M"),
(67108864, 1<<31, "64M+"),
]
bucket_count = [0] * len(size_buckets)
bucket_bytes = [0] * len(size_buckets)
def classify_size(n):
for i, (lo, hi, _) in enumerate(size_buckets):
if lo <= n < hi: return i
return len(size_buckets) - 1
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
region_base = mbi.BaseAddress or 0
region_size = mbi.RegionSize
if mbi.State == MEM_COMMIT:
total_committed += region_size
prot = mbi.Protect & 0xFF
# Classify region type
if mbi.Type == MEM_IMAGE:
by_type["image"] += region_size
elif mbi.Type == MEM_MAPPED:
by_type["mapped"] += region_size
elif mbi.Type == MEM_PRIVATE:
if prot == 0x40: by_type["private_rwx"] += region_size
elif prot == 0x20: by_type["private_rx"] += region_size
elif prot == 0x04: by_type["private_rw"] += region_size
else: by_type["other"] += region_size
else:
by_type["other"] += region_size
# For private RW/RWX: bucket by size + scan for known vtables
if mbi.Type == MEM_PRIVATE and prot in (0x04, 0x40):
bi = classify_size(region_size)
bucket_count[bi] += 1
bucket_bytes[bi] += region_size
# Scan for known vtable bytes (skip huge regions to bound time)
if region_size <= 64 * 1024 * 1024:
try:
data = rd(h, region_base, region_size)
if data:
for vt, name in KNOWN_VTABLES.items():
vt_bytes = struct.pack('<I', vt)
count = 0
off = 0
while True:
off = data.find(vt_bytes, off)
if off < 0: break
if (off & 3) == 0: count += 1
off += 4
class_instance_count[name] += count
except Exception:
pass
next_addr = region_base + region_size
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
def mb(n): return f"{n/(1024*1024):,.1f}"
print(f"=== pid {pid} byte accounting ===")
print(f"Total committed: {mb(total_committed)} MB")
print()
print("By region type:")
for label, n in by_type.items():
pct = (n*100/total_committed) if total_committed else 0
print(f" {label:14s} {mb(n):>9} MB ({pct:5.1f}%)")
print()
print("Private RW/RWX region size distribution:")
print(f" {'bucket':<12} {'count':>6} {'total MB':>10}")
for i, (lo, hi, label) in enumerate(size_buckets):
if bucket_count[i] == 0: continue
print(f" {label:<12} {bucket_count[i]:>6} {mb(bucket_bytes[i]):>10}")
print()
print("Known-class vtable counts (and estimated bytes):")
print(f" {'class':<22} {'count':>6} {'est bytes':>12} {'est MB':>8}")
total_class_bytes = 0
for name in sorted(class_instance_count, key=lambda x: -class_instance_count[x]):
n = class_instance_count[name]
if n == 0: continue
sz = class_size_estimate.get(name, 200)
bytes_total = n * sz
total_class_bytes += bytes_total
print(f" {name:<22} {n:>6} {bytes_total:>12,} {mb(bytes_total):>8}")
print()
print(f" Identified-class total: ~{mb(total_class_bytes)} MB")
print(f" Of private RW: ~{mb(by_type['private_rw'])} MB")
unidentified = by_type['private_rw'] - total_class_bytes
print(f" UNIDENTIFIED in priv RW: ~{mb(unidentified)} MB ← what we don't account for")