leakhunt/tools/byte_accounting.py
acbot 57b5e43d0e 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>
2026-05-23 21:07:58 +02:00

179 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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