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>
160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
"""physobj_owner_scan.py <dump.dmp>
|
|
|
|
Identify owners of leaked CPhysicsObj instances (EoR vtable 0x007c78e0).
|
|
|
|
Method:
|
|
1. Enumerate all CPhysicsObj instances by scanning RW memory for DWORD = 0x007c78e0
|
|
at offset 0 of any heap-aligned 4-byte slot. Each hit's address is a CPhysicsObj*.
|
|
2. For each instance, scan ALL committed RW memory for DWORDs equal to that
|
|
CPhysicsObj's base address (these are owners holding strong refs).
|
|
3. For each owner-pointer hit, walk BACKWARDS up to 0x400 bytes within the same
|
|
region looking for a DWORD that points into image memory (the owner's vtable).
|
|
4. Histogram (vtable, field_offset) — these are the leak holders.
|
|
|
|
Output: ranked owner vtables and ranked (vtable, field_offset) pairs.
|
|
"""
|
|
import struct, sys
|
|
from collections import Counter, defaultdict
|
|
from minidump.minidumpfile import MinidumpFile
|
|
|
|
CPHYSOBJ_VT = 0x007c78e0
|
|
|
|
|
|
def _ei(v):
|
|
if v is None:
|
|
return 0
|
|
if hasattr(v, 'value'):
|
|
return int(v.value)
|
|
return int(v)
|
|
|
|
|
|
def main():
|
|
md = MinidumpFile.parse(sys.argv[1])
|
|
reader = md.get_reader().get_buffered_reader()
|
|
|
|
mods = []
|
|
for m in md.modules.modules:
|
|
mods.append((m.baseaddress, m.size, m.name))
|
|
|
|
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
|
|
|
|
# Gather all RW private/mapped regions and cache buffers
|
|
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))
|
|
total_bytes = sum(s for _, s in scan_regions)
|
|
print(f"scanning {len(scan_regions)} regions ({total_bytes / (1024 * 1024):.1f} MB)")
|
|
|
|
region_bufs = [] # (base, buf)
|
|
physobj_addrs = []
|
|
for base, size in scan_regions:
|
|
try:
|
|
reader.move(base)
|
|
buf = reader.read(size)
|
|
except Exception:
|
|
continue
|
|
if not buf:
|
|
continue
|
|
region_bufs.append((base, buf))
|
|
end = (len(buf) // 4) * 4
|
|
for off in range(0, end, 4):
|
|
v = struct.unpack_from("<I", buf, off)[0]
|
|
if v == CPHYSOBJ_VT:
|
|
physobj_addrs.append(base + off)
|
|
|
|
print(f"found {len(physobj_addrs)} CPhysicsObj instances")
|
|
if not physobj_addrs:
|
|
return
|
|
|
|
physobj_set = set(physobj_addrs)
|
|
|
|
# Now scan ALL regions for DWORDs whose value is in physobj_set
|
|
# For each hit, walk back 0x400 bytes to find a vtable.
|
|
LOOKBACK = 0x400
|
|
vtable_hits = Counter()
|
|
vt_off_hits = Counter()
|
|
examples = defaultdict(list)
|
|
field_offsets_per_vtable = defaultdict(Counter)
|
|
no_vtable = 0
|
|
raw_hits = 0
|
|
self_skipped = 0
|
|
|
|
for base, buf in region_bufs:
|
|
end = (len(buf) // 4) * 4
|
|
for off in range(0, end, 4):
|
|
v = struct.unpack_from("<I", buf, off)[0]
|
|
if v not in physobj_set:
|
|
continue
|
|
hit_va = base + off
|
|
if hit_va == v:
|
|
# vtable slot of the physobj itself; ignore
|
|
self_skipped += 1
|
|
continue
|
|
raw_hits += 1
|
|
start = max(0, off - LOOKBACK)
|
|
found = False
|
|
for back in range(off - 4, start - 4, -4):
|
|
if back < 0:
|
|
break
|
|
vv = struct.unpack_from("<I", buf, back)[0]
|
|
if vv < 0x00400000 or vv > 0x10000000:
|
|
continue
|
|
if is_image(vv):
|
|
field_off = off - back
|
|
vtable_hits[vv] += 1
|
|
vt_off_hits[(vv, field_off)] += 1
|
|
field_offsets_per_vtable[vv][field_off] += 1
|
|
if len(examples[(vv, field_off)]) < 3:
|
|
examples[(vv, field_off)].append((hit_va, v))
|
|
found = True
|
|
break
|
|
if not found:
|
|
no_vtable += 1
|
|
|
|
print(f"raw owner-ptr hits: {raw_hits}")
|
|
print(f"self-vtable slot skipped: {self_skipped}")
|
|
print(f"hits with no preceding vtable in lookback: {no_vtable}")
|
|
print()
|
|
print(f"=== Top owner vtables (regardless of offset) ===")
|
|
for vt, cnt in vtable_hits.most_common(20):
|
|
owner = mod_of(vt) or "?"
|
|
top_offs = field_offsets_per_vtable[vt].most_common(4)
|
|
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
|
|
print(f" vt=0x{vt:08x} hits={cnt:<6} ({owner}) {offs_str}")
|
|
|
|
print()
|
|
print(f"=== Top (owner_vtable, field_offset) pairs ===")
|
|
for (vt, off), cnt in vt_off_hits.most_common(30):
|
|
owner = mod_of(vt) or "?"
|
|
ex = examples[(vt, off)][0]
|
|
print(f" vt=0x{vt:08x} +0x{off:03x} hits={cnt:<6} ({owner}) e.g. owner@0x{(ex[0] - off):08x} ptr@0x{ex[0]:08x} -> physobj@0x{ex[1]:08x}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|