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:
commit
57b5e43d0e
199 changed files with 1648333 additions and 0 deletions
162
tools/diff_owner_scans.py
Normal file
162
tools/diff_owner_scans.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""diff_owner_scans.py <low.dmp> <high.dmp>
|
||||
|
||||
Run the owner-vtable scan internally on two dumps and emit a diff:
|
||||
which vtables show up disproportionately in the high-leak dump
|
||||
versus the low-leak baseline. The leaders are residual-leak suspects.
|
||||
|
||||
Output is ranked by ratio (high_count / low_count) with min thresholds
|
||||
to filter noise.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import Counter, defaultdict
|
||||
from minidump.minidumpfile import MinidumpFile
|
||||
|
||||
|
||||
def _ei(v):
|
||||
if v is None: return 0
|
||||
if hasattr(v, 'value'): return int(v.value)
|
||||
return int(v)
|
||||
|
||||
|
||||
def scan(path):
|
||||
md = MinidumpFile.parse(path)
|
||||
reader = md.get_reader().get_buffered_reader()
|
||||
|
||||
mods = [(m.baseaddress, m.size, m.name) for m in md.modules.modules]
|
||||
def mod_of(addr):
|
||||
for b, s, n in mods:
|
||||
if b <= addr < b + s:
|
||||
return n.split("\\")[-1]
|
||||
return None
|
||||
|
||||
image_ranges = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty = _ei(r.State), _ei(r.Type)
|
||||
if st == 0x1000 and ty == 0x1000000:
|
||||
image_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
|
||||
image_ranges.sort()
|
||||
def is_image(addr):
|
||||
for lo, hi in image_ranges:
|
||||
if lo <= addr < hi:
|
||||
return True
|
||||
if addr < lo:
|
||||
return False
|
||||
return False
|
||||
|
||||
leaked = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
|
||||
and 256*1024 <= r.RegionSize < 512*1024:
|
||||
leaked.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
deltas = [0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x40, 0x50, 0x60]
|
||||
cand_to_region = {}
|
||||
for base, _sz in leaked:
|
||||
for d in deltas:
|
||||
cand_to_region[base + d] = base
|
||||
|
||||
scan_regions = []
|
||||
for r in md.memory_info.infos:
|
||||
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
|
||||
if st != 0x1000: continue
|
||||
if ty == 0x1000000: continue
|
||||
if pr not in (0x04, 0x40): continue
|
||||
scan_regions.append((r.BaseAddress, r.RegionSize))
|
||||
|
||||
hits = []
|
||||
for base, size in scan_regions:
|
||||
try:
|
||||
reader.move(base)
|
||||
buf = reader.read(size)
|
||||
except Exception:
|
||||
continue
|
||||
if not buf: continue
|
||||
end = (len(buf) // 4) * 4
|
||||
for off in range(0, end, 4):
|
||||
v = struct.unpack_from("<I", buf, off)[0]
|
||||
if v in cand_to_region:
|
||||
hits.append((base + off, cand_to_region[v], v, base, off, buf))
|
||||
|
||||
LOOKBACK = 0x200
|
||||
vtable_only = Counter()
|
||||
field_per_vt = defaultdict(Counter)
|
||||
|
||||
for hit_va, _leak_base, _ptr_val, _reg_base, off, buf in hits:
|
||||
start = max(0, off - LOOKBACK)
|
||||
for back in range(off - 4, start - 4, -4):
|
||||
if back < 0: break
|
||||
v = struct.unpack_from("<I", buf, back)[0]
|
||||
if v < 0x00400000 or v > 0x10000000:
|
||||
continue
|
||||
if is_image(v):
|
||||
vtable_only[v] += 1
|
||||
field_per_vt[v][off - back] += 1
|
||||
break
|
||||
|
||||
return {
|
||||
"leaked_regions": len(leaked),
|
||||
"vtable_counts": vtable_only,
|
||||
"field_per_vt": field_per_vt,
|
||||
"mod_of": mod_of,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
low_path, high_path = sys.argv[1], sys.argv[2]
|
||||
print(f"Scanning low-leak control: {low_path}")
|
||||
low = scan(low_path)
|
||||
print(f" leaked regions: {low['leaked_regions']}")
|
||||
print(f" unique vtables: {len(low['vtable_counts'])}")
|
||||
print()
|
||||
print(f"Scanning high-leak target: {high_path}")
|
||||
high = scan(high_path)
|
||||
print(f" leaked regions: {high['leaked_regions']}")
|
||||
print(f" unique vtables: {len(high['vtable_counts'])}")
|
||||
print()
|
||||
|
||||
# Baseline scale = ratio of leaked-region counts. Anything growing
|
||||
# significantly faster than this is suspect.
|
||||
scale = high['leaked_regions'] / max(low['leaked_regions'], 1)
|
||||
print(f"Baseline scale (high_leaked / low_leaked): {scale:.1f}x")
|
||||
print(f" vtables with ratio >> {scale:.0f}x are the residual-leak suspects")
|
||||
print()
|
||||
|
||||
# Combine vtable counts
|
||||
all_vts = set(low['vtable_counts']) | set(high['vtable_counts'])
|
||||
|
||||
# Rank by ratio with a minimum high-count floor so we ignore one-offs
|
||||
MIN_HIGH = 20
|
||||
rows = []
|
||||
for vt in all_vts:
|
||||
lc = low['vtable_counts'].get(vt, 0)
|
||||
hc = high['vtable_counts'].get(vt, 0)
|
||||
if hc < MIN_HIGH:
|
||||
continue
|
||||
# Avoid divide-by-zero; treat 0 baseline as huge ratio
|
||||
ratio = hc / max(lc, 0.5)
|
||||
delta = hc - lc
|
||||
rows.append((vt, lc, hc, ratio, delta))
|
||||
|
||||
# Top by ratio
|
||||
rows.sort(key=lambda r: r[3], reverse=True)
|
||||
print(f"=== Top vtables by ratio (high/low), min high-count {MIN_HIGH} ===")
|
||||
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module fields")
|
||||
for vt, lc, hc, ratio, delta in rows[:30]:
|
||||
mod = high['mod_of'](vt) or "?"
|
||||
top_offs = high['field_per_vt'][vt].most_common(3)
|
||||
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
||||
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod:<40} {offs_str}")
|
||||
|
||||
# Top by absolute delta (also informative)
|
||||
print()
|
||||
print(f"=== Top vtables by absolute count delta (high - low) ===")
|
||||
rows.sort(key=lambda r: r[4], reverse=True)
|
||||
print(f"{'vtable':<12} {'low':>6} {'high':>6} {'ratio':>8} {'delta':>7} module")
|
||||
for vt, lc, hc, ratio, delta in rows[:20]:
|
||||
mod = high['mod_of'](vt) or "?"
|
||||
print(f"0x{vt:08x} {lc:>6} {hc:>6} {ratio:>8.1f} {delta:>7} {mod}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue