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,144 @@
"""patch_v15_positionhash_audit.py <pid>
READ-ONLY audit. Does NOT modify the target process.
Goal: confirm that the dominant Position-leak path is through
CBaseQualities::_posStatsTable (PackableHashTable<uint, Position>),
and identify which weenie types host the most retained Positions.
Method:
1. Scan live private memory for Position vt 0x00797910 instances.
2. For each, walk back through nearby heap headers to find the
containing PositionPropertyValue (vt write at offset -0x08 +
m_cRef at -0x04). PositionPropertyValue is the per-property
ref-counted holder that lives inside the hash table.
3. From the PositionPropertyValue, look for back-pointers into a
PackableHashTable node, then up to the owning CBaseQualities.
4. Print: count per (weenie-vt, dominant property key).
Caveats: heap-layout heuristics are fragile. Many Positions are
stack-locals, not heap-allocated those should be filtered out by
checking the alignment-stride of the containing region.
Use this BEFORE designing any v14-style ctor trace, because it
narrows the search: if 95% of leaked Positions are inside one
weenie type's quality table, the patch target is that weenie's
unload path, not Position itself.
"""
import ctypes
import ctypes.wintypes as wt
import struct
import sys
from collections import Counter
POSITION_VT = 0x00797910
# PositionPropertyValue vtable EoR address — DERIVE before use.
# 2013 was at &PositionPropertyValue::vftable near 0x00796928.
# In EoR the address is near 0x00797928 (placeholder; verify).
POSITION_PROP_VAL_VT = 0x00797928 # PLACEHOLDER — verify
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]
k.CloseHandle.restype = wt.BOOL
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, ctypes.c_void_p,
ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
def audit(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
return 1
position_addrs = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == 0x1000 and mbi.Type == 0x20000
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
position_addrs.append((mbi.BaseAddress + off,
mbi.BaseAddress,
mbi.RegionSize))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"Total Position instances found: {len(position_addrs)}")
# Stage 2: classify by containing region size + density to
# separate stack/embedded from heap allocations
region_density = Counter()
for addr_, base, size in position_addrs:
region_density[(base, size)] += 1
print("\nDensity histogram (region_size_kb, hits_per_region, "
"count):")
bucket = Counter()
for (base, size), n in region_density.items():
kb = size // 1024
bucket[(kb, n)] += 1
for (kb, n), c in sorted(bucket.items())[:30]:
if c > 1:
print(f" region={kb:>5}KB hits={n:>4} count={c:>4}")
# Stage 3: for first 200 Position hits, look 8 bytes backwards
# for PositionPropertyValue vtable (PPV layout: [vt][m_cRef][position])
ppv_hits = 0
for i, (paddr, base, size) in enumerate(position_addrs[:1000]):
ppv_vt_addr = paddr - 8
if ppv_vt_addr < base:
continue
# Read 4 bytes
buf4 = (ctypes.c_ubyte * 4)()
sz4 = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, ppv_vt_addr, buf4, 4,
ctypes.byref(sz4)):
continue
vt = struct.unpack("<I", bytes(buf4))[0]
if vt == POSITION_PROP_VAL_VT:
ppv_hits += 1
print(f"\nOf first 1000 Position hits, {ppv_hits} are inside "
f"PositionPropertyValue (vt+m_cRef at -8).")
print("If this number is high (>500), the leak is in the "
"property-value layer.")
print("If low, the leak is in raw Position heap allocations "
"(check PositionPack / position_queue paths).")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(audit(int(sys.argv[1])))