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,102 @@
"""probe_260k_allocation_structure.py <pid>
For each 260KB private RW region, dump VirtualQuery details (AllocationBase,
Protect, AllocationProtect, RegionSize) plus VirtualQuery for the pages
immediately AFTER the region to detect if it's inside a larger reservation."""
import ctypes, ctypes.wintypes as wt, sys, struct
from collections import Counter
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
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 vq(h, addr):
mbi = MBI()
if k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
return mbi
return None
def prot_name(p):
names = {0x01: "NA", 0x02: "RO", 0x04: "RW", 0x08: "WC", 0x10: "EX",
0x20: "ER", 0x40: "ERW", 0x80: "ERWC"}
return names.get(p & 0xFF, f"0x{p:x}")
pid = int(sys.argv[1])
h = k.OpenProcess(0x410, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
candidates = []
addr = 0
while True:
mbi = vq(h, addr)
if not mbi: break
base = mbi.BaseAddress or 0
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
if (mbi.Protect & 0xFF) in (0x04, 0x40):
candidates.append(mbi.BaseAddress)
next_addr = base + mbi.RegionSize
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
print(f"Found {len(candidates)} candidate 260KB regions")
# Statistics: base == alloc_base?
self_alloc = 0
in_larger = 0
neighbor_stats = Counter()
for b in candidates:
mbi = vq(h, b)
if not mbi: continue
base = mbi.BaseAddress
ab = mbi.AllocationBase
if base == ab:
self_alloc += 1
else:
in_larger += 1
# Check neighbor immediately after the 260KB region
after_addr = base + 266240
nbr = vq(h, after_addr)
if nbr:
same_alloc = (nbr.AllocationBase == ab)
nbr_state = "COMMIT" if nbr.State == 0x1000 else ("RESERVE" if nbr.State == 0x2000 else "FREE")
key = f"same_alloc={same_alloc} state={nbr_state}"
neighbor_stats[key] += 1
print(f"\nself_alloc (base == AllocationBase): {self_alloc}")
print(f"in_larger (base != AllocationBase): {in_larger}")
print(f"\nNeighbor (page immediately after 260KB region):")
for key, n in sorted(neighbor_stats.items(), key=lambda x: -x[1]):
print(f" {key}: {n}")
# Dump first 8 in detail
print(f"\nDetailed dump of first 8 regions:")
for b in candidates[:8]:
mbi = vq(h, b)
if not mbi: continue
print(f"\n Region @0x{b:08x}:")
print(f" BaseAddress = 0x{mbi.BaseAddress:08x}")
print(f" AllocationBase = 0x{mbi.AllocationBase:08x} delta={(mbi.BaseAddress - mbi.AllocationBase):d}")
print(f" RegionSize = {mbi.RegionSize}")
print(f" Protect = {prot_name(mbi.Protect)}")
print(f" AllocProtect = {prot_name(mbi.AllocationProtect)}")
print(f" State = 0x{mbi.State:x}")
print(f" Type = 0x{mbi.Type:x}")
# Look at AllocationBase's full size
if mbi.BaseAddress != mbi.AllocationBase:
ab_mbi = vq(h, mbi.AllocationBase)
if ab_mbi:
print(f" --- AllocationBase region: ---")
print(f" base=0x{ab_mbi.BaseAddress:08x} size={ab_mbi.RegionSize} prot={prot_name(ab_mbi.Protect)} type=0x{ab_mbi.Type:x}")
# Look at neighbor after region
nbr = vq(h, b + 266240)
if nbr:
same = (nbr.AllocationBase == mbi.AllocationBase)
print(f" --- Neighbor after: 0x{nbr.BaseAddress:08x} size={nbr.RegionSize} prot={prot_name(nbr.Protect)} state=0x{nbr.State:x} same_alloc={same}")
k.CloseHandle(h)