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

111
tools/find_mesh_holders.py Normal file
View file

@ -0,0 +1,111 @@
"""find_mesh_holders.py <pid>
Find what classes hold D3DXMesh (vtable 0x007ed3b0) references in a
live process.
Method:
1. Enumerate D3DXMesh instances (DWORDs equal to vtable 0x007ed3b0)
2. Scan all committed RW memory for DWORDs equal to a D3DXMesh
instance address those are pointers TO meshes
3. For each pointer hit, walk backwards up to 0x100 bytes within
the same region to find the containing object's vtable
4. Histogram by (owner_vtable, offset_of_mesh_pointer_within_owner)
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007ed3b0
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000; MEM_IMAGE=0x1000000
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.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
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
if not h: print('open fail'); sys.exit(1)
# Pass 1: enumerate regions and snapshot RW + image
rw_regions = [] # (base, data)
image_ranges = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and (mbi.Protect&0xff) != 0x01:
if mbi.Type==MEM_IMAGE:
image_ranges.append((mbi.BaseAddress, mbi.BaseAddress+mbi.RegionSize))
elif mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) 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)) and sz.value:
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
def is_image(p):
for lo, hi in image_ranges:
if lo <= p < hi: return True
return False
# Pass 2: find all D3DXMesh instance addresses
mesh_addrs = set()
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-4, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
mesh_addrs.add(base + off)
print(f'D3DXMesh instances: {len(mesh_addrs)}')
# Pass 3: scan all RW memory for DWORDs equal to a mesh address (= pointer to a mesh)
hits = [] # list of (referring_addr, mesh_addr)
mesh_addr_set = mesh_addrs
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-4, 4):
v = struct.unpack_from('<I', data, off)[0]
if v in mesh_addr_set:
# exclude self-references (vtable slot inside the mesh's own object data)
if base + off in mesh_addr_set: continue
hits.append((base + off, v, base, off, data))
print(f'pointers TO meshes: {len(hits)}')
# Pass 4: for each hit, look backwards within same region for vtable (image-resident DWORD)
LOOKBACK = 0x100
vtable_hits = Counter()
field_offsets = Counter()
examples = {}
no_vt = 0
for hit_va, ptr_val, base, off, data in hits:
found = False
start = max(0, off - LOOKBACK)
for back in range(off - 4, start - 4, -4):
if back < 0: break
v = struct.unpack_from('<I', data, back)[0]
if 0x00400000 <= v < 0x10000000 and is_image(v):
field_off = off - back
vtable_hits[(v, field_off)] += 1
field_offsets[v] += 1
ex = examples.setdefault((v, field_off), [])
if len(ex) < 2:
ex.append((hit_va, ptr_val))
found = True
break
if not found:
no_vt += 1
print(f'pointers with no preceding vtable in 0x100 lookback: {no_vt}')
print()
print('=== Top owner vtables (D3DXMesh refs) ===')
for vt, cnt in field_offsets.most_common(15):
print(f' 0x{vt:08x} total refs: {cnt}')
print()
print('=== Top (vtable, field_offset) pairs ===')
for (vt, fo), cnt in vtable_hits.most_common(20):
ex = examples[(vt, fo)][0]
print(f' 0x{vt:08x} +0x{fo:03x} count={cnt} e.g. hit@0x{ex[0]:08x} -> mesh@0x{ex[1]:08x}')