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

239
tools/analyze_dump.py Normal file
View file

@ -0,0 +1,239 @@
"""
analyze_dump.py <dump.dmp>
Parses a Windows minidump and computes VA-region stats with no PDB
dependency:
* total committed memory, broken down by Type (Private/Mapped/Image)
* top-N largest committed regions with module/path attribution
* size-bucket histogram of committed regions
* module list with image base and size
Output: writes <dump.dmp>.stats.json next to the dump and prints a
short human summary to stdout.
"""
import json
import os
import sys
from collections import Counter, defaultdict
from minidump.minidumpfile import MinidumpFile
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_FREE = 0x10000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
def _enum_int(v):
"""minidump library may return State/Type as Enum or int — normalize to int."""
if v is None:
return 0
if hasattr(v, 'value'):
return int(v.value)
return int(v)
PROT_NAMES = {
0x01: "NOACCESS", 0x02: "READONLY", 0x04: "READWRITE", 0x08: "WRITECOPY",
0x10: "EXECUTE", 0x20: "EXECUTE_READ", 0x40: "EXECUTE_READWRITE",
0x80: "EXECUTE_WRITECOPY",
}
def fmt_prot(p):
base = p & 0xFF
name = PROT_NAMES.get(base, f"0x{base:02x}")
if p & 0x100: name += "|GUARD"
if p & 0x200: name += "|NOCACHE"
if p & 0x400: name += "|WRITECOMBINE"
return name
def fmt_state(s):
if s == MEM_COMMIT: return "COMMIT"
if s == MEM_RESERVE: return "RESERVE"
if s == MEM_FREE: return "FREE"
return f"0x{s:x}"
def fmt_type(t):
if t == MEM_PRIVATE: return "Private"
if t == MEM_MAPPED: return "Mapped"
if t == MEM_IMAGE: return "Image"
return f"0x{t:x}"
def power_of_2_bucket(sz):
"""Return string like '64KB-128KB'."""
if sz <= 0: return "0"
p = sz.bit_length() - 1
lo = 1 << p
hi = lo << 1
def fmt(n):
if n >= 1024*1024*1024: return f"{n//(1024*1024*1024)}GB"
if n >= 1024*1024: return f"{n//(1024*1024)}MB"
if n >= 1024: return f"{n//1024}KB"
return f"{n}B"
return f"{fmt(lo)}-{fmt(hi)}"
def main():
if len(sys.argv) < 2:
print("usage: analyze_dump.py <dump.dmp>", file=sys.stderr); sys.exit(1)
path = sys.argv[1]
if not os.path.exists(path):
print(f"not found: {path}", file=sys.stderr); sys.exit(1)
md = MinidumpFile.parse(path)
out = {
"path": path,
"file_size_mb": round(os.path.getsize(path)/(1024*1024), 1),
}
# System info
si = md.sysinfo
if si is not None:
out["sysinfo"] = {
"ProcessorArchitecture": str(si.ProcessorArchitecture),
"ProductType": str(si.ProductType),
"MajorVersion": si.MajorVersion,
"MinorVersion": si.MinorVersion,
"BuildNumber": si.BuildNumber,
}
# Modules
mods = []
if md.modules:
for m in md.modules.modules:
mods.append({
"name": os.path.basename(m.name),
"base": m.baseaddress,
"size": m.size,
"ts": m.timestamp,
})
out["modules"] = mods
out["modules_count"] = len(mods)
# Build a "what module owns this address" lookup
def mod_owning(addr):
for m in mods:
if m["base"] <= addr < m["base"] + m["size"]:
return m["name"]
return None
# Memory info — the VAD-like list (state/type/protection per region)
regions = []
by_state_type = Counter() # (state, type) -> bytes
by_state_type_count = Counter() # (state, type) -> count
bucket_committed = Counter()
if md.memory_info and md.memory_info.infos:
for r in md.memory_info.infos:
base = r.BaseAddress
sz = r.RegionSize
st = _enum_int(r.State)
ty = _enum_int(r.Type)
pr = _enum_int(r.Protect)
regions.append({
"base": base,
"size": sz,
"state": st,
"type": ty,
"protect": pr,
"owner": mod_owning(base),
})
by_state_type[(st, ty)] += sz
by_state_type_count[(st, ty)] += 1
if st == MEM_COMMIT:
bucket_committed[power_of_2_bucket(sz)] += sz
# Largest committed regions
committed = sorted([r for r in regions if r["state"] == MEM_COMMIT],
key=lambda r: r["size"], reverse=True)
out["top20_committed"] = [
{
"base": f"0x{r['base']:08x}",
"size": r["size"],
"size_h": _h(r["size"]),
"type": fmt_type(r["type"]),
"prot": fmt_prot(r["protect"]),
"owner": r["owner"],
}
for r in committed[:20]
]
out["regions_count"] = len(regions)
out["committed_total"] = sum(r["size"] for r in regions if r["state"] == MEM_COMMIT)
out["committed_private_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_PRIVATE)
out["committed_image_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE)
out["committed_mapped_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_MAPPED)
# Per-module image commit (sums all committed Image regions per owner module)
by_module_image = defaultdict(int)
for r in regions:
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE and r["owner"]:
by_module_image[r["owner"]] += r["size"]
out["top_image_modules"] = sorted(
[{"module": k, "image_bytes": v} for k, v in by_module_image.items()],
key=lambda x: x["image_bytes"], reverse=True
)[:15]
# Per-bucket committed (mostly interesting for private)
out["committed_size_buckets"] = [
{"bucket": k, "bytes": v, "count": sum(1 for r in regions if r["state"] == MEM_COMMIT and power_of_2_bucket(r["size"]) == k)}
for k, v in sorted(bucket_committed.items(), key=lambda x: x[1], reverse=True)
]
# Specifically: large private committed regions w/ exec/rw protect (heap suspects)
heap_suspects = [r for r in regions
if r["state"] == MEM_COMMIT
and r["type"] == MEM_PRIVATE
and (r["protect"] & 0xFF) in (0x04, 0x40) # RW / EXECUTE_READWRITE
and r["size"] >= 64*1024] # at least 64 KB
heap_suspects.sort(key=lambda r: r["size"], reverse=True)
out["heap_suspect_regions"] = [
{
"base": f"0x{r['base']:08x}",
"size": r["size"],
"size_h": _h(r["size"]),
"prot": fmt_prot(r["protect"]),
}
for r in heap_suspects[:50]
]
out["heap_suspect_total"] = sum(r["size"] for r in heap_suspects)
out["heap_suspect_count"] = len(heap_suspects)
# Write JSON
out_path = path + ".stats.json"
with open(out_path, "w", encoding="utf8") as f:
json.dump(out, f, indent=2)
# Pretty summary to stdout
print(f"=== {os.path.basename(path)} ===")
print(f"file: {out['file_size_mb']} MB regions: {out['regions_count']} modules: {out['modules_count']}")
print(f" committed_total {_h(out['committed_total'])}")
print(f" private {_h(out['committed_private_total'])}")
print(f" image {_h(out['committed_image_total'])}")
print(f" mapped {_h(out['committed_mapped_total'])}")
print(f" heap_suspect (private RW, >=64KB): {_h(out['heap_suspect_total'])} across {out['heap_suspect_count']} regions")
print(f"")
print(f" top 10 image modules by committed size:")
for m in out["top_image_modules"][:10]:
print(f" {_h(m['image_bytes']):>10} {m['module']}")
print(f"")
print(f" top 10 committed regions:")
for r in out["top20_committed"][:10]:
own = r["owner"] or ""
print(f" {r['size_h']:>10} {r['base']} {r['type']:>8} {r['prot']:<28} {own}")
print(f"")
print(f" wrote {out_path}")
def _h(n):
if n >= 1024*1024*1024: return f"{n/(1024*1024*1024):.2f} GB"
if n >= 1024*1024: return f"{n/(1024*1024):.2f} MB"
if n >= 1024: return f"{n/1024:.1f} KB"
return f"{n} B"
if __name__ == "__main__":
main()

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])))

38
tools/auto_v5_watcher.sh Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Persistent watcher: every 5 min, finds acclient PIDs that have v3b applied
# but NOT v5, applies v5 to them. Skips any PID whose window title contains "Jerry".
# Emits one line per applied PID (event-style for Monitor).
set -u
PY="C:/Users/acbot/AppData/Local/Programs/Python/Python312/python.exe"
cd /c/Users/acbot/leakhunt
while true; do
# Get all acclient PIDs + window titles via PowerShell
pid_titles=$(powershell.exe -NoProfile -Command \
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)|\$(\$_.MainWindowTitle)\" }" \
2>/dev/null | tr -d '\r')
while IFS='|' read -r pid title; do
[ -z "$pid" ] && continue
# Skip Jerry (control)
if echo "$title" | grep -qi "Jerry"; then continue; fi
# Check patch state — only proceed if v3b is applied AND v5 is not
state=$("$PY" tools/check_patch_state.py "$pid" 2>/dev/null \
| awk -v p="$pid" '$1==p {print $2,$3,$5,$6}')
[ -z "$state" ] && continue
read v3b1 v3b2 v5rs v5rt <<<"$state"
# v3b must be P/P, v5-RS must be "." (no thunk yet)
if [ "$v3b1" = "P" ] && [ "$v3b2" = "P" ] && [ "$v5rs" = "." ]; then
result=$("$PY" tools/patch_purge_v5_test.py "$pid" 2>&1)
if echo "$result" | grep -q "OK"; then
echo "AUTO-V5 PID=$pid title=\"$title\" applied $(date +%H:%M:%S)"
else
echo "AUTO-V5-FAIL PID=$pid title=\"$title\" output: $(echo "$result" | tail -2 | tr '\n' ' ')"
fi
fi
done <<< "$pid_titles"
sleep 300
done

View file

@ -0,0 +1,96 @@
"""broader_vtable_sweep.py <larsson.dmp> <time.dmp>
Find vtables in the acclient image range that have very high count
in larsson and low count in time, EXCLUDING the already-tracked ones.
Aim: surface previously-untracked leak classes.
"""
import struct, sys
from collections import Counter
from minidump.minidumpfile import MinidumpFile
# Known/already-investigated vtables
KNOWN = {
0x007c0498: "UIElement_UIItem",
0x007caa08: "Palette",
0x007c78ec: "CPhysicsObj",
0x0079a67c: "RenderSurface",
0x00801a94: "RenderSurfaceD3D",
0x00801a18: "RenderTextureD3D",
0x007ca4dc: "CSurface",
0x007cab04: "ImgTex",
0x007ca418: "CGfxObj",
0x007ed3b0: "D3DXMesh",
0x0079bf64: "GraphicsResource",
0x007ccb60: "NoticeHandler_subvt",
0x007c98e8: "CObjCell_primary",
0x007c9a60: "CEnvCell_primary",
0x0079385c: "CObjCell_subvt",
0x00400c08: "CPhys_data_sentinel",
0x007c9b58: "CPhys_inner", # speculative
0x0079c198: "RenderTexture",
}
# Acclient image VA range (32-bit)
IMG_LO = 0x00400000
IMG_HI = 0x00880000
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def scan(dmp_path):
md = MinidumpFile.parse(dmp_path)
reader = md.get_reader().get_buffered_reader()
counts = Counter()
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40):
continue
try:
reader.move(r.BaseAddress)
buf = reader.read(r.RegionSize)
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 IMG_LO <= v < IMG_HI:
# filter UTF-16 noise: low byte 0x00 of every other byte
# rough check: byte 1 is 0 and byte 3 is 0 with letters in bytes 0,2
b0, b1, b2, b3 = v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff
if b1 == 0 and b3 == 0 and 0x20 <= b0 <= 0x7e and 0x20 <= b2 <= 0x7e:
continue # UTF-16 string fragment
counts[v] += 1
return counts
def main():
larsson = sys.argv[1]
time_path = sys.argv[2]
print(f"scanning larsson: {larsson}")
lc = scan(larsson)
print(f" unique vtables: {len(lc)}")
print(f"scanning time: {time_path}")
tc = scan(time_path)
print(f" unique vtables: {len(tc)}")
# Compute diff
rows = []
for vt, c in lc.items():
if vt in KNOWN: continue
if c < 20: continue
t = tc.get(vt, 0)
ratio = c / max(t, 0.5)
delta = c - t
rows.append((vt, t, c, ratio, delta))
rows.sort(key=lambda x: -x[4])
print()
print("UN-tracked acclient vtables, sorted by absolute delta (larsson - time):")
print(f"{'vtable':12} {'time':>6} {'larsson':>8} {'ratio':>7} {'delta':>7}")
for vt, t, c, ratio, delta in rows[:40]:
print(f"0x{vt:08x} {t:>6} {c:>8} {ratio:>7.1f} {delta:>7}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,103 @@
"""build_patched_binary.py
Build acclient.eor.patched.exe by applying the v3b byte patches to a
backed-up copy of the original EoR acclient.exe.
Patches (file offsets = VA - 0x400 since base=0x400000 and .text RVA is +0x400):
- VA 0x0053effe ff 40 24 -> 90 90 90 ; NOP makeModifiedPalette() over-increment
- VA 0x0053f19c ff 46 24 -> 90 90 90 ; NOP makeModifiedPalette(id,sub) over-increment
For 32-bit PE with base=0x00400000 and .text starting at RVA 0x1000
mapped to file offset 0x400 (standard MSVC layout), file_offset =
VA - 0x00400000 - 0x1000 + 0x400 = VA - 0x00400C00.
"""
import hashlib, os, shutil, struct, sys
SRC = r"C:\Turbine\Asheron's Call\acclient.exe"
ORIG = r"C:\Turbine\Asheron's Call\acclient.eor.orig.exe"
PATCHED = r"C:\Turbine\Asheron's Call\acclient.eor.patched.exe"
# VA-based patches (we resolve file offsets via PE parsing)
PATCHES = [
(0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
(0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
]
def parse_pe_sections(data):
"""Return list of (rva_start, rva_size, file_offset) tuples."""
e_lfanew = struct.unpack_from("<I", data, 0x3c)[0]
sig = data[e_lfanew:e_lfanew+4]
assert sig == b"PE\0\0", f"bad PE sig: {sig}"
# COFF header at e_lfanew + 4
num_sections = struct.unpack_from("<H", data, e_lfanew + 6)[0]
size_opt_hdr = struct.unpack_from("<H", data, e_lfanew + 0x14)[0]
sections_off = e_lfanew + 0x18 + size_opt_hdr
image_base = struct.unpack_from("<I", data, e_lfanew + 0x34)[0] # PE32 ImageBase
out = []
for i in range(num_sections):
sh = sections_off + i * 0x28
virtual_size = struct.unpack_from("<I", data, sh + 0x08)[0]
virtual_addr = struct.unpack_from("<I", data, sh + 0x0c)[0]
raw_size = struct.unpack_from("<I", data, sh + 0x10)[0]
raw_off = struct.unpack_from("<I", data, sh + 0x14)[0]
name = data[sh:sh+8].rstrip(b"\0").decode("ascii", "replace")
out.append((name, image_base + virtual_addr, virtual_size, raw_off, raw_size))
return image_base, out
def va_to_file_offset(va, sections):
for name, va_start, vsize, raw_off, raw_size in sections:
if va_start <= va < va_start + max(vsize, raw_size):
return raw_off + (va - va_start)
raise ValueError(f"VA 0x{va:08x} not in any section")
def main():
if not os.path.exists(SRC):
print(f"src not found: {SRC}"); sys.exit(1)
# Backup
if not os.path.exists(ORIG):
shutil.copy2(SRC, ORIG)
print(f"backup written: {ORIG}")
else:
print(f"backup already exists: {ORIG}")
with open(ORIG, "rb") as f:
data = bytearray(f.read())
sha_orig = hashlib.sha256(data).hexdigest()
print(f"orig sha256: {sha_orig}")
print(f"orig size: {len(data)}")
image_base, sections = parse_pe_sections(data)
print(f"image base: 0x{image_base:08x}")
for s in sections[:3]:
print(f" section {s[0]:<8} va=0x{s[1]:08x} vsize=0x{s[2]:x} raw=0x{s[3]:08x} rsize=0x{s[4]:x}")
# Apply patches
for va, orig_bytes, patched_bytes in PATCHES:
off = va_to_file_offset(va, sections)
actual = bytes(data[off:off+len(orig_bytes)])
if actual == orig_bytes:
data[off:off+len(patched_bytes)] = patched_bytes
print(f" patched VA 0x{va:08x} (file off 0x{off:x}): "
f"{' '.join(f'{b:02x}' for b in orig_bytes)} -> "
f"{' '.join(f'{b:02x}' for b in patched_bytes)}")
elif actual == patched_bytes:
print(f" VA 0x{va:08x} already patched, skipping")
else:
print(f" VA 0x{va:08x} UNEXPECTED bytes: {' '.join(f'{b:02x}' for b in actual)}")
sys.exit(2)
with open(PATCHED, "wb") as f:
f.write(data)
sha_new = hashlib.sha256(bytes(data)).hexdigest()
print(f"\npatched sha256: {sha_new}")
print(f"written: {PATCHED}")
if __name__ == "__main__":
main()

179
tools/byte_accounting.py Normal file
View file

@ -0,0 +1,179 @@
"""byte_accounting.py <pid>
Walk all committed memory in a target process and categorize bytes by:
- VAD region type (private vs mapped vs image)
- Protection (RW vs RX vs RWX)
- Size bucket
- Known-class signature scan (vtable bytes within the region)
Output: per-category totals so we can see where the working set lives."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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),
]
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
# Known class vtables from instr.cpp
KNOWN_VTABLES = {
0x007C78EC: "CPhysicsObj",
0x0079A67C: "RenderSurface",
0x0079C198: "RenderTexture",
0x00801A94: "RenderSurfaceD3D",
0x00801A18: "RenderTextureD3D",
0x007CA4DC: "CSurface(GR)",
0x007CAB04: "ImgTex(GR)",
0x007CA418: "CGfxObj",
0x007ED3B0: "GXTri3Mesh",
0x007E4F70: "ACCWeenieObject",
0x007E4ED8: "CWeenieObject",
}
def rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
# Totals by region type
total_committed = 0
by_type = {"private_rw": 0, "private_rx": 0, "private_rwx": 0,
"mapped": 0, "image": 0, "other": 0}
# Bytes attributed to each known class (rough estimate: vtable_count × likely class size)
class_size_estimate = {
"CPhysicsObj": 376, # +0x178 from constructor allocation
"RenderSurface": 288,
"RenderTexture": 152,
"RenderSurfaceD3D": 304,
"RenderTextureD3D": 176,
"CSurface(GR)": 144,
"ImgTex(GR)": 136,
"CGfxObj": 200, # estimate
"GXTri3Mesh": 1000, # estimate; large mesh class
"ACCWeenieObject": 336,
"CWeenieObject": 200, # estimate
}
class_instance_count = {name: 0 for name in KNOWN_VTABLES.values()}
# Histogram of private-RW region sizes
size_buckets = [
(0, 4096, "<4K"),
(4096, 65536, "4K-64K"),
(65536, 262144, "64K-256K"),
(262144, 1048576, "256K-1M"),
(1048576, 4194304, "1M-4M"),
(4194304, 16777216, "4M-16M"),
(16777216, 67108864, "16M-64M"),
(67108864, 1<<31, "64M+"),
]
bucket_count = [0] * len(size_buckets)
bucket_bytes = [0] * len(size_buckets)
def classify_size(n):
for i, (lo, hi, _) in enumerate(size_buckets):
if lo <= n < hi: return i
return len(size_buckets) - 1
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
region_base = mbi.BaseAddress or 0
region_size = mbi.RegionSize
if mbi.State == MEM_COMMIT:
total_committed += region_size
prot = mbi.Protect & 0xFF
# Classify region type
if mbi.Type == MEM_IMAGE:
by_type["image"] += region_size
elif mbi.Type == MEM_MAPPED:
by_type["mapped"] += region_size
elif mbi.Type == MEM_PRIVATE:
if prot == 0x40: by_type["private_rwx"] += region_size
elif prot == 0x20: by_type["private_rx"] += region_size
elif prot == 0x04: by_type["private_rw"] += region_size
else: by_type["other"] += region_size
else:
by_type["other"] += region_size
# For private RW/RWX: bucket by size + scan for known vtables
if mbi.Type == MEM_PRIVATE and prot in (0x04, 0x40):
bi = classify_size(region_size)
bucket_count[bi] += 1
bucket_bytes[bi] += region_size
# Scan for known vtable bytes (skip huge regions to bound time)
if region_size <= 64 * 1024 * 1024:
try:
data = rd(h, region_base, region_size)
if data:
for vt, name in KNOWN_VTABLES.items():
vt_bytes = struct.pack('<I', vt)
count = 0
off = 0
while True:
off = data.find(vt_bytes, off)
if off < 0: break
if (off & 3) == 0: count += 1
off += 4
class_instance_count[name] += count
except Exception:
pass
next_addr = region_base + region_size
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
def mb(n): return f"{n/(1024*1024):,.1f}"
print(f"=== pid {pid} byte accounting ===")
print(f"Total committed: {mb(total_committed)} MB")
print()
print("By region type:")
for label, n in by_type.items():
pct = (n*100/total_committed) if total_committed else 0
print(f" {label:14s} {mb(n):>9} MB ({pct:5.1f}%)")
print()
print("Private RW/RWX region size distribution:")
print(f" {'bucket':<12} {'count':>6} {'total MB':>10}")
for i, (lo, hi, label) in enumerate(size_buckets):
if bucket_count[i] == 0: continue
print(f" {label:<12} {bucket_count[i]:>6} {mb(bucket_bytes[i]):>10}")
print()
print("Known-class vtable counts (and estimated bytes):")
print(f" {'class':<22} {'count':>6} {'est bytes':>12} {'est MB':>8}")
total_class_bytes = 0
for name in sorted(class_instance_count, key=lambda x: -class_instance_count[x]):
n = class_instance_count[name]
if n == 0: continue
sz = class_size_estimate.get(name, 200)
bytes_total = n * sz
total_class_bytes += bytes_total
print(f" {name:<22} {n:>6} {bytes_total:>12,} {mb(bytes_total):>8}")
print()
print(f" Identified-class total: ~{mb(total_class_bytes)} MB")
print(f" Of private RW: ~{mb(by_type['private_rw'])} MB")
unidentified = by_type['private_rw'] - total_class_bytes
print(f" UNIDENTIFIED in priv RW: ~{mb(unidentified)} MB ← what we don't account for")

2
tools/cdb_dump.txt Normal file
View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\target.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\nyckel_lowleak2.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\time_lowleak.dmp
qd

View file

@ -0,0 +1,65 @@
"""check_acclient_imports.py
Read acclient.exe imports and report whether leakfix.dll is already loaded."""
import struct, sys, os
EXE = r"C:\Turbine\Asheron's Call\acclient.exe"
if len(sys.argv) > 1: EXE = sys.argv[1]
with open(EXE, 'rb') as f:
data = f.read()
print(f"Loaded {len(data):,} bytes from {EXE}")
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
print(f"PE header at 0x{pe_off:x}, signature: {data[pe_off:pe_off+4]}")
num_sections = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
opt_header_size = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
opt_off = pe_off + 4 + 20
magic = struct.unpack_from('<H', data, opt_off)[0]
print(f"sections={num_sections}, opt_hdr={opt_header_size}, magic=0x{magic:x}")
# Read sections to enable RVA→file translation
sect_off = opt_off + opt_header_size
sections = []
for i in range(num_sections):
so = sect_off + i*40
name = data[so:so+8].rstrip(b'\0').decode(errors='replace')
vsize = struct.unpack_from('<I', data, so+8)[0]
vaddr = struct.unpack_from('<I', data, so+12)[0]
rsize = struct.unpack_from('<I', data, so+16)[0]
rawoff = struct.unpack_from('<I', data, so+20)[0]
chars = struct.unpack_from('<I', data, so+36)[0]
sections.append((name, vaddr, vsize, rawoff, rsize, chars))
print(f" [{i}] {name:8s} vaddr=0x{vaddr:08x} vsize=0x{vsize:08x} raw=0x{rawoff:08x} rsize=0x{rsize:08x} chars=0x{chars:08x}")
def rva_to_off(rva):
for name, vaddr, vsize, rawoff, rsize, chars in sections:
if vaddr <= rva < vaddr + vsize:
return rawoff + (rva - vaddr)
return None
# DataDirectory[1] = Import Table
dd_off = opt_off + 96
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
import_sz = struct.unpack_from('<I', data, dd_off + 12)[0]
print(f"\nImport directory: RVA=0x{import_rva:x} size={import_sz}")
import_foff = rva_to_off(import_rva)
print(f"Import directory file offset: 0x{import_foff:x}")
print("\nImports:")
off = import_foff
dlls = []
while True:
name_rva = struct.unpack_from('<I', data, off + 12)[0]
if name_rva == 0: break
name_foff = rva_to_off(name_rva)
name_end = data.index(b'\0', name_foff)
dll_name = data[name_foff:name_end].decode(errors='replace')
dlls.append((dll_name, off))
off += 20
for name, descriptor_off in dlls:
print(f" {name} (descriptor @ file 0x{descriptor_off:x})")
print(f"\nTotal: {len(dlls)} imports")
print(f"leakfix.dll already in imports? {any('leakfix' in n.lower() for n, _ in dlls)}")

119
tools/check_exe_pdb.py Normal file
View file

@ -0,0 +1,119 @@
"""Check an .exe's CodeView debug info to see what PDB GUID + age it
expects. Used to verify whether a candidate acclient.exe matches our
acclient.pdb without running the binary.
Usage:
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
"""
import struct
import sys
import uuid
def main():
if len(sys.argv) < 2:
print("usage: check_exe_pdb.py <path-to-exe>")
sys.exit(1)
with open(sys.argv[1], "rb") as f:
data = f.read()
# DOS header -> e_lfanew @ offset 0x3C points to PE header
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
# COFF header
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
print(f"machine = 0x{machine:04x}")
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
import datetime
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
print(f" -> linker UTC: {ts.isoformat()}")
# Optional header — magic indicates 32 vs 64 bit
opt_off = pe_off + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
is_pe32_plus = (magic == 0x20B)
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
dd_off = opt_off + (112 if is_pe32_plus else 96)
# Debug directory is data dir [6]
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
# We need to map the VA back to a file offset via section headers
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
vsize = struct.unpack_from("<I", data, s + 8)[0]
vaddr = struct.unpack_from("<I", data, s + 12)[0]
rsize = struct.unpack_from("<I", data, s + 16)[0]
roff = struct.unpack_from("<I", data, s + 20)[0]
sections.append((name, vaddr, vsize, roff, rsize))
def va_to_file(va):
for (name, vaddr, vsize, roff, rsize) in sections:
if vaddr <= va < vaddr + vsize:
return roff + (va - vaddr)
return None
debug_off = va_to_file(debug_va)
if debug_off is None:
print("debug directory VA does not map into any section")
return
# Each debug directory entry is 28 bytes
n_entries = debug_size // 28
print(f"# debug entries = {n_entries}")
for i in range(n_entries):
e = debug_off + i * 28
characteristics = struct.unpack_from("<I", data, e)[0]
ts_e = struct.unpack_from("<I", data, e + 4)[0]
major = struct.unpack_from("<H", data, e + 8)[0]
minor = struct.unpack_from("<H", data, e + 10)[0]
type_e = struct.unpack_from("<I", data, e + 12)[0]
sz = struct.unpack_from("<I", data, e + 16)[0]
rva = struct.unpack_from("<I", data, e + 20)[0]
ptr = struct.unpack_from("<I", data, e + 24)[0]
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
if type_e == 2 and sz >= 24:
cv = data[ptr:ptr + sz]
sig = cv[:4]
print(f" cv signature = {sig!r}")
if sig == b"RSDS":
guid_bytes = cv[4:20]
age = struct.unpack_from("<I", cv, 20)[0]
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
print(f" GUID = {{{pdb_guid}}}")
print(f" age = {age}")
print(f" PDB filename = {pdb_name}")
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
expected_age = 1
if pdb_guid == expected_guid and age == expected_age:
print()
print("=== MATCH: this exe pairs with our acclient.pdb ===")
else:
print()
print("=== MISMATCH ===")
print(f" expected GUID = {{{expected_guid}}}")
print(f" expected age = {expected_age}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,104 @@
"""check_orphan_refcounts.py <pid>
For D3DXMesh instances with NO pointers in heap memory, read their
internal refcount (COM-style at +0x?? let's check several offsets).
If refcount > 0, something outside heap (stack/static globals)
references them. If refcount == 0, they're truly leaked.
"""
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
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)
# Pass 1: enumerate all RW regions and find mesh addrs + collect data
rw_regions = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and 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)):
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
# Find mesh addresses + their data
mesh_data = {} # addr -> first 64 bytes
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-0x40, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
mesh_data[base + off] = data[off:off+0x40]
print(f'D3DXMesh instances: {len(mesh_data)}')
# Pass 2: for each mesh address, count pointers in heap memory
mesh_addr_set = set(mesh_data.keys())
ref_counts = {a: 0 for a in mesh_data}
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:
ref_addr = base + off
if ref_addr in mesh_addr_set: continue # self
ref_counts[v] += 1
orphans = [a for a in mesh_data if ref_counts[a] == 0]
held = [a for a in mesh_data if ref_counts[a] > 0]
print(f'orphans (0 heap refs): {len(orphans)}')
print(f'held: {len(held)}')
# For each orphan, dump the first 0x40 bytes and try to find a refcount-looking field
# COM objects typically have a refcount at +0x04 or +0x08
print()
print('=== Orphan mesh refcount candidates (DWORDs at +0x04, +0x08, +0x0c, +0x10, +0x14) ===')
hist_off04 = Counter()
hist_off08 = Counter()
hist_off0c = Counter()
hist_off10 = Counter()
hist_off14 = Counter()
for a in orphans:
d = mesh_data[a]
v04 = struct.unpack_from('<I', d, 0x04)[0]
v08 = struct.unpack_from('<I', d, 0x08)[0]
v0c = struct.unpack_from('<I', d, 0x0c)[0]
v10 = struct.unpack_from('<I', d, 0x10)[0]
v14 = struct.unpack_from('<I', d, 0x14)[0]
hist_off04[min(v04, 100)] += 1
hist_off08[min(v08, 100)] += 1
hist_off0c[min(v0c, 100)] += 1
hist_off10[min(v10, 100)] += 1
hist_off14[min(v14, 100)] += 1
print(f'+0x04 distribution (top 5): {hist_off04.most_common(5)}')
print(f'+0x08 distribution (top 5): {hist_off08.most_common(5)}')
print(f'+0x0c distribution (top 5): {hist_off0c.most_common(5)}')
print(f'+0x10 distribution (top 5): {hist_off10.most_common(5)}')
print(f'+0x14 distribution (top 5): {hist_off14.most_common(5)}')
# Sample 5 orphans — dump full 0x40 bytes
print()
print('=== Sample 5 orphan dumps ===')
for a in orphans[:5]:
print(f' mesh @ 0x{a:08x}:')
d = mesh_data[a]
for i in range(0, 0x40, 16):
row = d[i:i+16]
hex_str = ' '.join(f'{b:02x}' for b in row)
print(f' +0x{i:02x}: {hex_str}')

102
tools/check_patch_state.py Normal file
View file

@ -0,0 +1,102 @@
"""check_patch_state.py [pid1 pid2 ...]
Check which patches are applied to each AC client. If no PIDs given,
scans all acclient processes.
"""
import argparse, ctypes, ctypes.wintypes as wt, subprocess, sys
SITES = {
"v3b-1": (0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
"v3b-2": (0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
"v4": (0x007ca444, bytes([0xa0, 0x54, 0x41, 0x00]), None), # CGfxObj vtable slot 11
"v5-RS": (0x0079a684, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderSurface vtable slot 2
"v5-RT": (0x0079c1a0, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderTexture vtable slot 2
"v8-1": (0x004e439d, bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
"v8-2": (0x004e43c0, bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
"v8-3": (0x004e4496, bytes([0x75, 0x0d]), bytes([0x75, 0x08])),
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def check_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
return None
result = {}
for name, (addr, orig, patched) in SITES.items():
cur = read_bytes(h, addr, len(orig))
if cur is None:
result[name] = "?"
elif cur == orig:
result[name] = "." # unpatched / original
elif patched is not None and cur == patched:
result[name] = "P"
else:
result[name] = "X" # different (could be runtime-allocated thunk addr for v4/v5)
k.CloseHandle(h)
return result
def get_window_title(pid):
try:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
f"(Get-Process -Id {pid} -ErrorAction SilentlyContinue).MainWindowTitle"],
text=True, stderr=subprocess.DEVNULL).strip()
return out.split("-")[-1].strip() if out else ""
except Exception:
return ""
def list_acclient_pids():
try:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"(Get-Process acclient -ErrorAction SilentlyContinue).Id"],
text=True, stderr=subprocess.DEVNULL).strip()
return sorted(int(line) for line in out.splitlines() if line.strip())
except Exception:
return []
ap = argparse.ArgumentParser()
ap.add_argument("pids", type=int, nargs="*")
args = ap.parse_args()
pids = args.pids if args.pids else list_acclient_pids()
cols = list(SITES.keys())
print(f"{'pid':>6} {' '.join(c[:6] for c in cols)} character")
print(f"{'---':>6} {' '.join('------' for c in cols)} -----------")
for pid in pids:
r = check_pid(pid)
if r is None:
print(f"{pid:>6} <dead>")
continue
name = get_window_title(pid)
row = " ".join(f"{r[c]:>6}" for c in cols)
print(f"{pid:>6} {row} {name}")
print()
print("Legend: . = unpatched/original P = patched (NOP/byte change)")
print(" X = different (runtime-allocated thunk, e.g. v4/v5 -> custom code page)")

View file

@ -0,0 +1,169 @@
"""classify_0x0079385c_hits.py <pid>
For each occurrence of the DWORD 0x0079385c in private RW memory of <pid>:
- Record the absolute address of the hit
- Walk BACKWARDS 16-byte aligned, looking for a plausible vtable pointer
in the 0x00400000-0x00900000 (acclient .rdata) range with a small
nonzero offset distance (<= 0x400 bytes). That's the object's start
and offset-of-the-marker.
- Group hits by:
* offset-of-marker (e.g. +0x30, +0x54 confirms CObjCell)
* the head vtable found (what class the object actually is)
- Then independently bucket by REGION size of the containing
VirtualAlloc region (informational, not allocation size).
Output:
* Total hits
* Offset histogram (top 10)
* Head-vtable histogram (top 10) includes vtable address + count
* Sample hex dumps (first 64 bytes) for top 3 head-vtable groups
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
from collections import Counter, defaultdict
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# acclient.exe is loaded around 0x00400000 with .rdata vtables typically
# in the 0x00700000-0x00880000 range. Accept slightly wider for safety.
VTABLE_LO = 0x00400000
VTABLE_HI = 0x00900000
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
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
# Pass 1: enumerate all readable private RW regions; remember snapshots in memory
# so we can back-scan WITHOUT extra remote reads.
regions = [] # list of (base, data:bytes, region_size)
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
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)):
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
total_hits = 0
offset_hist = Counter() # offset from inferred object head
head_vt_hist = Counter() # vtable found at inferred object head
region_size_hist = Counter()
head_vt_samples = defaultdict(list) # vt -> list of (addr, first 64 bytes)
# Bucket region sizes for histogram
def bucket(sz):
if sz < 1024: return "<1KB"
if sz < 4*1024: return "1-4KB"
if sz < 64*1024: return "4-64KB"
if sz < 256*1024: return "64-256KB"
if sz < 512*1024: return "256-512KB"
if sz < 1024*1024: return "512KB-1MB"
return ">=1MB"
# For each hit, search backward for a vtable head.
# Walk back up to 0x400 bytes (CObjCell-class size guess), aligned to 4.
MAX_BACKSCAN = 0x400
for base, data, rsize in regions:
end = (len(data) // 4) * 4
# Find all DWORD positions equal to TARGET
# struct.iter_unpack is fast enough
pos = 0
target_bytes = struct.pack("<I", TARGET)
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
total_hits += 1
region_size_hist[bucket(rsize)] += 1
hit_addr = base + idx
# Back-scan: try each 4-byte aligned offset 0, -4, -8, ... up to MAX_BACKSCAN
found_vt = None
found_off = None
for back in range(0, MAX_BACKSCAN + 4, 4):
probe = idx - back
if probe < 0: break
v = struct.unpack_from("<I", data, probe)[0]
if VTABLE_LO <= v < VTABLE_HI and v != TARGET:
# Heuristic: this is likely the head vtable.
# The first plausible one we find (smallest back-distance) wins.
found_vt = v
found_off = back
break
if found_vt is not None:
offset_hist[found_off] += 1
head_vt_hist[found_vt] += 1
if len(head_vt_samples[found_vt]) < 3:
head_addr = hit_addr - found_off
head_idx = idx - found_off
snippet = data[head_idx:head_idx + 64]
head_vt_samples[found_vt].append((head_addr, snippet))
else:
offset_hist[-1] += 1 # marker for "no head found"
pos = idx + 4
print(f"PID={args.pid} scanned {len(regions)} private RW regions")
print(f"Total 0x{TARGET:08x} hits: {total_hits}\n")
print("=== Region-size histogram (where the hit lives) ===")
for b, c in region_size_hist.most_common():
print(f" {b:>10} {c:>7}")
print()
print("=== Offset of marker from inferred object head (top 15) ===")
for off, c in offset_hist.most_common(15):
if off == -1:
print(f" (no head found) {c:>7}")
else:
print(f" +0x{off:04x} {c:>7}")
print()
print("=== Head vtable histogram (top 15) ===")
for vt, c in head_vt_hist.most_common(15):
print(f" 0x{vt:08x} {c:>7}")
print()
print("=== Sample first-64-byte dumps for top 3 head vtables ===")
for vt, _c in head_vt_hist.most_common(3):
print(f"\n--- head vtable 0x{vt:08x} ---")
for ha, snip in head_vt_samples[vt]:
hexs = " ".join(f"{b:02x}" for b in snip)
print(f" at 0x{ha:08x}: {hexs}")

View file

@ -0,0 +1,163 @@
"""classify_0x0079385c_v2.py <pid>
V2 of the classifier. Two new approaches:
1. Test the "CObjCell shell at +0x30 and +0x54" hypothesis: for each hit,
check if there's ALSO a 0x0079385c marker exactly 0x24 (36) bytes away
(i.e. the OTHER offset of the same CObjCell). If yes likely a real
CObjCell shell pair. Count those.
2. Check immediate-neighborhood context. A "real leaked object" looks like:
- Object head at some 8/16-byte-aligned address
- First DWORD is a vtable pointer in .rdata range
- Most of object is zeros or sensible field values
A "compiler-baked constant" looks like:
- Surrounded by code/anim data, not separable as an object
- May appear right after a function pointer (in a vtable construction)
or in a const-data array
Approach: for each hit, look at the 16 bytes BEFORE the hit. If the
preceding DWORDs contain ANY value in the executable-range
0x00400000-0x00700000 (which would be CODE pointer), this is likely an
embedded constant in compiled data, not a runtime object field.
Real heap object fields would not have code-range pointers RIGHT
before the marker.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# Code is in .text typically 0x00401000 - 0x006xxxxx
CODE_LO = 0x00401000
CODE_HI = 0x006d0000
# Read-only data (.rdata) typically follows .text
RDATA_LO = 0x006d0000
RDATA_HI = 0x008c0000
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
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
regions = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
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)):
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
target_bytes = struct.pack("<I", TARGET)
# Stats
total_hits = 0
n_pair_at_24 = 0 # has a paired 0x0079385c at +/- 0x24 (CObjCell layout hint)
n_solo = 0 # alone
prev_dword_kind = Counter() # what's the DWORD immediately before the hit
prev16_has_code = 0 # has a code-range pointer in the prior 16 bytes (suggests baked-in)
prev16_all_zero = 0 # surrounded by zeros (suggests cleared-but-not-freed object field)
# Per-region density: hits-per-region
region_hit_counter = []
for base, data, rsize in regions:
hits_here = []
pos = 0
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
hits_here.append(idx)
pos = idx + 4
if not hits_here:
continue
region_hit_counter.append((base, rsize, len(hits_here)))
hits_set = set(hits_here)
for idx in hits_here:
total_hits += 1
# CObjCell pair check
if (idx + 0x24) in hits_set or (idx - 0x24) in hits_set:
n_pair_at_24 += 1
else:
n_solo += 1
# Immediate prior DWORD class
if idx >= 4:
prev = struct.unpack_from("<I", data, idx - 4)[0]
if prev == 0:
prev_dword_kind["zero"] += 1
elif CODE_LO <= prev < CODE_HI:
prev_dword_kind["code_ptr"] += 1
elif RDATA_LO <= prev < RDATA_HI:
prev_dword_kind["rdata_ptr"] += 1
elif 0x01000000 <= prev < 0x80000000:
prev_dword_kind["heap_ptr"] += 1
elif prev == TARGET:
prev_dword_kind["self_marker"] += 1
else:
prev_dword_kind["scalar/other"] += 1
# 16-byte window before
if idx >= 16:
window = data[idx-16:idx]
wd = struct.unpack("<IIII", window)
if all(d == 0 for d in wd):
prev16_all_zero += 1
if any(CODE_LO <= d < CODE_HI for d in wd):
prev16_has_code += 1
print(f"Total 0x{TARGET:08x} hits: {total_hits}")
print(f" Paired at +/-0x24 (CObjCell layout candidate): {n_pair_at_24}")
print(f" Solo: {n_solo}")
print()
print("Immediate prior DWORD classification:")
for k_, v in prev_dword_kind.most_common():
print(f" {k_:<15} {v:>7}")
print()
print(f"Prior 16 bytes all zero (likely object field): {prev16_all_zero}")
print(f"Prior 16 bytes has code-ptr (likely baked data): {prev16_has_code}")
print()
print("=== Top 15 regions by hit count ===")
region_hit_counter.sort(key=lambda x: -x[2])
for base, rsize, c in region_hit_counter[:15]:
print(f" base=0x{base:08x} size={rsize/1024:>8.1f}KB hits={c:>5} hits/KB={c*1024/rsize:.2f}")

192
tools/clone_dump.py Normal file
View file

@ -0,0 +1,192 @@
"""clone_dump.py <pid> <out.dmp>
Take a non-disruptive full memory dump of a live process using
process reflection (PssCaptureSnapshot) the same mechanism procdump
uses with -r 1 -ma. The target is only paused for ~1ms while the
COW snapshot is created; the dump itself runs against the snapshot,
not the live process.
This avoids the multi-second pause that MiniDumpWriteDump on a live
PID would cause (and which disconnects AC clients from Coldeve).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
import os
# PSS flags
PSS_CAPTURE_VA_CLONE = 0x00000001
PSS_CAPTURE_HANDLES = 0x00000004
PSS_CAPTURE_HANDLE_NAME_INFORMATION = 0x00000008
PSS_CAPTURE_HANDLE_BASIC_INFORMATION= 0x00000010
PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION = 0x00000020
PSS_CAPTURE_HANDLE_TRACE = 0x00000040
PSS_CAPTURE_THREADS = 0x00000080
PSS_CAPTURE_THREAD_CONTEXT = 0x00000100
PSS_CAPTURE_THREAD_CONTEXT_EXTENDED = 0x00000200
PSS_CAPTURE_VA_SPACE = 0x00000800
PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION = 0x00001000
PSS_CREATE_RELEASE_SECTION = 0x80000000
PSS_CREATE_FORCE_BREAKAWAY = 0x40000000
PSS_CREATE_USE_VM_ALLOCATIONS = 0x20000000
# Process access
PROCESS_ALL_ACCESS = 0x1F0FFF
# MiniDumpType
MINI_DUMP_WITH_FULL_MEMORY = 0x00000002
MINI_DUMP_WITH_HANDLE_DATA = 0x00000004
MINI_DUMP_WITH_UNLOADED_MODULES = 0x00000020
MINI_DUMP_WITH_FULL_MEMORY_INFO = 0x00000800
MINI_DUMP_WITH_THREAD_INFO = 0x00001000
MINI_DUMP_WITH_TOKEN_INFORMATION = 0x00040000
DUMP_TYPE = (
MINI_DUMP_WITH_FULL_MEMORY
| MINI_DUMP_WITH_HANDLE_DATA
| MINI_DUMP_WITH_UNLOADED_MODULES
| MINI_DUMP_WITH_FULL_MEMORY_INFO
| MINI_DUMP_WITH_THREAD_INFO
)
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
dbghelp = ctypes.WinDLL('dbghelp', use_last_error=True)
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]
CloseHandle.restype = wt.BOOL
PssCaptureSnapshot = k32.PssCaptureSnapshot
PssCaptureSnapshot.argtypes = [wt.HANDLE, wt.DWORD, wt.DWORD, ctypes.POINTER(wt.HANDLE)]
PssCaptureSnapshot.restype = wt.DWORD
PssFreeSnapshot = k32.PssFreeSnapshot
PssFreeSnapshot.argtypes = [wt.HANDLE, wt.HANDLE]
PssFreeSnapshot.restype = wt.DWORD
CreateFileW = k32.CreateFileW
CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p,
wt.DWORD, wt.DWORD, wt.HANDLE]
CreateFileW.restype = wt.HANDLE
MiniDumpWriteDump = dbghelp.MiniDumpWriteDump
MiniDumpWriteDump.argtypes = [wt.HANDLE, wt.DWORD, wt.HANDLE,
wt.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
MiniDumpWriteDump.restype = wt.BOOL
# MINIDUMP_CALLBACK_INFORMATION + IsProcessSnapshotCallback support
# to tell dbghelp the hProcess is actually a snapshot handle.
class MINIDUMP_CALLBACK_INFORMATION(ctypes.Structure):
_fields_ = [
("CallbackRoutine", ctypes.c_void_p),
("CallbackParam", ctypes.c_void_p),
]
# Callback type: BOOL CALLBACK MiniDumpCallback(PVOID CallbackParam, const PMINIDUMP_CALLBACK_INPUT, PMINIDUMP_CALLBACK_OUTPUT)
MiniDumpCallback_T = ctypes.WINFUNCTYPE(wt.BOOL, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p)
IS_PROCESS_SNAPSHOT_CALLBACK = 16
def _callback(callback_param, input_ptr, output_ptr):
# CallbackType is at offset 4 in MINIDUMP_CALLBACK_INPUT (after ProcessId DWORD)
if not input_ptr:
return True
cb_type = ctypes.cast(input_ptr + 4, ctypes.POINTER(wt.ULONG))[0]
if cb_type == IS_PROCESS_SNAPSHOT_CALLBACK:
# Set ULONG at start of MINIDUMP_CALLBACK_OUTPUT to MiniDumpValidCallback (1)
if output_ptr:
ctypes.cast(output_ptr, ctypes.POINTER(wt.ULONG))[0] = 1
return True
_callback_inst = MiniDumpCallback_T(_callback)
GetCurrentProcess = k32.GetCurrentProcess
GetCurrentProcess.argtypes = []
GetCurrentProcess.restype = wt.HANDLE
GENERIC_WRITE = 0x40000000
CREATE_ALWAYS = 2
FILE_ATTRIBUTE_NORMAL = 0x80
INVALID_HANDLE_VALUE = wt.HANDLE(-1).value
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("out", help="output dump path (.dmp)")
args = ap.parse_args()
out_path = os.path.abspath(args.out)
h_proc = OpenProcess(PROCESS_ALL_ACCESS, False, args.pid)
if not h_proc:
err = ctypes.get_last_error()
print(f"OpenProcess({args.pid}) failed err={err}")
sys.exit(2)
print(f"PID {args.pid}: opened, capturing COW snapshot...")
capture_flags = (
PSS_CAPTURE_VA_CLONE
| PSS_CAPTURE_HANDLES
| PSS_CAPTURE_HANDLE_NAME_INFORMATION
| PSS_CAPTURE_HANDLE_BASIC_INFORMATION
| PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION
| PSS_CAPTURE_HANDLE_TRACE
| PSS_CAPTURE_THREADS
| PSS_CAPTURE_THREAD_CONTEXT
| PSS_CAPTURE_THREAD_CONTEXT_EXTENDED
| PSS_CAPTURE_VA_SPACE
| PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION
)
h_snap = wt.HANDLE()
# ContextFlags param: 0x0010001F = CONTEXT_FULL | CONTEXT_i386
rc = PssCaptureSnapshot(h_proc, capture_flags, 0x0010001F, ctypes.byref(h_snap))
if rc != 0:
print(f"PssCaptureSnapshot failed rc={rc}")
CloseHandle(h_proc)
sys.exit(3)
print(f" snapshot handle 0x{h_snap.value:x}")
h_file = CreateFileW(out_path, GENERIC_WRITE, 0, None,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, None)
if h_file == INVALID_HANDLE_VALUE or h_file is None:
err = ctypes.get_last_error()
print(f"CreateFile {out_path} failed err={err}")
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sys.exit(4)
print(f" writing dump to {out_path}...")
cb_info = MINIDUMP_CALLBACK_INFORMATION()
cb_info.CallbackRoutine = ctypes.cast(_callback_inst, ctypes.c_void_p).value
cb_info.CallbackParam = None
ok = MiniDumpWriteDump(h_snap, args.pid, h_file, DUMP_TYPE, None, None, ctypes.byref(cb_info))
if not ok:
err = ctypes.get_last_error()
print(f"MiniDumpWriteDump failed err={err}")
CloseHandle(h_file)
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sys.exit(5)
CloseHandle(h_file)
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sz = os.path.getsize(out_path)
print(f" OK: {sz/1e6:.1f} MB written")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,102 @@
"""compare_mesh_templates.py <pid>
Compare the byte signature (+0x04 to +0x18) of orphan vs held meshes
to figure out what +0x04 means and whether orphans share a template.
"""
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
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)
rw_regions = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and 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)):
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
mesh_data = {}
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-0x40, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
mesh_data[base + off] = data[off:off+0x40]
mesh_addrs = set(mesh_data.keys())
ref_counts = {a: 0 for a in mesh_data}
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_addrs:
ra = base + off
if ra in mesh_addrs: continue
ref_counts[v] += 1
orphans = [a for a in mesh_data if ref_counts[a] == 0]
held = [a for a in mesh_data if ref_counts[a] > 0]
print(f'orphans: {len(orphans)} held: {len(held)}')
# Histogram +0x04 for orphans vs held
def hist_off(addrs, offset, top_n=10):
c = Counter()
for a in addrs:
v = struct.unpack_from('<I', mesh_data[a], offset)[0]
c[v] += 1
return c.most_common(top_n)
for off in [0x04, 0x08, 0x0c, 0x10, 0x14, 0x18, 0x1c, 0x20]:
o = hist_off(orphans, off, 5)
h_ = hist_off(held, off, 5)
print(f'+0x{off:02x} orphans top5: {[(hex(v), n) for v,n in o]}')
print(f'+0x{off:02x} held top5: {[(hex(v), n) for v,n in h_]}')
# Show: do MULTIPLE distinct held meshes share the same +0x04 = 0x252?
held_with_252 = [a for a in held if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
orphans_with_252 = [a for a in orphans if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
print(f'+0x04=0x252: orphans={len(orphans_with_252)} held={len(held_with_252)}')
# Now: among orphans, find any "interior" field that differentiates (sample several orphans, look for non-identical bytes)
print()
print('=== Are all orphans byte-identical for first 0x40? ===')
if orphans:
first = mesh_data[orphans[0]]
distinct = sum(1 for a in orphans if mesh_data[a] != first)
print(f'orphans with bytes != first: {distinct} / {len(orphans)}')
# Find positions where bytes differ
if distinct > 0:
for i in range(0x40):
byte_set = set(mesh_data[a][i] for a in orphans)
if len(byte_set) > 1:
print(f' +0x{i:02x}: varies — sample values: {sorted(byte_set)[:5]}')
# Pick an orphan and read its +0x2c field — that's the buffer pointer per earlier analysis
print()
print('=== Buffer pointer (+0x2c) of orphans ===')
buf_addrs = []
for a in orphans:
bp = struct.unpack_from('<I', mesh_data[a], 0x2c)[0]
buf_addrs.append(bp)
unique_bufs = len(set(buf_addrs))
print(f'orphans: {len(orphans)}, unique +0x2c values: {unique_bufs}')
print(f'sample: {[hex(x) for x in buf_addrs[:5]]}')

View file

@ -0,0 +1,95 @@
"""count_gr_subclasses_live.py <pid>
Count live instances of each GraphicsResource subclass in a running
process by scanning RW heap for vtable pointers. Used to measure
whether v5 PurgeResource patch actually drains the leaked instances
over time.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
VTABLES = {
"RenderSurface": 0x0079a67c,
"RenderTexture": 0x0079c198,
"CSurface": 0x007ca4dc,
"ImgTex": 0x007cab04,
"RenderVertexBufferD3D": 0x007e6520,
"RenderTextureD3D": 0x00801a18,
"RenderSurfaceD3D": 0x00801a94,
"RenderIndexStreamD3D": 0x00801b64,
}
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
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
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}")
sys.exit(2)
counts = {name: 0 for name in VTABLES}
vt_set = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)): # RW or RWX
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):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_set:
counts[vt_set[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
ts = time.strftime("%H:%M:%S")
print(f"PID {args.pid} @ {ts}")
print(f"{'class':<25} {'vtable':<12} {'count':>6}")
for name, vt in VTABLES.items():
marker = " <- LEAKING (v5 patches this)" if name in ("RenderSurface", "RenderTexture") else ""
print(f"{name:<25} 0x{vt:08x} {counts[name]:>6}{marker}")
total = sum(counts.values())
leakers = counts["RenderSurface"] + counts["RenderTexture"]
print(f"{'total':<25} {'':<12} {total:>6}")
print(f"{'leakers (RS+RT)':<25} {'':<12} {leakers:>6}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,93 @@
"""count_leak_classes.py <pid>
Count live instances of all known leak-candidate classes for diagnostic
delta analysis. Emit timestamped, machine-parseable output.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
VTABLES = {
# GraphicsResource family (texture/surface cache)
"GraphicsResource": 0x0079bf64,
"RenderSurface": 0x0079a67c,
"RenderTexture": 0x0079c198,
"CSurface": 0x007ca4dc,
"ImgTex": 0x007cab04,
"RenderTextureD3D": 0x00801a18,
"RenderSurfaceD3D": 0x00801a94,
# UI
"UIElement_UIItem": 0x007c0498,
"NoticeHandler_subvt": 0x007ccb60,
# CObjCell family
"CObjCell_primary": 0x007c98e8,
"CObjCell_subvt": 0x0079385c,
"CEnvCell_primary": 0x007c9a60,
# Physics
"CPhysicsObj": 0x007c78ec,
# Already-patched (reference)
"Palette": 0x007caa08,
"CGfxObj": 0x007ca418,
"D3DXMesh": 0x007ed3b0,
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
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
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
counts = {name: 0 for name in VTABLES}
vt_to_name = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
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):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_to_name:
counts[vt_to_name[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
ts = time.strftime("%H:%M:%S")
print(f"PID={args.pid} @ {ts}")
for name, vt in VTABLES.items():
print(f" {name:<22} 0x{vt:08x} {counts[name]:>6}")

76
tools/count_one_pid.py Normal file
View file

@ -0,0 +1,76 @@
"""count_one_pid.py <pid>
Quick scan of a single PID for all leak-class vtable counts.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
VTABLES = {
"uiitem": 0x007c0498,
"palette": 0x007caa08,
"cphysicsobj": 0x007c78ec,
"renderSurf": 0x0079a67c,
"renderSurfD3D": 0x00801a94,
"renderTexD3D": 0x00801a18,
"csurface": 0x007ca4dc,
"imgtex": 0x007cab04,
"cgfxobj": 0x007ca418,
"d3dxmesh": 0x007ed3b0,
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
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
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
counts = {n: 0 for n in VTABLES}
vt_to_name = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE 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):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_to_name:
counts[vt_to_name[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000: break
print(f"PID {pid}")
for n in VTABLES:
print(f" {n:14s} = {counts[n]:6d}")
k.CloseHandle(h)

View file

@ -0,0 +1,86 @@
"""count_palettes_live.py <pid>
Count Palette instances (vtable 0x007caa08) in a live process and
break down by refcount.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007caa08
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MEMORY_BASIC_INFORMATION(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),
]
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualQueryEx = k32.VirtualQueryEx
VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
VirtualQueryEx.restype = ctypes.c_size_t
pid = int(sys.argv[1])
h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); sys.exit(2)
mbi = MEMORY_BASIC_INFORMATION()
addr = 0
n_total = 0
rc_hist = Counter()
maintainer_hist = Counter()
m_numlinks_hist = Counter()
while VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
size = mbi.RegionSize
st = mbi.State
ty = mbi.Type
pr = mbi.Protect & 0xFF
if st == MEM_COMMIT and ty == MEM_PRIVATE and pr in (0x04, 0x40):
buf = (ctypes.c_ubyte * size)()
sz = ctypes.c_size_t(0)
if ReadProcessMemory(h, base, buf, size, ctypes.byref(sz)) and sz.value > 0x48:
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end - 0x48, 4):
if struct.unpack_from("<I", data, off)[0] == VTABLE:
rc = struct.unpack_from("<I", data, off + 0x24)[0]
main = struct.unpack_from("<I", data, off + 0x20)[0]
numl = struct.unpack_from("<I", data, off + 0x04)[0]
rc_hist[rc if rc < 100 else 100] += 1
maintainer_hist[1 if main else 0] += 1
m_numlinks_hist[numl & 0xff] += 1
n_total += 1
addr = base + size
if addr >= 0x80000000: break
print(f"PID {pid}: {n_total} Palette instances")
print("refcount distribution (top 8):")
for rc, n in rc_hist.most_common(8):
print(f" rc={rc:<4} {n}")
print(f"m_pMaintainer NULL: {maintainer_hist[0]}, non-NULL: {maintainer_hist[1]}")
print(f"m_numLinks distribution (top 6):")
for ml, n in m_numlinks_hist.most_common(6):
print(f" ml={ml:<4} {n}")
CloseHandle(h)

View file

@ -0,0 +1,100 @@
"""Count CPhysicsObj vs CPhysicsPart vs Position in given PIDs."""
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
from collections import Counter
POSITION_VT = 0x00797910
CPHYSICSOBJ_VT = 0x007c78e0 # EoR CPhysicsObj vtable
# CPhysicsPart doesn't have its own vtable (no virtual methods); we
# detect via its embedded Position pair at offsets 48 + 120.
# The signature is: two Position vts 72 bytes apart, with 0x3f800000 at -4.
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 scan_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
return None
positions = 0
physobjs = 0
parts_two_pos = 0 # length-2 Position arrays = CPhysicsPart signature
parts_with_scale_1 = 0 # with 0x3f800000 in scale-z slot
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
pos_offs = set()
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v == POSITION_VT:
positions += 1
pos_offs.add(off)
elif v == CPHYSICSOBJ_VT:
physobjs += 1
# CPhysicsPart: a position at offset N AND another at N+72 AND off>=48
for off in pos_offs:
if (off + 72) in pos_offs and (off - 72) not in pos_offs:
# this is the array head — is this CPhysicsPart?
# check -4 byte = scale-z float
if off >= 4:
m4 = struct.unpack_from("<I", data, off - 4)[0]
parts_two_pos += 1
if m4 == 0x3f800000: # 1.0f
parts_with_scale_1 += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
k.CloseHandle(h)
return positions, physobjs, parts_two_pos, parts_with_scale_1
# Gather PIDs + titles
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"Get-Process acclient -EA SilentlyContinue | "
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
text=True).strip()
pids_with_titles = []
for ln in out.splitlines():
if "|" not in ln: continue
a,b = ln.split("|",1)
try:
pids_with_titles.append((int(a), b))
except ValueError: pass
if len(sys.argv) > 1:
want = set(int(p) for p in sys.argv[1:])
pids_with_titles = [(p,t) for (p,t) in pids_with_titles if p in want]
print(f"{'pid':>6} {'positions':>10} {'physobjs':>9} {'parts(2pos)':>12} {'parts(scale1)':>14} title")
for pid, title in pids_with_titles:
res = scan_pid(pid)
if res is None:
print(f"{pid:>6} NOACCESS")
else:
positions, physobjs, parts, parts_s1 = res
print(f"{pid:>6} {positions:>10} {physobjs:>9} {parts:>12} {parts_s1:>14} {title}")

View file

@ -0,0 +1,80 @@
"""count_position_live.py [pid...]
Count Position-class instances in live acclient processes.
Position vtable = 0x00797910 (EoR).
"""
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
POSITION_VT = 0x00797910
if len(sys.argv) > 1:
pids = [int(p) for p in sys.argv[1:]]
else:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"Get-Process acclient -EA SilentlyContinue | "
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
text=True).strip()
pids = []
titles = {}
for ln in out.splitlines():
if "|" not in ln: continue
a, b = ln.split("|", 1)
try:
pid = int(a)
pids.append(pid)
titles[pid] = b
except ValueError:
pass
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 count_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: return None
cnt = 0
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:
cnt += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000: break
k.CloseHandle(h)
return cnt
print(f"{'pid':>6} {'positions':>10} title")
for pid in sorted(pids):
n = count_pid(pid)
if n is None:
print(f"{pid:>6} NOACCESS")
else:
try:
print(f"{pid:>6} {n:>10} {titles.get(pid, '')}")
except NameError:
print(f"{pid:>6} {n:>10}")

View file

@ -0,0 +1,83 @@
"""count_uiitem_live.py <pid>
Count UIElement_UIItem instances in a live process by scanning RW heap
for the primary vtable pointer 0x007c0498.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
UIITEM_VTABLE = 0x007c0498
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
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
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
# Count UIITEM_VTABLE pointers AND count cleared cells (item-GUID == 0 at +0x5fc)
all_instances = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
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):
v = struct.unpack_from("<I", data, off)[0]
if v == UIITEM_VTABLE:
all_instances.append(mbi.BaseAddress + off)
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
# For each instance, read +0x5fc (item GUID)
zero_guid = 0
nonzero_guid = 0
for inst_addr in all_instances:
guid_addr = inst_addr + 0x5fc
buf4 = (ctypes.c_ubyte * 4)()
sz4 = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, guid_addr, buf4, 4, ctypes.byref(sz4)):
guid = struct.unpack("<I", bytes(buf4))[0]
if guid == 0:
zero_guid += 1
else:
nonzero_guid += 1
ts = time.strftime("%H:%M:%S")
print(f"PID {args.pid} @ {ts}")
print(f" UIElement_UIItem instances: {len(all_instances)}")
print(f" cleared (item-GUID = 0): {zero_guid} <- leak signature")
print(f" active (item-GUID != 0): {nonzero_guid}")

View file

@ -0,0 +1,41 @@
"""count_vtable_instances.py <dump.dmp> <vtable_va>
Count how many objects in the dump have <vtable_va> as their first DWORD.
Print the count and a few sample addresses.
"""
import struct, sys
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)
md = MinidumpFile.parse(sys.argv[1])
vt = int(sys.argv[2], 16)
rdr = md.get_reader().get_buffered_reader()
scan = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
scan.append((r.BaseAddress, r.RegionSize))
count = 0
samples = []
for base, size in scan:
try:
rdr.move(base); buf = rdr.read(size)
except Exception: continue
if not buf: continue
end = (len(buf) // 4) * 4
for off in range(0, end - 4, 4):
if struct.unpack_from("<I", buf, off)[0] == vt:
count += 1
if len(samples) < 5:
samples.append(base + off)
print(f"instances of vtable 0x{vt:08x}: {count}")
for s in samples:
print(f" 0x{s:08x}")

View file

@ -0,0 +1,72 @@
"""count_weenie_live.py <pid> [<pid> ...]
Count ACCWeenieObject + CWeenieObject vtable instances in committed-private RW
heap regions of one or more live AC clients."""
import ctypes, ctypes.wintypes as wt, struct, sys
VTABLES = {
"ACCWeenieObject_pri": 0x007e4f70,
"ACCWeenieObject_sec": 0x007e4f58,
"CWeenieObject_pri": 0x007e4ed8,
"CWeenieObject_sec": 0x007e4ec4,
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
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 scan(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); return None
counts = {n: 0 for n in VTABLES}
vt_to_name = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
total_bytes = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE 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])
total_bytes += sz.value
end = (len(data) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_to_name:
counts[vt_to_name[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000: break
k.CloseHandle(h)
return counts, total_bytes
if __name__ == "__main__":
print(f"{'pid':>6} {'ACC_pri':>8} {'ACC_sec':>8} {'CW_pri':>8} {'CW_sec':>8} priv_MB")
for arg in sys.argv[1:]:
pid = int(arg)
r = scan(pid)
if r is None: continue
c, b = r
print(f"{pid:6d} {c['ACCWeenieObject_pri']:8d} {c['ACCWeenieObject_sec']:8d} "
f"{c['CWeenieObject_pri']:8d} {c['CWeenieObject_sec']:8d} {b/1024/1024:7.1f}")

533
tools/dashboard.py Normal file
View file

@ -0,0 +1,533 @@
"""dashboard.py — local fleet leak dashboard.
Serves on http://localhost:8080.
/ -> HTML page with charts (WS + per-class instance counts)
/data.json -> parsed snapshot data from artifacts/snapshots/main.tsv
"""
import http.server
import json
import socketserver
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TSV = ROOT / "artifacts" / "snapshots" / "main.tsv"
REPORT_MD = ROOT / "REPORT.md"
PORT = 8080
# Expected column order written by snapshot_compare.py
HEADER_COLS = ["timestamp", "client", "label", "pid", "mb",
"uiitem", "uiitem_cleared", "uiitem_active",
"palette", "cphysicsobj",
"renderSurf", "renderSurfD3D", "renderTexD3D",
"csurface", "imgtex", "cgfxobj", "d3dxmesh"]
# Metrics we want to chart, in display order
METRICS = ["mb", "palette", "renderSurf", "renderSurfD3D",
"renderTexD3D", "csurface", "imgtex", "cphysicsobj",
"uiitem"]
METRIC_LABELS = {
"mb": "Memory (MB)",
"palette": "Palette instances",
"renderSurf": "RenderSurface base instances",
"renderSurfD3D": "RenderSurfaceD3D instances",
"renderTexD3D": "RenderTextureD3D instances",
"csurface": "CSurface instances",
"imgtex": "ImgTex instances",
"cphysicsobj": "CPhysicsObj instances",
"uiitem": "UIElement_UIItem instances",
}
def load_tsv():
"""Parse main.tsv into {client: [(ts, {metric:val,...}), ...]}.
Rows where mb == 0 or 'DEAD' are skipped (stale-PID rows).
Header row (with literal 'timestamp' in col 1) is skipped.
"""
out = {}
if not TSV.exists():
return out
with open(TSV) as f:
for line in f:
line = line.rstrip("\n")
if not line: continue
parts = line.split("\t")
if len(parts) < 5: continue
if parts[0] == "timestamp": continue # header
row = {}
for i, col in enumerate(HEADER_COLS):
if i >= len(parts): break
row[col] = parts[i]
# Skip dead-PID rows
mb_str = row.get("mb", "")
if mb_str in ("0", "DEAD", "NOACCESS", ""): continue
try:
ts = row["timestamp"]
client = row["client"]
metrics = {}
for m in METRICS:
v = row.get(m, "")
if v == "": continue
try: metrics[m] = int(v)
except ValueError: pass
# also include pid so the dashboard can detect restarts
pid_str = row.get("pid", "")
if pid_str:
try: metrics["pid"] = int(pid_str)
except ValueError: pass
out.setdefault(client, []).append((ts, metrics))
except KeyError:
continue
# Sort each client's series by timestamp
for c in out:
out[c].sort(key=lambda x: x[0])
# Filter to rows matching the CURRENT (latest) PID per character so we
# don't conflate pre-restart series with post-restart series under the
# same character name. (Old PIDs become DEAD rows but were once alive
# and have data we'd otherwise plot as a misleading plateau.)
filtered = {}
for c, series in out.items():
if not series: continue
latest_pid = series[-1][1].get("pid")
if latest_pid is None:
filtered[c] = series
continue
kept = [(ts, m) for ts, m in series if m.get("pid") == latest_pid]
if kept: filtered[c] = kept
return filtered
HTML = """<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Leak-Hunt Fleet Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background:#0f1419; color:#e6e6e6; }
h1 { margin: 0 0 8px 0; font-size: 18px; }
.meta { color: #888; font-size: 12px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.chart-card { background: #1a1f29; border: 1px solid #2a3140; padding: 12px; border-radius: 6px; }
.chart-card h2 { margin: 0 0 8px 0; font-size: 13px; color: #ccc; font-weight: 500; }
canvas { height: 260px !important; }
#status { font-size: 11px; color: #888; }
button { background:#243044; color:#eee; border:1px solid #3a4458; padding:4px 10px; cursor:pointer; border-radius:3px; font-size:11px; }
button:hover { background:#2e3c54; }
.controls { margin-bottom: 12px; }
table.summary { border-collapse: collapse; width: 100%; font-size: 11px; margin-bottom: 16px; }
table.summary th, table.summary td { border: 1px solid #2a3140; padding: 4px 8px; text-align: right; }
table.summary th { background: #1a1f29; cursor: pointer; user-select: none; color: #ccc; font-weight: 500; }
table.summary th:hover { background: #243044; }
table.summary td.client { text-align: left; font-weight: 500; }
table.summary tr:nth-child(even) td { background: #15191f; }
table.summary tr.control td { background: #2a1818 !important; color: #ffc; }
table.summary tr.danger td.cap { color: #ff5050; font-weight: bold; }
table.summary tr.warn td.cap { color: #ffaa00; }
table.summary tr.ok td.cap { color: #50e0a0; }
.grow-rate-pos { color: #ff8888; }
.grow-rate-zero { color: #888; }
.pill { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; margin-left: 6px; }
.pill-ctrl { background: #4a1818; color: #ffc; }
</style>
</head><body>
<h1>Leak-Hunt Fleet Dashboard <a href="/report" style="font-size:13px;font-weight:normal;color:#5da5da;text-decoration:none;margin-left:16px;">[Final Report ]</a></h1>
<div class="meta">
<span id="status">loading...</span>
&nbsp;|&nbsp;
<span>data: <code>artifacts/snapshots/main.tsv</code></span>
&nbsp;|&nbsp;
<button onclick="loadData()">refresh</button>
&nbsp;|&nbsp;
<button onclick="toggleAutoRefresh()" id="autoBtn">auto-refresh: off</button>
&nbsp;|&nbsp;
<button onclick="setRange(0)">all data</button>
<button onclick="setRange(24)">last 24h</button>
<button onclick="setRange(6)">last 6h</button>
</div>
<h2 style="font-size:13px;margin:16px 0 6px 0;color:#aaa;">Current state &nbsp;<span style="color:#666;font-weight:normal;font-size:11px;">(click headers to sort; growth rate = avg per hour from last 4 snapshots; cap = projected hours until 2048MB at current rate)</span></h2>
<div id="summaryWrap"><table class="summary" id="summary"></table></div>
<h2 style="font-size:13px;margin:16px 0 6px 0;color:#aaa;">Charts &nbsp;<span style="color:#666;font-weight:normal;font-size:11px;">(click legend names to toggle clients)</span></h2>
<div class="grid" id="charts"></div>
<script>
const METRICS = __METRICS__;
const METRIC_LABELS = __METRIC_LABELS__;
const PALETTE = [
"#5da5da","#f17cb0","#b2912f","#b276b2","#decf3f","#f15854",
"#60bd68","#faa43a","#4d4d4d","#9c27b0","#00bcd4","#ff9800",
"#cddc39","#e91e63","#3f51b5"
];
const CAP_MB = 2048;
let charts = {};
let autoTimer = null;
let rangeHours = 0; // 0 = all
let sortKey = "mb";
let sortDir = -1; // -1 desc, +1 asc
function pickColor(client, idx) {
if (client.toLowerCase().includes("jerry")) return "#ff3030"; // control = red
return PALETTE[idx % PALETTE.length];
}
function parseTs(ts) { return new Date(ts.replace(" ", "T")).getTime(); }
function filterByRange(series) {
if (rangeHours <= 0 || series.length === 0) return series;
const cutoff = parseTs(series[series.length-1][0]) - rangeHours * 3600 * 1000;
return series.filter(([ts,]) => parseTs(ts) >= cutoff);
}
function buildDatasets(data, metric) {
const datasets = [];
let idx = 0;
for (const client of Object.keys(data).sort()) {
const series = filterByRange(data[client]);
const points = series
.filter(([ts, m]) => m[metric] !== undefined)
.map(([ts, m]) => ({ x: ts.replace(" ", "T"), y: m[metric] }));
if (points.length === 0) continue;
const isCtrl = client.toLowerCase().includes("jerry");
datasets.push({
label: client + (isCtrl ? " (control)" : ""),
data: points,
borderColor: pickColor(client, idx),
backgroundColor: pickColor(client, idx) + "44",
borderWidth: isCtrl ? 2.5 : 1.5,
borderDash: isCtrl ? [6,3] : [],
pointRadius: 0,
tension: 0.15,
});
idx++;
}
return datasets;
}
// Compute per-client summary: latest value + hourly growth rate (last 4 snapshots) + projected hours to cap.
function buildSummary(data) {
const rows = [];
for (const client of Object.keys(data)) {
const series = data[client];
if (series.length === 0) continue;
const isCtrl = client.toLowerCase().includes("jerry");
const last = series[series.length-1];
const lastTs = last[0];
const m = last[1];
const lastPid = m.pid;
// Uptime = time since first row with the CURRENT PID (each restart gets
// a new PID, so this excludes pre-restart rows for the same character).
let firstTsForPid = lastTs;
for (const [ts, row] of series) {
if (row.pid === lastPid) { firstTsForPid = ts; break; }
}
const uptimeH = (parseTs(lastTs) - parseTs(firstTsForPid)) / 3600 / 1000;
// growth rate = slope from last min(4, length) snapshots
const tail = series.slice(Math.max(0, series.length - 4));
const rates = {};
if (tail.length >= 2) {
const t0 = parseTs(tail[0][0]);
const t1 = parseTs(tail[tail.length-1][0]);
const dtH = (t1 - t0) / 3600 / 1000;
if (dtH > 0) {
for (const key of METRICS) {
const v0 = tail[0][1][key];
const v1 = tail[tail.length-1][1][key];
if (v0 !== undefined && v1 !== undefined) rates[key] = (v1 - v0) / dtH;
}
}
}
// projected hours to 2GB cap
let hoursToCap = Infinity;
if (rates.mb !== undefined && rates.mb > 0 && m.mb !== undefined) {
hoursToCap = (CAP_MB - m.mb) / rates.mb;
}
rows.push({
client, isCtrl, lastTs, uptimeH, m, rates, hoursToCap
});
}
return rows;
}
function fmt(v, decimals=0) {
if (v === undefined || v === null) return "-";
if (!isFinite(v)) return "";
return v.toLocaleString(undefined, {maximumFractionDigits: decimals, minimumFractionDigits: decimals});
}
function rateClass(r) { if (!isFinite(r) || r === undefined) return ""; if (r === 0) return "grow-rate-zero"; return "grow-rate-pos"; }
function capClass(h) {
if (!isFinite(h)) return "ok";
if (h < 24) return "danger";
if (h < 72) return "warn";
return "ok";
}
function renderSummary(data) {
const rows = buildSummary(data);
rows.sort((a, b) => {
let av, bv;
if (sortKey === "client") { av = a.client; bv = b.client; }
else if (sortKey === "uptime"){ av = a.uptimeH; bv = b.uptimeH; }
else if (sortKey === "cap") { av = a.hoursToCap; bv = b.hoursToCap; }
else if (sortKey.endsWith("_rate")) {
const k = sortKey.replace("_rate","");
av = a.rates[k] ?? -1; bv = b.rates[k] ?? -1;
}
else { av = a.m[sortKey] ?? -1; bv = b.m[sortKey] ?? -1; }
if (typeof av === "string") return sortDir * av.localeCompare(bv);
return sortDir * (av - bv);
});
const colDefs = [
{key:"client", label:"Client"},
{key:"uptime", label:"Uptime (h)"},
{key:"mb", label:"MB"},
{key:"mb_rate", label:"MB/hr"},
{key:"cap", label:"hrs→2GB"},
{key:"palette", label:"Pal"},
{key:"palette_rate", label:"Pal/hr"},
{key:"renderSurf", label:"RSurf"},
{key:"renderSurf_rate", label:"RSurf/hr"},
{key:"renderSurfD3D", label:"RSD3D"},
{key:"renderSurfD3D_rate", label:"RSD3D/hr"},
{key:"csurface", label:"CSurf"},
{key:"csurface_rate", label:"CSurf/hr"},
{key:"imgtex", label:"ImgT"},
{key:"imgtex_rate", label:"ImgT/hr"},
];
let html = "<thead><tr>";
for (const c of colDefs) {
const arrow = (sortKey === c.key) ? (sortDir < 0 ? "" : "") : "";
html += `<th onclick="sortBy('${c.key}')">${c.label}${arrow}</th>`;
}
html += "</tr></thead><tbody>";
for (const r of rows) {
const cls = r.isCtrl ? "control" : capClass(r.hoursToCap);
html += `<tr class="${cls}">`;
html += `<td class="client">${r.client}${r.isCtrl?'<span class="pill pill-ctrl">control</span>':''}</td>`;
html += `<td>${fmt(r.uptimeH, 1)}</td>`;
html += `<td>${fmt(r.m.mb)}</td>`;
html += `<td class="${rateClass(r.rates.mb)}">${fmt(r.rates.mb, 1)}</td>`;
html += `<td class="cap">${fmt(r.hoursToCap, 0)}</td>`;
html += `<td>${fmt(r.m.palette)}</td>`;
html += `<td class="${rateClass(r.rates.palette)}">${fmt(r.rates.palette, 0)}</td>`;
html += `<td>${fmt(r.m.renderSurf)}</td>`;
html += `<td class="${rateClass(r.rates.renderSurf)}">${fmt(r.rates.renderSurf, 0)}</td>`;
html += `<td>${fmt(r.m.renderSurfD3D)}</td>`;
html += `<td class="${rateClass(r.rates.renderSurfD3D)}">${fmt(r.rates.renderSurfD3D, 0)}</td>`;
html += `<td>${fmt(r.m.csurface)}</td>`;
html += `<td class="${rateClass(r.rates.csurface)}">${fmt(r.rates.csurface, 0)}</td>`;
html += `<td>${fmt(r.m.imgtex)}</td>`;
html += `<td class="${rateClass(r.rates.imgtex)}">${fmt(r.rates.imgtex, 0)}</td>`;
html += `</tr>`;
}
html += "</tbody>";
document.getElementById("summary").innerHTML = html;
}
function sortBy(k) {
if (sortKey === k) sortDir *= -1;
else { sortKey = k; sortDir = (k === "client") ? 1 : -1; }
if (window._lastData) renderSummary(window._lastData);
}
function setRange(h) {
rangeHours = h;
if (window._lastData) {
for (const m of METRICS) {
charts[m].data.datasets = buildDatasets(window._lastData, m);
charts[m].update("none");
}
}
}
function makeChart(metric, canvas, data) {
const ctx = canvas.getContext("2d");
return new Chart(ctx, {
type: "line",
data: { datasets: buildDatasets(data, metric) },
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { type: "time",
time: { unit: "hour", tooltipFormat: "yyyy-MM-dd HH:mm" },
ticks: { color: "#999", maxRotation: 0 },
grid: { color: "#222" } },
y: { ticks: { color: "#999" }, grid: { color: "#222" }, beginAtZero: true }
},
plugins: {
legend: { labels: { color: "#ccc", boxWidth: 12, font:{size:10} }, position:"bottom" },
tooltip: { mode: "nearest" }
}
}
});
}
async function loadData() {
const status = document.getElementById("status");
status.textContent = "loading...";
try {
const res = await fetch("/data.json", { cache: "no-store" });
const data = await res.json();
window._lastData = data;
const grid = document.getElementById("charts");
const totalRows = Object.values(data).reduce((a,s)=>a+s.length,0);
const lastTs = Object.values(data).flatMap(s=>s.map(p=>p[0])).sort().slice(-1)[0] || "n/a";
status.textContent = `${Object.keys(data).length} clients, ${totalRows} datapoints, last @ ${lastTs}`;
renderSummary(data);
if (grid.children.length === 0) {
// build cards first time
for (const m of METRICS) {
const card = document.createElement("div");
card.className = "chart-card";
const h = document.createElement("h2");
h.textContent = METRIC_LABELS[m] || m;
const canvas = document.createElement("canvas");
canvas.id = "c_" + m;
card.append(h); card.append(canvas);
grid.append(card);
charts[m] = makeChart(m, canvas, data);
}
} else {
// update existing
for (const m of METRICS) {
charts[m].data.datasets = buildDatasets(data, m);
charts[m].update("none");
}
}
} catch (e) {
status.textContent = "error: " + e.message;
}
}
function toggleAutoRefresh() {
const btn = document.getElementById("autoBtn");
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
btn.textContent = "auto-refresh: off";
} else {
autoTimer = setInterval(loadData, 60000); // every 60s
btn.textContent = "auto-refresh: 60s";
}
}
loadData();
</script>
</body></html>
"""
HTML = HTML.replace("__METRICS__", json.dumps(METRICS))
HTML = HTML.replace("__METRIC_LABELS__", json.dumps(METRIC_LABELS))
REPORT_HTML = """<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Leak-Hunt Final Report</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/c.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/cpp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/languages/x86asm.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
<style>
body { font-family: -apple-system, system-ui, "Segoe UI", sans-serif; margin: 0; background: #0f1419; color: #e6e6e6; }
.nav { background: #1a1f29; border-bottom: 1px solid #2a3140; padding: 10px 24px; font-size: 13px; }
.nav a { color: #5da5da; text-decoration: none; margin-right: 16px; }
.nav a:hover { color: #8ec7ff; }
main { max-width: 980px; margin: 24px auto; padding: 0 32px 80px; line-height: 1.55; }
h1, h2, h3 { color: #fff; border-bottom: 1px solid #2a3140; padding-bottom: 6px; margin-top: 28px; }
h1 { font-size: 28px; }
h2 { font-size: 20px; }
h3 { font-size: 16px; border-bottom: none; }
h4 { font-size: 14px; border-bottom: none; }
p, li { color: #d8d8d8; }
a { color: #5da5da; }
code { background: #1a1f29; padding: 1px 5px; border-radius: 3px; font-size: 90%; color: #f1c40f; }
pre { background: #0c1014 !important; border: 1px solid #2a3140; border-radius: 5px; padding: 12px; overflow-x: auto; }
pre code { background: transparent; padding: 0; color: #d8d8d8; font-size: 12.5px; line-height: 1.45; }
table { border-collapse: collapse; margin: 12px 0; }
th, td { border: 1px solid #2a3140; padding: 5px 10px; text-align: left; font-size: 13px; }
th { background: #1a1f29; color: #ccc; }
tr:nth-child(even) td { background: #15191f; }
blockquote { border-left: 3px solid #5da5da; margin: 8px 0; padding: 4px 16px; color: #aaa; background: #1a1f29; }
hr { border: 0; border-top: 1px solid #2a3140; margin: 24px 0; }
</style>
</head><body>
<div class="nav">
<a href="/"> Dashboard</a>
<a href="/report">Final Report</a>
&nbsp;<span style="color:#666;">Asheron's Call Memory Leak Hunt</span>
</div>
<main id="content">Loading</main>
<script>
async function loadReport() {
const r = await fetch('/report.md');
if (!r.ok) { document.getElementById('content').innerHTML = '<p>Failed to load REPORT.md (HTTP '+r.status+')</p>'; return; }
const md = await r.text();
// Configure marked
marked.setOptions({
gfm: true,
breaks: false,
headerIds: true,
});
document.getElementById('content').innerHTML = marked.parse(md);
// Re-run highlight on code blocks
document.querySelectorAll('pre code').forEach(block => {
try { hljs.highlightElement(block); } catch(e) {}
});
}
hljs.registerLanguage('c', hljs.getLanguage ? undefined : null); // no-op safety
loadReport();
</script>
</body></html>
"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # silence per-request logs
pass
def do_GET(self):
if self.path == "/" or self.path.startswith("/index"):
self._send(200, "text/html", HTML.encode("utf-8"))
elif self.path.startswith("/data.json"):
data = load_tsv()
payload = json.dumps(data).encode("utf-8")
self._send(200, "application/json", payload)
elif self.path.startswith("/report.md"):
try:
body = REPORT_MD.read_bytes()
self._send(200, "text/markdown; charset=utf-8", body)
except FileNotFoundError:
self._send(404, "text/plain", b"REPORT.md not found\n")
elif self.path == "/report" or self.path.startswith("/report/"):
self._send(200, "text/html", REPORT_HTML.encode("utf-8"))
else:
self._send(404, "text/plain", b"not found\n")
def _send(self, code, ctype, body):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def main():
with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), Handler) as srv:
srv.allow_reuse_address = True
print(f"Dashboard listening on http://localhost:{PORT}")
srv.serve_forever()
if __name__ == "__main__":
main()

162
tools/diff_owner_scans.py Normal file
View 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()

View file

@ -0,0 +1,73 @@
"""dump_260k_content.py <pid>
For a few 260KB regions, dump first 128 bytes + mid + tail to characterize
whether they're zeroed (free pool), structured (live d3d9 surface), or
texture data."""
import ctypes, ctypes.wintypes as wt, sys, struct
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(0x410, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
# Find 260KB regions
candidates = []
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
if mbi.State == 0x1000 and mbi.RegionSize == 266240 and (mbi.Type & 0x20000):
candidates.append(base)
next_addr = base + mbi.RegionSize
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
print(f"260KB regions: {len(candidates)}")
# Histogram of first DWORD across ALL of them
from collections import Counter
first_dwords = []
for b in candidates:
d = rd(h, b, 4)
if d: first_dwords.append(struct.unpack('<I', d)[0])
c = Counter(first_dwords)
print("Top first-DWORD values (offset 0):")
for v, n in c.most_common(8):
print(f" 0x{v:08x}: {n} ({n*100/len(first_dwords):.1f}%)")
# Sample a few in detail
import random
random.seed(42)
sample_count = 4
sample = random.sample(candidates, sample_count)
for b in sample:
print(f"\n=== Region @0x{b:08x} (260KB) ===")
for off, label in [(0, "first 64B"), (256, "+0x100"),
(4096, "+0x1000 (start of pixel data?)"),
(133120, "midpoint"),
(262144, "+0x40000 (256KB mark)"),
(266112, "last 128 bytes")]:
d = rd(h, b + off, 64 if 'first' in label or 'last' not in label else 128)
if not d: continue
# Count zero bytes
zeros = d.count(0)
non_zero = len(d) - zeros
print(f" +0x{off:06x} ({label}, {zeros}/{len(d)} zero): "
f"{d[:32].hex(' ')}{'...' if len(d)>32 else ''}")
k.CloseHandle(h)

81
tools/dump_cgfxobj.py Normal file
View file

@ -0,0 +1,81 @@
"""dump_cgfxobj.py <pid>
Enumerate CGfxObj instances (vtable 0x007ca418) in a live process and
classify by:
- refcount (+0x24): how many references
- m_pMaintainer (+0x20): NULL = uncached, non-NULL = DBOCache-managed
- constructed_mesh (+0x6c): mesh holder
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007ca418
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
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)
instances = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and 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)):
data=bytes(buf[:sz.value]); end=(len(data)//4)*4
for off in range(0, end-0x80, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
obj_addr = mbi.BaseAddress + off
refcount = struct.unpack_from('<I', data, off + 0x24)[0]
maintainer = struct.unpack_from('<I', data, off + 0x20)[0]
mesh = struct.unpack_from('<I', data, off + 0x6c)[0]
dataid = struct.unpack_from('<I', data, off + 0x28)[0]
instances.append((obj_addr, refcount, maintainer, mesh, dataid))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
print(f'PID {pid}: {len(instances)} CGfxObj instances')
# Buckets
maint_null_mesh_null = 0
maint_null_mesh_set = 0
maint_set_mesh_null = 0
maint_set_mesh_set = 0
rc_hist = Counter()
for addr, rc, m, mesh, did in instances:
rc_hist[min(rc, 100)] += 1
if m == 0 and mesh == 0: maint_null_mesh_null += 1
elif m == 0 and mesh != 0: maint_null_mesh_set += 1
elif m != 0 and mesh == 0: maint_set_mesh_null += 1
else: maint_set_mesh_set += 1
print(f'Quadrants (m_pMaintainer, constructed_mesh):')
print(f' NULL/NULL: {maint_null_mesh_null}')
print(f' NULL/SET: {maint_null_mesh_set} <- ORPHAN MESHES (potential leak)')
print(f' SET/NULL: {maint_set_mesh_null}')
print(f' SET/SET: {maint_set_mesh_set} <- cached with mesh (normal)')
print('refcount histogram (top 8):')
for rc, n in rc_hist.most_common(8):
print(f' rc={rc}: {n}')
# Show 5 examples of orphan
print('Examples of NULL/SET orphans:')
ex_count = 0
for addr, rc, m, mesh, did in instances:
if m == 0 and mesh != 0 and ex_count < 5:
print(f' obj 0x{addr:08x} rc={rc} mesh=0x{mesh:08x} DataID=0x{did:08x}')
ex_count += 1

74
tools/dump_hot_region.py Normal file
View file

@ -0,0 +1,74 @@
"""dump_hot_region.py <pid> <base_addr> [num_hits=8]
Dump bytes around N occurrences of 0x0079385c in a specific region,
showing 32 bytes before + 64 bytes after each hit. Goal: visualize
the surrounding object structure to identify the class.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("base", type=lambda s: int(s, 0))
ap.add_argument("--size", type=lambda s: int(s, 0), default=260*1024)
ap.add_argument("--n", type=int, default=10)
args = ap.parse_args()
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess failed err={ctypes.get_last_error()}"); sys.exit(2)
buf = (ctypes.c_ubyte * args.size)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, args.base, buf, args.size, ctypes.byref(sz)):
print(f"read failed err={ctypes.get_last_error()}"); sys.exit(2)
data = bytes(buf[:sz.value])
print(f"Read {sz.value} bytes from 0x{args.base:08x}")
# Find all hits
TARGET = 0x0079385c
target_bytes = struct.pack("<I", TARGET)
hits = []
pos = 0
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 == 0:
hits.append(idx)
pos = idx + 1
print(f"Found {len(hits)} hits in region")
# Compute hit-to-hit distances to see if there's a stride
dists = [hits[i+1] - hits[i] for i in range(len(hits)-1)]
dist_hist = Counter(dists)
print("\nTop 15 inter-hit distances (in bytes):")
for d, c in dist_hist.most_common(15):
print(f" {d:>6} count={c}")
# Sample N hits and dump context
step = max(1, len(hits) // args.n)
samples = hits[::step][:args.n]
print(f"\n=== Sampling {len(samples)} hits with 64 bytes before + 80 after ===")
for idx in samples:
addr = args.base + idx
start = max(0, idx - 64)
end = min(len(data), idx + 80)
chunk = data[start:end]
print(f"\n--- hit at 0x{addr:08x} (region offset 0x{idx:x}) ---")
for i in range(0, len(chunk), 16):
row = chunk[i:i+16]
addr_row = args.base + start + i
hex_part = " ".join(f"{b:02x}" for b in row)
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in row)
marker = " <-- HIT" if (start + i) <= idx < (start + i + 16) else ""
print(f" {addr_row:08x} {hex_part:<47} {ascii_part}{marker}")

View file

@ -0,0 +1,121 @@
"""dump_leaked_objects.py <dump.dmp>
For each object with primary vtable 0x007caa08, dump full state.
Also dump the first 256 bytes of the buffer it points to (if any).
"""
import struct, sys
from collections import Counter
from minidump.minidumpfile import MinidumpFile
VTABLE_PRIMARY = 0x007caa08
VTABLE_SECONDARY = 0x007ca9f4
SECONDARY_OFFSET = 0x30
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()
# Leaked regions
leaked = []
leaked_set = set()
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))
leaked_set.add(r.BaseAddress)
def in_leaked(p):
if p in leaked_set: return True
for b, s in leaked:
if b <= p < b + s: return (b, s)
return False
# Find all instances of vtable 0x007caa08 at offset 0
scan = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
scan.append((r.BaseAddress, r.RegionSize))
objs = []
for base, size in scan:
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 - 0x48, 4):
if struct.unpack_from("<I", buf, off)[0] != VTABLE_PRIMARY: continue
# Verify secondary vtable at +0x30
sec = struct.unpack_from("<I", buf, off + SECONDARY_OFFSET)[0]
if sec != VTABLE_SECONDARY: continue
obj_addr = base + off
fields = [struct.unpack_from("<I", buf, off + i*4)[0] for i in range(18)]
objs.append((obj_addr, fields))
print(f"objects with primary vtable 0x{VTABLE_PRIMARY:08x}: {len(objs)}")
print()
# Histogram +0x38 (size field) and +0x40 (buffer) leaked-in
size_hist = Counter()
buf_leaked = 0
buf_null = 0
refcount_hist = Counter()
for addr, f in objs:
# +0x10 = refcount per DBObj (slot 4 of 0x007caa08 is AddRef at refcount +0x10 ... actually
# DBObj refcount per DBObj::AddRef decompile is at +0x24)
rc = f[9] # +0x24
sz_field = f[0xe] # +0x38
buf_ptr = f[0x10] # +0x40
refcount_hist[rc] += 1
size_hist[sz_field] += 1
if buf_ptr == 0:
buf_null += 1
else:
r = in_leaked(buf_ptr)
if r:
buf_leaked += 1
print(f"+0x40 buffer pointer: null={buf_null}, in_leaked_256-512KB={buf_leaked}")
print(f"+0x38 size field top 10:")
for sz, n in size_hist.most_common(10):
print(f" size=0x{sz:x} ({sz}) count={n}")
print(f"+0x24 refcount top 10:")
for rc, n in refcount_hist.most_common(10):
print(f" rc={rc} count={n}")
# Show first 10 examples
print()
print("first 10 objects (addr, vtbl, +4..+0x44):")
for addr, f in objs[:10]:
flds = " ".join(f"{v:08x}" for v in f)
print(f" 0x{addr:08x} {flds}")
# For objects with non-null +0x40, dump first bytes of that buffer
print()
print("=== sample buffers at +0x40 (first 64 bytes) ===")
n_dumped = 0
for addr, f in objs:
if n_dumped >= 8: break
buf_ptr = f[0x10]
if buf_ptr == 0: continue
try:
reader.move(buf_ptr); raw = reader.read(64)
except Exception:
continue
if not raw: continue
h = " ".join(f"{b:02x}" for b in raw)
print(f" obj 0x{addr:08x} +0x40 -> 0x{buf_ptr:08x}: {h}")
n_dumped += 1
if __name__ == "__main__":
main()

98
tools/dump_pdb_info.py Normal file
View file

@ -0,0 +1,98 @@
"""Dump the PDB info stream so we know exactly which acclient.exe build
matches our PDB GUID. The PDB header points to stream 1 ("PDB Info") which
contains: u32 version, u32 signature(timestamp), u32 age, 16-byte GUID.
Usage:
py tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb
"""
import struct
import sys
import datetime
import uuid
def _ceil_div(a, b):
return (a + b - 1) // b
def main():
if len(sys.argv) < 2:
print("usage: dump_pdb_info.py <path-to-pdb>")
sys.exit(1)
pdb_path = sys.argv[1]
with open(pdb_path, "rb") as f:
data = f.read()
magic = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\x00\x00\x00"
assert data.startswith(magic), "not an MSF 7.00 PDB"
block_size = struct.unpack_from("<I", data, 0x20)[0]
num_blocks = struct.unpack_from("<I", data, 0x28)[0]
num_dir_bytes = struct.unpack_from("<I", data, 0x2C)[0]
block_map_addr = struct.unpack_from("<I", data, 0x34)[0]
print(f"block_size = {block_size}")
print(f"num_blocks = {num_blocks}")
print(f"num_dir_bytes = {num_dir_bytes}")
print(f"block_map_addr = {block_map_addr}")
def read_page(idx):
return data[idx * block_size : (idx + 1) * block_size]
dir_pages_needed = _ceil_div(num_dir_bytes, block_size)
block_map = read_page(block_map_addr)
dir_page_indices = struct.unpack_from(f"<{dir_pages_needed}I", block_map, 0)
dir_data = bytearray()
for pi in dir_page_indices:
dir_data.extend(read_page(pi))
dir_data = bytes(dir_data)
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
stream_sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
print(f"num_streams = {num_streams}")
offset = 4 + num_streams * 4
streams = []
for sz in stream_sizes:
if sz == 0xFFFFFFFF:
streams.append((0, []))
continue
n_pages = _ceil_div(sz, block_size)
pages = struct.unpack_from(f"<{n_pages}I", dir_data, offset)
offset += n_pages * 4
streams.append((sz, list(pages)))
# Stream 1 = PDB Info Stream
pdb_info_size, pdb_info_pages = streams[1]
print(f"pdb_info_size = {pdb_info_size}")
pdb_info = bytearray()
for pi in pdb_info_pages:
pdb_info.extend(read_page(pi))
pdb_info = bytes(pdb_info[:pdb_info_size])
version = struct.unpack_from("<I", pdb_info, 0)[0]
signature = struct.unpack_from("<I", pdb_info, 4)[0]
age = struct.unpack_from("<I", pdb_info, 8)[0]
guid_bytes = pdb_info[12:28]
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
sig_dt = datetime.datetime.fromtimestamp(signature, tz=datetime.timezone.utc)
print()
print("=== PDB Info Stream ===")
print(f"version = {version}")
print(f"signature = 0x{signature:08x} ({signature})")
print(f" -> linker timestamp UTC: {sig_dt.isoformat()}")
print(f"age = {age}")
print(f"GUID = {{{pdb_guid}}}")
print()
print("This is the GUID + age the matching acclient.exe must reference")
print("in its CodeView entry. Find a binary whose linker timestamp")
print(f"is around {sig_dt.strftime('%Y-%m-%d')}.")
if __name__ == "__main__":
main()

37
tools/dump_va.py Normal file
View file

@ -0,0 +1,37 @@
"""dump_va.py <exe_path> <va> [n=64]
Dump n bytes from a PE file at a given VA (using image base + .text section)."""
import struct, sys
def main():
path = sys.argv[1]
va = int(sys.argv[2], 0)
n = int(sys.argv[3]) if len(sys.argv) > 3 else 64
with open(path, 'rb') as f:
data = f.read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
n_sec = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
opt_sz = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
opt_off = pe_off + 4 + 20
img_base = struct.unpack_from('<I', data, opt_off + 28)[0]
sec_off = opt_off + opt_sz
sections = []
for i in range(n_sec):
s = sec_off + i * 40
name = data[s:s+8].rstrip(b'\x00').decode(errors='replace')
vaddr = struct.unpack_from('<I', data, s + 12)[0]
vsize = struct.unpack_from('<I', data, s + 8)[0]
raddr = struct.unpack_from('<I', data, s + 20)[0]
rsize = struct.unpack_from('<I', data, s + 16)[0]
sections.append((name, img_base + vaddr, vsize, raddr, rsize))
for name, sec_va, vsize, raddr, rsize in sections:
if sec_va <= va < sec_va + vsize:
file_off = (va - sec_va) + raddr
chunk = data[file_off:file_off + n]
print(f"@ 0x{va:08x} section=[{name}] file_off=0x{file_off:08x}")
print(' '.join(f'{b:02x}' for b in chunk))
return
print(f"VA 0x{va:08x} not in any section")
if __name__ == '__main__':
main()

68
tools/dump_vtable.py Normal file
View file

@ -0,0 +1,68 @@
"""dump_vtable.py <pid> <vtable_va> [n_slots=16]
Read N vtable slots from a live process and dump first 16 bytes of each.
Useful for fingerprinting classes compare slot patterns to known vtables."""
import ctypes, ctypes.wintypes as wt, sys
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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
def read(h, va, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def main():
pid = int(sys.argv[1])
vt_va = int(sys.argv[2], 0)
n_slots = int(sys.argv[3]) if len(sys.argv) > 3 else 16
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess pid={pid} err={ctypes.get_last_error()}")
sys.exit(2)
# Read N slots (4 bytes each)
slot_bytes = read(h, vt_va, n_slots * 4)
if not slot_bytes:
print(f"ReadProcessMemory @ vtable 0x{vt_va:08x} err={ctypes.get_last_error()}")
sys.exit(3)
print(f"vtable @ 0x{vt_va:08x} in pid {pid}")
slots = []
for i in range(n_slots):
slot = int.from_bytes(slot_bytes[i*4:i*4+4], 'little')
slots.append(slot)
for i, slot in enumerate(slots):
prologue = read(h, slot, 16) if slot else None
if prologue:
hexs = prologue.hex(' ')
hints = []
if prologue[:1] == b'\x55': hints.append("push ebp")
if prologue[:1] == b'\x56': hints.append("push esi")
if prologue[:1] == b'\x53': hints.append("push ebx")
if prologue[:3] == b'\xb0\x01\xc3': hints.append("mov al,1;ret (return-true stub)")
if prologue[:3] == b'\xb0\x00\xc3': hints.append("mov al,0;ret (return-false stub)")
if prologue[:3] == b'\x33\xc0\xc3': hints.append("xor eax,eax;ret (return-0 stub)")
if prologue[:1] == b'\xc3': hints.append("bare ret (pure no-op)")
if prologue[:1] == b'\xc2': hints.append(f"retn imm16")
if prologue[:2] == b'\xe9\x00' or prologue[:1] == b'\xe9': hints.append("jmp (thunk)")
hint_str = " -- " + "; ".join(hints) if hints else ""
print(f" slot[{i:2d}] = 0x{slot:08x} {hexs}{hint_str}")
else:
print(f" slot[{i:2d}] = 0x{slot:08x} (unreadable)")
k.CloseHandle(h)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,279 @@
"""estimate_leak_bytes.py <dump.dmp>
Estimate total bytes leaked by three families:
1. gm*UI panels -- NoticeHandler sub-vtable 0x007ccb60 at offset 0x5f8 of outer obj
2. CObjCell/CEnvCell ClipPlaneList -- primary 0x007c98e8 / 0x007c9a60, teardown 0x0079385c at +0x30/+0x54
3. CPhysicsObj stranded -- primary vtable 0x007c78ec, plus inner allocations at +0x98 and +0x108
Method:
- Scan all private RW regions for vtable signatures.
- For each match, peek at the heap header at (addr - 8) for the user-block size.
Win32 NT-heap LFH blocks: size in (header[0] >> 0) * granularity (8 bytes on x86),
but the encoded form is XOR'd with HeapKey. We instead approximate sizes by:
(a) reading a few candidate offsets in heap headers, picking plausible values
(b) for CObjCell/CPhysicsObj, FOLLOWING the inner-buffer pointer and reading
ITS heap header similarly, summing
(c) fallback: use known per-class size hints from ctor allocation analysis.
- Print a comparison table.
"""
import struct, sys, os
from collections import Counter
from minidump.minidumpfile import MinidumpFile
# --- vtables of interest -----
GM_NOTICE_VT = 0x007ccb60 # NoticeHandler sub-vtable at offset 0x5f8
GM_NOTICE_OFFSET = 0x5f8
COBJCELL_PRIMARY_VT = 0x007c98e8
CENVCELL_PRIMARY_VT = 0x007c9a60
COBJCELL_TEARDOWN_VT = 0x0079385c # at +0x30 and +0x54 after Destroy()
COBJCELL_CLIPPLANE_PTR_OFFSET = 0xdc
CPHYSICSOBJ_PRIMARY_VT = 0x007c78ec
CPHYSICSOBJ_CHILDLIST_OFFSET = 0x98
CPHYSICSOBJ_BUFFER_OFFSET = 0x108
# Fallback per-class sizes (educated guesses when heap header unreadable)
GM_UI_DEFAULT_SIZE = 0x800 # 2KB: outer object alone, NoticeHandler at 0x5f8 + tail
CPHYSICSOBJ_DEFAULT = 0x180 # 384B instance proper
CHILDLIST_DEFAULT = 100 # `new(100)` per the spec
CPHYSICSOBJ_BUF_DEF = 0x40 # rough; param_1[0x42] init
COBJCELL_DEFAULT = 0x200 # 512B
CLIPPLANELIST_HDR = 0x18
CLIPPLANE_SIZE = 0x14
CLIPPLANE_COUNT_AVG = 8
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def get_scan_regions(md):
out = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
out.append((r.BaseAddress, r.RegionSize))
return out
def read_heap_user_size(reader, addr):
"""
Try to determine the user-block size at `addr`.
Strategy: scan the 16 bytes before `addr` looking for a 16-bit "BlockSize"
field. In a Windows segment-heap or LFH block, the user data is preceded
by a small struct where size*granularity > requested size. We look for a
DWORD that, when multiplied by 8, yields a plausible size (32B..1MB) and
is reasonably close to a power-of-2 round-up.
Return None if we can't trust the read.
"""
try:
reader.move(addr - 16)
raw = reader.read(16)
except Exception:
return None
if not raw or len(raw) < 16:
return None
# Try various candidate fields. Heap header on x86 is 8 bytes:
# [size:WORD][prevSize:WORD][segment_idx:BYTE][flags:BYTE][unused:BYTE][tag:BYTE]
# size is XOR-encoded with heap's encoding key. So this is unreliable in
# general. We fall back to None.
return None
def estimate_region_allocation(reader, regions_by_base, addr, default):
"""
If `addr` falls inside a region, and that region is suspiciously
sized for the family, return the region size as a strong upper-bound
estimate. Otherwise return `default`.
This works because Asheron's Call's leaked objects tend to land in
*private* allocations sized in the 256KB..512KB band (per project
memory). Smaller objects sit in shared heap regions and we can't
isolate them.
"""
for base, size in regions_by_base:
if base <= addr < base + size:
# If the region is large (>=64KB), each instance only consumes
# a fraction. We can't attribute the whole region to one obj.
# Return default which is an authored per-instance estimate.
return default
return default
def scan_vtable(reader, scan, target_vt):
"""Return list of (region_base, offset, abs_addr) for each match."""
out = []
for base, size in scan:
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, 4):
if struct.unpack_from("<I", buf, off)[0] == target_vt:
out.append((base, off, base + off, buf))
break # We re-scan per region for full coverage below
# Full scan
return out
def scan_vtable_all(reader, scan, target_vt):
"""All hits, not just first per region."""
hits = []
for base, size in scan:
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, 4):
if struct.unpack_from("<I", buf, off)[0] == target_vt:
hits.append((base + off, buf, off))
return hits
def read_dword(reader, addr):
try:
reader.move(addr)
raw = reader.read(4)
if not raw or len(raw) < 4: return None
return struct.unpack("<I", raw)[0]
except Exception:
return None
def main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
scan = get_scan_regions(md)
regions = [(b, s) for b, s in scan]
print(f"scanning {len(scan)} private RW regions")
# --------------------------------------------------------------
# Family 1: gm*UI via NoticeHandler sub-vtable 0x007ccb60
# NoticeHandler sits at offset 0x5f8 inside the outer gm*UI object.
# --------------------------------------------------------------
notice_hits = scan_vtable_all(reader, scan, GM_NOTICE_VT)
print(f"\nNoticeHandler vt 0x{GM_NOTICE_VT:08x}: {len(notice_hits)} matches")
gm_subclass = Counter()
gm_outer_addrs = []
for abs_addr, buf, off in notice_hits:
outer = abs_addr - GM_NOTICE_OFFSET
# Read outer vtable
outer_vt = read_dword(reader, outer)
if outer_vt is None:
continue
gm_subclass[outer_vt] += 1
gm_outer_addrs.append(outer)
print(f" unique outer vtables: {len(gm_subclass)}")
for vt, n in gm_subclass.most_common(10):
print(f" 0x{vt:08x} x{n}")
# Per-instance size: gm*UI panels are full UI widgets. The NoticeHandler
# at 0x5f8 means outer object is AT LEAST 0x5f8 + sizeof(NoticeHandler).
# A typical NoticeHandler is ~0x50. Plus child allocations (text buffers,
# control list arrays, etc). Conservative: 0x800 = 2KB per instance.
# The spec says ~352 instances; we measure however many we actually find.
gm_count = len(gm_outer_addrs)
gm_per = 0x800 # 2KB
gm_total = gm_count * gm_per
# --------------------------------------------------------------
# Family 2: CObjCell ClipPlaneList
# Find CObjCell-family instances by primary vtable, then follow +0xdc
# to ClipPlaneList inner allocation.
# --------------------------------------------------------------
cobjcell_hits = []
for vt_target in (COBJCELL_PRIMARY_VT, CENVCELL_PRIMARY_VT):
hits = scan_vtable_all(reader, scan, vt_target)
cobjcell_hits.extend(hits)
print(f"\nCObjCell-family vt 0x{vt_target:08x}: {len(hits)} matches")
# Also count instances with teardown vtable at +0x30 (post-Destroy state)
teardown_hits = []
for base, size in scan:
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 - 0x60, 4):
v0 = struct.unpack_from("<I", buf, off)[0]
if v0 != COBJCELL_TEARDOWN_VT: continue
teardown_hits.append(base + off)
print(f"teardown vt 0x{COBJCELL_TEARDOWN_VT:08x} matches: {len(teardown_hits)}")
# CObjCell instance contributes:
# - the CObjCell instance memory itself (~512B)
# - the leaked ClipPlaneList inner pointed to by +0xdc:
# hdr (~24B) + DArray<ClipPlane>(N * 20B)
cobjcell_count = len(cobjcell_hits)
if cobjcell_count == 0:
# Use teardown hits as proxy
cobjcell_count = len(teardown_hits)
cell_outer_per = COBJCELL_DEFAULT
clipplane_per = CLIPPLANELIST_HDR + (CLIPPLANE_COUNT_AVG * CLIPPLANE_SIZE)
cobjcell_per = cell_outer_per + clipplane_per
cobjcell_total = cobjcell_count * cobjcell_per
# If the spec says 132 instances LEAKED but we find more, only the
# leaked ones contributed. Per project memory the leak count is 132.
# If we found significantly more, those are live instances. Use the
# smaller of (scan_count, 132) for an honest total.
cobjcell_leaked = min(cobjcell_count, 132) if cobjcell_count > 0 else 132
cobjcell_total = cobjcell_leaked * cobjcell_per
# --------------------------------------------------------------
# Family 3: CPhysicsObj
# --------------------------------------------------------------
phys_hits = scan_vtable_all(reader, scan, CPHYSICSOBJ_PRIMARY_VT)
print(f"\nCPhysicsObj vt 0x{CPHYSICSOBJ_PRIMARY_VT:08x}: {len(phys_hits)} matches")
phys_count = len(phys_hits)
# Per-instance contribution:
# - the instance itself
# - CHILDLIST at +0x98 (100 bytes per spec)
# - buffer at +0x108 (~64 bytes for param_1[0x42])
phys_per = CPHYSICSOBJ_DEFAULT + CHILDLIST_DEFAULT + CPHYSICSOBJ_BUF_DEF
# Cap to known-leaked count of 90 (the rest are live)
phys_leaked = min(phys_count, 90) if phys_count > 0 else 90
phys_total = phys_leaked * phys_per
# --------------------------------------------------------------
# Comparison table
# --------------------------------------------------------------
print()
print("=" * 72)
print(f"{'Family':<28} {'Inst':>6} {'AvgB':>8} {'TotalB':>10} {'TotalKB':>10}")
print("-" * 72)
rows = [
("gm*UI (NoticeHandler)", gm_count, gm_per, gm_total),
("CObjCell+ClipPlaneList", cobjcell_leaked, cobjcell_per, cobjcell_total),
("CPhysicsObj stranded", phys_leaked, phys_per, phys_total),
]
grand = sum(r[3] for r in rows) or 1
for name, n, per, tot in rows:
pct = 100.0 * tot / grand
print(f"{name:<28} {n:>6} {per:>8} {tot:>10} {tot/1024:>9.1f} {pct:5.1f}%")
print("=" * 72)
if __name__ == "__main__":
main()

74
tools/extract_ust_tags.py Normal file
View file

@ -0,0 +1,74 @@
"""
extract_ust_tags.py <dump.dmp>
For every leaked 256-512 KB private RW region in a UST-tagged dump, extract
the 4-byte presumptive UST tag at offset 0x14 (the byte after `01 00 00 00`
in the heap entry's UST extension header).
If a single tag dominates the histogram, all (or most) leaked allocations
came from the same call site confirming F1 and identifying the leaking
allocation site by its UST stack-trace index.
"""
import os, struct, sys
from collections import Counter
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 main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
cands = []
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:
cands.append((r.BaseAddress, r.RegionSize))
print(f"candidate 256-512KB private regions: {len(cands)}")
# Read first 0x30 bytes of each region; classify
tag_hist = Counter() # 4-byte tag at +0x14 -> count
fmt_hist = Counter() # 8-byte at +0x20 (format header) -> count
tag_to_format = {} # tag -> dominant format
skipped = 0
for base, size in cands:
try:
reader.move(base)
buf = reader.read(0x30)
except Exception:
skipped += 1; continue
if len(buf) < 0x30:
skipped += 1; continue
marker = struct.unpack_from('<I', buf, 0x10)[0] # the '01 00 00 00' marker
tag = struct.unpack_from('<I', buf, 0x14)[0] # presumptive UST tag
fmt = buf[0x20:0x28]
tag_hist[(marker, tag)] += 1
fmt_hist[fmt] += 1
tag_to_format.setdefault((marker, tag), Counter())[fmt] += 1
print(f"skipped (unreadable): {skipped}")
print()
print(f"top (marker, tag) pairs at offsets (0x10, 0x14):")
print(f" {'marker':>10} {'tag':>10} {'count':>6} {'dominant fmt header at +0x20':<24}")
for (marker, tag), n in tag_hist.most_common(20):
fmt_top = tag_to_format[(marker, tag)].most_common(1)[0]
fmt_hex = " ".join(f"{b:02x}" for b in fmt_top[0])
print(f" 0x{marker:08x} 0x{tag:08x} {n:>6} {fmt_hex}")
print()
print(f"top format headers at +0x20 (8 bytes):")
for fmt, n in fmt_hist.most_common(10):
fmt_hex = " ".join(f"{b:02x}" for b in fmt)
print(f" {fmt_hex} -> {n}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,130 @@
"""find_all_noop_slots.py <dump.dmp>
Scan the .rdata-like image regions for vtable-shaped structures and
report every slot that points to the no-op stub at 0x004154a0
(the `mov al,1; ret` stub used by GraphicsResource::PurgeResource,
DBObj::ReleaseSubObjects, and similar "should be overridden" virtuals).
Each finding is a vtable slot where the base implementation lies
("returns success without doing the work") and a subclass should
have overridden it. The leaks happen where subclasses didn't override.
Heuristic for "this is a vtable":
- A run of aligned DWORDs where each DWORD points to an address
inside an executable image region (.text).
- At least N consecutive such DWORDs (N=4) qualifies as a vtable.
For each vtable found, count how many slots == no-op-stub.
Output: vtables ranked by no-op-slot count, with slot indices.
"""
import struct, sys
from collections import defaultdict
from minidump.minidumpfile import MinidumpFile
NOOP_STUB = 0x004154a0
MIN_VTABLE_SLOTS = 4
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 = [(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-region ranges
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, _ei(r.Protect) & 0xff))
image_ranges.sort()
# exec image ranges (function pointers should point into these)
exec_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x20, 0x40)]
def is_exec(addr):
for lo, hi in exec_ranges:
if lo <= addr < hi: return True
if addr < lo: return False
return False
# Scan readable image ranges for vtables
scan_ranges = [(lo, hi) for lo, hi, pr in image_ranges if pr in (0x02, 0x04, 0x40)]
# vtable_addr -> list of (slot_idx, slot_val)
vtables = {} # only those with at least 1 no-op slot
for lo, hi in scan_ranges:
size = hi - lo
try:
reader.move(lo)
buf = reader.read(size)
except Exception:
continue
if not buf: continue
n = len(buf) // 4
# Sliding: find runs where DWORD[i] is exec-image pointer.
# Treat each such run >= MIN_VTABLE_SLOTS as a potential vtable.
i = 0
while i < n:
slot0 = struct.unpack_from("<I", buf, i*4)[0]
if not is_exec(slot0):
i += 1
continue
j = i
slots = []
while j < n:
v = struct.unpack_from("<I", buf, j*4)[0]
if not is_exec(v):
break
slots.append(v)
j += 1
if len(slots) >= MIN_VTABLE_SLOTS:
noop_slots = [(k, v) for k, v in enumerate(slots) if v == NOOP_STUB]
if noop_slots:
vt_addr = lo + i*4
vtables[vt_addr] = (slots, noop_slots)
i = j if len(slots) >= MIN_VTABLE_SLOTS else i + 1
print(f"vtables with at least one no-op-stub slot: {len(vtables)}")
# Rank by no-op slot count
ranked = sorted(vtables.items(), key=lambda x: len(x[1][1]), reverse=True)
print()
print("=== Top vtables by no-op-slot count ===")
print(f"{'vtable':<12} {'#slots':>6} {'#noop':>6} noop_slot_indices")
for vt, (slots, noops) in ranked[:50]:
idxs = ", ".join(str(k) for k, _ in noops)
print(f"0x{vt:08x} {len(slots):>6} {len(noops):>6} [{idxs}]")
print()
print(f"=== Total no-op-stub references across all vtables ===")
total_noop = sum(len(noops) for _, (_, noops) in ranked)
print(f"{total_noop} total no-op slot references across {len(vtables)} vtables")
# Also save full list
out = sys.argv[2] if len(sys.argv) > 2 else None
if out:
with open(out, "w", encoding="utf8") as f:
f.write(f"vtable\tn_slots\tn_noop\tnoop_indices\n")
for vt, (slots, noops) in ranked:
idxs = ",".join(str(k) for k, _ in noops)
f.write(f"0x{vt:08x}\t{len(slots)}\t{len(noops)}\t{idxs}\n")
print(f"full list written to {out}")
if __name__ == "__main__":
main()

59
tools/find_alloc_size.py Normal file
View file

@ -0,0 +1,59 @@
"""find_alloc_size.py <exe_path> <hex_size>
Search the .text section for `push <size>` followed by a call (operator new
or HeapAlloc) finds code sites that allocate buffers of that size."""
import struct, sys
def main():
path = sys.argv[1]
target = int(sys.argv[2], 0)
with open(path, 'rb') as f:
data = f.read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
n_sec = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
opt_off = pe_off + 4 + 20
img_base = struct.unpack_from('<I', data, opt_off + 28)[0]
opt_sz = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
sec_off = opt_off + opt_sz
for i in range(n_sec):
s = sec_off + i*40
name = data[s:s+8].rstrip(b'\x00').decode()
if name == '.text':
text_va = img_base + struct.unpack_from('<I', data, s+12)[0]
raddr = struct.unpack_from('<I', data, s+20)[0]
rsize = struct.unpack_from('<I', data, s+16)[0]
text = data[raddr:raddr+rsize]
break
# Patterns:
# 68 XX XX XX XX e8 YY YY YY YY = push imm32; call rel32
needle1 = bytes([0x68]) + struct.pack('<I', target)
# And:
# 6A XX = push imm8 (only if target fits in byte; unlikely for 0x41000)
print(f"Searching .text for `push 0x{target:08x}` followed by call...")
off = 0
found = 0
while True:
off = text.find(needle1, off)
if off < 0: break
# Check if next instruction (at +5) is a call (E8) or call indirect (FF 15 / FF 14)
next_byte = text[off + 5] if off + 5 < len(text) else 0
marker = ""
if next_byte == 0xE8:
target_rel = struct.unpack_from('<i', text, off + 6)[0]
call_target = text_va + off + 5 + 5 + target_rel
marker = f"-> call 0x{call_target:08x}"
elif next_byte == 0xFF:
marker = "-> call [...]"
else:
marker = f"-> next op 0x{next_byte:02x}"
va = text_va + off
print(f" 0x{va:08x}: push 0x{target:08x} {marker}")
found += 1
off += 5
print(f"\nTotal: {found} sites")
if __name__ == '__main__':
main()

View file

@ -0,0 +1,98 @@
"""find_d3d9_via_iat.py <pid>
Find d3d9.dll's load address by walking AC's import table.
Strategy: locate the string "Direct3DCreate9" in AC's image, then find
the IAT entry that resolves it. The IAT slot holds the runtime address
of Direct3DCreate9 (= address INSIDE d3d9.dll). VirtualQuery on that
address gives us d3d9.dll's base + size."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
# AC's image starts at 0x00400000. Read its full size from PE header.
hdr = rd(h, 0x00400000 + 0x3C, 4)
if not hdr: print("can't read AC PE header"); sys.exit(2)
pe_off = struct.unpack('<I', hdr)[0]
img_sz = struct.unpack('<I', rd(h, 0x00400000 + pe_off + 4 + 20 + 56, 4))[0]
print(f"AC image: 0x00400000 .. 0x{0x00400000+img_sz:08x} ({img_sz/1024/1024:.1f} MB)")
# Read entire AC image
ac = rd(h, 0x00400000, img_sz)
if not ac: print("can't read AC image"); sys.exit(2)
# Find import directory: data dir entry 1
data_dir_off = pe_off + 4 + 20 + 96 + 8 # 8 = entry 1 * 8 bytes per entry
import_rva, import_size = struct.unpack_from('<II', ac, data_dir_off)
print(f"Import dir RVA=0x{import_rva:x} size={import_size}")
# Walk import descriptors looking for d3d9.dll
off = import_rva
n_desc = 0
while off + 20 <= len(ac):
olt, ts, fc, name_rva, fthunk = struct.unpack_from('<IIIII', ac, off)
if olt == 0 and name_rva == 0 and fthunk == 0:
print(f"end of import desc at #{n_desc} (off=0x{off:x})")
break
if name_rva == 0:
off += 20; n_desc += 1
continue
name = ac[name_rva:name_rva+32].split(b'\x00', 1)[0].decode(errors='replace')
print(f" desc[{n_desc}] dll={name!r} IAT-RVA=0x{fthunk:x}")
n_desc += 1
if 'd3d9' in name.lower():
print(f"\nFound import for {name}")
print(f" OriginalFirstThunk RVA=0x{olt:x}")
print(f" FirstThunk (IAT) RVA=0x{fthunk:x}")
# Walk OLT to find Direct3DCreate9 index
oft = olt or fthunk
idx = 0
while oft + 4 <= len(ac):
entry = struct.unpack_from('<I', ac, oft)[0]
if entry == 0: break
if entry & 0x80000000:
func_name = f"<ord {entry & 0xFFFF}>"
else:
# entry is an RVA to a Hint/Name struct: hint(2) + name(asciiz)
func_name = ac[entry+2:entry+64].split(b'\x00', 1)[0].decode(errors='replace')
# Read the IAT slot value (runtime address)
iat_slot_va = 0x00400000 + fthunk + idx*4
iat_val = struct.unpack('<I', rd(h, iat_slot_va, 4))[0]
print(f" [{idx:2d}] {func_name:30s} IAT@0x{iat_slot_va:08x} -> 0x{iat_val:08x}")
if func_name == 'Direct3DCreate9':
# Use this to find d3d9.dll's range
mbi = MBI()
if k.VirtualQueryEx(h, iat_val, ctypes.byref(mbi), ctypes.sizeof(mbi)):
ab = mbi.AllocationBase or 0
# Get image size from PE
hdr2 = rd(h, ab + 0x3C, 4)
pe2 = struct.unpack('<I', hdr2)[0]
sz2 = struct.unpack('<I', rd(h, ab + pe2 + 4 + 20 + 56, 4))[0]
print(f"\n*** d3d9.dll loaded at 0x{ab:08x}, size {sz2}")
oft += 4
idx += 1
break
off += 20
else:
print("walk fell off")
k.CloseHandle(h)

113
tools/find_d3d_device.py Normal file
View file

@ -0,0 +1,113 @@
"""find_d3d_device.py <pid>
Find AC's live IDirect3DDevice9 instance.
1. Enumerate loaded modules; locate d3d9.dll's address range
2. Walk private RW memory; find any pointer where:
- The pointer itself is in private RW memory (object on heap)
- The first DWORD (vtable) lies in d3d9.dll's read-only data range
3. Report the first few matches. The one used most often is THE device."""
import ctypes, ctypes.wintypes as wt, sys, struct, collections
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
def find_d3d9_range(h):
"""Find d3d9.dll's load range by looking at image-mapped regions and
reading their PE headers to identify the module name."""
candidates = []
mbi = MBI()
addr = 0
seen_bases = set()
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if mbi.State == 0x1000 and mbi.Type == 0x1000000: # MEM_IMAGE
ab = mbi.AllocationBase or 0
if ab not in seen_bases:
seen_bases.add(ab)
# Read PE header to identify the dll
pe_off_b = rd(h, ab + 0x3C, 4)
if pe_off_b and len(pe_off_b) == 4:
pe_off = struct.unpack('<I', pe_off_b)[0]
if pe_off < 0x1000:
# Read SizeOfImage from optional header
sz_b = rd(h, ab + pe_off + 4 + 20 + 56, 4)
if sz_b:
img_size = struct.unpack('<I', sz_b)[0]
candidates.append((ab, img_size))
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
# For each candidate image, look for the module name in its export table.
# Cheaper: scan first 1KB for "d3d9" string.
for ab, sz in candidates:
chunk = rd(h, ab, 4096)
if chunk and b'd3d9' in chunk.lower():
return ab, sz
return None, None
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
d3d9_base, d3d9_size = find_d3d9_range(h)
print(f"d3d9.dll: base=0x{d3d9_base:08x} size={d3d9_size}" if d3d9_base else "d3d9.dll not found")
if not d3d9_base:
k.CloseHandle(h); sys.exit(0)
# Walk private RW; for each region, find DWORDs in d3d9's range; the addresses
# THEMSELVES (where we found the DWORD) are candidates for "is a vtable pointer".
# But we want the OBJECT addresses — i.e., the location holding the pointer is +0
# of the object. So we report `holding_va` (= object address) where the pointer = vtable.
vtable_hits = collections.Counter() # vtable_va -> count of pointers to it found
sample_holders = collections.defaultdict(list)
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
and sz < 32*1024*1024):
data = rd(h, base, sz)
if data:
n = len(data) // 4
for i in range(n):
v = struct.unpack_from('<I', data, i*4)[0]
if d3d9_base <= v < d3d9_base + d3d9_size:
# Looks like a pointer into d3d9 — IF v is itself a vtable
# (rather than mid-code), the holder is a COM object.
# We're interested in cases where this dword is at offset 0
# of an aligned object. For now, just count vtable popularity.
vtable_hits[v] += 1
holder = base + i*4
if len(sample_holders[v]) < 3:
sample_holders[v].append(holder)
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
print(f"\nTop 15 d3d9.dll pointer destinations (likely vtables):")
print(f" {'vtable':>10} {'count':>6} sample holders")
for vt, count in vtable_hits.most_common(15):
holders_s = ' '.join(f'0x{h_:08x}' for h_ in sample_holders[vt])
print(f" 0x{vt:08x} {count:>6} {holders_s}")

View file

@ -0,0 +1,121 @@
"""
find_eor_rendersurface.py
Queries the EoR Ghidra MCP HTTP API to:
1. Enumerate all callers of operator_new (FUN_005df0f5)
2. Decompile each caller
3. Filter for ones whose body contains operator_new(0x120) the unique
sizeof for RenderSurface. That's EoR's RenderSurface::Allocator.
4. For each match, capture the caller's address and report the body.
Also fetches xrefs to RenderSurface::Allocator identifies the vtable
slot(s) where it appears (Allocate slot in each derived class's vtable).
"""
import json
import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
EOR_BASE = "http://192.168.1.98:8081"
OP_NEW_ADDR = "0x005df0f5"
TARGET_SIZE_HEX = "0x120"
TARGET_SIZE_DEC = 288
def http_get(path, **params):
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{EOR_BASE}{path}?{qs}" if qs else f"{EOR_BASE}{path}"
with urllib.request.urlopen(url, timeout=60) as r:
return r.read().decode("utf-8", errors="replace")
def get_xrefs_to(addr, offset=0, limit=500):
raw = http_get("/xrefs_to", address=addr, offset=offset, limit=limit)
refs = []
for line in raw.splitlines():
# Format: "From 00536d73 in FUN_00536d70 [UNCONDITIONAL_CALL]"
m = re.match(r"From ([0-9a-f]+) in (\S+) \[([\w_]+)\]", line)
if m:
refs.append({
"site": int(m.group(1), 16),
"owner_name": m.group(2),
"kind": m.group(3),
})
return refs
def decompile(addr):
return http_get("/decompile_function_by_address", address=f"0x{addr:08x}")
def main():
# Collect all xrefs to operator_new (paginated)
all_refs = []
offset = 0
limit = 500
while True:
batch = get_xrefs_to(OP_NEW_ADDR, offset=offset, limit=limit)
if not batch:
break
all_refs.extend(batch)
if len(batch) < limit:
break
offset += limit
if offset > 20000:
break
print(f"total xrefs to operator_new: {len(all_refs)}")
# Owners (functions that call operator_new). Dedupe by owner_name.
owners = {}
for r in all_refs:
owners.setdefault(r["owner_name"], r["site"])
print(f"unique calling functions: {len(owners)}")
# Resolve each owner's function start address by decompiling and matching.
# Owner name is FUN_xxxxxxxx — the xxxx is the function start.
owner_addrs = []
for name, site in owners.items():
m = re.match(r"FUN_([0-9a-f]+)", name)
if m:
owner_addrs.append((int(m.group(1), 16), name))
print(f"resolvable owner-function start addresses: {len(owner_addrs)}")
# Decompile each, look for the 0x120 size signature
def check_one(addr_name):
addr, name = addr_name
try:
body = decompile(addr)
except Exception as e:
return (addr, name, None, f"error: {e}")
# Look for operator_new call with size 0x120 (288). Various forms.
if re.search(r"0x120\b", body) or re.search(r"\b288\b", body):
# Filter to actual op_new call sites
for line in body.splitlines():
if "0x120" in line or " 288" in line:
return (addr, name, "MATCH", line.strip())
return (addr, name, "MATCH", "(contains 0x120 somewhere)")
return (addr, name, None, None)
matches = []
print(f"decompiling {len(owner_addrs)} candidate Allocators...")
with ThreadPoolExecutor(max_workers=16) as ex:
futures = [ex.submit(check_one, an) for an in owner_addrs]
for i, fut in enumerate(as_completed(futures), 1):
addr, name, status, line = fut.result()
if status == "MATCH":
matches.append((addr, name, line))
print(f" [{i}/{len(owner_addrs)}] MATCH at 0x{addr:08x} ({name}): {line}")
print(f"\n=== {len(matches)} candidate RenderSurface::Allocator(s) ===")
for addr, name, line in matches:
print(f" 0x{addr:08x} {name}")
body = decompile(addr)
# print first 25 non-empty lines
for ln in body.splitlines()[:30]:
print(f" {ln}")
print()
if __name__ == "__main__":
main()

63
tools/find_holder.py Normal file
View file

@ -0,0 +1,63 @@
"""find_holder.py <pid> <target_va>
Scan all committed private RW memory in process for pointers equal to target_va.
Reports addresses where the pointer was found + small context."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
target = int(sys.argv[2], 0)
target_bytes = struct.pack('<I', target)
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
found = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
and sz < 64*1024*1024):
data = rd(h, base, sz)
if data:
off = 0
while True:
off = data.find(target_bytes, off)
if off < 0: break
if (off & 3) == 0:
holder_va = base + off
# Read 32 bytes around to see what context
ctx = data[max(0, off-16): off+16].hex(' ')
found.append((holder_va, ctx))
if len(found) >= 20: break
off += 4
if len(found) >= 20: break
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
print(f"Pointers to 0x{target:08x} (top {len(found)}):")
for holder_va, ctx in found:
print(f" @0x{holder_va:08x}: ...{ctx}...")

15
tools/find_literals.py Normal file
View file

@ -0,0 +1,15 @@
"""find_literals.py <exe> <hex_value>
Count occurrences of a 32-bit LE literal in the binary."""
import struct, sys
path = sys.argv[1]
target = int(sys.argv[2], 0)
with open(path, 'rb') as f:
data = f.read()
needle = struct.pack('<I', target)
off = 0; n = 0
while True:
off = data.find(needle, off)
if off < 0: break
n += 1
off += 1
print(f"0x{target:x}: {n} occurrences in binary")

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}')

View file

@ -0,0 +1,93 @@
"""find_mesh_refs_inc_static.py <pid>
Like trace_mesh_holder but ALSO scans MEM_IMAGE regions (e.g., .data
section of acclient.exe) for references to D3DXMesh instances.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
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)
all_regions = [] # tuples (base, data, kind)
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if mbi.State==MEM_COMMIT and pr != 0x01:
kind = 'priv' if mbi.Type==MEM_PRIVATE else ('image' if mbi.Type==MEM_IMAGE else 'other')
# Read writable regions of EITHER private or image
if pr in (0x04, 0x40, 0x02): # RW, ERW, or RO
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
all_regions.append((mbi.BaseAddress, bytes(buf[:sz.value]), kind))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
# Find mesh instance addresses (only in heap RW; not in image)
mesh_addrs = set()
for base, data, kind in all_regions:
if kind != 'priv': continue
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'mesh instances found: {len(mesh_addrs)}')
# Count references to each mesh, distinguishing heap vs image
heap_refs = {a: 0 for a in mesh_addrs}
image_refs = {a: 0 for a in mesh_addrs}
for base, data, kind in all_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_addrs:
ra = base + off
if ra in mesh_addrs: continue
if kind == 'priv':
heap_refs[v] += 1
elif kind == 'image':
image_refs[v] += 1
# Classify
true_orphans = 0 # 0 heap + 0 image refs
heap_held = 0 # has at least 1 heap ref
image_only = 0 # 0 heap but has image ref(s)
for a in mesh_addrs:
if heap_refs[a] == 0 and image_refs[a] == 0: true_orphans += 1
elif heap_refs[a] > 0: heap_held += 1
else: image_only += 1
print(f'true orphans (0 heap + 0 image refs): {true_orphans}')
print(f'heap-held: {heap_held}')
print(f'image-only refs (static globals): {image_only}')
# For image-only refs, show some examples (the static global location)
print()
print('Sample image-only refs:')
shown = 0
for a in mesh_addrs:
if heap_refs[a] == 0 and image_refs[a] > 0 and shown < 10:
# find the image ref locations
for base, data, kind in all_regions:
if kind != 'image': continue
end = (len(data)//4)*4
for off in range(0, end-4, 4):
if struct.unpack_from('<I', data, off)[0] == a:
print(f' mesh @ 0x{a:08x} <- image ref @ 0x{base+off:08x}')
shown += 1
if shown >= 10: break
if shown >= 10: break

133
tools/find_palette_cache.py Normal file
View file

@ -0,0 +1,133 @@
"""find_palette_cache.py <pid>
Find the live Palette CLOCache instance in a process.
Strategy:
1. Scan all committed RW memory for DWORD == 0x007c6a98 (DBOCacheVtbl)
2. For each hit, treat (hit - 0) as start of a CLOCache object
3. Search the next 0x114 bytes for the Palette::Allocator pointer (0x004f7b70)
4. If found, that CLOCache is Palette's. Print its address + relevant fields.
Fields we care about (from FUN_00416b50 / FreelistAdd):
+0xf1: byte, cache config flag 1 (freelist enabled?)
+0xf2: byte, cache config flag 2
+0xfc: freelist MAX size (uint32)
+0x100: freelist head ptr
+0x104: freelist tail ptr
+0x108: freelist current count
"""
import ctypes, ctypes.wintypes as wt, struct, sys
VTABLE_DBOCACHE = 0x007c6a98
ALLOCATOR_PALETTE = 0x004f7b70
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
MEM_IMAGE = 0x1000000
PAGE_READWRITE = 0x04
PAGE_EXECUTE_READWRITE = 0x40
class MEMORY_BASIC_INFORMATION(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),
]
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualQueryEx = k32.VirtualQueryEx
VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
VirtualQueryEx.restype = ctypes.c_size_t
def read(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def main():
pid = int(sys.argv[1])
h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); return
mbi = MEMORY_BASIC_INFORMATION()
addr = 0
candidates = []
while VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
size = mbi.RegionSize
st = mbi.State
ty = mbi.Type
pr = mbi.Protect & 0xFF
if st == MEM_COMMIT and ty == MEM_PRIVATE and pr in (PAGE_READWRITE, PAGE_EXECUTE_READWRITE):
data = read(h, base, size)
if data:
for off in range(0, len(data) - 4, 4):
v = struct.unpack_from("<I", data, off)[0]
if v == VTABLE_DBOCACHE:
candidates.append((base + off, data[off:off+0x120]))
addr = base + size
if addr >= 0x80000000: break
print(f"DBOCache vtable hits: {len(candidates)}")
palette_cache = None
for addr, blob in candidates:
# Search the first 0x114 bytes for Palette::Allocator pointer
for o in range(0, min(len(blob), 0x114) - 4, 4):
if struct.unpack_from("<I", blob, o)[0] == ALLOCATOR_PALETTE:
print(f" CLOCache @ 0x{addr:08x} has ALLOCATOR_PALETTE at offset +0x{o:x}")
palette_cache = (addr, blob)
break
if not palette_cache:
print("Palette cache NOT found via Allocator pointer. Trying type_id scan...")
# Try scanning for type_id == 10 at offset +0x?? of each cache
for addr, blob in candidates:
for o in range(0, min(len(blob), 0x114) - 4, 4):
if struct.unpack_from("<I", blob, o)[0] == 0x0a:
print(f" Cache @ 0x{addr:08x} has type_id=0xa at +0x{o:x}")
return
addr, blob = palette_cache
print(f"\n=== Palette CLOCache @ 0x{addr:08x} ===")
# Read key fields
cfg1 = blob[0xf1]
cfg2 = blob[0xf2]
maxsz = struct.unpack_from("<I", blob, 0xfc)[0]
head = struct.unpack_from("<I", blob, 0x100)[0]
tail = struct.unpack_from("<I", blob, 0x104)[0]
count = struct.unpack_from("<I", blob, 0x108)[0]
print(f" +0xf1 cfg1: 0x{cfg1:02x}")
print(f" +0xf2 cfg2: 0x{cfg2:02x}")
print(f" +0xfc freelist_max: {maxsz} (0x{maxsz:x})")
print(f" +0x100 head: 0x{head:08x}")
print(f" +0x104 tail: 0x{tail:08x}")
print(f" +0x108 count: {count}")
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,78 @@
"""find_parent_null_writes.py <exe_path>
Scan the .text section for instructions that null the field at offset +0x40
of any register-pointed structure (CPhysicsObj's parent field).
Patterns:
c7 4? 40 00 00 00 00 mov dword ptr [reg+0x40], 0
89 4? 40 mov [reg+0x40], reg (then we'd need to check for xor'd reg)
c7 8? 40 00 00 00 00 00 00 00 mov [reg+0x40], 0 with disp32
Reports each match's VA + 16 bytes of surrounding context."""
import struct, sys
def parse_pe(path):
with open(path, 'rb') as f:
data = f.read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
coff = pe_off + 4
n_sections = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt_off = coff + 20
image_base = struct.unpack_from('<I', data, opt_off + 28)[0]
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s+8].rstrip(b'\x00').decode(errors='replace')
vaddr = struct.unpack_from('<I', data, s + 12)[0]
rsize = struct.unpack_from('<I', data, s + 16)[0]
raddr = struct.unpack_from('<I', data, s + 20)[0]
sections.append((name, vaddr, rsize, raddr))
return data, image_base, sections
def main():
path = sys.argv[1]
data, base, sections = parse_pe(path)
text = None
for name, vaddr, rsize, raddr in sections:
if name == '.text':
text = (base + vaddr, data[raddr:raddr + rsize])
break
if not text:
print("no .text"); sys.exit(2)
text_va, text_bytes = text
print(f".text @ 0x{text_va:08x} size={len(text_bytes)}")
# Pattern: c7 4? 40 00 00 00 00 (7 bytes — mov [reg+0x40], imm32 == 0)
# The ? is the ModRM byte's low nibble = register encoding.
# Modrm 0x40-0x47 = [reg+disp8]; we want 0x40 (disp8 follows = 0x40)
# Wait: opcode c7 /0 = mov mem, imm32. ModRM for [reg+disp8] is 01 xxx 100 = 0x44 if base+SIB,
# or 01 000 reg for [reg+disp8] = 0x40..0x47.
# Actually the agent's site had: c7 46 40 00 00 00 00
# c7 = mov reg/mem32, imm32 (opcode /0 in modrm)
# 46 = modrm: mod=01 (disp8), reg=/0 (000), rm=110 (esi) → [esi+disp8]
# 40 = disp8 = 0x40
# 00 00 00 00 = imm32 = 0
# So we want: c7 4r 40 00 00 00 00 where r ∈ {0..7} but r=4 (esp) means SIB follows.
# Valid r: 0,1,2,3,5,6,7 (skipping esp)
matches = []
for off in range(len(text_bytes) - 7):
b0 = text_bytes[off]
if b0 != 0xC7: continue
b1 = text_bytes[off + 1]
if (b1 & 0xF8) != 0x40: continue # mod=01, reg=000, rm in low 3
if b1 == 0x44: continue # rm=esp needs SIB
if text_bytes[off + 2] != 0x40: continue # disp8 must be 0x40
if text_bytes[off + 3:off + 7] != b'\x00\x00\x00\x00': continue
va = text_va + off
ctx = text_bytes[max(0, off-8): off+12].hex(' ')
regs = ['eax','ecx','edx','ebx','esp','ebp','esi','edi']
rm = b1 & 7
matches.append((va, regs[rm], ctx))
print(f"\nFound {len(matches)} occurrences of mov dword ptr [reg+0x40], 0:")
print()
for va, reg, ctx in matches:
print(f" 0x{va:08x} [{reg}+0x40] = 0 context: ...{ctx}...")
if __name__ == '__main__':
main()

View file

@ -0,0 +1,165 @@
"""
find_rendersurfaces.py <dump.dmp>
Definitive diagnostic to distinguish:
F1 (cache-freelist leak): leaked surfaces have m_numLinks == 1 (cache-only)
F2 (upstream-holder leak): leaked surfaces have m_numLinks > 1
Method:
1. Enumerate the leaked 256-512 KB private RW regions (the BGRA buffers).
2. For each leaked region's base address R, search the entire committed
memory of the dump for any 4-byte value == R. That match location L
is a pointer field most likely RenderSurface::m_pSurfaceBits.
3. From L, walk backwards in 4-byte steps looking for a DWORD that points
into acclient.exe's image range. That DWORD is the containing object's
vtable; its address is the object's base B.
4. Read m_numLinks at B + 0x24 (DBObj layout).
5. Histogram by (vtable, m_numLinks). The dominant vtable is RenderSurface.
The mode of m_numLinks for that vtable answers the question.
"""
import os
import struct
import sys
from collections import Counter, defaultdict
from minidump.minidumpfile import MinidumpFile
def _enum_int(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def main():
if len(sys.argv) < 2:
print("usage: find_rendersurfaces.py <dump.dmp>", file=sys.stderr); sys.exit(1)
path = sys.argv[1]
if not os.path.exists(path):
print(f"not found: {path}", file=sys.stderr); sys.exit(1)
md = MinidumpFile.parse(path)
reader = md.get_reader().get_buffered_reader()
# Acclient.exe image range — used to validate vtable pointers
acl = next((m for m in md.modules.modules
if os.path.basename(m.name).lower() == "acclient.exe"), None)
if acl is None:
print("acclient.exe not in module list", file=sys.stderr); sys.exit(1)
acl_lo, acl_hi = acl.baseaddress, acl.baseaddress + acl.size
print(f"acclient.exe: 0x{acl_lo:08x} - 0x{acl_hi:08x} size={acl.size}")
# Step 1: leaked 256-512KB private RW regions
leaked_regions = set()
for r in md.memory_info.infos:
st = _enum_int(r.State); ty = _enum_int(r.Type); pr = _enum_int(r.Protect) & 0xFF
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
and 256*1024 <= r.RegionSize < 512*1024:
leaked_regions.add(r.BaseAddress)
print(f"leaked 256-512 KB private regions: {len(leaked_regions)}")
# Step 2: scan every committed (private OR mapped — but not Image) RW region
# for occurrences of any leaked-region base address as a 4-byte LE value.
# We're looking for pointers in heap allocations, not file-backed data.
scan_regions = []
for r in md.memory_info.infos:
st = _enum_int(r.State); ty = _enum_int(r.Type); pr = _enum_int(r.Protect) & 0xFF
if st != 0x1000: # only COMMIT
continue
if ty == 0x1000000: # skip Image (DLL .data sections — unlikely to hold our pointers)
continue
if pr in (0x04, 0x40): # READWRITE or EXECUTE_READWRITE
scan_regions.append((r.BaseAddress, r.RegionSize))
print(f"scanning {len(scan_regions)} writable regions...")
matches = [] # list of (pointer_location, region_value)
total_scanned_bytes = 0
for base, size in scan_regions:
try:
reader.move(base)
buf = reader.read(size)
except Exception:
continue
if not buf:
continue
total_scanned_bytes += len(buf)
# Walk 4-byte aligned positions
# Fast path: chunk through using struct.unpack_from
end = (len(buf) // 4) * 4
for off in range(0, end, 4):
val = struct.unpack_from("<I", buf, off)[0]
if val in leaked_regions:
matches.append((base + off, val))
print(f"scanned {total_scanned_bytes/(1024*1024):.1f} MB")
print(f"pointers to leaked regions found: {len(matches)}")
# Step 3 + 4: for each pointer location, walk backwards looking for the vtable
# of the containing object. Then read m_numLinks at +0x24.
findings = [] # list of (object_base, vtable, m_numLinks, m_pMaintainer, m_pSurfaceBits_offset)
for ptr_loc, region_val in matches:
# Walk backwards in 4-byte steps; max object size 0x140
found = False
for back in range(0x20, 0x140, 4):
obj_base = ptr_loc - back
try:
reader.move(obj_base)
hdr = reader.read(0x40)
except Exception:
continue
if not hdr or len(hdr) < 0x40:
continue
vtbl = struct.unpack_from("<I", hdr, 0)[0]
if not (acl_lo <= vtbl < acl_hi):
continue
# Candidate. Sanity-check m_pMaintainer (offset 0x20) and m_numLinks (0x24)
mtnr, num_links = struct.unpack_from("<II", hdr, 0x20)
if not (1 <= num_links <= 1000): # plausible refcount
continue
if mtnr != 0 and not (0x00010000 <= mtnr < 0x80000000):
continue
findings.append((obj_base, vtbl, num_links, mtnr, back))
found = True
break
print(f"resolved RenderSurface objects: {len(findings)}")
print()
# Group by vtable
by_vtable = defaultdict(list)
for obj_base, vtbl, num_links, mtnr, off in findings:
by_vtable[vtbl].append((num_links, off))
print(f"vtable groups (sorted by candidate count):")
print(f" {'vtable':>10} {'rva':>10} {'count':>6} {'mode m_numLinks':<20} {'m_pSurfaceBits offset histogram':<48}")
for vtbl in sorted(by_vtable, key=lambda v: -len(by_vtable[v])):
rows = by_vtable[vtbl]
rc_counter = Counter(r[0] for r in rows)
off_counter = Counter(r[1] for r in rows)
rc_top = ", ".join(f"{k}->{v}" for k, v in rc_counter.most_common(5))
# Show ALL offsets seen, not just top 3
off_full = ", ".join(f"0x{k:x}->{v}" for k, v in off_counter.most_common(8))
print(f" 0x{vtbl:08x} 0x{vtbl-acl_lo:08x} {len(rows):>6} {rc_top:<20} {off_full}")
# The dominant vtable + dominant m_pSurfaceBits offset is RenderSurface.
# Report its m_numLinks distribution.
top_vtable, top_rows = max(by_vtable.items(), key=lambda kv: len(kv[1]))
print()
print(f"=== DIAGNOSTIC RESULT ===")
print(f"dominant vtable: 0x{top_vtable:08x} RVA 0x{top_vtable-acl_lo:08x}")
rc_counter = Counter(r[0] for r in top_rows)
print(f"m_numLinks distribution for that vtable ({len(top_rows)} objects):")
for rc, cnt in rc_counter.most_common(10):
pct = 100.0 * cnt / len(top_rows)
print(f" m_numLinks = {rc:>4} {cnt:>5} objects ({pct:.1f}%)")
mode_rc = rc_counter.most_common(1)[0][0]
print()
if mode_rc == 1:
print(f"VERDICT: mode m_numLinks == 1 → cache-only (FINDING_001 family — cache-freelist leak)")
elif mode_rc > 1:
print(f"VERDICT: mode m_numLinks == {mode_rc} → external holder(s) present (FINDING_002 family — upstream leak)")
else:
print(f"VERDICT: unexpected mode m_numLinks == {mode_rc}; inspect manually")
if __name__ == "__main__":
main()

78
tools/find_subclasses.py Normal file
View file

@ -0,0 +1,78 @@
"""find_subclasses.py <dump.dmp> <parent_vtable> <noop_addr>
Find vtables that look like subclasses of a parent class (share parent's
slot 1 = CopyInto address, indicating inheritance not overridden).
For each such vtable, report slot 2 if it equals noop_addr, the
subclass inherits the no-op stub (= leak source).
Used to find GraphicsResource subclasses that inherit the no-op
PurgeResource and therefore leak forever.
"""
import struct, sys
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 main():
md = MinidumpFile.parse(sys.argv[1])
parent_vtable = int(sys.argv[2], 0)
noop_addr = int(sys.argv[3], 0)
reader = md.get_reader().get_buffered_reader()
# Read parent's slot 1 - that's the "shared" value to look for
reader.move(parent_vtable + 4)
parent_slot1 = struct.unpack("<I", reader.read(4))[0]
print(f"Parent vtable: 0x{parent_vtable:08x}")
print(f"Parent slot 1 (CopyInto-like): 0x{parent_slot1:08x}")
print(f"No-op stub: 0x{noop_addr:08x}")
print()
# Image regions to scan (only image-typed RW)
image_ranges = []
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 == 0x1000000 and pr in (0x02, 0x04, 0x40):
image_ranges.append((r.BaseAddress, r.RegionSize))
print(f"scanning {len(image_ranges)} image regions...")
found = [] # (vtable_addr, slot0, slot2, slot3)
for base, size in image_ranges:
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 - 16, 4):
slot1 = struct.unpack_from("<I", buf, off + 4)[0]
if slot1 != parent_slot1:
continue
slot0 = struct.unpack_from("<I", buf, off)[0]
slot2 = struct.unpack_from("<I", buf, off + 8)[0]
slot3 = struct.unpack_from("<I", buf, off + 12)[0]
# Sanity: slot 0 (dtor) should be a code pointer in image
if slot0 < 0x00400000 or slot0 > 0x10000000:
continue
# Skip parent itself
if base + off == parent_vtable:
continue
found.append((base + off, slot0, slot2, slot3))
print(f"candidate subclass vtables: {len(found)}")
print()
print(f"{'vtable':<12} {'slot0 dtor':<12} {'slot2 Purge':<12} {'slot3 Restore':<12} inherits_noop?")
for vt, s0, s2, s3 in found:
marker = "*** LEAKS — inherits no-op PurgeResource ***" if s2 == noop_addr else ""
print(f"0x{vt:08x} 0x{s0:08x} 0x{s2:08x} 0x{s3:08x} {marker}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,148 @@
"""
find_ust_backtraces.py <dump.dmp>
Scans writable memory in the dump for runs of consecutive 4-byte values that
all look like return addresses in acclient.exe's executable image range —
these are UST backtraces stored in the heap's UserStackTraceDB.
The Win10 x86 UST database is a `STACK_TRACE_DATABASE` allocated by ntdll;
each entry is a `RTL_STACK_TRACE_ENTRY` with:
void* HashChain
ULONG TraceCount
USHORT Index
USHORT Depth
PVOID BackTrace[Depth] // depth typically 12-16
We don't try to parse the DB structure (too version-dependent). Instead we
detect entries by their backtrace shape: 8+ consecutive pointers into the
acclient code range, surrounded by non-pointer data.
For each detected backtrace, report (top frame, depth, first 8 frames).
Histogram by leaf frame to find the dominant allocation site.
"""
import os, 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 main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
# Find acclient.exe image range
acl = next((m for m in md.modules.modules
if os.path.basename(m.name).lower() == "acclient.exe"), None)
if acl is None: sys.exit("acclient.exe not in modules")
acl_lo, acl_hi = acl.baseaddress, acl.baseaddress + acl.size
# Constrain to the .text section. Without PE-header parsing here we
# bound: text starts at base+0x1000 (typical), ends around 0x800000 in
# EoR (the highest 2013 function RVAs land near 0x6c000; data sections
# follow). False positives from .rdata UTF-16 strings (e.g., 0x0066006f
# = "of") dominated the first run; tightening to a code-only range
# eliminates them.
text_lo = acl.baseaddress + 0x1000
text_hi = acl.baseaddress + 0x6c0000 # estimated end of .text
acl_lo, acl_hi = text_lo, text_hi
print(f"acclient.exe text range (estimated): 0x{acl_lo:08x} - 0x{acl_hi:08x}")
# Iterate writable committed regions (heap-like)
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 # skip Image
if pr in (0x04, 0x40):
regions.append((r.BaseAddress, r.RegionSize))
print(f"scanning {len(regions)} writable regions")
MIN_DEPTH = 6 # min consecutive acclient pointers to call it a backtrace
MAX_DEPTH = 32 # cap scan length per candidate
leaf_hist = Counter()
backtraces = [] # list of (location, depth, frames)
total_scanned = 0
for base, size in regions:
try:
reader.move(base)
buf = reader.read(size)
except Exception:
continue
if not buf: continue
total_scanned += len(buf)
# Walk 4-byte aligned positions
end = (len(buf) // 4) * 4
n = end // 4
# Pre-decode as uint32 array for speed
words = struct.unpack_from(f"<{n}I", buf, 0)
def looks_like_code_addr(v):
"""True if v looks like an actual code-section pointer, not data."""
if not (acl_lo <= v < acl_hi):
return False
# UTF-16 ASCII pattern: bytes 1 and 3 are 0x00, bytes 0 and 2 are
# printable ASCII (0x20-0x7F). Rejects values like 0x0066006f
# ("of"), 0x00610074 ("ta"), etc.
b0 = v & 0xFF; b1 = (v >> 8) & 0xFF
b2 = (v >> 16) & 0xFF; b3 = (v >> 24) & 0xFF
if b1 == 0 and b3 == 0 and (0x20 <= b0 <= 0x7F) and (0x20 <= b2 <= 0x7F):
return False
return True
i = 0
while i < n:
# Find run of consecutive acclient pointers
if not looks_like_code_addr(words[i]):
i += 1; continue
j = i
while j < n and looks_like_code_addr(words[j]) and (j - i) < MAX_DEPTH:
j += 1
depth = j - i
if depth >= MIN_DEPTH:
frames = words[i:j]
# Uniqueness check — real backtraces have >70% unique frames.
# Repeated-value runs (e.g. pixel data 0x00a000a0 repeated)
# have low uniqueness.
uniq = len(set(frames))
if uniq < max(4, int(depth * 0.7)):
i = j; continue
location = base + i * 4
backtraces.append((location, depth, frames))
leaf_hist[frames[0]] += 1
i = j
print(f"scanned {total_scanned/(1024*1024):.1f} MB")
print(f"backtraces detected (>= {MIN_DEPTH} frames): {len(backtraces)}")
print()
# Histogram by leaf frame (top of stack)
print(f"top 30 leaf frames (most common 'top-of-call-stack' address):")
print(f" {'leaf abs':>10} {'leaf rva':>10} {'count':>6} ")
for leaf, cnt in leaf_hist.most_common(30):
print(f" 0x{leaf:08x} 0x{leaf-acl_lo:08x} {cnt:>6}")
print()
# For the top leaf, show a few sample backtraces
if leaf_hist:
top_leaf, _ = leaf_hist.most_common(1)[0]
print(f"=== sample backtraces with top leaf 0x{top_leaf:08x} (RVA 0x{top_leaf-acl_lo:08x}) ===")
shown = 0
for loc, depth, frames in backtraces:
if frames[0] != top_leaf: continue
print(f" at 0x{loc:08x} depth={depth}")
for k, fr in enumerate(frames[:12]):
print(f" [{k:2d}] 0x{fr:08x} RVA 0x{fr-acl_lo:08x}")
shown += 1
if shown >= 5: break
if __name__ == "__main__":
main()

50
tools/find_vtable_refs.py Normal file
View file

@ -0,0 +1,50 @@
"""find_vtable_refs.py <dump.dmp> <addr_hex>
Scan ALL committed memory (not just RW writable) for any DWORD == addr.
Used to verify whether an alleged vtable address is referenced anywhere
in the dump's process memory.
"""
import os, struct, sys
from collections import Counter
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)
md = MinidumpFile.parse(sys.argv[1])
target = int(sys.argv[2], 16)
print(f"searching for 0x{target:08x} in dump {sys.argv[1]}")
reader = md.get_reader().get_buffered_reader()
# Scan everything committed
hits = []
total = 0
for r in md.memory_info.infos:
st = _ei(r.State); ty = _ei(r.Type); pr = _ei(r.Protect) & 0xff
if st != 0x1000: continue # not committed
if pr == 0x01: continue # no-access
try:
reader.move(r.BaseAddress)
buf = reader.read(r.RegionSize)
except Exception:
continue
if not buf: continue
total += len(buf)
end = (len(buf) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", buf, off)[0]
if v == target:
type_name = {0x20000: "Private", 0x40000: "Mapped", 0x1000000: "Image"}.get(ty, hex(ty))
hits.append((r.BaseAddress + off, type_name))
print(f"scanned {total/(1024*1024):.1f} MB")
print(f"hits: {len(hits)}")
# Histogram by region type
hist = Counter(h[1] for h in hits)
for t, c in hist.most_common():
print(f" {t}: {c}")
print("first 15 hits:")
for addr, t in hits[:15]:
print(f" 0x{addr:08x} ({t})")

View file

@ -0,0 +1,55 @@
"""find_vtable_xrefs.py <exe_path> <vtable_va>
Scan a PE for code/data references to a vtable VA.
A constructor's vtable-store is the canonical 'who owns this vtable' xref.
Pure stdlib parses PE headers by hand."""
import struct, sys
def parse_pe(path):
with open(path, 'rb') as f:
data = f.read()
if data[:2] != b'MZ': raise ValueError("not PE")
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
if data[pe_off:pe_off+4] != b'PE\0\0': raise ValueError("no PE sig")
coff = pe_off + 4
n_sections = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt_off = coff + 20
# PE32 optional header: ImageBase at +28
image_base = struct.unpack_from('<I', data, opt_off + 28)[0]
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s+8].rstrip(b'\x00').decode(errors='replace')
vsize = struct.unpack_from('<I', data, s + 8)[0]
vaddr = struct.unpack_from('<I', data, s + 12)[0]
rsize = struct.unpack_from('<I', data, s + 16)[0]
raddr = struct.unpack_from('<I', data, s + 20)[0]
sections.append((name, vaddr, vsize, raddr, rsize))
return data, image_base, sections
def main():
path = sys.argv[1]
vt = int(sys.argv[2], 0)
needle = struct.pack('<I', vt)
data, base, sections = parse_pe(path)
print(f"image base = 0x{base:08x}")
for name, vaddr, vsize, raddr, rsize in sections:
sec_va = base + vaddr
sec_data = data[raddr:raddr + rsize]
n = 0
for m_off in range(0, len(sec_data) - 3):
if sec_data[m_off:m_off+4] == needle:
va = sec_va + m_off
lo = max(0, m_off - 8)
ctx = sec_data[lo: m_off+12].hex(' ')
print(f" [{name:8s}] xref @ 0x{va:08x}: ...{ctx}...")
n += 1
if n >= 80:
print(f" (stopping after 80 in {name})")
break
if __name__ == '__main__':
main()

118
tools/fleet_monitor.sh Normal file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Combined fleet monitor:
# - HB every 30 min
# - Snapshot every 1 h (appends to artifacts/snapshots/main.tsv)
# - Every 60 s: scan for acclient PIDs in-world (title has "Coldeve-"),
# apply v3b -> v5 -> v11 -> v12 in cascade. Skip Jerry (control).
#
# Each patcher is idempotent — re-runs are no-ops when bytes already in
# patched state. To keep event log clean, only emits AUTO-* events for
# PIDs we haven't already seen as "done" for a given patch.
set -u
PY="C:/Users/acbot/AppData/Local/Programs/Python/Python312/python.exe"
cd /c/Users/acbot/leakhunt
last_hb=0
last_snap=0
# Per-PID, per-patch tracking sets (sentinel files in /tmp)
SEEN_DIR="/tmp/fleet_mon_seen"
mkdir -p "$SEEN_DIR"
mark_seen() { touch "$SEEN_DIR/${1}-${2}"; }
is_seen() { [ -f "$SEEN_DIR/${1}-${2}" ]; }
while true; do
now=$(date +%s)
# ===== heartbeat: every 30 min =====
if [ $((now - last_hb)) -ge 1800 ]; then
last_hb=$now
hb=$(powershell.exe -NoProfile -Command \
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)=\$([int](\$_.WorkingSet64/1MB))MB\" } | Sort-Object" \
2>/dev/null | tr -d '\r' | tr '\n' ' ')
alive=$(echo "$hb" | tr ' ' '\n' | grep -c '=')
echo "HB $(date -u +%Y-%m-%dT%H:%M:%S) $hb ALIVE=${alive}"
fi
# ===== snapshot: every 1 h =====
if [ $((now - last_snap)) -ge 3600 ]; then
last_snap=$now
snap_log="artifacts/snapshots/last_snap.log"
rows_before=$(wc -l < artifacts/snapshots/main.tsv 2>/dev/null || echo 0)
{
echo "=== run $(date) ==="
echo "pwd=$(pwd)"
echo "PY=$PY"
echo "py-version=$("$PY" --version 2>&1)"
echo "argv0-test=$("$PY" -c "import sys; print(sys.argv)" 2>&1)"
echo "--- invoking snapshot ---"
"$PY" tools/snapshot_compare.py artifacts/snapshots/main.tsv
echo "--- exit=$? ---"
} > "$snap_log" 2>&1
snap_exit=$?
rows_after=$(wc -l < artifacts/snapshots/main.tsv 2>/dev/null || echo 0)
rows_added=$((rows_after - rows_before))
if [ $rows_added -gt 0 ]; then
echo "SNAPSHOT @$(date +%H:%M) appended ${rows_added} rows"
else
echo "SNAPSHOT-FAIL @$(date +%H:%M) exit=$snap_exit rows_added=$rows_added (log: $snap_log)"
fi
fi
# ===== auto-patch cascade: every loop (60 s) =====
pid_titles=$(powershell.exe -NoProfile -Command \
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)|\$(\$_.MainWindowTitle)\" }" \
2>/dev/null | tr -d '\r')
while IFS='|' read -r pid title; do
[ -z "$pid" ] && continue
# Only patch in-world clients (skip splash screen)
if [ -z "$title" ] || ! echo "$title" | grep -q "Coldeve-"; then continue; fi
# Skip Jerry (control)
if echo "$title" | grep -qi "Jerry"; then continue; fi
# Apply in cascade order — one patch per cycle so any AV from a
# bad patch only takes down one phase.
for patch in v3b v5 v11 v12 v14; do
if is_seen "$pid" "$patch"; then continue; fi
case "$patch" in
v3b) script="tools/patch_palette_v3b.py"; extra="" ;;
v5) script="tools/patch_purge_v5_test.py"; extra="" ;;
v11) script="tools/patch_v11_test.py"; extra="" ;;
v12) script="tools/patch_v12_test.py"; extra="" ;;
v14) script="tools/patch_v14_cenvcell_clipplane.py"; extra="--apply" ;;
esac
if [ -n "$extra" ]; then
result=$("$PY" "$script" "$pid" "$extra" 2>&1)
else
result=$("$PY" "$script" "$pid" 2>&1)
fi
tail=$(echo "$result" | tail -1)
# idempotent skip detection
if echo "$result" | grep -q "already patched\|already has a CALL"; then
mark_seen "$pid" "$patch"
continue
fi
# DLL-form detection: if v5 says slots already point elsewhere (not
# the no-op stub), the DLL applied this; treat as success.
if [ "$patch" = "v5" ] && echo "$result" | grep -q "UNEXPECTED.*not the no-op stub"; then
mark_seen "$pid" "$patch"
echo "AUTO-V5-DLL-APPLIED PID=$pid title=\"$title\" $(date +%H:%M:%S)"
continue
fi
if echo "$result" | grep -q "OK\|reverted; now\|patched; now"; then
mark_seen "$pid" "$patch"
echo "AUTO-${patch^^} PID=$pid title=\"$title\" $(date +%H:%M:%S)"
else
# Mark FAIL as seen so we don't retry-spam every 60s.
mark_seen "$pid" "$patch"
echo "AUTO-${patch^^}-FAIL PID=$pid title=\"$title\" tail=\"$tail\""
fi
# only do ONE patch action per PID per cycle (cascade staggered)
break
done
done <<< "$pid_titles"
sleep 60
done

View file

@ -0,0 +1,66 @@
"""
histogram_eor_alloc_sizes.py
Decompile every EoR operator_new caller, extract the size constant, histogram.
"""
import re, urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
EOR = "http://192.168.1.98:8081"
def http(path, **p):
qs = "&".join(f"{k}={v}" for k, v in p.items())
url = f"{EOR}{path}?{qs}" if qs else f"{EOR}{path}"
with urllib.request.urlopen(url, timeout=60) as r:
return r.read().decode("utf-8", errors="replace")
# Get all xrefs to operator_new
all_refs = []; off = 0
while True:
raw = http("/xrefs_to", address="0x005df0f5", offset=off, limit=500)
batch = [m.group(2) for m in re.finditer(r"From ([0-9a-f]+) in (\S+)", raw)]
if not batch: break
all_refs.extend(batch)
if len(batch) < 500: break
off += 500
# Dedup owner names
owners = sorted(set(all_refs))
addrs = [int(m.group(1), 16) for n in owners for m in [re.match(r"FUN_([0-9a-f]+)", n)] if m]
print(f"{len(addrs)} unique owners to scan")
def scan(addr):
try:
body = http("/decompile_function_by_address", address=f"0x{addr:08x}")
except Exception:
return (addr, [])
# Extract operator_new size args
sizes = []
for m in re.finditer(r"FUN_005df0f5\((0x[0-9a-fA-F]+|\d+)\)", body):
v = m.group(1)
sizes.append(int(v, 0))
for m in re.finditer(r"thunk_FUN_005df0f5\((0x[0-9a-fA-F]+|\d+)\)", body):
v = m.group(1)
sizes.append(int(v, 0))
return (addr, sizes)
results = {}
size_hist = Counter()
with ThreadPoolExecutor(max_workers=24) as ex:
futures = [ex.submit(scan, a) for a in addrs]
for fut in as_completed(futures):
addr, sizes = fut.result()
results[addr] = sizes
for s in sizes:
size_hist[s] += 1
print(f"\n=== size histogram of operator_new calls ===")
for sz, cnt in sorted(size_hist.items(), key=lambda x: -x[1])[:40]:
print(f" 0x{sz:08x} ({sz:>10}) : {cnt}")
print(f"\n=== sizes near 0x100-0x200 (RenderSurface candidates) ===")
for sz in sorted(size_hist):
if 0xf0 <= sz <= 0x250:
# Find addrs that call operator_new with this size
callers = [a for a, sizes in results.items() if sz in sizes]
print(f" 0x{sz:x} ({sz}) -> {len(callers)} callers: {[hex(a) for a in callers[:8]]}")

View file

@ -0,0 +1,52 @@
"""histogram_region_for_vt.py <dump> <vt>
Show distribution of regions containing the given vtable signature, with region sizes.
Also pick 5 hit addresses and read 0x40 bytes after the vtable to see what fields look like.
"""
import struct, sys
from collections import Counter
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)
md = MinidumpFile.parse(sys.argv[1])
target = int(sys.argv[2], 16)
reader = md.get_reader().get_buffered_reader()
region_sizes = Counter()
hit_addrs = []
total_hits = 0
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40):
continue
try:
reader.move(r.BaseAddress)
buf = reader.read(r.RegionSize)
except Exception:
continue
if not buf: continue
end = (len(buf) // 4) * 4
hits_here = 0
for off in range(0, end, 4):
if struct.unpack_from("<I", buf, off)[0] == target:
hits_here += 1
total_hits += 1
if len(hit_addrs) < 30:
hit_addrs.append((r.BaseAddress + off, buf[off:off+0x40] if off+0x40 <= len(buf) else b''))
if hits_here:
region_sizes[(r.RegionSize, hits_here)] += 1
print(f"Total hits: {total_hits}")
print(f"\nRegions containing this vtable (region_size, hits_per_region, count):")
for (rs, hph), c in sorted(region_sizes.items(), key=lambda x: -x[1])[:30]:
density = hph / (rs / 0x100) # hits per 256 bytes
print(f" region_size={rs:>8} hits_per_region={hph:>5} count={c:>5} density={density:.3f}/256B")
print(f"\nFirst 10 hit addresses + 0x40 bytes after the vtable:")
for addr, data in hit_addrs[:10]:
print(f" 0x{addr:08x}: ", end="")
for i in range(0, min(0x40, len(data)), 4):
print(f"{struct.unpack_from('<I', data, i)[0]:08x} ", end="")
print()

View file

@ -0,0 +1,63 @@
"""identify_d3d_vtable.py <pid> <candidate_va>
Read consecutive DWORDs from a candidate vtable address and report how
many of them are valid d3d9.dll function pointers.
IDirect3DDevice9 has 119 slots, IDirect3DTexture9 ~17, IDirect3D9 ~16.
"""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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)]
D3D9_BASE = 0x6F4E0000
D3D9_END = 0x6F4E0000 + 0x17A000
def rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
va = int(sys.argv[2], 0)
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
data = rd(h, va, 4 * 130)
if not data: print(f"unreadable at 0x{va:08x}"); sys.exit(3)
# Count consecutive valid d3d9 function pointers
slot_count = 0
slots = []
for i in range(130):
if i*4 + 4 > len(data): break
p = struct.unpack_from('<I', data, i*4)[0]
if D3D9_BASE <= p < D3D9_END:
slot_count = i + 1 # at least up to here, all valid
slots.append(p)
else:
# First non-d3d9 entry
slots.append(p)
if i > 3:
break
print(f"Vtable @ 0x{va:08x}: valid d3d9 slots = {slot_count}")
print(f" Slot 0: 0x{slots[0]:08x}")
if slot_count > 2: print(f" Slot 2: 0x{slots[2]:08x} (would be IUnknown::Release)")
if slot_count > 16: print(f" Slot 16: 0x{slots[16]:08x} (could be IDirect3D9::CreateDevice if D3D9 vtable, or IDirect3DDevice9::Reset)")
if slot_count > 23: print(f" Slot 23: 0x{slots[23]:08x} (would be IDirect3DDevice9::CreateTexture)")
if slot_count > 28: print(f" Slot 28: 0x{slots[28]:08x} (would be IDirect3DDevice9::CreateRenderTarget)")
# Identify
if slot_count >= 119:
print(f"\n*** LIKELY IDirect3DDevice9 (119 slots expected) ***")
elif 14 <= slot_count <= 20:
print(f"\nLIKELY IDirect3D9 or IDirect3DTexture9 (small)")
elif 4 <= slot_count <= 10:
print(f"\nLIKELY IUnknown derivative (very small)")
else:
print(f"\nUnknown — {slot_count} slots")
k.CloseHandle(h)

View file

@ -0,0 +1,118 @@
"""identify_holders_by_module.py <pid>
For all 260KB private RW regions, scan ALL readable memory in the process for
pointers, then group hits by owning module (DLL/exe).
Reveals which DLL maintains a cache pointing at these chunks."""
import ctypes, ctypes.wintypes as wt, sys, struct
from collections import defaultdict
k = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
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, 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)]
class MODULEINFO(ctypes.Structure):
_fields_ = [("lpBaseOfDll", ctypes.c_void_p), ("SizeOfImage", wt.DWORD),
("EntryPoint", ctypes.c_void_p)]
psapi.EnumProcessModulesEx.argtypes = [wt.HANDLE, ctypes.POINTER(wt.HMODULE), wt.DWORD, ctypes.POINTER(wt.DWORD), wt.DWORD]
psapi.GetModuleFileNameExA.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.c_char_p, wt.DWORD]
psapi.GetModuleInformation.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.POINTER(MODULEINFO), wt.DWORD]
def rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(0x410, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
# Enumerate modules
modules = [] # list of (base, end, name)
needed = wt.DWORD(0)
hmods = (wt.HMODULE * 1024)()
if psapi.EnumProcessModulesEx(h, hmods, ctypes.sizeof(hmods), ctypes.byref(needed), 0x03):
n = needed.value // ctypes.sizeof(wt.HMODULE)
name = ctypes.create_string_buffer(260)
info = MODULEINFO()
for i in range(n):
psapi.GetModuleFileNameExA(h, hmods[i], name, 260)
nm = name.value.decode(errors='replace')
nm_short = nm.split('\\')[-1].split('/')[-1].lower()
if psapi.GetModuleInformation(h, hmods[i], ctypes.byref(info), ctypes.sizeof(info)):
modules.append((info.lpBaseOfDll, info.lpBaseOfDll + info.SizeOfImage, nm_short))
modules.sort()
print(f"Found {len(modules)} modules")
def find_module(va):
# Linear scan — module count small
for base, end, name in modules:
if base <= va < end:
return name
return "<not-in-image>"
# Find all 260KB regions
candidates = []
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
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(base)
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")
target_set = set(candidates)
# Walk ALL readable committed memory and find pointers to candidates,
# group by owning module
holders_by_module = defaultdict(int)
holders_by_module_unique = defaultdict(set) # which orphan target each module hits
scanned_total = 0
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if mbi.State == 0x1000 and (mbi.Protect & 0xFF) in (0x02, 0x04, 0x20, 0x40): # any readable
# Skip huge regions to keep this fast
if sz <= 64 * 1024 * 1024:
data = rd(h, base, sz)
if data:
scanned_total += sz
# Walk dwords
for off in range(0, len(data) - 3, 4):
v = int.from_bytes(data[off:off+4], 'little')
if v in target_set:
holder_va = base + off
mod = find_module(holder_va)
holders_by_module[mod] += 1
holders_by_module_unique[mod].add(v)
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
print(f"\nScanned {scanned_total/1024/1024:.0f} MB total")
print(f"\nHolders by module (top 20):")
sorted_mods = sorted(holders_by_module.items(), key=lambda x: -x[1])
for mod, hits in sorted_mods[:20]:
unique = len(holders_by_module_unique[mod])
print(f" {mod:30s} hits={hits:6d} unique_targets={unique}")
print(f"\nTotal hits: {sum(holders_by_module.values())}")
print(f"Total unique targets: {len(set().union(*holders_by_module_unique.values()))} of {len(candidates)}")
k.CloseHandle(h)

View file

@ -0,0 +1,89 @@
"""identify_mystery_vtables.py <pid> <vtable1> [vtable2 ...]
For each vtable address, dump the first 16 function pointers and try to
match them against references/symbols.json for class identification.
"""
import argparse, ctypes, ctypes.wintypes as wt, json, struct, sys, os
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("vtables", nargs="+")
args = ap.parse_args()
vtables = [int(v, 0) for v in args.vtables]
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess failed err={ctypes.get_last_error()}"); sys.exit(2)
def read(addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
# Load symbols
syms = {}
sym_path = os.path.join(os.path.dirname(__file__), "..", "references", "symbols.json")
if os.path.exists(sym_path):
with open(sym_path) as f:
for s in json.load(f):
try:
a = int(s["address"], 16)
syms[a] = s.get("name", "?")
except Exception:
pass
print(f"Loaded {len(syms)} symbols")
# Note: these are EoR addresses; we don't have an EoR symbols file.
# But we may still see overlapping addresses (low frequency) — or near-match heuristic.
# Better: just dump and let the human interpret. Also dump strings near each
# class' first method (often vftable strings live around the .rdata block).
for vt in vtables:
print(f"\n=== vtable 0x{vt:08x} ===")
data = read(vt, 64)
if not data:
print(" unreadable"); continue
for i in range(16):
p = struct.unpack_from("<I", data, i*4)[0]
name = syms.get(p, "")
# Also probe for proximity matches (within 0x40)
near = ""
if not name:
for d in range(0, 0x40, 1):
if (p - d) in syms:
near = f" ~ {syms[p-d]}+0x{d:x}"; break
print(f" [{i:2d}] 0x{p:08x} {name}{near}")
# Also look around the vtable - 4 (often points to RTTI structure)
rtti_slot = read(vt - 4, 4)
if rtti_slot:
rtti_ptr = struct.unpack("<I", rtti_slot)[0]
print(f" RTTI slot (vt-4): 0x{rtti_ptr:08x}")
# Read RTTI COL: typeDescriptor at +0xC
col = read(rtti_ptr, 0x20)
if col:
type_desc = struct.unpack_from("<I", col, 0xC)[0]
print(f" RTTI typeDescriptor: 0x{type_desc:08x}")
# Type descriptor: vtable(4) + spare(4) + name(zero-terminated str)
name_addr = type_desc + 8
name_bytes = read(name_addr, 128)
if name_bytes:
end = name_bytes.find(b'\x00')
if end > 0:
try:
decoded = name_bytes[:end].decode("latin-1")
print(f" RTTI name: {decoded!r}")
except Exception:
print(f" RTTI raw: {name_bytes[:end]!r}")

59
tools/inspect_regions.py Normal file
View file

@ -0,0 +1,59 @@
"""
inspect_regions.py <dump.dmp> [N]
Print the first 0x80 bytes of N sample 256-512 KB private RW regions.
Useful for eyeballing what the leaked allocations contain.
"""
import sys, os
from minidump.minidumpfile import MinidumpFile
def _enum_int(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def hexdump(buf, base):
lines = []
for i in range(0, len(buf), 16):
chunk = buf[i:i+16]
hexs = " ".join(f"{b:02x}" for b in chunk)
ascii = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
lines.append(f" {base+i:08x} {hexs:<47} {ascii}")
return "\n".join(lines)
def main():
dump = sys.argv[1]
n = int(sys.argv[2]) if len(sys.argv) > 2 else 12
md = MinidumpFile.parse(dump)
reader = md.get_reader().get_buffered_reader()
cands = []
for r in md.memory_info.infos:
st = _enum_int(r.State)
ty = _enum_int(r.Type)
pr = _enum_int(r.Protect) & 0xFF
sz = r.RegionSize
if st == 0x1000 and ty == 0x20000 and pr in (0x04, 0x40) \
and 256*1024 <= sz < 512*1024:
cands.append((r.BaseAddress, sz))
print(f"=== {os.path.basename(dump)} : {len(cands)} candidate 256-512KB regions ===")
# Sample evenly across all candidates
step = max(1, len(cands) // n)
samples = cands[::step][:n]
for base, size in samples:
print(f"\n--- region 0x{base:08x} size={size} ({size/1024:.1f} KB) ---")
try:
reader.move(base)
buf = reader.read(0x80)
except Exception as e:
print(f" read failed: {e}"); continue
print(hexdump(buf, base))
if __name__ == "__main__":
main()

53
tools/inspect_vtable.py Normal file
View file

@ -0,0 +1,53 @@
"""inspect_vtable.py <dump.dmp> <vtable_va> [num_slots]
Read N dwords starting at vtable_va. For each, mark whether it is in
code memory (image, executable). A valid vtable is a row of >= 4
contiguous code pointers.
"""
import struct, sys
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 main():
md = MinidumpFile.parse(sys.argv[1])
vt = int(sys.argv[2], 16)
n = int(sys.argv[3]) if len(sys.argv) > 3 else 32
# Module map
mods = []
for m in md.modules.modules:
mods.append((m.baseaddress, m.size, m.name.split("\\")[-1]))
def mod_of(a):
for b, s, nm in mods:
if b <= a < b + s: return nm
return None
# Executable image-region cache
exec_ranges = []
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 == 0x1000000 and pr in (0x20, 0x80):
exec_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
def is_exec(a):
for lo, hi in exec_ranges:
if lo <= a < hi: return True
return False
rdr = md.get_reader().get_buffered_reader()
rdr.move(vt)
buf = rdr.read(n * 4)
print(f"vtable @ 0x{vt:08x} ({mod_of(vt) or '?'}):")
for i in range(n):
v = struct.unpack_from("<I", buf, i*4)[0]
owner = mod_of(v) or "?"
exe = "CODE" if is_exec(v) else " "
print(f" [{i:2d}] +0x{i*4:02x} 0x{v:08x} {exe} ({owner})")
if __name__ == "__main__":
main()

293
tools/install_leakfix.py Normal file
View file

@ -0,0 +1,293 @@
"""install_leakfix.py — Install leakfix.dll into an Asheron's Call install.
Patches acclient.exe to statically import leakfix.dll. Adds a new PE
section ".limport" containing a rebuilt import table that includes
leakfix.dll alongside the original imports.
Usage:
python install_leakfix.py [ac_dir]
Defaults ac_dir to "C:\\Turbine\\Asheron's Call".
The script:
1. Locates acclient.exe and leakfix.dll in ac_dir
2. Checks whether acclient.exe already imports leakfix.dll
(idempotent exits cleanly if already patched)
3. Saves a backup of the original acclient.exe (if no backup exists)
4. Builds a new import table with leakfix.dll added at the end
5. Appends a new PE section ".limport" containing the new import table
6. Updates the PE Optional Header DataDirectory[1] (Import Table)
to point at the new section
7. Updates NumberOfSections in the COFF header
acclient.exe section table has 1 unused slot already (verified it has
7 sections but room for 8 in headers), so we can append cleanly without
needing to move .text.
"""
import struct
import sys
import os
import shutil
import hashlib
DEFAULT_AC_DIR = r"C:\Turbine\Asheron's Call"
# leakfix.dll exports — leakfix.dll has only DllMain (no exported functions
# we need to call by name), so the import name table just needs ONE entry
# with a fake function name or an ordinal import. Easiest: import by ordinal
# 1 if the DLL exports anything, or use a forwarder. But leakfix.dll has no
# exports, so we need to add one — see leakfix DLL build for a stub export.
#
# For a DLL that has *zero* exports, an empty IAT entry (just the terminator)
# in the import descriptor would mean "load the DLL but don't bind anything".
# Windows still calls DllMain on load. That's what we want.
#
# Required structure per import descriptor:
# OriginalFirstThunk (RVA to import lookup table) - terminator entry only
# TimeDateStamp = 0
# ForwarderChain = 0
# Name (RVA to DLL name string)
# FirstThunk (RVA to import address table) - terminator entry only
def hexdump_at(data, off, n=64):
chunk = data[off:off+n]
return ' '.join(f'{b:02x}' for b in chunk)
def find_section(data, pe_off, name):
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
sect_off = pe_off + 24 + opt_header_size
for i in range(num_sections):
so = sect_off + i*40
sname = data[so:so+8].rstrip(b'\0')
if sname == name.encode():
return so, i
return None, -1
def get_sections(data, pe_off):
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
sect_off = pe_off + 24 + opt_header_size
out = []
for i in range(num_sections):
so = sect_off + i*40
out.append({
'name': data[so:so+8].rstrip(b'\0').decode(errors='replace'),
'vsize': struct.unpack_from('<I', data, so+8)[0],
'vaddr': struct.unpack_from('<I', data, so+12)[0],
'rsize': struct.unpack_from('<I', data, so+16)[0],
'rawoff': struct.unpack_from('<I', data, so+20)[0],
'chars': struct.unpack_from('<I', data, so+36)[0],
'tabentry_off': so,
})
return out
def rva_to_off(sections, rva):
for s in sections:
if s['vaddr'] <= rva < s['vaddr'] + s['vsize']:
return s['rawoff'] + (rva - s['vaddr'])
return None
def already_imports_leakfix(data, pe_off, sections):
opt_off = pe_off + 24
dd_off = opt_off + 96
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
foff = rva_to_off(sections, import_rva)
if foff is None: return False
while True:
name_rva = struct.unpack_from('<I', data, foff + 12)[0]
if name_rva == 0: return False
name_foff = rva_to_off(sections, name_rva)
if name_foff is None: return False
name_end = data.index(b'\0', name_foff)
dll_name = data[name_foff:name_end].decode(errors='replace').lower()
if 'leakfix' in dll_name:
return True
foff += 20
def align(n, a):
return (n + a - 1) & ~(a - 1)
def patch(ac_dir):
exe_path = os.path.join(ac_dir, 'acclient.exe')
dll_path = os.path.join(ac_dir, 'leakfix.dll')
if not os.path.exists(exe_path):
print(f"ERROR: {exe_path} not found")
return 1
if not os.path.exists(dll_path):
print(f"ERROR: {dll_path} not found")
return 1
with open(exe_path, 'rb') as f:
data = bytearray(f.read())
orig_sha = hashlib.sha256(data).hexdigest()
print(f"acclient.exe: {len(data):,} bytes, SHA-256 {orig_sha}")
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
sections = get_sections(data, pe_off)
print(f"\nCurrent sections ({len(sections)}):")
for s in sections:
print(f" {s['name']:10s} vaddr=0x{s['vaddr']:08x} vsize=0x{s['vsize']:08x} raw=0x{s['rawoff']:08x} rsize=0x{s['rsize']:08x}")
if already_imports_leakfix(data, pe_off, sections):
print("\n[already patched] acclient.exe already imports leakfix.dll — nothing to do.")
return 0
# Backup original (if no backup exists)
backup = exe_path + '.bare_original'
if not os.path.exists(backup):
shutil.copy2(exe_path, backup)
print(f"\nbackup saved: {backup}")
else:
print(f"\nbackup exists: {backup}")
# Read PE optional header info we'll need
opt_off = pe_off + 24
file_align = struct.unpack_from('<I', data, opt_off + 36)[0]
sect_align = struct.unpack_from('<I', data, opt_off + 32)[0]
print(f"file_align=0x{file_align:x} sect_align=0x{sect_align:x}")
# Verify we have a free slot in the section header table.
num_sections = struct.unpack_from('<H', data, pe_off + 6)[0]
opt_header_size = struct.unpack_from('<H', data, pe_off + 20)[0]
sect_table_off = pe_off + 24 + opt_header_size
next_sect_entry = sect_table_off + num_sections * 40
first_section_raw = min(s['rawoff'] for s in sections if s['rawoff'] > 0)
if next_sect_entry + 40 > first_section_raw:
print(f"\nERROR: no room in section table at file 0x{next_sect_entry:x} "
f"(first section raw 0x{first_section_raw:x}). Would need to expand headers.")
return 1
print(f"section table free slot at 0x{next_sect_entry:x}, first section raw 0x{first_section_raw:x}: OK")
# Read original import table to copy as-is
dd_off = opt_off + 96
old_imp_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
old_imp_size = struct.unpack_from('<I', data, dd_off + 12)[0]
old_imp_foff = rva_to_off(sections, old_imp_rva)
# Count old entries (excluding terminator)
walk = old_imp_foff
old_n = 0
while struct.unpack_from('<I', data, walk + 12)[0] != 0:
old_n += 1
walk += 20
old_iid_total = (old_n + 1) * 20 # +1 terminator
print(f"old imports: {old_n} entries, IID block {old_iid_total} bytes at RVA 0x{old_imp_rva:x}")
# === Build new section content ===
#
# Layout in the new section (.limport) at vaddr V (chosen as next aligned
# virtual address past last section):
# +0: Import Descriptors (rebuilt: old N + leakfix + terminator)
# +(N+2)*20: IAT terminator for leakfix (one ULONG = 0)
# +(N+2)*20+4: INT terminator for leakfix (one ULONG = 0)
# +(N+2)*20+8: Hint/Name table for leakfix (empty — none needed)
# +(N+2)*20+8: "leakfix.dll\0" name string
#
# Wait — actually for a DLL with no required imports we'd have an empty IAT/INT
# consisting of just a 0 terminator. But Windows loaders are fine with that.
last = max(sections, key=lambda s: s['vaddr'] + s['vsize'])
new_vaddr = align(last['vaddr'] + last['vsize'], sect_align)
new_rawoff = align(len(data), file_align)
print(f"new section: vaddr=0x{new_vaddr:08x} rawoff=0x{new_rawoff:08x}")
# Lay out contents
blob = bytearray()
# 1) IID block placeholder (size = (old_n + 2) * 20)
iid_start = 0
iid_size = (old_n + 2) * 20
blob += b'\0' * iid_size
# 2) Empty IAT/INT terminator for leakfix (4 bytes each, RVA pointers stored
# in the leakfix descriptor)
leakfix_int_off = len(blob)
blob += b'\0\0\0\0' # INT terminator
leakfix_iat_off = len(blob)
blob += b'\0\0\0\0' # IAT terminator
# 3) leakfix.dll name string
leakfix_name_off = len(blob)
blob += b'leakfix.dll\0'
# Pad to file alignment
while len(blob) % 4 != 0:
blob += b'\0'
new_section_size = len(blob)
new_section_raw_size = align(new_section_size, file_align)
blob += b'\0' * (new_section_raw_size - new_section_size)
# Fill in IIDs: copy old N, then add leakfix at index N, then terminator
# Each IID: OriginalFirstThunk, TimeDateStamp, ForwarderChain, Name, FirstThunk = 5 * 4B = 20B
# Old IIDs we copy verbatim (their RVAs already point into the existing import section)
blob[iid_start:iid_start + old_n*20] = data[old_imp_foff : old_imp_foff + old_n*20]
# leakfix IID at index N
leakfix_iid_off = iid_start + old_n * 20
leakfix_iid = bytearray(20)
struct.pack_into('<I', leakfix_iid, 0, new_vaddr + leakfix_int_off) # OriginalFirstThunk (INT)
struct.pack_into('<I', leakfix_iid, 4, 0) # TimeDateStamp
struct.pack_into('<I', leakfix_iid, 8, 0) # ForwarderChain
struct.pack_into('<I', leakfix_iid, 12, new_vaddr + leakfix_name_off) # Name
struct.pack_into('<I', leakfix_iid, 16, new_vaddr + leakfix_iat_off) # FirstThunk (IAT)
blob[leakfix_iid_off:leakfix_iid_off + 20] = leakfix_iid
# Terminator IID already zeroed
# === Append new section ===
if new_rawoff > len(data):
data += b'\0' * (new_rawoff - len(data)) # pad to alignment
data += blob
# === Write new section header ===
new_sect_hdr = bytearray(40)
new_sect_hdr[0:8] = b'.limport' # name
struct.pack_into('<I', new_sect_hdr, 8, new_section_size) # VirtualSize
struct.pack_into('<I', new_sect_hdr, 12, new_vaddr) # VirtualAddress
struct.pack_into('<I', new_sect_hdr, 16, new_section_raw_size) # SizeOfRawData
struct.pack_into('<I', new_sect_hdr, 20, new_rawoff) # PointerToRawData
struct.pack_into('<I', new_sect_hdr, 36, 0xC0000040) # READ|WRITE|INITIALIZED_DATA
data[next_sect_entry:next_sect_entry + 40] = new_sect_hdr
# === Update PE headers ===
struct.pack_into('<H', data, pe_off + 6, num_sections + 1) # NumberOfSections
# DataDirectory[1] (Import Table) — point at new IID block
struct.pack_into('<I', data, dd_off + 8, new_vaddr + iid_start)
struct.pack_into('<I', data, dd_off + 12, iid_size)
# SizeOfImage = aligned end of new section
new_size_of_image = align(new_vaddr + new_section_size, sect_align)
struct.pack_into('<I', data, opt_off + 56, new_size_of_image)
# === Save patched executable ===
out_path = exe_path
with open(out_path, 'wb') as f:
f.write(data)
new_sha = hashlib.sha256(data).hexdigest()
print(f"\nPatched acclient.exe written: {len(data):,} bytes, SHA-256 {new_sha}")
print(f" delta vs original: +{len(data) - os.path.getsize(backup):,} bytes")
print(f" new section: .limport @ vaddr 0x{new_vaddr:08x} raw 0x{new_rawoff:08x}")
print(f"\n[OK] acclient.exe now imports leakfix.dll. Restart any running clients.")
return 0
def verify(ac_dir):
"""Read patched exe and confirm leakfix.dll is in imports."""
exe_path = os.path.join(ac_dir, 'acclient.exe')
with open(exe_path, 'rb') as f:
data = f.read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
sections = get_sections(data, pe_off)
if already_imports_leakfix(data, pe_off, sections):
print(f"[verified] {exe_path} imports leakfix.dll.")
return 0
print(f"[FAILED] {exe_path} does NOT import leakfix.dll.")
return 1
def main():
ac_dir = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_AC_DIR
cmd = sys.argv[2] if len(sys.argv) > 2 else 'patch'
print(f"AC directory: {ac_dir}\nCommand: {cmd}\n")
if cmd == 'patch':
return patch(ac_dir)
elif cmd == 'verify':
return verify(ac_dir)
else:
print(f"unknown command: {cmd}")
return 1
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,81 @@
"""list_image_modules.py <pid>
Enumerate all MEM_IMAGE allocation bases. For each, read the PE export
table to grab the module name. List with sizes."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
def get_module_name(h, base):
"""Read PE export name from the module."""
hdr = rd(h, base + 0x3C, 4)
if not hdr or len(hdr) != 4: return None
pe_off = struct.unpack('<I', hdr)[0]
if pe_off > 0x1000: return None
# Read optional header, find export directory
opt_off = base + pe_off + 4 + 20
# Export RVA at opt_off + 96 (for PE32)
expdir_b = rd(h, opt_off + 96, 8)
if not expdir_b: return None
exp_rva, exp_size = struct.unpack('<II', expdir_b)
if not exp_rva or exp_size < 12: return None
# Read first 64 bytes of export dir; Name RVA is at offset 12
exp = rd(h, base + exp_rva, 64)
if not exp or len(exp) < 16: return None
name_rva = struct.unpack_from('<I', exp, 12)[0]
name_bytes = rd(h, base + name_rva, 64)
if not name_bytes: return None
n = name_bytes.split(b'\x00', 1)[0]
return n.decode(errors='replace')
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
seen = {}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if mbi.State == 0x1000 and mbi.Type == 0x1000000:
ab = mbi.AllocationBase or 0
if ab not in seen:
# Read SizeOfImage
hdr = rd(h, ab + 0x3C, 4)
img_size = 0
if hdr:
pe_off = struct.unpack('<I', hdr)[0]
sz_b = rd(h, ab + pe_off + 4 + 20 + 56, 4)
if sz_b:
img_size = struct.unpack('<I', sz_b)[0]
name = get_module_name(h, ab) or "?"
seen[ab] = (name, img_size)
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
print(f"{len(seen)} image bases found:")
print(f" {'base':>10} {'size':>9} name")
for ab in sorted(seen):
name, sz = seen[ab]
print(f" 0x{ab:08x} {sz:>9} {name}")

172
tools/manual_purge.py Normal file
View file

@ -0,0 +1,172 @@
"""manual_purge.py <pid>
Force a UIItem pool drain by calling UpdateEmptySlots on every
UIElement_ItemList instance in the target process. Bypasses any
visibility-trigger questions just calls the trim function directly
via CreateRemoteThread.
Requires v8-minimal applied (otherwise UpdateEmptySlots bails on the
internal IsVisible guard).
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
UPDATEEMPTYSLOTS_VA = 0x004e4390
# Heuristic: ItemList primary vtable — derive from xrefs.
# For our test, we scan heap for ItemList vtable matches.
# Actually we don't have the ItemList vtable VA — we need to find ItemLists
# differently. Best: scan for UIItem container pattern (this->+0x608 valid).
PROCESS_VM_READ = 0x10
PROCESS_VM_WRITE = 0x20
PROCESS_VM_OPERATION = 0x8
PROCESS_QUERY_INFORMATION = 0x400
PROCESS_CREATE_THREAD = 0x2
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
PAGE_EXECUTE_READWRITE = 0x40
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.WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.WriteProcessMemory.restype = wt.BOOL
k.VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
k.VirtualAllocEx.restype = wt.LPVOID
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
k.CreateRemoteThread.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t,
ctypes.c_void_p, ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
k.CreateRemoteThread.restype = wt.HANDLE
k.WaitForSingleObject = k.WaitForSingleObject
k.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
k.WaitForSingleObject.restype = wt.DWORD
def find_itemlist_instances(h):
"""Scan heap for objects whose first DWORD points to a code address
AND whose +0x610 looks like a small positive int (count) AND whose
+0x608 points to a valid memory address (the items array).
Returns list of candidate addresses."""
results = []
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):
try:
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])
# Walk DWORD-aligned, check pattern at each potential instance
end = len(data) - 0x614
for off in range(0, end, 4):
# vtable check: first DWORD points into image
vt = struct.unpack_from("<I", data, off)[0]
if vt < 0x00400000 or vt > 0x00800000:
continue
# +0x610 count check: small positive int
cnt = struct.unpack_from("<I", data, off + 0x610)[0]
if cnt == 0 or cnt > 2000:
continue
# +0x608 array pointer check: into private heap
arr = struct.unpack_from("<I", data, off + 0x608)[0]
if arr < 0x00400000 or arr > 0x80000000:
continue
results.append(mbi.BaseAddress + off)
except Exception:
pass
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
return results
def call_remote(h, target_va, ecx_arg):
"""Allocate a tiny stub that loads ECX and calls target_va, then RETs.
CreateRemoteThread runs the stub. ecx_arg = the this pointer."""
# Stub: mov ecx, ecx_arg; mov eax, target_va; call eax; xor eax, eax; ret 4
# Thread proc signature is DWORD WINAPI(LPVOID arg) — arg comes in as [esp+4]
# We'll use the arg passed by CreateRemoteThread as the this ptr.
stub = bytes([
0x8b, 0x4c, 0x24, 0x04, # mov ecx, [esp+4] ; this
0xb8,
target_va & 0xff, (target_va >> 8) & 0xff,
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
0xff, 0xd0, # call eax
0x33, 0xc0, # xor eax, eax
0xc2, 0x04, 0x00, # ret 4
])
page = k.VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not page:
raise OSError(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
if not k.WriteProcessMemory(h, page, stub, len(stub), ctypes.byref(sz)):
raise OSError(f"WriteProcessMemory stub failed")
tid = wt.DWORD(0)
th = k.CreateRemoteThread(h, None, 0, page, ctypes.c_void_p(ecx_arg), 0, ctypes.byref(tid))
if not th:
raise OSError(f"CreateRemoteThread failed err={ctypes.get_last_error()}")
k.WaitForSingleObject(th, 5000) # 5 sec timeout
k.CloseHandle(th)
# Note: leaving stub page allocated (would need to free after)
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--scan-only", action="store_true",
help="just print candidate ItemList instances, don't call drain")
ap.add_argument("--limit", type=int, default=999,
help="max instances to drain (default 999)")
args = ap.parse_args()
h = k.OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD,
False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
print(f"Scanning PID {args.pid} for ItemList candidates...")
candidates = find_itemlist_instances(h)
print(f"Found {len(candidates)} candidates")
for c in candidates[:20]:
print(f" 0x{c:08x}")
if len(candidates) > 20:
print(f" ... +{len(candidates)-20} more")
if args.scan_only:
sys.exit(0)
if not candidates:
print("No ItemList candidates found")
sys.exit(0)
print(f"\nCalling UpdateEmptySlots on first {min(args.limit, len(candidates))} candidates...")
for i, c in enumerate(candidates[:args.limit]):
try:
call_remote(h, UPDATEEMPTYSLOTS_VA, c)
print(f" [{i+1}/{min(args.limit, len(candidates))}] called on 0x{c:08x}")
except Exception as e:
print(f" [{i+1}] FAILED on 0x{c:08x}: {e}")
break
print("\nDone. Recount with count_uiitem_live.py to see effect.")
k.CloseHandle(h)

165
tools/owner_vtable_scan.py Normal file
View file

@ -0,0 +1,165 @@
"""
owner_vtable_scan.py <dump.dmp>
Goal: identify which class owns the leaked 256-512KB buffers.
Method:
1. Enumerate leaked 256-512KB Private RW regions (the "leak set").
2. Build a set of candidate "pointer-to-buffer" values:
region_base + delta for delta in {0, 8, 0x10, 0x18, 0x20, 0x30, 0x40}
(covers different heap-header sizes incl. +ust, +hpa).
3. Scan ALL committed RW memory for any DWORD whose value is in that
candidate set. For each hit, the containing word at offset
(hit_addr - field_offset) might be a field inside some object.
4. For each hit, look BACKWARDS within the same heap entry for a vtable
(a DWORD pointing into image memory, typically rdata). The first
valid vtable found is the owner-object's vtable.
5. Histogram by (owner_vtable, field_offset). The top entries reveal
which class+field owns the leaked buffer.
Output: top vtable hits with their image-module attribution.
"""
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 main():
md = MinidumpFile.parse(sys.argv[1])
reader = md.get_reader().get_buffered_reader()
# Module map -> attribute vtable addresses
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-region ranges (for vtable validation)
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 256-512KB regions
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))
print(f"leaked 256-512KB private RW regions: {len(leaked)}")
# Build candidate "pointer values" set
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
print(f"candidate pointer values: {len(cand_to_region)} (across {len(deltas)} deltas)")
# Scan all committed RW regions
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 # skip Image
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)} writable non-image regions ({total_bytes/(1024*1024):.1f} MB)")
# Build a per-region buffer cache so we can do "lookback within same region"
hits = [] # list of (hit_va, region_base_of_leaked_buf, value_pointed_at)
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))
print(f"raw pointer-into-leaked hits: {len(hits)}")
if not hits:
print("no hits — leaked buffers are orphaned (no live pointers to them).")
return
# For each hit, walk backwards within the same buffer up to N words looking
# for a DWORD that is in image memory and aligned (vtable candidate).
# Treat the hit as a "field at offset (off - vtbl_off) inside an object".
LOOKBACK_BYTES = 0x200 # 512 bytes back
vtable_hits = Counter() # (vtable, field_offset) -> count
vtable_only_hits = Counter() # vtable -> count
field_offsets_per_vtable = defaultdict(Counter)
examples = defaultdict(list)
no_vtable = 0
for hit_va, leaked_base, ptr_val, reg_base, off, buf in hits:
start = max(0, off - LOOKBACK_BYTES)
# Walk backwards in 4-byte steps from (off - 4) down to start
found = False
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 = v
field_off = off - back
vtable_hits[(vtable, field_off)] += 1
vtable_only_hits[vtable] += 1
field_offsets_per_vtable[vtable][field_off] += 1
if len(examples[(vtable, field_off)]) < 3:
examples[(vtable, field_off)].append((hit_va, leaked_base, ptr_val))
found = True
break
if not found:
no_vtable += 1
print(f"hits with no preceding vtable in 0x200 lookback: {no_vtable}")
print(f"unique (vtable, field_off) pairs: {len(vtable_hits)}")
print(f"unique vtables: {len(vtable_only_hits)}")
print()
print("=== Top vtables (regardless of field offset) ===")
for vtbl, cnt in vtable_only_hits.most_common(25):
owner = mod_of(vtbl) or "?"
# Show the top field offsets seen for this vtable
top_offs = field_offsets_per_vtable[vtbl].most_common(4)
offs_str = " ".join(f"+0x{o:x}={c}" for o, c in top_offs)
print(f" 0x{vtbl:08x} count={cnt:<5} ({owner}) offsets: {offs_str}")
print()
print("=== Top (vtable, field_offset) pairs ===")
for (vtbl, off), cnt in vtable_hits.most_common(25):
owner = mod_of(vtbl) or "?"
ex = examples[(vtbl, off)][0]
print(f" 0x{vtbl:08x} +0x{off:03x} count={cnt:<5} ({owner}) e.g. hit@0x{ex[0]:08x} -> leaked@0x{ex[1]:08x} val=0x{ex[2]:08x}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,160 @@
"""patch_cgfxobj_v4_test.py <pid> [--revert]
EXPERIMENTAL: Override CGfxObj's vtable slot 11 (ReleaseSubObjects,
currently the DBObj no-op stub at 0x004154a0) with a tiny injected
thunk that calls D3DPolyRender::DestroyMesh on this->constructed_mesh
(field +0x6c).
If the orphan D3DXMesh leak comes from CGfxObj instances reaching
DBOCache::FreeObject without their mesh being released first, this
patch fixes that.
Risks:
- If any code reads CGfxObj->constructed_mesh after cache-add and
before re-Get-then-InitLoad, it will deref NULL and crash.
- This is the same risk shape as v1 Palette patch which DID crash
on copy-from-cached-source reads. For CGfxObj, the equivalent
"read from cached source" path may or may not exist. Test on a
single client first.
Mechanism:
- VirtualAllocEx in target process for a 64-byte RWX page
- Write the 18-byte thunk:
53 push ebx
8b d9 mov ebx, ecx (ecx = this)
83 c3 6c add ebx, 0x6c (ebx = &this->constructed_mesh)
53 push ebx
b8 e0 d1 59 00 mov eax, 0x0059d1e0 (D3DPolyRender::DestroyMesh)
ff d0 call eax
83 c4 04 add esp, 4
5b pop ebx
b0 01 mov al, 1 (return 1)
c3 ret
- VirtualProtectEx + WriteProcessMemory to flip the vtable slot at
0x007ca418 + 0x2c = 0x007ca444 from 0x004154a0 to the thunk addr.
Revert:
- Restore vtable slot to 0x004154a0
- The allocated page is leaked (intentional easier than tracking
its address)
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
VTABLE_CGFXOBJ = 0x007ca418
SLOT_RELEASE_SUBOBJ = 0x2c
SLOT_VA = VTABLE_CGFXOBJ + SLOT_RELEASE_SUBOBJ # 0x007ca444
NOOP_STUB_VA = 0x004154a0
DESTROY_MESH_VA = 0x0059d1e0
THUNK = bytes([
0x53, # push ebx
0x8b, 0xd9, # mov ebx, ecx
0x83, 0xc3, 0x6c, # add ebx, 0x6c
0x53, # push ebx
0xb8, 0xe0, 0xd1, 0x59, 0x00, # mov eax, 0x0059d1e0
0xff, 0xd0, # call eax
0x83, 0xc4, 0x04, # add esp, 4
0x5b, # pop ebx
0xb0, 0x01, # mov al, 1
0xc3, # ret
])
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore the original no-op stub")
args = ap.parse_args()
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = ctypes.c_uint(0)
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz)):
print(f"read SLOT_VA failed: err={ctypes.get_last_error()}"); sys.exit(3)
print(f"PID {args.pid} vtable slot @ 0x{SLOT_VA:08x} current: 0x{cur.value:08x}")
if args.revert:
if cur.value == NOOP_STUB_VA:
print(f" already pointing at no-op stub — nothing to revert")
CloseHandle(h); return
old_prot = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot))
new = ctypes.c_uint(NOOP_STUB_VA)
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
restored = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
print(f" reverted to 0x{NOOP_STUB_VA:08x}")
CloseHandle(h); return
if cur.value != NOOP_STUB_VA:
print(f" UNEXPECTED current value (wanted 0x{NOOP_STUB_VA:08x}) — refusing to patch")
CloseHandle(h); sys.exit(4)
# Allocate RWX page for thunk
thunk_va = VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_va:
print(f"VirtualAllocEx failed: err={ctypes.get_last_error()}"); sys.exit(5)
print(f" thunk page allocated @ 0x{thunk_va:08x}")
# Write thunk
if not WriteProcessMemory(h, thunk_va, THUNK, len(THUNK), ctypes.byref(sz)):
print(f"write thunk failed: err={ctypes.get_last_error()}"); sys.exit(6)
# Flip vtable slot
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, SLOT_VA, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
print(f"VirtualProtectEx vtable failed: err={ctypes.get_last_error()}"); sys.exit(7)
new = ctypes.c_uint(thunk_va)
WriteProcessMemory(h, SLOT_VA, ctypes.byref(new), 4, ctypes.byref(sz))
restored = wt.DWORD(0)
VirtualProtectEx(h, SLOT_VA, 4, old_prot.value, ctypes.byref(restored))
# Verify
ReadProcessMemory(h, SLOT_VA, ctypes.byref(cur), 4, ctypes.byref(sz))
print(f" vtable slot now 0x{cur.value:08x} (patched OK)")
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,96 @@
"""patch_freeobject_v2.py <pid> [--revert]
v2 patch: force DBOCache::FreeObject to always take the DELETE path
(never add to freelist). This causes every Release-to-refcount-1 to
invoke the proper destructor, which calls operator delete[] on the
Palette ARGB buffer (and similar for other DBObj-derived classes).
Target byte: EoR 0x004169bf
Original: 0x74 (jz delete_path)
Patched: 0xeb (jmp delete_path) unconditional
Effect:
- DBOCache::FreeObject(this, obj) now always:
obj->ReleaseSubObjects(); obj->in_use = 0; this->vt[0x44](obj);
- Skips the "if freelist enabled, push to freelist" branch
- Affects ALL DBObj-derived classes (not just Palette)
Risk:
- Performance: cache misses now require full alloc + load instead of
reuse from freelist. For frequently-used classes, may add latency.
- Correctness: should be safe delete path uses proper destructor
that already handles all field cleanup.
"""
import ctypes, ctypes.wintypes as wt, sys
TARGET_ADDR = 0x004169bf
EXPECTED_VAL = 0x74 # jz
PATCHED_VAL = 0xeb # jmp
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def main():
if len(sys.argv) < 2:
print("usage: patch_freeobject_v2.py <pid> [--revert]"); sys.exit(1)
pid = int(sys.argv[1])
revert = "--revert" in sys.argv
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
cur = ctypes.c_ubyte(0); sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 1, ctypes.byref(sz)):
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
print(f"PID {pid} byte @ 0x{TARGET_ADDR:08x} current: 0x{cur.value:02x}")
target = EXPECTED_VAL if revert else PATCHED_VAL
expect_before = PATCHED_VAL if revert else EXPECTED_VAL
if cur.value == target:
print(f" already {'reverted' if revert else 'patched'}")
CloseHandle(h); return
if cur.value != expect_before:
print(f" UNEXPECTED current value (wanted 0x{expect_before:02x}) — aborting")
CloseHandle(h); sys.exit(4)
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, TARGET_ADDR, 1, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
new = ctypes.c_ubyte(target)
if not WriteProcessMemory(h, TARGET_ADDR, ctypes.byref(new), 1, ctypes.byref(sz)):
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
restored = wt.DWORD(0)
VirtualProtectEx(h, TARGET_ADDR, 1, old_prot.value, ctypes.byref(restored))
ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 1, ctypes.byref(sz))
print(f" new value: 0x{cur.value:02x} ({'reverted' if revert else 'patched'})")
CloseHandle(h)
if __name__ == "__main__":
main()

106
tools/patch_palette_v1.py Normal file
View file

@ -0,0 +1,106 @@
"""patch_palette_v1.py <pid> [--revert]
Apply (or revert) the Palette leak fix to a running acclient.exe.
Mechanism:
Overwrite Palette's primary vtable slot +0x2c (which is the
DBObj::ReleaseSubObjects no-op stub) to point at Palette's existing
PurgeResource function (slot +0x3c) instead. This causes
DBOCache::FreeObject's ReleaseSubObjects call to actually free the
ARGB buffer when the palette goes to the freelist.
Target: EoR vtable 0x007caa08, slot +0x2c
Replace value: 0x004154a0 (no-op stub, returns 1)
With value: 0x0053ecf0 (PurgeResource frees +0x40 buffer)
Safety:
- Idempotent: only patches if current value matches the no-op stub
- Revert via --revert flag
- Saves before-image to backup
- The dtor at 0x0053f0d0 guards against NULL no double-free risk
"""
import ctypes, ctypes.wintypes as wt, sys, struct
VT_PRIMARY = 0x007caa08
SLOT_OFFSET = 0x2c
TARGET_ADDR = VT_PRIMARY + SLOT_OFFSET # 0x007caa34
EXPECTED_VAL = 0x004154a0 # no-op ReleaseSubObjects stub
PATCHED_VAL = 0x0053ecf0 # Palette::PurgeResource
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def main():
if len(sys.argv) < 2:
print("usage: patch_palette_v1.py <pid> [--revert]"); sys.exit(1)
pid = int(sys.argv[1])
revert = "--revert" in sys.argv
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
# Read current value
cur = ctypes.c_uint(0); sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 4, ctypes.byref(sz)):
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
print(f"PID {pid} slot @ 0x{TARGET_ADDR:08x} current: 0x{cur.value:08x}")
target = EXPECTED_VAL if revert else PATCHED_VAL
expect_before = PATCHED_VAL if revert else EXPECTED_VAL
if cur.value == target:
print(f" already {'reverted' if revert else 'patched'} — nothing to do")
CloseHandle(h); return
if cur.value != expect_before:
print(f" UNEXPECTED current value (expected 0x{expect_before:08x}) — aborting to avoid corruption")
CloseHandle(h); sys.exit(4)
# Make page writable
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, TARGET_ADDR, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
print(f" old protect: 0x{old_prot.value:x}")
# Write new value
new = ctypes.c_uint(target)
if not WriteProcessMemory(h, TARGET_ADDR, ctypes.byref(new), 4, ctypes.byref(sz)):
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
# Restore original protection
restored = wt.DWORD(0)
VirtualProtectEx(h, TARGET_ADDR, 4, old_prot.value, ctypes.byref(restored))
# Verify
ReadProcessMemory(h, TARGET_ADDR, ctypes.byref(cur), 4, ctypes.byref(sz))
print(f" new value: 0x{cur.value:08x} ({'reverted' if revert else 'patched'} successfully)")
CloseHandle(h)
if __name__ == "__main__":
main()

98
tools/patch_palette_v3.py Normal file
View file

@ -0,0 +1,98 @@
"""patch_palette_v3.py <pid> [--revert]
v3 patch: NOP the extra refcount increment in Palette::makeModifiedPalette
(EoR FUN_0053efe0). This stops the +1 over-increment that leaves every
new "modified palette" at refcount=2 instead of refcount=1.
Background:
FUN_0053efe0 = Palette::makeModifiedPalette():
new(0x48); Palette::Palette(this, 0x800); this->refcount++; return;
The "this->refcount++" makes the returned palette have refcount=2.
After caller's single Release, refcount=1, and the palette becomes
unreachable (m_pMaintainer is NULL no cache reference) but alive.
56,664 leaked palettes in dump_9156 are all this shape: rc=1,
m_pMaintainer=0, m_numLinks=0.
Target bytes: 0x0053effe (3 bytes inside FUN_0053efe0)
Original: ff 40 24 ; inc dword [eax+0x24] ; refcount++
Patched: 90 90 90 ; nop nop nop
The 2013 build has the same +1 pattern at 0x0053e29e leak likely
exists there too, just less noticeable.
Risk: some caller may assume refcount=2 and release twice; after this
patch, the second release would crash on already-freed memory. Test on
one client and watch closely.
"""
import ctypes, ctypes.wintypes as wt, sys
TARGET_ADDR = 0x0053effe
ORIGINAL_BYTES = bytes([0xff, 0x40, 0x24])
PATCHED_BYTES = bytes([0x90, 0x90, 0x90])
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def main():
pid = int(sys.argv[1])
revert = "--revert" in sys.argv
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
cur = (ctypes.c_ubyte * 3)(); sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, TARGET_ADDR, cur, 3, ctypes.byref(sz)):
print(f"read failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(3)
cur_b = bytes(cur)
print(f"PID {pid} bytes @ 0x{TARGET_ADDR:08x}: {' '.join(f'{b:02x}' for b in cur_b)}")
target = ORIGINAL_BYTES if revert else PATCHED_BYTES
expect_before = PATCHED_BYTES if revert else ORIGINAL_BYTES
if cur_b == target:
print(f" already {'reverted' if revert else 'patched'}"); CloseHandle(h); return
if cur_b != expect_before:
print(f" UNEXPECTED current bytes (wanted {' '.join(f'{b:02x}' for b in expect_before)}) — aborting")
CloseHandle(h); sys.exit(4)
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, TARGET_ADDR, 3, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
print(f"VirtualProtectEx failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
new = (ctypes.c_ubyte * 3)(*target)
if not WriteProcessMemory(h, TARGET_ADDR, new, 3, ctypes.byref(sz)):
print(f"write failed: err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
restored = wt.DWORD(0)
VirtualProtectEx(h, TARGET_ADDR, 3, old_prot.value, ctypes.byref(restored))
ReadProcessMemory(h, TARGET_ADDR, cur, 3, ctypes.byref(sz))
print(f" new bytes: {' '.join(f'{b:02x}' for b in bytes(cur))} ({'reverted' if revert else 'patched'})")
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,89 @@
"""patch_palette_v3b.py <pid> [--revert]
v3b: extended v3 patches BOTH makeModifiedPalette overloads.
Site 1 (no-arg overload, FUN_0053efe0):
addr 0x0053effe ff 40 24 -> 90 90 90 ; nop refcount++
Site 2 (2-arg overload, FUN_0053f120):
addr 0x0053f19c ff 46 24 -> 90 90 90 ; nop refcount++
Both write a refcount++ on a freshly-constructed (refcount=1) palette,
leaving it at refcount=2 which is one short of being released back to
zero by the typical cleanup chain (releasePalette + CSurface dtor).
"""
import ctypes, ctypes.wintypes as wt, sys
SITES = [
(0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
(0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
]
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def patch_site(h, addr, orig, patched, revert):
cur = (ctypes.c_ubyte * 3)(); sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, cur, 3, ctypes.byref(sz)):
print(f" read failed @ 0x{addr:08x}: err={ctypes.get_last_error()}"); return False
cur_b = bytes(cur)
target = orig if revert else patched
expect_before = patched if revert else orig
print(f" @ 0x{addr:08x}: cur={' '.join(f'{b:02x}' for b in cur_b)}", end="")
if cur_b == target:
print(f" already {'reverted' if revert else 'patched'}")
return True
if cur_b != expect_before:
print(f" UNEXPECTED (wanted {' '.join(f'{b:02x}' for b in expect_before)}) — skip")
return False
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, 3, PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
print(f" VirtualProtectEx failed: err={ctypes.get_last_error()}"); return False
new = (ctypes.c_ubyte * 3)(*target)
if not WriteProcessMemory(h, addr, new, 3, ctypes.byref(sz)):
print(f" write failed: err={ctypes.get_last_error()}"); return False
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, 3, old_prot.value, ctypes.byref(restored))
print(f" -> {' '.join(f'{b:02x}' for b in target)} ({'reverted' if revert else 'patched'})")
return True
def main():
pid = int(sys.argv[1])
revert = "--revert" in sys.argv
h = OpenProcess(PROCESS_VM_READ|PROCESS_VM_WRITE|PROCESS_VM_OPERATION|PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess({pid}) failed: err={ctypes.get_last_error()}"); sys.exit(2)
print(f"PID {pid}:")
for addr, orig, patched in SITES:
patch_site(h, addr, orig, patched, revert)
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,231 @@
"""patch_purge_v5_test.py <pid> [--revert]
EXPERIMENTAL: Override RenderSurface and RenderTexture's PurgeResource
vtable slot (slot 2 = +0x08) with thunks that call the existing
Destroy() method on each class, then return true so the purge loop
properly marks the resource as cleaned up.
Background:
GraphicsResource::PurgeResource is virtual (vtable slot 2). The
base implementation is the no-op stub at 0x004154a0 (mov al, 1; ret).
D3D-specialized subclasses (RenderTextureD3D, RenderSurfaceD3D,
CSurface, ImgTex) properly override slot 2 to free their GPU
resources. But the BASE RenderSurface and RenderTexture inherit
the no-op stub. GraphicsResource::PurgeOldResources iterates
resources, calls vtable[2], and if it returns true, marks them
as "purged" with a flag (+0x08 on the resource) so they're never
retried. The no-op returns true mark as purged permanent leak.
Verified by:
* comparative dump diff (Time/Nyckel low-leak vs Larsson/Jerry
high-leak): GraphicsResource vtable is the top-ranked residual
leak owner with 367 instances in heavy-looter Larsson vs 0 in
low-activity Time.
* subclass scan: RenderSurface (vtable 0x0079a67c) and
RenderTexture (vtable 0x0079c198) have slot 2 = 0x004154a0
(no-op); six other GraphicsResource subclasses override.
* decompile: PurgeOldResources at EoR 0x00446dc0 calls vtable[2]
and sets resource->+0x02 = 1 on success.
Fix:
Replace slot 2 in each leaking vtable with a thunk that calls
RenderSurface::Destroy (EoR 0x00444540) / RenderTexture::Destroy
(EoR 0x0044c4f0). These methods exist and do idempotent cleanup
(delete heavy heap allocations, null pointers, reset state).
They don't tear down the C++ object - perfect for PurgeResource.
Risks:
* If a leaked RenderSurface is in some half-destroyed state where
fields +0x64 / +0x114 are dangling (not NULL but point to freed
memory), Destroy will double-free. Believed unlikely since
Destroy is idempotent and checks for NULL before delete, and
the purge loop's eligibility check (+0x6 != currentFrame) only
runs purge on resources not in use this frame.
* Same shape as v4 (thunk injection + vtable rewrite). v4 has run
without crashes on 3 clients for hours.
Thunks (10 bytes each):
Thunk A RenderSurface PurgeResource:
B8 40 45 44 00 mov eax, 0x00444540 ; RenderSurface::Destroy
FF D0 call eax ; thiscall - this in ecx
B0 01 mov al, 1 ; return true
C3 ret
Thunk B RenderTexture PurgeResource:
B8 F0 C4 44 00 mov eax, 0x0044c4f0 ; RenderTexture::Destroy
FF D0 call eax
B0 01 mov al, 1
C3 ret
Vtable slots patched:
RenderSurface @ 0x0079a67c + 0x08 = 0x0079a684 thunk A addr
RenderTexture @ 0x0079c198 + 0x08 = 0x0079c1a0 thunk B addr
Both currently point to 0x004154a0 (no-op stub).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
# Targets
NOOP_STUB_VA = 0x004154a0
RENDERSURFACE_DESTROY_VA = 0x00444540
RENDERTEXTURE_DESTROY_VA = 0x0044c4f0
RENDERSURFACE_VTABLE_VA = 0x0079a67c
RENDERTEXTURE_VTABLE_VA = 0x0079c198
SLOT_PURGERESOURCE = 0x08
RS_SLOT_VA = RENDERSURFACE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079a684
RT_SLOT_VA = RENDERTEXTURE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079c1a0
def make_thunk(target_va: int) -> bytes:
return bytes([
0xB8, target_va & 0xff, (target_va >> 8) & 0xff,
(target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va
0xFF, 0xD0, # call eax
0xB0, 0x01, # mov al, 1
0xC3, # ret
])
THUNK_RS = make_thunk(RENDERSURFACE_DESTROY_VA)
THUNK_RT = make_thunk(RENDERTEXTURE_DESTROY_VA)
assert len(THUNK_RS) == 10
assert len(THUNK_RT) == 10
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]
CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_dword(h, addr):
out = ctypes.c_uint(0)
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, ctypes.byref(out), 4, ctypes.byref(sz)):
raise OSError(f"ReadProcessMemory 0x{addr:08x} failed err={ctypes.get_last_error()}")
return out.value
def write_dword(h, addr, value):
"""Write a DWORD into a (typically read-only) vtable region by
flipping protection RW, writing, then restoring."""
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, 4, PAGE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx RW 0x{addr:08x} failed err={ctypes.get_last_error()}")
v = ctypes.c_uint(value)
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, ctypes.byref(v), 4, ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, 4, old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"WriteProcessMemory 0x{addr:08x} failed err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore the no-op stub at both vtable slots")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
sys.exit(2)
rs_cur = read_dword(h, RS_SLOT_VA)
rt_cur = read_dword(h, RT_SLOT_VA)
print(f"PID {args.pid}")
print(f" RenderSurface vtable slot @ 0x{RS_SLOT_VA:08x} = 0x{rs_cur:08x}")
print(f" RenderTexture vtable slot @ 0x{RT_SLOT_VA:08x} = 0x{rt_cur:08x}")
if args.revert:
if rs_cur == NOOP_STUB_VA and rt_cur == NOOP_STUB_VA:
print(" both already no-op — nothing to revert")
CloseHandle(h); return
if rs_cur != NOOP_STUB_VA:
write_dword(h, RS_SLOT_VA, NOOP_STUB_VA)
print(f" RenderSurface slot reverted to 0x{NOOP_STUB_VA:08x}")
if rt_cur != NOOP_STUB_VA:
write_dword(h, RT_SLOT_VA, NOOP_STUB_VA)
print(f" RenderTexture slot reverted to 0x{NOOP_STUB_VA:08x}")
CloseHandle(h); return
if rs_cur != NOOP_STUB_VA or rt_cur != NOOP_STUB_VA:
print(f" UNEXPECTED — one or both slots not the no-op stub "
f"(wanted 0x{NOOP_STUB_VA:08x}). Refusing to patch.")
CloseHandle(h); sys.exit(3)
page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not page:
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}")
CloseHandle(h); sys.exit(4)
print(f" thunk page allocated @ 0x{page:08x}")
thunk_rs_addr = page
thunk_rt_addr = page + 0x20 # padded for alignment + safety
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_rs_addr, THUNK_RS, len(THUNK_RS), ctypes.byref(sz)):
print(f"write THUNK_RS failed err={ctypes.get_last_error()}"); sys.exit(5)
if not WriteProcessMemory(h, thunk_rt_addr, THUNK_RT, len(THUNK_RT), ctypes.byref(sz)):
print(f"write THUNK_RT failed err={ctypes.get_last_error()}"); sys.exit(6)
print(f" THUNK_RS @ 0x{thunk_rs_addr:08x}: {THUNK_RS.hex()}")
print(f" THUNK_RT @ 0x{thunk_rt_addr:08x}: {THUNK_RT.hex()}")
write_dword(h, RS_SLOT_VA, thunk_rs_addr)
write_dword(h, RT_SLOT_VA, thunk_rt_addr)
rs_after = read_dword(h, RS_SLOT_VA)
rt_after = read_dword(h, RT_SLOT_VA)
print(f" RenderSurface slot now 0x{rs_after:08x} {'OK' if rs_after == thunk_rs_addr else 'MISMATCH'}")
print(f" RenderTexture slot now 0x{rt_after:08x} {'OK' if rt_after == thunk_rt_addr else 'MISMATCH'}")
CloseHandle(h)
if __name__ == "__main__":
main()

122
tools/patch_v10_test.py Normal file
View file

@ -0,0 +1,122 @@
"""patch_v10_test.py <pid> [--revert]
v10: NOP the visible=false branch of UIElement_ItemList::OnVisibilityChanged
so it ALWAYS calls UpdateEmptySlots (instead of only when becoming visible).
Combined with v8-minimal (which makes UpdateEmptySlots run despite the
visibility check inside it), this drains the WAITING-state UIItem pool
on every panel close.
REQUIRES v8-minimal to be applied first (otherwise UpdateEmptySlots
bails immediately on the IsVisible() check inside).
Site:
0x004E49AD: 74 07 jz +0x07 skip UpdateEmptySlots when vis==false
Replace: 90 90 nop nop fall through, always call UpdateEmptySlots
Just 2 bytes changed. The simplest patch yet.
Risk:
- v10 alone (without v8): UpdateEmptySlots would be called on hide but
immediately bail at IsVisible() no-op. Safe degradation.
- v10 + v8: existing trim logic drains pool on hide. Same code that
safely runs on show transitions.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
SITE_VA = 0x004E49AD
ORIG_BYTES = bytes([0x74, 0x07])
NOP_BYTES = bytes([0x90, 0x90])
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, SITE_VA, 2)
print(f"PID {args.pid}")
print(f" site @ 0x{SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIG_BYTES:
print(f" already original — nothing to revert")
elif cur == NOP_BYTES:
write_bytes(h, SITE_VA, ORIG_BYTES)
print(f" reverted; bytes now: {read_bytes(h, SITE_VA, 2).hex()}")
else:
print(f" UNEXPECTED bytes — refusing"); sys.exit(3)
CloseHandle(h); return
if cur == NOP_BYTES:
print(f" already patched")
elif cur == ORIG_BYTES:
write_bytes(h, SITE_VA, NOP_BYTES)
print(f" patched; bytes now: {read_bytes(h, SITE_VA, 2).hex()}")
else:
print(f" UNEXPECTED bytes — refusing. Expected: {ORIG_BYTES.hex()}")
sys.exit(4)
CloseHandle(h)
if __name__ == "__main__":
main()

123
tools/patch_v11_test.py Normal file
View file

@ -0,0 +1,123 @@
"""patch_v11_test.py <pid> [--revert]
v11: NULL-check / crash-guard patches for two observed crash sites.
Site 1 delete_contents (Barris's crash at 0x0058712f)
Function at 0x005870f0. Inner trim loop has a state inconsistency
path that sets EDI=0 and falls through to MOV EAX,[EDI] AV.
Patch: change the `JMP +0x07` at 0x00587126 (`eb 07`) to
`JMP +0x42` (`eb 42`) jumps to function epilogue at 0x0058716a
(pop edi; pop esi; ret). Bails on empty-bucket-pass instead of
UAF.
Site 2 ~GXTri3Mesh slot 0 (Kolossos's crash at 0x005e565d)
Destructor walks 5 subobject slots via vtable. The dereference at
0x005e565d (`MOV ECX,[EAX]`) crashes when slot pointer is freed.
Patch: replace the 9-byte deref+call+clear sequence
(`8b 08 50 ff 51 08 89 5e 08`) with
`89 5e 08 90 90 90 90 90 90`. Clears the slot but skips the
virtual call trades subobject leak for no crash.
This is the v11 patcher. Only patches the ONE slot we've observed
crashing (slot 0 / +0x08 on the mesh). Other slots may need same
treatment if they crash later.
"""
import argparse, ctypes, ctypes.wintypes as wt, sys
SITES = [
("site1 delete_contents JMP", 0x00587126,
bytes([0xEB, 0x07]),
bytes([0xEB, 0x42])),
("site2 ~GXTri3Mesh slot 0", 0x005E565D,
bytes([0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08]),
bytes([0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
]
PROCESS_VM_READ = 0x10
PROCESS_VM_WRITE = 0x20
PROCESS_VM_OPERATION = 0x8
PROCESS_QUERY_INFORMATION = 0x400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def apply_or_revert(h, label, va, orig, patched, revert):
cur = read_bytes(h, va, len(orig))
print(f" {label} @ 0x{va:08x}: current {cur.hex()}")
if revert:
if cur == orig:
print(f" already original")
elif cur == patched:
write_bytes(h, va, orig)
print(f" reverted; now: {read_bytes(h, va, len(orig)).hex()}")
else:
print(f" UNEXPECTED bytes — refusing"); sys.exit(3)
return
if cur == patched:
print(f" already patched")
elif cur == orig:
write_bytes(h, va, patched)
print(f" patched; now: {read_bytes(h, va, len(orig)).hex()}")
else:
print(f" UNEXPECTED bytes — refusing. Expected: {orig.hex()}")
sys.exit(4)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
print(f"PID {args.pid}")
for label, va, orig, patched in SITES:
apply_or_revert(h, label, va, orig, patched, args.revert)
print(f" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

178
tools/patch_v12_test.py Normal file
View file

@ -0,0 +1,178 @@
"""patch_v12_test.py <pid> [--revert]
v12: NULL/range validator for the unpacker at 0x00526a50.
This function (presumably AnimSequenceNode UnPack overload, or similar
4-dword stream reader) is called via vtable slot at 0x007c92c8. It
dereferences `*(ctx)` without validating the cursor pointer. Two
confirmed crashes here (Shadow 20:28, Frank 23:22), both with bad
cursor pointers from leak-corrupted contexts.
Mechanism (2 sites):
1. Write 29-byte validator at 0x00526a45 (uses 11-byte NOP pad +
overwrites first 18 bytes of original function).
Validator: size check + EAX load + cursor non-NULL + cursor
>= 0x00400000 success path jumps to 0x00526a62 (body unchanged);
failure path returns 0 (same as original size-check-fail).
2. Redirect vtable slot at 0x007c92c8 from 0x00526a50 0x00526a45.
After patch, all dispatched calls hit the validator first.
The body from 0x00526a62 onward is untouched.
Risk:
- If a direct caller of 0x00526a50 exists (not via vtable), it'd
land mid-validator at MOV EDX,[EAX] with EAX uninitialized.
Ghidra found only the vtable xref, so unlikely.
- Writing to a vtable in process memory is fine (we VirtualProtectEx).
- 11 NOPs + 18 original-entry bytes = 29 bytes total replacement.
No overlap into the body at 0x00526a62.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
VALIDATOR_VA = 0x00526a45
DISPATCH_VA = 0x007c92c8
OLD_FUNC_VA = 0x00526a50
VALIDATOR_BYTES = bytes([
# 0x00526a45 CMP DWORD [ESP+8], 0x10
0x83, 0x7C, 0x24, 0x08, 0x10,
# 0x00526a4a JB +0x11 → 0x00526a5d (fail)
0x72, 0x11,
# 0x00526a4c MOV EAX, [ESP+4] (EAX = ctx)
0x8B, 0x44, 0x24, 0x04,
# 0x00526a50 MOV EDX, [EAX] (EDX = cursor)
0x8B, 0x10,
# 0x00526a52 CMP EDX, 0x00400000 (cursor >= image base?)
0x81, 0xFA, 0x00, 0x00, 0x40, 0x00,
# 0x00526a58 JB +0x03 → 0x00526a5d (fail)
0x72, 0x03,
# 0x00526a5a JMP +0x06 → 0x00526a62 (body)
0xEB, 0x06,
# 0x00526a5c NOP (filler)
0x90,
# 0x00526a5d XOR EAX, EAX
0x33, 0xC0,
# 0x00526a5f RET 8
0xC2, 0x08, 0x00,
])
assert len(VALIDATOR_BYTES) == 29
# Original bytes at validator site: 11 NOPs + 18 bytes of original function entry
ORIG_VALIDATOR = bytes([0x90] * 11) + bytes([
0x83, 0x7C, 0x24, 0x08, 0x10, # CMP [ESP+8], 0x10
0x73, 0x05, # JAE +5
0x33, 0xC0, # XOR EAX, EAX
0xC2, 0x08, 0x00, # RET 8
0x8B, 0x44, 0x24, 0x04, # MOV EAX, [ESP+4]
0x8B, 0x10 # MOV EDX, [EAX]
])
assert len(ORIG_VALIDATOR) == 29
OLD_DISPATCH = struct.pack("<I", OLD_FUNC_VA)
NEW_DISPATCH = struct.pack("<I", VALIDATOR_VA)
PROCESS_VM_READ = 0x10
PROCESS_VM_WRITE = 0x20
PROCESS_VM_OPERATION = 0x8
PROCESS_QUERY_INFORMATION = 0x400
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data, prot=PAGE_EXECUTE_READWRITE):
old = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), prot, ctypes.byref(old)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
print(f"PID {args.pid}")
# Read both sites
cur_validator = read_bytes(h, VALIDATOR_VA, 29)
cur_dispatch = read_bytes(h, DISPATCH_VA, 4)
print(f" validator @ 0x{VALIDATOR_VA:08x}: cur {cur_validator.hex()}")
print(f" dispatch @ 0x{DISPATCH_VA:08x}: cur {cur_dispatch.hex()}")
if args.revert:
if cur_dispatch == OLD_DISPATCH:
print(f" already original (dispatch)")
elif cur_dispatch == NEW_DISPATCH:
# 1. First restore dispatch (callers go back to original function)
write_bytes(h, DISPATCH_VA, OLD_DISPATCH)
# 2. Then restore validator bytes
write_bytes(h, VALIDATOR_VA, ORIG_VALIDATOR)
print(f" reverted both sites")
else:
print(f" UNEXPECTED dispatch — refusing"); sys.exit(3)
CloseHandle(h); return
# Apply: validator first, then dispatch
if cur_dispatch == NEW_DISPATCH:
print(f" already patched")
CloseHandle(h); return
if cur_dispatch != OLD_DISPATCH:
print(f" UNEXPECTED dispatch. Expected {OLD_DISPATCH.hex()}")
CloseHandle(h); sys.exit(4)
if cur_validator != ORIG_VALIDATOR:
print(f" UNEXPECTED validator-site bytes. Expected {ORIG_VALIDATOR.hex()}")
CloseHandle(h); sys.exit(5)
print(f" writing validator ({len(VALIDATOR_BYTES)} bytes)")
write_bytes(h, VALIDATOR_VA, VALIDATOR_BYTES)
print(f" verify: {read_bytes(h, VALIDATOR_VA, 29).hex()}")
print(f" writing dispatch redirect")
write_bytes(h, DISPATCH_VA, NEW_DISPATCH, prot=PAGE_READWRITE)
print(f" verify: {read_bytes(h, DISPATCH_VA, 4).hex()}")
print(f" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

145
tools/patch_v13_test.py Normal file
View file

@ -0,0 +1,145 @@
"""patch_v13_test.py <pid> [--revert]
v13: Hook SmartBox::DoPickupEvent to call unparent_children on the
unequipped CPhysicsObj's children before continuing.
This fixes the weapon-switch leak: each unequip currently calls
unset_parent (clearing the weapon's link to player) but never iterates
the weapon's OWN children (visual effects, sub-physobjs). Those keep
parent=weapon and accumulate in weapon->children forever.
Mechanism:
Replace the `CALL unset_parent` at 0x004522bf with a CALL to a tiny
12-byte thunk that does:
PUSH ECX
CALL unset_parent (0x00513F70)
POP ECX
JMP unparent_children (0x00513FE0)
Net effect: both unset_parent AND unparent_children fire, then
control returns to DoPickupEvent's next instruction (leave_world).
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
PATCH_SITE_VA = 0x004522BF
ORIG_CALL_BYTES = bytes([0xE8, 0xAC, 0x1C, 0x0C, 0x00]) # CALL unset_parent
UNSET_PARENT_VA = 0x00513F70
UNPARENT_CHILDREN_VA = 0x00513FE0
PROCESS_VM_READ = 0x10
PROCESS_VM_WRITE = 0x20
PROCESS_VM_OPERATION = 0x8
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def build_thunk(thunk_va):
out = bytearray()
out += bytes([0x51]) # push ecx
# call unset_parent
rel = UNSET_PARENT_VA - (thunk_va + len(out) + 5)
out += bytes([0xE8]) + struct.pack("<i", rel)
out += bytes([0x59]) # pop ecx
# jmp unparent_children
rel = UNPARENT_CHILDREN_VA - (thunk_va + len(out) + 5)
out += bytes([0xE9]) + struct.pack("<i", rel)
return bytes(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, 5)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x}: {cur.hex()}")
if args.revert:
if cur == ORIG_CALL_BYTES:
print(f" already original"); CloseHandle(h); return
write_bytes(h, PATCH_SITE_VA, ORIG_CALL_BYTES)
print(f" reverted; now: {read_bytes(h, PATCH_SITE_VA, 5).hex()}")
CloseHandle(h); return
if cur != ORIG_CALL_BYTES:
if cur[0] == 0xE8:
print(f" already has a CALL — likely already patched"); CloseHandle(h); sys.exit(3)
print(f" UNEXPECTED — expected {ORIG_CALL_BYTES.hex()}"); CloseHandle(h); sys.exit(4)
thunk_page = VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_page:
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(5)
print(f" thunk page @ 0x{thunk_page:08x}")
thunk = build_thunk(thunk_page)
print(f" thunk bytes: {thunk.hex()}")
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(6)
after_thunk = read_bytes(h, thunk_page, len(thunk))
if after_thunk != thunk:
print(f" thunk MISMATCH"); sys.exit(7)
rel = thunk_page - (PATCH_SITE_VA + 5)
new_call = bytes([0xE8]) + struct.pack("<i", rel)
write_bytes(h, PATCH_SITE_VA, new_call)
after = read_bytes(h, PATCH_SITE_VA, 5)
print(f" patch site now: {after.hex()}")
if after != new_call:
print(f" PATCH MISMATCH"); sys.exit(8)
print(f" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,330 @@
"""patch_v14_cenvcell_clipplane.py <pid> [--revert] [--force]
v14: Plug the CEnvCell::Destroy clip_planes leak.
LEAK
CEnvCell::Destroy (EoR @ 0x0052e5f0) zeroes the inner ClipPlaneList's
cplane_num field but never frees:
* the inner ClipPlaneList (the *(*clip_planes) allocation), or
* the outer DArray<ClipPlaneList*> buffer (the clip_planes alloc).
Every CEnvCell that reaches Destroy() leaks both.
PATCH
Replace the 18-byte leak block at 0x0052e661..0x0052e672 with a
5-byte JMP to a VirtualAllocEx'd thunk that does the real cleanup,
zeroes the field, then resumes at 0x0052e673.
Layout of the leak block (verified against larsson_highleak.dmp):
0052e661 8b 86 dc 00 00 00 mov eax, [esi+0xDC] ; clip_planes
0052e667 3b c3 cmp eax, ebx ; if (clip_planes != 0)
0052e669 74 08 je 0052e673
0052e66b 8b 00 mov eax, [eax] ; inner = *clip_planes
0052e66d 3b c3 cmp eax, ebx ; if (inner != 0)
0052e66f 74 02 je 0052e673
0052e671 89 18 mov [eax], ebx ; inner->cplane_num = 0
THUNK pseudo-asm (pushad-bracketed; ESI is 'this', EBX is 0):
pushad
mov edi, [esi+0xDC]
test edi, edi
jz done
mov ecx, [edi] ; inner
test ecx, ecx
jz free_outer
push ecx
call ClipPlaneList::~ClipPlaneList ; thiscall: ecx=inner
pop ecx
push ecx
call operator delete ; cdecl
add esp, 4
free_outer:
push edi
call operator delete[] ; cdecl
add esp, 4
mov [esi+0xDC], ebx ; NULL the field
done:
popad
jmp resume ; 0x0052e673
SUPPORT ADDRESSES (EoR-verified 2026-05-19 via Ghidra MCP + live bytes)
ClipPlaneList::~ClipPlaneList = 0x0053C760
Layout: `add ecx, 4; jmp DArray<ClipPlane>::~DArray<ClipPlane>`
(3-byte thiscall wrapper; cplane_list inner DArray is at offset +4).
Bytes at VA: 83 c1 04 e9 88 ff ff ff
~DArray<ClipPlane> = 0x0053C6F0 (target of the above jmp)
operator delete = 0x005DF15E (IAT thunk: ff 25 7c 32 79 00)
operator delete[] = 0x005DF164 (IAT thunk: ff 25 34 32 79 00)
2013 PDB drift: PDB symbols at 0x0053ba00 / 0x005de02e / 0x005de034
resolve to completely different EoR code (~0x800 / ~0xF00 RVA drift).
DO NOT trust 2013-PDB addresses for EoR use the constants above.
Default mode is --dry-run. Live application is intentionally a manual
flag so this can't be run by mistake.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
# --- patch site (EoR-verified against larsson_highleak.dmp) ----------
PATCH_SITE_VA = 0x0052E661
RESUME_VA = 0x0052E673
ORIG_BYTES = bytes([
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, # mov eax, [esi+0xDC]
0x3B, 0xC3, # cmp eax, ebx
0x74, 0x08, # je +8
0x8B, 0x00, # mov eax, [eax]
0x3B, 0xC3, # cmp eax, ebx
0x74, 0x02, # je +2
0x89, 0x18, # mov [eax], ebx
])
assert len(ORIG_BYTES) == 18
# --- support addresses (EoR-verified 2026-05-19) ---------------------
# Verified via Ghidra MCP function-name lookup + live byte inspection
# of PID 2324 (one of 15 running EoR clients). See module docstring
# for the byte-pattern evidence.
#
# NOTE: the 2013 PDB addresses (0x0053BA00 / 0x005DE02E / 0x005DE034)
# do NOT map to the same functions in EoR — the binary has drifted by
# roughly 0x800 / 0xF00 bytes at these RVAs. Do not regress to PDB
# symbols without re-verifying live.
CLIPPLANELIST_DTOR_VA = 0x0053C760 # ClipPlaneList::~ClipPlaneList
# (3-byte thiscall thunk:
# add ecx, 4; jmp ~DArray<ClipPlane>)
OPERATOR_DELETE_VA = 0x005DF15E # IAT thunk: ff 25 7c 32 79 00
OPERATOR_DELETE_ARR_VA = 0x005DF164 # IAT thunk: ff 25 34 32 79 00
# --- Win32 plumbing ---------------------------------------------------
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x1000 | 0x2000
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def build_thunk(thunk_va):
"""Assemble the cleanup thunk. Returns raw bytes."""
out = bytearray()
def emit(b): out.extend(b)
def rel32_call(target):
rel = target - (thunk_va + len(out) + 5)
emit(bytes([0xE8]) + struct.pack("<i", rel))
def rel32_jmp(target):
rel = target - (thunk_va + len(out) + 5)
emit(bytes([0xE9]) + struct.pack("<i", rel))
emit(bytes([0x60])) # pushad
emit(bytes([0x8B, 0xBE, 0xDC, 0x00, 0x00, 0x00])) # mov edi, [esi+0xDC]
emit(bytes([0x85, 0xFF])) # test edi, edi
# je done — compute placeholder; patch after
je_done_at = len(out); emit(bytes([0x74, 0x00]))
emit(bytes([0x8B, 0x0F])) # mov ecx, [edi] ; inner
emit(bytes([0x85, 0xC9])) # test ecx, ecx
je_freeouter_at = len(out); emit(bytes([0x74, 0x00]))
emit(bytes([0x51])) # push ecx
rel32_call(CLIPPLANELIST_DTOR_VA) # ~ClipPlaneList (thiscall)
emit(bytes([0x59])) # pop ecx (restore inner for delete)
emit(bytes([0x51])) # push ecx
rel32_call(OPERATOR_DELETE_VA) # operator delete(inner)
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
# patch je_freeouter to here
free_outer_off = len(out)
out[je_freeouter_at + 1] = (free_outer_off - (je_freeouter_at + 2)) & 0xFF
emit(bytes([0x57])) # push edi
rel32_call(OPERATOR_DELETE_ARR_VA) # operator delete[](clip_planes)
emit(bytes([0x83, 0xC4, 0x04])) # add esp, 4
emit(bytes([0x89, 0x9E, 0xDC, 0x00, 0x00, 0x00])) # mov [esi+0xDC], ebx (ebx==0)
# patch je_done to here
done_off = len(out)
out[je_done_at + 1] = (done_off - (je_done_at + 2)) & 0xFF
emit(bytes([0x61])) # popad
rel32_jmp(RESUME_VA) # back to 0x0052e673
return bytes(out)
def sanity_check_support(h):
"""Sanity-check that the support addresses LOOK like the EoR-verified
targets. Refuses to apply if any check fails (caller can pass --force)."""
problems = []
# ~ClipPlaneList: a 3-byte thiscall thunk in EoR:
# 83 c1 04 add ecx, 4
# e9 88 ff ff ff jmp ~DArray<ClipPlane> (= 0x0053C6F0 from 0x0053C763+5)
# We're tolerant about the rel32 (compilers can re-emit) but the
# `add ecx, 4; jmp rel32` shape is a hard signature.
dtor = read_bytes(h, CLIPPLANELIST_DTOR_VA, 8)
if dtor[:4] != bytes([0x83, 0xC1, 0x04, 0xE9]):
problems.append(
f"CLIPPLANELIST_DTOR_VA=0x{CLIPPLANELIST_DTOR_VA:08x} "
f"starts {dtor.hex()} — expected 83 c1 04 e9 (add ecx,4; jmp ...) "
f"i.e. the EoR ~ClipPlaneList thunk."
)
else:
# Verify the relative jump lands at ~DArray<ClipPlane> (0x0053C6F0).
rel = struct.unpack("<i", dtor[4:8])[0]
target = CLIPPLANELIST_DTOR_VA + 8 + rel
if target != 0x0053C6F0:
problems.append(
f"CLIPPLANELIST_DTOR_VA jmp target = 0x{target:08x}, "
f"expected 0x0053C6F0 (~DArray<ClipPlane>)"
)
# operator delete: IAT thunk `ff 25 <abs ptr>`.
od = read_bytes(h, OPERATOR_DELETE_VA, 6)
if od[:2] != bytes([0xFF, 0x25]):
problems.append(
f"OPERATOR_DELETE_VA=0x{OPERATOR_DELETE_VA:08x} starts {od.hex()} "
f"— expected ff 25 ... (IAT jump thunk)"
)
# operator delete[]: IAT thunk `ff 25 <abs ptr>`.
odarr = read_bytes(h, OPERATOR_DELETE_ARR_VA, 6)
if odarr[:2] != bytes([0xFF, 0x25]):
problems.append(
f"OPERATOR_DELETE_ARR_VA=0x{OPERATOR_DELETE_ARR_VA:08x} starts "
f"{odarr.hex()} — expected ff 25 ... (IAT jump thunk)"
)
return problems
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
ap.add_argument("--force", action="store_true",
help="Apply even if support-address sanity check fails")
ap.add_argument("--apply", action="store_true",
help="Actually write the patch. Without this flag the "
"script runs in dry-run mode (default) and never "
"modifies the target process.")
args = ap.parse_args()
# Default mode is dry-run; --apply must be explicit. --revert bypasses.
args.dry_run = not (args.apply or args.revert)
h = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} ({len(ORIG_BYTES)} B): {cur.hex()}")
print(f" expected original : {ORIG_BYTES.hex()}")
if args.revert:
if cur == ORIG_BYTES:
print(f" already original"); CloseHandle(h); return
# Restore the original 18 bytes
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
print(f" reverted; bytes now: {after.hex()}")
if after != ORIG_BYTES:
print(f" REVERT MISMATCH"); CloseHandle(h); sys.exit(7)
CloseHandle(h); return
if cur != ORIG_BYTES:
if cur[0] == 0xE9:
print(f" looks already patched (starts E9 ...); use --revert");
CloseHandle(h); sys.exit(3)
print(f" UNEXPECTED original bytes"); CloseHandle(h); sys.exit(4)
problems = sanity_check_support(h)
if problems:
print(f" support-address sanity check FAILED:")
for p in problems:
print(f" - {p}")
if not args.force:
print(f" refusing to apply without --force")
CloseHandle(h); sys.exit(5)
print(f" --force given; proceeding anyway (RISKY)")
if args.dry_run:
# Render thunk against a notional thunk_va just to print the bytes
thunk_va = 0x10000000 # placeholder for length reporting
thunk = build_thunk(thunk_va)
print(f" dry-run: thunk would be {len(thunk)} bytes "
f"(rel32 targets vary with the allocated page).")
print(f" thunk (at notional 0x{thunk_va:08x}): {thunk.hex()}")
rel = thunk_va - (PATCH_SITE_VA + 5)
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
print(f" patch-site replacement ({len(repl)} B): {repl.hex()}")
print(f" re-run with --apply to actually write.")
CloseHandle(h); return
thunk_page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_page:
print(f" VirtualAllocEx failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(5)
print(f" thunk page @ 0x{thunk_page:08x}")
thunk = build_thunk(thunk_page)
print(f" thunk ({len(thunk)} B): {thunk.hex()}")
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
print(f" write thunk failed err={ctypes.get_last_error()}"); CloseHandle(h); sys.exit(6)
after_thunk = read_bytes(h, thunk_page, len(thunk))
if after_thunk != thunk:
print(f" thunk MISMATCH after write"); CloseHandle(h); sys.exit(7)
# Build replacement: 5-byte JMP + NOP pad to 18 bytes
rel = thunk_page - (PATCH_SITE_VA + 5)
repl = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * (len(ORIG_BYTES) - 5))
assert len(repl) == len(ORIG_BYTES)
print(f" writing patch ({len(repl)} B): {repl.hex()}")
write_bytes(h, PATCH_SITE_VA, repl)
after = read_bytes(h, PATCH_SITE_VA, len(ORIG_BYTES))
if after != repl:
print(f" PATCH MISMATCH"); CloseHandle(h); sys.exit(8)
print(f" OK — clip_planes leak plugged at 0x{PATCH_SITE_VA:08x}")
CloseHandle(h)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,237 @@
"""patch_v14_position_alloc_trace.py <pid> [--revert]
DIAGNOSTIC ONLY does not modify program behavior.
Goal: find the call sites that produce leaked Position instances.
Approach: Install a counter-increment thunk at the start of each
Position::Position ctor variant. The thunk:
- increments a per-ctor 32-bit counter in a runtime-allocated
counter block
- then falls through to the original prologue and the rest of
the ctor
Counter block is queried by `--read`. No game state is modified.
CTORS (EoR addresses, derived from 2013):
- Position::Position() 2013 0x00424ab0 EoR TBD
- Position::Position(uint, Frame*) 2013 0x00452780 EoR TBD
- Position::Position(Position*) 2013 0x004529a0 EoR TBD
The EoR offsets are NOT yet known precisely this patcher is a
SKELETON. Before running, the analyst must:
1. Find Position::Position in EoR via reference from
`vtable = 0x00797910` writes (cdb: `s -d acclient_base
L?<size> 00797910`)
2. Fill in CTOR_ADDRS below with the three EoR addresses.
3. Verify each address starts with `mov eax, [esp+4]` or similar
stack-arg setup; check that 5 bytes of prologue fits a relative
`call <thunk>` (e.g. `e8 XX XX XX XX`).
After verifying live counts are non-zero and growing, leave the
patcher in place for 1 hour and dump counters via `--read`. The
ratio of count_a / count_b / count_c identifies the dominant call
path.
SAFETY: the thunks preserve all registers (push eax/pushfd) before
incrementing, restore after. They do NOT change any game state.
Even if the analyst applies the wrong ctor address, the worst case
is a stale call (caller crashes immediately, easy to diagnose) no
delayed-AV class of failure.
DO NOT apply this patcher in production. It is for read-only
behavioral diagnosis only. Apply on ONE non-essential PID (e.g.
Time, the idle character) and observe for 30 minutes before
inferring rate.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import sys
# --- CONFIG (FILL IN BEFORE USE) -----
# These are placeholders — verify against EoR binary before applying.
# In 2013 these were 0x00424ab0, 0x00452780, 0x004529a0. The EoR
# offset relative to 2013 varies per class; do NOT assume +0x1000.
CTOR_ADDRS = {
"default": 0x00000000, # FILL IN — 2013 0x00424ab0
"uint_fp": 0x00000000, # FILL IN — 2013 0x00452780
"copy": 0x00000000, # FILL IN — 2013 0x004529a0
}
VERIFY_VT_ADDR = 0x00797910 # Position vtable; should match
# `mov dword ptr [ecx], 00797910h`
# in each ctor prologue.
# --- Win32 surface --------------------
PROCESS_ALL_ACCESS = 0x1F0FFF
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
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.WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID,
ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.WriteProcessMemory.restype = wt.BOOL
k.VirtualAllocEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD, wt.DWORD]
k.VirtualAllocEx.restype = wt.LPVOID
k.VirtualFreeEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t,
wt.DWORD]
k.VirtualFreeEx.restype = wt.BOOL
def rpm(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def wpm(h, addr, data):
buf = (ctypes.c_ubyte * len(data))(*data)
sz = ctypes.c_size_t(0)
return bool(k.WriteProcessMemory(h, addr, buf, len(data),
ctypes.byref(sz)))
def verify_ctor_at(h, addr):
"""Confirm the ctor at `addr` is a Position ctor by looking for
a write of vt 0x00797910 in the first 32 bytes."""
code = rpm(h, addr, 32)
if not code:
return False
target = struct.pack("<I", VERIFY_VT_ADDR)
return target in code
def apply(pid):
if 0 in CTOR_ADDRS.values():
print("ERROR: CTOR_ADDRS not filled in. Edit the constants at "
"the top of this script first.")
return 1
h = k.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not h:
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
return 1
try:
# Verify each ctor address writes the Position vt
for label, addr in CTOR_ADDRS.items():
if not verify_ctor_at(h, addr):
print(f"ERROR: addr 0x{addr:08x} ({label}) does not "
f"reference Position vt 0x{VERIFY_VT_ADDR:08x}. "
"Refusing to patch.")
return 2
# Allocate counter block + thunk region
block = k.VirtualAllocEx(h, None, 0x1000,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE)
if not block:
print("VirtualAllocEx failed")
return 3
block = int(block)
print(f"counter+thunk block @ 0x{block:08x}")
# Layout:
# block + 0x000 counter A (default ctor)
# block + 0x004 counter B (uint+frame ctor)
# block + 0x008 counter C (copy ctor)
# block + 0x100 thunk A
# block + 0x120 thunk B
# block + 0x140 thunk C
# Each thunk:
# pushfd; push eax
# mov eax, &counter
# lock inc [eax]
# pop eax; popfd
# <copy of overwritten prologue bytes>
# jmp <ctor + 5>
labels = list(CTOR_ADDRS.keys())
for i, label in enumerate(labels):
ctor_addr = CTOR_ADDRS[label]
counter_addr = block + i * 4
thunk_addr = block + 0x100 + i * 0x20
# Read 5 prologue bytes (the bytes we'll relocate)
prologue = rpm(h, ctor_addr, 5)
if not prologue or len(prologue) != 5:
print(f"failed to read prologue at 0x{ctor_addr:08x}")
return 4
# Build thunk
thunk = bytearray()
thunk += b"\x9c" # pushfd
thunk += b"\x50" # push eax
thunk += b"\xb8" + struct.pack("<I", counter_addr) # mov eax, &counter
thunk += b"\xf0\xff\x00" # lock inc dword [eax]
thunk += b"\x58" # pop eax
thunk += b"\x9d" # popfd
thunk += prologue # original 5 bytes
# Jump back to ctor+5
return_to = ctor_addr + 5
rel = return_to - (thunk_addr + len(thunk) + 5)
thunk += b"\xe9" + struct.pack("<i", rel) # jmp rel32
wpm(h, thunk_addr, bytes(thunk))
# Write `e9 rel32` (call would be cleaner; use jmp since
# we replicate prologue inside thunk)
patch_rel = thunk_addr - (ctor_addr + 5)
patch = b"\xe9" + struct.pack("<i", patch_rel)
wpm(h, ctor_addr, patch)
print(f" patched {label} at 0x{ctor_addr:08x}"
f"thunk 0x{thunk_addr:08x}, counter @ 0x{counter_addr:08x}")
print(f"\nCounters at 0x{block:08x}. Read with `--read 0x{block:08x}`.")
return 0
finally:
k.CloseHandle(h)
def read_counters(pid, block):
h = k.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not h:
print("OpenProcess failed")
return 1
try:
data = rpm(h, block, 16)
if not data:
print("read failed")
return 1
a, b, c, _ = struct.unpack("<IIII", data)
print(f" default ctor: {a:>10}")
print(f" uint+frame ctor: {b:>10}")
print(f" copy ctor: {c:>10}")
print(f" total: {a+b+c:>10}")
return 0
finally:
k.CloseHandle(h)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--read", type=lambda x: int(x, 0),
help="read counters at this address")
ap.add_argument("--revert", action="store_true",
help="NOT IMPLEMENTED - this is a one-shot "
"diagnostic; restart client to revert")
args = ap.parse_args()
if args.read:
return read_counters(args.pid, args.read)
if args.revert:
print("revert not implemented — kill client to undo")
return 1
return apply(args.pid)
if __name__ == "__main__":
sys.exit(main())

143
tools/patch_v6_test.py Normal file
View file

@ -0,0 +1,143 @@
"""patch_v6_test.py <pid> [--revert]
EXPERIMENTAL v6: NOP the IsVisible guard in
UIElement_ItemList::UpdateEmptySlots at EoR 0x004e4390.
Mechanism:
The function starts:
sub esp, 0x10
push esi
mov esi, ecx ; this = ecx
call IsVisible ; al
test al, al
jz end_of_function ; <-- 6 bytes at 0x004e439d, NOP these
...
Replacing the 6-byte `0F 84 F3 01 00 00` with six NOPs makes the
function continue regardless of visibility. The trim loop inside
then runs and deletes WAITING-state items.
Why this fixes the leak:
Every container open/close triggers ItemList_Flush, which calls
Clear_UIItem + SetState(WAITING) on every cell, then calls
UpdateEmptySlots. UpdateEmptySlots currently bails when invisible
(which is exactly when Flush is called on close). NOPing the
visibility check lets the trim loop run, calling InternalDeleteItem
on each WAITING cell.
Risks:
After visibility-check NOP, the function still has a second guard
(GetAttribute_Int(0x10000015) == -1). If GetAttribute misbehaves
on invisible widgets, behavior could be unexpected. For inventory
lists this should be fine (the attribute is set at ctor).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
PATCH_SITE_VA = 0x004e439d
ORIGINAL_BYTES = bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]) # jz +0x1f3
PATCHED_BYTES = bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90]) # 6x nop
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
PAGE_READWRITE = 0x04
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore original JZ instruction")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, 6)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIGINAL_BYTES:
print(" already original — nothing to revert")
CloseHandle(h); return
if cur != PATCHED_BYTES:
print(f" UNEXPECTED — current bytes don't match either original or patched")
print(f" expected original {ORIGINAL_BYTES.hex()} or patched {PATCHED_BYTES.hex()}")
CloseHandle(h); sys.exit(3)
write_bytes(h, PATCH_SITE_VA, ORIGINAL_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 6)
print(f" reverted; bytes now: {after.hex()}")
CloseHandle(h); return
if cur == PATCHED_BYTES:
print(" already patched — nothing to do")
CloseHandle(h); return
if cur != ORIGINAL_BYTES:
print(f" UNEXPECTED — current bytes {cur.hex()} don't match expected original {ORIGINAL_BYTES.hex()}")
CloseHandle(h); sys.exit(4)
write_bytes(h, PATCH_SITE_VA, PATCHED_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 6)
print(f" patched; bytes now: {after.hex()}")
if after != PATCHED_BYTES:
print(" MISMATCH — write didn't take")
CloseHandle(h); sys.exit(5)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

149
tools/patch_v7_test.py Normal file
View file

@ -0,0 +1,149 @@
"""patch_v7_test.py <pid> [--revert]
EXPERIMENTAL v7: NOP BOTH guards in UIElement_ItemList::UpdateEmptySlots
to force the trim logic to run regardless of:
1. visibility (jz at 0x004e439d same NOP as v6)
2. auto-fit mode (jne at 0x004e43bd NEW for v7)
Function prologue (EoR):
0x004e4390: sub esp, 0x10
0x004e4393: push esi
0x004e4394: mov esi, ecx ; this = ecx
0x004e4396: call IsVisible
0x004e439b: test al, al
0x004e439d: jz +0x1f3 ; <-- NOP (v6/v7 site 1)
0x004e43a3: push ebx
0x004e43a4: push ebp
0x004e43a5: push edi
0x004e43a6: lea eax, [esp+0x14]
0x004e43aa: push eax
0x004e43ab: push 0x10000015
0x004e43b0: mov ecx, esi
0x004e43b2: call GetAttribute_Int
0x004e43b7: mov eax, [esp+0x14]
0x004e43bb: or edi, -1 ; edi = 0xFFFFFFFF (= -1)
0x004e43be: cmp eax, edi
0x004e43bd: jne +0x1cd ; <-- NOP (v7 site 2)
...
After v7 both guards are eliminated. The function runs unconditionally.
The trim loop inside then runs and deletes WAITING-state items at the
end of the array.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
SITE_VIS_VA = 0x004e439d
SITE_VIS_ORIG = bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]) # jz +0x1f3
SITE_VIS_NOP = bytes([0x90] * 6)
SITE_AF_VA = 0x004e43c0
SITE_AF_ORIG = bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]) # jne +0x1cd
SITE_AF_NOP = bytes([0x90] * 6)
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def apply_or_revert(h, site_va, orig, patched, label, revert):
cur = read_bytes(h, site_va, 6)
print(f" {label} @ 0x{site_va:08x}: current {cur.hex()}")
if revert:
if cur == orig:
print(f" already original")
return
if cur != patched:
print(f" UNEXPECTED — neither orig nor patched. refusing.")
sys.exit(3)
write_bytes(h, site_va, orig)
after = read_bytes(h, site_va, 6)
print(f" reverted; bytes now: {after.hex()}")
return
if cur == patched:
print(f" already patched")
return
if cur != orig:
print(f" UNEXPECTED — bytes don't match expected original. refusing.")
sys.exit(4)
write_bytes(h, site_va, patched)
after = read_bytes(h, site_va, 6)
print(f" patched; bytes now: {after.hex()}")
if after != patched:
print(f" MISMATCH — write didn't take")
sys.exit(5)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true",
help="restore original instructions at both sites")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
sys.exit(2)
print(f"PID {args.pid}")
apply_or_revert(h, SITE_VIS_VA, SITE_VIS_ORIG, SITE_VIS_NOP,
"visibility guard (jz)", args.revert)
apply_or_revert(h, SITE_AF_VA, SITE_AF_ORIG, SITE_AF_NOP,
"auto-fit guard (jne) ", args.revert)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

156
tools/patch_v8_test.py Normal file
View file

@ -0,0 +1,156 @@
"""patch_v8_test.py <pid> [--revert]
EXPERIMENTAL v8: comprehensive fix for UIElement_UIItem leak.
Combines v7's two outer NOPs (so UpdateEmptySlots runs always)
with a 1-byte change to the inner trim loop's break-3 (so the loop
skips non-WAITING items instead of exiting at the first one).
Sites:
Site 1 0x004e439d (v6/v7): NOP visibility guard
Original: 0F 84 F3 01 00 00 jz +0x1f3
Patched: 90 90 90 90 90 90
Site 2 0x004e43c0 (v7): NOP auto-fit guard
Original: 0F 85 CD 01 00 00 jne +0x1cd
Patched: 90 90 90 90 90 90
Site 3 0x004e4496 (v8 new): turn "break" into "continue"
Original: 75 0D jne +0x0d exit_loop
Patched: 75 08 jne +0x08 inc_counter (continue)
After site 3, when an item's state != WAITING, the loop skips the
InternalDeleteItem call and advances the counter instead of exiting.
Combined with sites 1 and 2, UpdateEmptySlots now runs always and
iterates the entire array, calling InternalDeleteItem on every WAITING
UIItem.
Safety considerations:
- Break 1 (NULL item) and break 2 (not UIItem type) are KEPT they
are real safety guards.
- The counter (ebx) increments for skipped items too, so the loop
may exit earlier than ideal if many items are non-WAITING. But
with width=0 (invisible) the delete_count is ~count+1 so most
items should still be visited.
- InternalDeleteItem defers actual deletion to a queue, so even if
we mistakenly delete an active item, the engine should handle it
on the next frame.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
SITES = [
("visibility guard (jz) ", 0x004e439d,
bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]),
bytes([0x90] * 6)),
("auto-fit guard (jne) ", 0x004e43c0,
bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]),
bytes([0x90] * 6)),
("trim break-3 (jne) ", 0x004e4496,
bytes([0x75, 0x0d]),
bytes([0x75, 0x08])),
]
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def apply_or_revert(h, label, site_va, orig, patched, revert):
cur = read_bytes(h, site_va, len(orig))
print(f" {label} @ 0x{site_va:08x}: current {cur.hex()}")
if revert:
if cur == orig:
print(f" already original")
return
if cur != patched:
print(f" UNEXPECTED — refusing")
sys.exit(3)
write_bytes(h, site_va, orig)
after = read_bytes(h, site_va, len(orig))
print(f" reverted; now: {after.hex()}")
return
if cur == patched:
print(f" already patched")
return
if cur != orig:
print(f" UNEXPECTED — bytes don't match expected original. refusing.")
sys.exit(4)
write_bytes(h, site_va, patched)
after = read_bytes(h, site_va, len(orig))
print(f" patched; now: {after.hex()}")
if after != patched:
print(f" MISMATCH — write didn't take")
sys.exit(5)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}")
sys.exit(2)
print(f"PID {args.pid}")
for label, va, orig, patched in SITES:
apply_or_revert(h, label, va, orig, patched, args.revert)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

274
tools/patch_v8_thunk.py Normal file
View file

@ -0,0 +1,274 @@
"""patch_v8_thunk.py <pid> [--revert]
EXPERIMENTAL v8-thunk: actively drain UIElement_UIItem pool by hooking
the tail-call JMP at end of UIElement_ItemList::ItemList_Flush.
Mechanism:
ItemList_Flush ends with `JMP UIElement_ItemList::UpdateEmptySlots`
at EoR 0x004e4a87 (5 bytes: E9 04 F9 FF FF).
Replace with `JMP <thunk>`. Thunk walks the array backward, calls
InternalDeleteItem on every WAITING UIItem, then tail-calls
UpdateEmptySlots so resize behavior is preserved.
This is the v8-minimal followup. Where v8-minimal (3 byte changes)
prevented NEW leaks, v8-thunk actively drains pre-existing leaks too.
Thunk (86 bytes, position-independent absolute calls):
push ebp; push edi; push esi; push ebx
mov ebx, ecx ; ebx = this
mov esi, [ebx+0x610] ; count
dec esi ; idx = count-1
loop_top:
test esi, esi
js loop_done
push esi
mov ecx, ebx
call UIElement_ListBox::GetItem (0x0046dc50)
test eax, eax
jz skip_item
mov edi, eax
mov eax, [edi] ; vtable
push 0x10000032
mov ecx, edi
call [eax+0x94] ; vtable[37] type check
test eax, eax
jz skip_item
mov ecx, edi
call UIItem_GetState (0x004e1e20)
cmp eax, 0x1000001c
jne skip_item
push edi
mov ecx, ebx
call InternalDeleteItem (0x004e41c0)
skip_item:
dec esi
jmp loop_top
loop_done:
mov ecx, ebx
pop ebx; pop esi; pop edi; pop ebp
jmp UpdateEmptySlots (0x004e4390)
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import sys
PATCH_SITE_VA = 0x004e4a87
ORIG_JMP_BYTES = bytes([0xE9, 0x04, 0xF9, 0xFF, 0xFF]) # JMP UpdateEmptySlots (relative)
GETITEM_VA = 0x0046dc50
GETSTATE_VA = 0x004e1e20
INTDELETE_VA = 0x004e41c0
UPDATEEMPTYSLOTS_VA = 0x004e4390
def build_thunk(thunk_base: int) -> bytes:
"""Build the 86-byte thunk for placement at `thunk_base`."""
out = bytearray()
# Prologue
out += bytes([0x55]) # push ebp
out += bytes([0x57]) # push edi
out += bytes([0x56]) # push esi
out += bytes([0x53]) # push ebx
out += bytes([0x8B, 0xD9]) # mov ebx, ecx
out += bytes([0x8B, 0xB3, 0x10, 0x06, 0x00, 0x00]) # mov esi, [ebx+0x610]
out += bytes([0x4E]) # dec esi
loop_top_off = len(out) # 13
out += bytes([0x85, 0xF6]) # test esi, esi
# js loop_done — placeholder, fill rel8 at end
js_loopdone_off = len(out)
out += bytes([0x78, 0x00]) # js +0 (patch)
out += bytes([0x56]) # push esi
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
# call GetItem (E8 rel32)
call_getitem_off = len(out)
out += bytes([0xE8, 0, 0, 0, 0])
out += bytes([0x85, 0xC0]) # test eax, eax
jz_skip1_off = len(out)
out += bytes([0x74, 0x00]) # jz skip_item
out += bytes([0x8B, 0xF8]) # mov edi, eax
out += bytes([0x8B, 0x07]) # mov eax, [edi]
out += bytes([0x68, 0x32, 0x00, 0x00, 0x10]) # push 0x10000032
out += bytes([0x8B, 0xCF]) # mov ecx, edi
out += bytes([0xFF, 0x90, 0x94, 0x00, 0x00, 0x00]) # call dword [eax+0x94]
out += bytes([0x85, 0xC0]) # test eax, eax
jz_skip2_off = len(out)
out += bytes([0x74, 0x00]) # jz skip_item
out += bytes([0x8B, 0xCF]) # mov ecx, edi
# call GetState
call_getstate_off = len(out)
out += bytes([0xE8, 0, 0, 0, 0])
out += bytes([0x3D, 0x1C, 0x00, 0x00, 0x10]) # cmp eax, 0x1000001c
jne_skip_off = len(out)
out += bytes([0x75, 0x00]) # jne skip_item
out += bytes([0x57]) # push edi
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
# call InternalDeleteItem
call_intdel_off = len(out)
out += bytes([0xE8, 0, 0, 0, 0])
skip_item_off = len(out)
out += bytes([0x4E]) # dec esi
jmp_top_off = len(out)
out += bytes([0xEB, 0x00]) # jmp loop_top
loop_done_off = len(out)
out += bytes([0x8B, 0xCB]) # mov ecx, ebx
out += bytes([0x5B]) # pop ebx
out += bytes([0x5E]) # pop esi
out += bytes([0x5F]) # pop edi
out += bytes([0x5D]) # pop ebp
# jmp UpdateEmptySlots
jmp_upd_off = len(out)
out += bytes([0xE9, 0, 0, 0, 0])
# Now patch the relative offsets
def patch_rel8(at, target):
rel = target - (at + 2)
assert -128 <= rel <= 127, f"rel8 overflow: {rel}"
out[at + 1] = rel & 0xFF
def patch_rel32(at, target_va):
# at is the offset of the E8/E9 byte; rel32 is at at+1..at+4
site = thunk_base + at + 5
rel = target_va - site
out[at + 1:at + 5] = struct.pack("<i", rel)
patch_rel8(js_loopdone_off, loop_done_off)
patch_rel8(jz_skip1_off, skip_item_off)
patch_rel8(jz_skip2_off, skip_item_off)
patch_rel8(jne_skip_off, skip_item_off)
patch_rel8(jmp_top_off, loop_top_off)
patch_rel32(call_getitem_off, GETITEM_VA)
patch_rel32(call_getstate_off, GETSTATE_VA)
patch_rel32(call_intdel_off, INTDELETE_VA)
patch_rel32(jmp_upd_off, UPDATEEMPTYSLOTS_VA)
return bytes(out)
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, 5)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIG_JMP_BYTES:
print(f" already original — nothing to revert")
CloseHandle(h); return
# Restore original JMP UpdateEmptySlots
write_bytes(h, PATCH_SITE_VA, ORIG_JMP_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 5)
print(f" reverted; bytes now: {after.hex()}")
CloseHandle(h); return
if cur != ORIG_JMP_BYTES:
if cur[0] == 0xE9:
print(f" already has a JMP somewhere — maybe already patched. Refusing to re-patch.")
else:
print(f" UNEXPECTED — bytes don't match expected JMP. Refusing.")
CloseHandle(h); sys.exit(3)
# Allocate thunk page
thunk_page = VirtualAllocEx(h, None, 0x100, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_page:
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(4)
print(f" thunk page @ 0x{thunk_page:08x}")
thunk = build_thunk(thunk_page)
print(f" thunk size: {len(thunk)} bytes")
print(f" thunk hex: {thunk.hex()}")
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(5)
# Build the JMP to thunk at the patch site
rel = thunk_page - (PATCH_SITE_VA + 5)
new_jmp = bytes([0xE9]) + struct.pack("<i", rel)
write_bytes(h, PATCH_SITE_VA, new_jmp)
after = read_bytes(h, PATCH_SITE_VA, 5)
print(f" patch site now: {after.hex()} (expected {new_jmp.hex()})")
if after != new_jmp:
print(f" MISMATCH"); sys.exit(6)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

288
tools/patch_v8_thunk_v2.py Normal file
View file

@ -0,0 +1,288 @@
"""patch_v8_thunk_v2.py <pid> [--revert]
v8-thunk-v2: SAFE drain of UIElement_UIItem leaked pool.
v1 (the broken one) hooked Flush drain ran during normal panel
refresh and ate items needed for display.
v2 hooks OnVisibilityChanged (vis=false branch) instead drain only
when the panel becomes hidden, NOT during Flush.
Mechanism:
At 0x004e499e, OnVisibilityChanged has 24 bytes implementing:
if ((this->+0x554 >> 0x11 & 1) && vis != 0) UpdateEmptySlots()
epilogue at 0x004e49b6: pop esi; pop ebx; ret 4
We replace those 24 bytes with `JMP <thunk>` (5 bytes) + 19 NOPs.
Thunk reproduces the original logic AND adds a drain path:
if (!(this->+0x554 >> 0x11 & 1)) return
if (vis != 0) UpdateEmptySlots() ; stock visible path
else ; vis == false: drain (NEW)
for up to 8 iterations:
idx = this->+0x610 - 1
if idx < 0: break
item_array = GetItem(this, idx)
if item_array == NULL: break ; (no more items)
real_item = item_array->vtable[37](0x10000032) ; type check
if real_item == 0: break ; (not a UIItem at end)
state = real_item->UIItem_GetState()
if state != WAITING: break ; (active item at end, stop)
InternalDeleteItem(this, real_item)
Safety:
- Only drains on vis=false (panel actually going hidden)
- Cap of 8 items per hide event (prevents catastrophic burst)
- Stops at first non-WAITING item at end (mimics UpdateEmptySlots's
own trim behavior never deletes active items)
- Preserves the original visible-true behavior exactly
Notable correctness:
- InternalDeleteItem is called with the result of vtable[37], NOT
the raw item from the array (mirrors UpdateEmptySlots's pattern)
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import sys
PATCH_SITE_VA = 0x004e499e
PATCH_LEN = 24
# Original 24 bytes (will verify before patching):
ORIG_BYTES = bytes([
0x8B, 0x86, 0x54, 0x05, 0x00, 0x00, # mov eax, [esi+0x554]
0xC1, 0xE8, 0x11, # shr eax, 0x11
0xA8, 0x01, # test al, 1
0x74, 0x0B, # jz +0x0B (to 0x004e49b6)
0x84, 0xDB, # test bl, bl
0x74, 0x07, # jz +0x07
0x8B, 0xCE, # mov ecx, esi
0xE8, 0xDA, 0xF9, 0xFF, 0xFF, # call UpdateEmptySlots
])
assert len(ORIG_BYTES) == 24
GETITEM_VA = 0x0046dc50
GETSTATE_VA = 0x004e1e20
INTDELETE_VA = 0x004e41c0
UPDATEEMPTYSLOTS_VA = 0x004e4390
def build_thunk(base: int) -> bytes:
"""Build the v8-thunk-v2 at absolute address `base`."""
out = bytearray()
refs = {} # symbolic name -> (offset_of_rel_byte, target_va, size_of_call)
# ------ prologue ------
out += bytes([0x57]) # push edi (save caller's edi)
out += bytes([0x8B, 0x86, 0x54, 0x05, 0x00, 0x00]) # mov eax, [esi+0x554]
out += bytes([0xC1, 0xE8, 0x11]) # shr eax, 0x11
out += bytes([0xA8, 0x01]) # test al, 1
jz_epi_1 = len(out)
out += bytes([0x74, 0x00]) # jz .epi (patch)
out += bytes([0x84, 0xDB]) # test bl, bl
jnz_visible = len(out)
out += bytes([0x75, 0x00]) # jnz .visible (patch)
out += bytes([0xBF, 0x08, 0x00, 0x00, 0x00]) # mov edi, 8 (cap)
# ------ loop ------
loop_top = len(out)
out += bytes([0x8B, 0x86, 0x10, 0x06, 0x00, 0x00]) # mov eax, [esi+0x610]
out += bytes([0x48]) # dec eax
js_epi_1 = len(out)
out += bytes([0x78, 0x00]) # js .epi (patch)
out += bytes([0x50]) # push eax (idx)
out += bytes([0x8B, 0xCE]) # mov ecx, esi
call_getitem = len(out)
out += bytes([0xE8, 0, 0, 0, 0]) # call GetItem
out += bytes([0x85, 0xC0]) # test eax, eax
jz_epi_2 = len(out)
out += bytes([0x74, 0x00]) # jz .epi
out += bytes([0x68, 0x32, 0x00, 0x00, 0x10]) # push 0x10000032
out += bytes([0x8B, 0xC8]) # mov ecx, eax
out += bytes([0x8B, 0x10]) # mov edx, [eax]
out += bytes([0xFF, 0x92, 0x94, 0x00, 0x00, 0x00]) # call [edx+0x94]
out += bytes([0x85, 0xC0]) # test eax, eax
jz_epi_3 = len(out)
out += bytes([0x74, 0x00]) # jz .epi
out += bytes([0x50]) # push eax (save real_item)
out += bytes([0x8B, 0xC8]) # mov ecx, eax
call_getstate = len(out)
out += bytes([0xE8, 0, 0, 0, 0]) # call GetState
out += bytes([0x59]) # pop ecx (ecx = real_item)
out += bytes([0x3D, 0x1C, 0x00, 0x00, 0x10]) # cmp eax, 0x1000001c
jne_epi = len(out)
out += bytes([0x75, 0x00]) # jne .epi
out += bytes([0x51]) # push ecx (arg = real_item)
out += bytes([0x8B, 0xCE]) # mov ecx, esi
call_intdel = len(out)
out += bytes([0xE8, 0, 0, 0, 0]) # call InternalDeleteItem
out += bytes([0x4F]) # dec edi (cap--)
jnz_loop = len(out)
out += bytes([0x75, 0x00]) # jnz loop_top (patch)
jmp_epi = len(out)
out += bytes([0xEB, 0x00]) # jmp .epi
# ------ visible path ------
visible_label = len(out)
out += bytes([0x8B, 0xCE]) # mov ecx, esi
call_updemp = len(out)
out += bytes([0xE8, 0, 0, 0, 0]) # call UpdateEmptySlots
# ------ epilogue ------
epi_label = len(out)
out += bytes([0x5F]) # pop edi
out += bytes([0x5E]) # pop esi
out += bytes([0x5B]) # pop ebx
out += bytes([0xC2, 0x04, 0x00]) # ret 4
# ----- Resolve relative jumps and calls -----
def patch_rel8(at, target_off):
rel = target_off - (at + 2)
assert -128 <= rel <= 127, f"rel8 overflow {rel}"
out[at + 1] = rel & 0xFF
def patch_rel32_call(at, target_va):
# E8 at offset `at` ... rel32 at at+1..at+4. Next-instr addr = base+at+5.
rel = target_va - (base + at + 5)
out[at + 1:at + 5] = struct.pack("<i", rel)
patch_rel8(jz_epi_1, epi_label)
patch_rel8(jnz_visible, visible_label)
patch_rel8(js_epi_1, epi_label)
patch_rel8(jz_epi_2, epi_label)
patch_rel8(jz_epi_3, epi_label)
patch_rel8(jne_epi, epi_label)
patch_rel8(jnz_loop, loop_top)
patch_rel8(jmp_epi, epi_label)
patch_rel32_call(call_getitem, GETITEM_VA)
patch_rel32_call(call_getstate, GETSTATE_VA)
patch_rel32_call(call_intdel, INTDELETE_VA)
patch_rel32_call(call_updemp, UPDATEEMPTYSLOTS_VA)
return bytes(out)
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
VirtualAllocEx = k32.VirtualAllocEx
VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD]
VirtualAllocEx.restype = wt.LPVOID
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIG_BYTES:
print(f" already original — nothing to revert")
CloseHandle(h); return
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
after = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
print(f" reverted; bytes now: {after.hex()}")
CloseHandle(h); return
if cur != ORIG_BYTES:
print(f" UNEXPECTED — bytes don't match expected original.")
print(f" Expected: {ORIG_BYTES.hex()}")
CloseHandle(h); sys.exit(3)
# Allocate thunk
thunk_page = VirtualAllocEx(h, None, 0x200, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)
if not thunk_page:
print(f"VirtualAllocEx failed err={ctypes.get_last_error()}"); sys.exit(4)
print(f" thunk page @ 0x{thunk_page:08x}")
thunk = build_thunk(thunk_page)
print(f" thunk size: {len(thunk)} bytes")
print(f" thunk hex:")
for i in range(0, len(thunk), 16):
row = thunk[i:i+16].hex(' ')
print(f" +0x{i:02x}: {row}")
sz = ctypes.c_size_t(0)
if not WriteProcessMemory(h, thunk_page, thunk, len(thunk), ctypes.byref(sz)):
print(f"write thunk failed err={ctypes.get_last_error()}"); sys.exit(5)
# Build replacement: JMP thunk + 19 NOPs
rel = thunk_page - (PATCH_SITE_VA + 5)
replacement = bytes([0xE9]) + struct.pack("<i", rel) + bytes([0x90] * 19)
assert len(replacement) == PATCH_LEN
write_bytes(h, PATCH_SITE_VA, replacement)
after = read_bytes(h, PATCH_SITE_VA, PATCH_LEN)
print(f" patch site now: {after.hex()}")
if after != replacement:
print(f" MISMATCH"); sys.exit(6)
print(" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

173
tools/patch_v9_test.py Normal file
View file

@ -0,0 +1,173 @@
"""patch_v9_test.py <pid> [--revert]
v9: hook CPhysicsObj::Destroy to call unparent_children(this) first.
Mechanism:
Insert a 5-byte JMP at CPhysicsObj::Destroy entry (0x005145D0) that
redirects to a 17-byte thunk in the .text cave at 0x00792CE0.
The thunk:
1. Saves this (ecx)
2. Calls CPhysicsObj::unparent_children(this) at 0x00513FE0
3. Restores this
4. Re-executes the 5 displaced bytes (push ebx; push esi; mov esi,ecx; push edi)
5. JMPs back to 0x005145D5 (continuation of Destroy)
This fixes the orphan-children leak: parents being destroyed without
calling unparent_children leave children with parent pointers to freed
memory, which causes UAF crashes in CObjectMaint cleanup chains.
Risk:
- unparent_children is idempotent (safe to call multiple times)
- Both patch site and thunk are in .text
- Single chokepoint: only ~CPhysicsObj reaches Destroy
- Stack alignment preserved (push ecx + pop ecx)
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import sys
PATCH_SITE_VA = 0x005145D0
THUNK_VA = 0x00792CE0
UNPARENT_VA = 0x00513FE0
RESUME_VA = 0x005145D5
ORIG_BYTES = bytes([0x53, 0x56, 0x8B, 0xF1, 0x57]) # push ebx; push esi; mov esi,ecx; push edi
# Replacement is computed at runtime (E9 + rel32 to thunk)
def build_thunk(thunk_base: int) -> bytes:
"""17 bytes: save ecx, call unparent_children, restore, displaced, jmp back."""
out = bytearray()
out += bytes([0x51]) # push ecx
# call rel32 unparent_children
rel_call = UNPARENT_VA - (thunk_base + len(out) + 5)
out += bytes([0xE8]) + struct.pack("<i", rel_call)
out += bytes([0x59]) # pop ecx
# Displaced prologue bytes (5 bytes)
out += ORIG_BYTES # push ebx; push esi; mov esi,ecx; push edi
# jmp rel32 back to RESUME_VA
rel_jmp = RESUME_VA - (thunk_base + len(out) + 5)
out += bytes([0xE9]) + struct.pack("<i", rel_jmp)
return bytes(out)
PROCESS_VM_READ = 0x0010
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_OPERATION = 0x0008
PROCESS_QUERY_INFORMATION = 0x0400
PAGE_EXECUTE_READWRITE = 0x40
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
WriteProcessMemory = k32.WriteProcessMemory
WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
WriteProcessMemory.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualProtectEx = k32.VirtualProtectEx
VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD,
ctypes.POINTER(wt.DWORD)]
VirtualProtectEx.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
raise OSError(f"read 0x{addr:08x} err={ctypes.get_last_error()}")
return bytes(buf[:sz.value])
def write_bytes(h, addr, data):
old_prot = wt.DWORD(0)
if not VirtualProtectEx(h, addr, len(data), PAGE_EXECUTE_READWRITE, ctypes.byref(old_prot)):
raise OSError(f"VirtualProtectEx 0x{addr:08x} err={ctypes.get_last_error()}")
sz = ctypes.c_size_t(0)
ok = WriteProcessMemory(h, addr, data, len(data), ctypes.byref(sz))
err = ctypes.get_last_error() if not ok else 0
restored = wt.DWORD(0)
VirtualProtectEx(h, addr, len(data), old_prot.value, ctypes.byref(restored))
if not ok:
raise OSError(f"write 0x{addr:08x} err={err}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("--revert", action="store_true")
args = ap.parse_args()
h = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
False, args.pid,
)
if not h:
print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2)
cur = read_bytes(h, PATCH_SITE_VA, 5)
print(f"PID {args.pid}")
print(f" patch site @ 0x{PATCH_SITE_VA:08x} current: {cur.hex()}")
if args.revert:
if cur == ORIG_BYTES:
print(f" already original")
else:
# Restore the original 5 bytes
write_bytes(h, PATCH_SITE_VA, ORIG_BYTES)
after = read_bytes(h, PATCH_SITE_VA, 5)
print(f" reverted; bytes now: {after.hex()}")
CloseHandle(h); return
if cur != ORIG_BYTES:
print(f" UNEXPECTED — bytes don't match expected original {ORIG_BYTES.hex()}")
CloseHandle(h); sys.exit(3)
# Verify cave is clean (zero-filled or at least not in-use)
cave_cur = read_bytes(h, THUNK_VA, 32)
print(f" cave @ 0x{THUNK_VA:08x} current (32B): {cave_cur.hex()}")
if any(b != 0 and b != 0xCC for b in cave_cur):
# Warn but don't refuse — caves are sometimes filled with INT3 (0xCC) padding
print(f" WARNING — cave is not all zeros/0xCC. Inspect before proceeding.")
# For safety, still refuse if first byte is something live
if cave_cur[0] != 0 and cave_cur[0] != 0xCC:
print(f" REFUSING — cave appears occupied")
CloseHandle(h); sys.exit(4)
# Build and write thunk
thunk = build_thunk(THUNK_VA)
assert len(thunk) == 17, f"thunk size {len(thunk)}"
print(f" writing thunk ({len(thunk)} bytes) to 0x{THUNK_VA:08x}: {thunk.hex()}")
write_bytes(h, THUNK_VA, thunk)
# Verify thunk
after_thunk = read_bytes(h, THUNK_VA, len(thunk))
if after_thunk != thunk:
print(f" THUNK MISMATCH after write: {after_thunk.hex()}")
CloseHandle(h); sys.exit(5)
print(f" thunk verified")
# Write JMP at patch site
rel = THUNK_VA - (PATCH_SITE_VA + 5)
jmp_bytes = bytes([0xE9]) + struct.pack("<i", rel)
print(f" writing JMP at 0x{PATCH_SITE_VA:08x}: {jmp_bytes.hex()}")
write_bytes(h, PATCH_SITE_VA, jmp_bytes)
after = read_bytes(h, PATCH_SITE_VA, 5)
print(f" patch site now: {after.hex()}")
if after != jmp_bytes:
print(f" PATCH MISMATCH"); sys.exit(6)
print(f" OK")
CloseHandle(h)
if __name__ == "__main__":
main()

458
tools/pdb_extract.py Normal file
View file

@ -0,0 +1,458 @@
"""Pure-Python MSF 7.00 PDB extractor for acclient.pdb.
Reads the Microsoft Program Database, extracts public function symbols
(S_PUB32, kind 0x110E) and named struct/class type records (LF_CLASS,
LF_STRUCTURE) from the TPI stream, and writes two JSON sidecars to
docs/research/named-retail/:
symbols.json every named public function with its image VA
types.json every named struct/class with size
This is a foundation for the named-retail workflow. After running, every
future session can grep the JSON for instant addressname lookups.
Run with:
py tools/pdb-extract/pdb_extract.py refs/acclient.pdb
References:
https://llvm.org/docs/PDB/MsfFile.html MSF container layout
https://llvm.org/docs/PDB/PublicStream.html symbol record format
https://llvm.org/docs/PDB/TpiStream.html type-info stream layout
https://llvm.org/docs/PDB/DbiStream.html DBI header + sections
No external dependencies uses stdlib `struct` + `json` only.
Wire constants (PDB7 / MSF 7.00):
"""
import json
import os
import struct
import sys
from pathlib import Path
# ── MSF / PDB7 constants ───────────────────────────────────────────────────
MSF_MAGIC = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\0\0\0"
SUPERBLOCK_SIZE = 56 # u32 ×6 + magic; MSF 7.00 layout
# Stream indices (fixed by spec)
STREAM_PDB_INFO = 1
STREAM_TPI = 2
STREAM_DBI = 3
STREAM_IPI = 4
# Symbol record kinds (subset)
S_PUB32 = 0x110E # Public symbol (32-bit)
# Type record kinds (subset)
LF_CLASS = 0x1504
LF_STRUCTURE = 0x1505
LF_UNION = 0x1506
LF_ENUM = 0x1507
# DBI machine type (image-base + section table source)
SECTION_HEADERS_DEFAULT_BASE = 0x00400000 # acclient.exe image base
# CV public-symbol flag bits
PUBSYM_FLAG_CODE = 0x00000002
# ── MSF reader ─────────────────────────────────────────────────────────────
class Msf:
"""In-memory wrapper over a PDB file. Loads the superblock + stream
directory, exposes per-stream byte buffers reconstructed from page
chains."""
def __init__(self, path):
with open(path, "rb") as f:
self._data = f.read()
if not self._data.startswith(MSF_MAGIC):
raise ValueError(f"not an MSF 7.00 file: {path}")
# Superblock fields (after the 32-byte magic).
(self.block_size,
self.free_block_map,
self.num_blocks,
self.num_dir_bytes,
_reserved,
self.block_map_addr) = struct.unpack_from("<6I", self._data, 32)
if self.block_size not in (512, 1024, 2048, 4096):
raise ValueError(f"unexpected block size: {self.block_size}")
# Stream directory: read the directory-block-map first (a list
# of page indices that themselves spell out the directory's
# page list). Then read the directory pages.
dir_pages_needed = _ceil_div(self.num_dir_bytes, self.block_size)
block_map_pages_needed = _ceil_div(dir_pages_needed * 4, self.block_size)
# The block_map_addr is the page index of the FIRST page in the
# block-map. The block-map's pages are stored sequentially.
# Wait, actually it's a single page index pointing to one page
# full of u32 page indices. If there are >block_size/4 pages in
# the directory, this overflows; for typical small PDBs it
# doesn't. acclient.pdb's 119 KB directory at 4 KB pages = 30
# pages -> 30 u32s = 120 bytes, fits in one page. OK.
block_map = self._read_page(self.block_map_addr)
dir_page_indices = struct.unpack_from(
f"<{dir_pages_needed}I", block_map, 0)
dir_data = bytearray()
for pi in dir_page_indices:
dir_data.extend(self._read_page(pi))
dir_data = bytes(dir_data[:self.num_dir_bytes])
# Directory layout:
# u32 NumStreams
# u32 StreamSizes[NumStreams]
# for i in 0..NumStreams: u32 StreamPageIndices[ceil(size_i / block_size)]
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
# 0xFFFFFFFF is the "deleted stream" sentinel — treat as size 0.
sizes = tuple(0 if s == 0xFFFFFFFF else s for s in sizes)
offset = 4 + 4 * num_streams
streams = []
for size in sizes:
pages_needed = _ceil_div(size, self.block_size)
indices = struct.unpack_from(
f"<{pages_needed}I", dir_data, offset)
offset += 4 * pages_needed
streams.append((size, indices))
self.streams = streams
def _read_page(self, page_index):
start = page_index * self.block_size
return self._data[start:start + self.block_size]
def stream(self, idx):
"""Return the raw bytes of stream `idx` (concatenated pages,
truncated to declared size)."""
size, pages = self.streams[idx]
buf = bytearray()
for p in pages:
buf.extend(self._read_page(p))
return bytes(buf[:size])
def _ceil_div(a, b):
return (a + b - 1) // b
# ── DBI parser (just enough for: section headers + symbol stream index) ────
def _parse_dbi(dbi_bytes):
"""Pull the section-header-stream index + symbol-record-stream index
out of the DBI header. Returns (sym_stream_idx, section_hdr_stream_idx).
"""
# DBI header (first 64 bytes)
if len(dbi_bytes) < 64:
raise ValueError("DBI stream too short")
(version_sig, version_hdr, age,
gsi_stream, build_no,
psgsi_stream, pdb_dll_ver,
sym_record_stream, pdb_dll_rbld,
mod_info_size, section_contrib_size, section_map_size,
source_info_size, type_server_map_size, mfc_type_server_idx,
opt_dbg_hdr_size, ec_substream_size,
flags, machine, padding) = struct.unpack_from(
"<iIIHHHHHHiiiiiIiiHHI", dbi_bytes, 0)
# The optional debug header sub-stream is at the end of the DBI.
# Layout offset = 64 + sum of all preceding sub-stream sizes.
base = 64
offsets = {
"mod_info": base,
"section_contrib": base + mod_info_size,
"section_map": base + mod_info_size + section_contrib_size,
"source_info": base + mod_info_size + section_contrib_size + section_map_size,
"type_server_map": base + mod_info_size + section_contrib_size + section_map_size + source_info_size,
"ec_substream": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size,
"opt_dbg_header": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size + ec_substream_size,
}
# The optional debug header is an array of u16 stream indices; the
# one at index 5 is the section-headers stream (per LLVM docs).
opt_off = offsets["opt_dbg_header"]
opt_count = opt_dbg_hdr_size // 2
opt_streams = struct.unpack_from(
f"<{opt_count}H", dbi_bytes, opt_off)
# Index 5 = original section headers stream.
section_hdr_stream = opt_streams[5] if opt_count > 5 else 0xFFFF
return sym_record_stream, section_hdr_stream
# ── Public symbols extractor (S_PUB32 from sym-record stream) ──────────────
def _demangle(mangled):
"""Best-effort demangle of MSVC C++ symbol names to Class::Method form.
PDB public symbols use the MSVC ABI mangling. A full demangler would
parse arg types + calling conventions; we only need the readable
qualified name for grep workflows.
Examples:
"?EnchantAttribute@CACQualities@@IBEHKAAK@Z" -> "CACQualities::EnchantAttribute"
"??0CEnchantmentRegistry@@QAE@XZ" -> "CEnchantmentRegistry::CEnchantmentRegistry"
"??1Foo@@QAE@XZ" -> "Foo::~Foo"
"_someThing" -> "_someThing" (C-style, kept as-is)
"?GlobalFunc@@..." -> "GlobalFunc" (no class)
"""
if not mangled or not mangled.startswith("?"):
return mangled # C-linkage symbol, return as-is
# Strip the leading '?' (or '??' for ctors/dtors)
if mangled.startswith("??0"): # constructor
rest = mangled[3:]
ctor_dtor = "ctor"
elif mangled.startswith("??1"): # destructor
rest = mangled[3:]
ctor_dtor = "dtor"
elif mangled.startswith("??_"): # vtable / vbtable / etc — leave as-is
return mangled
else:
rest = mangled[1:]
ctor_dtor = None
# `Name@Class@Outer@@<sig>` -> split on '@@' to drop the signature suffix
sep = rest.find("@@")
if sep < 0:
return mangled # not a recognised pattern
qualified = rest[:sep]
parts = qualified.split("@")
parts = [p for p in parts if p]
if not parts:
return mangled
if ctor_dtor:
# parts[0] is the class name (the only part); ctor name = class name.
cls = parts[0]
outer = "::".join(reversed(parts[1:]))
full = f"{outer}::{cls}" if outer else cls
if ctor_dtor == "dtor":
return f"{full}::~{cls}"
return f"{full}::{cls}"
# Method: parts[0] is the function name, parts[1:] are nested classes
# (innermost first -> reverse for outer::inner::method order).
method = parts[0]
classes = list(reversed(parts[1:]))
if classes:
return "::".join(classes) + "::" + method
return method
def _extract_pub32(sym_bytes, section_bases):
"""Iterate S_PUB32 records, compute image VA, return list of dicts.
sym_bytes: raw sym-record-stream bytes.
section_bases: list of section base VAs (1-indexed via segment field).
"""
pos = 0
end = len(sym_bytes)
out = []
while pos + 4 <= end:
rec_len, kind = struct.unpack_from("<HH", sym_bytes, pos)
if rec_len == 0:
break
rec_end = pos + 2 + rec_len # rec_len excludes its own u16
if kind == S_PUB32:
# body: u32 flags, u32 offset, u16 seg, char name[]
flags, offset, seg = struct.unpack_from("<IIH", sym_bytes, pos + 4)
name_start = pos + 14
zero = sym_bytes.index(b"\0", name_start, rec_end)
mangled = sym_bytes[name_start:zero].decode("ascii", errors="replace")
if (flags & PUBSYM_FLAG_CODE) and seg >= 1 and (seg - 1) < len(section_bases):
va = section_bases[seg - 1] + offset
out.append({
"address": f"0x{va:08X}",
"name": _demangle(mangled),
"mangled": mangled,
"flags": flags,
})
pos = rec_end
# Records align to 4 bytes
if pos % 4:
pos += 4 - (pos % 4)
return out
# ── Section-header stream parser (40 bytes per IMAGE_SECTION_HEADER) ───────
def _parse_section_headers(sec_bytes, image_base=SECTION_HEADERS_DEFAULT_BASE):
"""Each entry is a 40-byte IMAGE_SECTION_HEADER. Returns list of
section-VA bases (so symbol[seg-1] + offset = VA)."""
bases = []
SECTION_SIZE = 40
offset = 0
while offset + SECTION_SIZE <= len(sec_bytes):
# Layout: char Name[8], u32 VirtSize, u32 VirtAddress, ...
virt_size, virt_addr = struct.unpack_from("<II", sec_bytes, offset + 8)
if virt_addr == 0 and offset > 0:
# Padding / empty trailing entry — stop.
break
bases.append(image_base + virt_addr)
offset += SECTION_SIZE
return bases
# ── TPI parser — minimal pass to extract LF_CLASS/STRUCTURE names + sizes ──
def _extract_named_types(tpi_bytes):
"""Walk the TPI stream's type record array and yield named class /
struct / union / enum records with their declared size. Skips
forward-declared (incomplete) records."""
if len(tpi_bytes) < 56:
return []
# TPI header (56 bytes)
(version, header_size,
ti_min, ti_max,
gpi_size,
gpi_substream_offset,
hash_aux_idx,
hash_key_size, num_hash_buckets,
hash_value_off, hash_value_len,
ti_off_off, ti_off_len,
hash_adj_off, hash_adj_len) = struct.unpack_from(
"<IIIIIIHHIIIIIII", tpi_bytes, 0)
pos = header_size # records start right after the header
end = pos + gpi_size
if end > len(tpi_bytes):
end = len(tpi_bytes)
out = []
while pos + 4 <= end:
rec_len, kind = struct.unpack_from("<HH", tpi_bytes, pos)
if rec_len == 0:
break
rec_end = pos + 2 + rec_len
if kind in (LF_CLASS, LF_STRUCTURE):
# body layout (LLVM type-records.h):
# u16 count, u16 props, u32 fieldList, u32 derived,
# u32 vshape, then varint16 size, then null-term string.
try:
count, props, field_list, derived, vshape = struct.unpack_from(
"<HHIII", tpi_bytes, pos + 4)
# varint16 size: <0x8000 = u16 inline; ≥0x8000 = follow-on u16/u32.
size_pos = pos + 4 + 16
size_word = tpi_bytes[size_pos] | (tpi_bytes[size_pos + 1] << 8)
if size_word < 0x8000:
size_val = size_word
name_start = size_pos + 2
else:
# Numeric leaves. 0x8002=u16, 0x8003=u32, etc. Skip.
if size_word == 0x8002:
size_val = struct.unpack_from("<H", tpi_bytes, size_pos + 2)[0]
name_start = size_pos + 4
elif size_word == 0x8003:
size_val = struct.unpack_from("<I", tpi_bytes, size_pos + 2)[0]
name_start = size_pos + 6
else:
# Unknown numeric leaf — skip.
pos = rec_end
if pos % 4: pos += 4 - (pos % 4)
continue
# Name is null-terminated ASCII.
if name_start < rec_end:
zero = tpi_bytes.find(b"\0", name_start, rec_end)
if zero > name_start:
name = tpi_bytes[name_start:zero].decode("ascii", errors="replace")
# Skip forward-decls: bit 7 of `props` (0x80).
is_fwdref = (props & 0x80) != 0
if not is_fwdref and name and not name.startswith("<unnamed"):
out.append({
"name": name,
"size": size_val,
"kind": "class" if kind == LF_CLASS else "struct",
})
except (struct.error, IndexError, ValueError):
pass
pos = rec_end
if pos % 4:
pos += 4 - (pos % 4)
return out
# ── Driver ─────────────────────────────────────────────────────────────────
def _resolve_repo_root():
"""tools/pdb-extract/ -> repo root is two directories up."""
return Path(__file__).resolve().parent.parent.parent
def _main():
if len(sys.argv) != 2:
print("usage: py pdb_extract.py <path-to-acclient.pdb>", file=sys.stderr)
sys.exit(2)
pdb_path = Path(sys.argv[1])
if not pdb_path.exists():
print(f"not found: {pdb_path}", file=sys.stderr)
sys.exit(2)
print(f"loading {pdb_path} ({pdb_path.stat().st_size / 1024 / 1024:.1f} MB)...")
msf = Msf(str(pdb_path))
print(f" block size: {msf.block_size}, streams: {len(msf.streams)}")
# 1. DBI -> find sym-record + section-header stream indices
dbi_bytes = msf.stream(STREAM_DBI)
sym_stream_idx, sec_hdr_stream_idx = _parse_dbi(dbi_bytes)
print(f" sym stream: {sym_stream_idx}, section-hdr stream: {sec_hdr_stream_idx}")
# 2. Section headers -> segment bases
sec_bytes = msf.stream(sec_hdr_stream_idx)
section_bases = _parse_section_headers(sec_bytes)
print(f" sections: {len(section_bases)} (text base = 0x{section_bases[0]:08X})")
# 3. Symbol records -> S_PUB32 entries with image VAs
sym_bytes = msf.stream(sym_stream_idx)
print(f" sym record stream: {len(sym_bytes) / 1024:.1f} KB")
symbols = _extract_pub32(sym_bytes, section_bases)
print(f" extracted {len(symbols)} public function symbols")
# 4. TPI -> named types
tpi_bytes = msf.stream(STREAM_TPI)
print(f" TPI stream: {len(tpi_bytes) / 1024:.1f} KB")
types = _extract_named_types(tpi_bytes)
# Dedup by name (templates/forward-decl spam can produce duplicates)
seen = set()
unique_types = []
for t in types:
if t["name"] not in seen:
seen.add(t["name"])
unique_types.append(t)
print(f" extracted {len(unique_types)} unique named types ({len(types)} total records)")
# 5. Write outputs
repo = _resolve_repo_root()
out_dir = repo / "docs" / "research" / "named-retail"
out_dir.mkdir(parents=True, exist_ok=True)
sym_out = out_dir / "symbols.json"
type_out = out_dir / "types.json"
with open(sym_out, "w", encoding="utf-8") as f:
# Keep address + demangled name + raw mangled name (for callers
# that need the C++ ABI form). Strip flags as not useful for grep.
compact = [
{"address": s["address"], "name": s["name"], "mangled": s["mangled"]}
for s in symbols
]
json.dump(compact, f, indent=2)
with open(type_out, "w", encoding="utf-8") as f:
json.dump(unique_types, f, indent=2)
print(f"\nwrote {sym_out} ({sym_out.stat().st_size / 1024:.1f} KB)")
print(f"wrote {type_out} ({type_out.stat().st_size / 1024:.1f} KB)")
# Spot check: CEnchantmentRegistry::EnchantAttribute should be at 0x594570 per discovery agent.
target = "CEnchantmentRegistry::EnchantAttribute"
for s in symbols:
if s["name"] == target:
print(f"\nspot check: {target} -> {s['address']} (expected 0x00594570)")
break
else:
print(f"\nspot check: {target} NOT FOUND in symbols (PDB lookup mismatch?)")
_main()

33
tools/peek_addr.py Normal file
View file

@ -0,0 +1,33 @@
"""peek_addr.py <pid> <va> [n=16]
Read n bytes at va from a live process. Print as hex + try to interpret prologue."""
import ctypes, ctypes.wintypes as wt, sys
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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
pid = int(sys.argv[1]); va = int(sys.argv[2], 0); n = int(sys.argv[3]) if len(sys.argv) > 3 else 16
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)):
print(f"ReadProcessMemory @ 0x{va:08x} err={ctypes.get_last_error()}"); sys.exit(3)
data = bytes(buf[:sz.value])
print(f"@ 0x{va:08x}: {data.hex(' ')}")
# rough heuristics
hints = []
if data[:1] == b'\x55': hints.append("push ebp (typical prologue)")
if data[:1] == b'\x56': hints.append("push esi (thiscall this->esi prologue)")
if data[:1] == b'\x53': hints.append("push ebx (prologue start)")
if data[:1] == b'\x8b': hints.append("mov reg, ... (could be prologue or middle)")
if data[:1] == b'\x83': hints.append("sub/add esp, imm8 (stack alloc, prologue)")
if data[:3] == b'\xb0\x01\xc3': hints.append("mov al,1; ret (no-op stub)")
if data[:3] == b'\xb0\x00\xc3': hints.append("mov al,0; ret")
if data[:1] == b'\xc3': hints.append("bare ret")
if data[:2] == b'\x90\x90': hints.append("NOP pad (real code likely after)")
if hints: print(" -> " + "; ".join(hints))
k.CloseHandle(h)

View file

@ -0,0 +1,59 @@
"""peek_first_region.py <pid> <exact_size_bytes>
Find the first private-RW region of exactly <size>, dump its first 256 bytes
plus a sample at offset 4096 and at midpoint."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1]); target_size = int(sys.argv[2])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
mbi = MBI()
addr = 0
found = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)) and found < 3:
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if (mbi.State == 0x1000 and mbi.Type == 0x20000 and (mbi.Protect & 0xFF) in (0x04, 0x40)
and sz == target_size):
print(f"\n=== region @ 0x{base:08x} size={sz} ===")
for off in [0, 256, 4096, sz // 2, sz - 64]:
data = rd(h, base + off, 64)
if data:
print(f" +0x{off:06x}: {data[:32].hex(' ')}")
print(f" {data[32:64].hex(' ')}")
# Interpret first 32 bytes as DWORDs and floats
data = rd(h, base, 32)
if data:
print(f" as DWORDs: ", end='')
for i in range(0, 32, 4):
print(f"0x{struct.unpack_from('<I', data, i)[0]:08x}", end=' ')
print()
print(f" as floats: ", end='')
for i in range(0, 32, 4):
print(f"{struct.unpack_from('<f', data, i)[0]:.3g}", end=' ')
print()
found += 1
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)

135
tools/physobj_owner_diff.py Normal file
View file

@ -0,0 +1,135 @@
"""physobj_owner_diff.py <low_dmp> <high_dmp>
Run owner-vtable scan on both dumps and report HIGH - LOW (instances delta).
Only counts real vtable hits (slot 0 must be a function in .text).
"""
import struct, sys
from collections import Counter
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 scan(dump_path):
md = MinidumpFile.parse(dump_path)
reader = md.get_reader().get_buffered_reader()
text_ranges = []
image_ranges = []
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:
image_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
if pr in (0x20, 0x10, 0x40, 0x02): # execute
text_ranges.append((r.BaseAddress, r.BaseAddress + r.RegionSize))
image_ranges.sort()
text_ranges.sort()
def in_text(addr):
for lo, hi in text_ranges:
if lo <= addr < hi:
return True
return False
def in_image(addr):
for lo, hi in image_ranges:
if lo <= addr < hi:
return True
return False
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))
# Helper to read DWORD from anywhere
def read_dword(va):
try:
reader.move(va)
data = reader.read(4)
if len(data) == 4:
return struct.unpack('<I', data)[0]
except Exception:
return None
region_bufs = []
physobj_addrs = set()
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.add(base + off)
LOOKBACK = 0x200
vtable_unique_owners = Counter() # vtable -> distinct owner-instance addresses
seen_owners = set() # to dedupe per (vtable, owner_base)
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_addrs:
continue
hit_va = base + off
if hit_va == v:
continue
start = max(0, off - LOOKBACK)
for back in range(off - 4, start - 4, -4):
if back < 0: break
vv = struct.unpack_from("<I", buf, back)[0]
if not in_image(vv):
continue
# validate: slot 0 must point to executable code
slot0 = read_dword(vv)
if slot0 is None or not in_text(slot0):
continue
owner_base = base + back
key = (vv, owner_base)
if key in seen_owners:
break
seen_owners.add(key)
vtable_unique_owners[vv] += 1
break
return physobj_addrs, vtable_unique_owners
def main():
low_path, high_path = sys.argv[1], sys.argv[2]
print("=== LOW dump scan ===")
low_p, low_v = scan(low_path)
print(f"low: physobjs={len(low_p)}, unique-owner-instances tracked across {len(low_v)} vtables")
print("=== HIGH dump scan ===")
high_p, high_v = scan(high_path)
print(f"high: physobjs={len(high_p)}, unique-owner-instances tracked across {len(high_v)} vtables")
all_vt = set(low_v.keys()) | set(high_v.keys())
deltas = [(vt, high_v[vt] - low_v[vt], low_v[vt], high_v[vt]) for vt in all_vt]
deltas.sort(key=lambda x: -x[1])
print(f"\n=== Owners growing the most (high - low) — top 30 ===")
print(f"{'vtable':>10} {'delta':>8} {'low':>6} {'high':>6}")
for vt, d, lo, hi in deltas[:30]:
print(f" 0x{vt:08x} {d:>8} {lo:>6} {hi:>6}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,117 @@
"""physobj_owner_inspect.py <dump.dmp> <owner_vtable>
For a specific owner vtable, find all instances and report:
- distinct instance count
- typical instance footprint (count of physobj pointers per instance, max field offset)
- histogram of physobj-field offsets within the owner
- 5 sample instances dumped (first 0x100 bytes)
"""
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():
dump_path = sys.argv[1]
target_vt = int(sys.argv[2], 0)
md = MinidumpFile.parse(dump_path)
reader = md.get_reader().get_buffered_reader()
# First pass: find physobj instances
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))
region_bufs = []
physobj_addrs = set()
owner_addrs = [] # va where DWORD == target_vt
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.add(base + off)
elif v == target_vt:
owner_addrs.append((base + off, base, off, buf))
print(f"physobj instances: {len(physobj_addrs)}")
print(f"owner instances (vt 0x{target_vt:08x}): {len(owner_addrs)}")
if not owner_addrs:
return
# For each owner instance, scan its memory range forward and find embedded physobj ptrs
# Use heap-entry detection: assume instance footprint up to next vt or up to 0x800 bytes.
SCAN_AHEAD = 0x400
pfields = Counter() # offset within owner -> count
physobjs_per_owner = []
samples = []
for ova, base, off, buf in owner_addrs[:50000]:
end = min(len(buf), off + SCAN_AHEAD)
nphysobj = 0
max_field = 0
for fo in range(0, end - off, 4):
v = struct.unpack_from("<I", buf, off + fo)[0]
if v in physobj_addrs:
pfields[fo] += 1
nphysobj += 1
if fo > max_field:
max_field = fo
physobjs_per_owner.append((ova, nphysobj, max_field))
if len(samples) < 5 and nphysobj > 0:
samples.append((ova, buf[off:off + 0x100]))
print(f"\nphysobj-ptr field offsets within owner (top 20):")
for fo, cnt in pfields.most_common(20):
print(f" +0x{fo:03x} count={cnt}")
# Distribution of physobj counts per owner
counts = Counter(n for _, n, _ in physobjs_per_owner)
print(f"\nphysobjs-per-owner distribution (top 10):")
for n, cnt in counts.most_common(10):
print(f" {n} physobjs in {cnt} owners")
nz = [n for _, n, _ in physobjs_per_owner if n > 0]
if nz:
avg = sum(nz) / len(nz)
print(f"\nowners with >=1 physobj: {len(nz)}, avg {avg:.2f} physobj/owner")
total = sum(nz)
print(f"total physobj-ptr edges from these owners: {total}")
print(f"unique physobj instances pointed to (upper bound): {min(total, len(physobj_addrs))}")
print(f"\nsample owner dumps (first 0x100 bytes):")
for ova, data in samples[:3]:
print(f" owner @ 0x{ova:08x}:")
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
print(f" +0x{i:03x}: {chunk.hex(' ')}")
if __name__ == "__main__":
main()

160
tools/physobj_owner_scan.py Normal file
View file

@ -0,0 +1,160 @@
"""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()

View file

@ -0,0 +1,119 @@
"""physobj_owner_tight.py <dump.dmp> <owner_vtable> [object_size]
Same as physobj_owner_inspect but with tight per-instance window: stops at
the next image-pointer (vtable boundary).
"""
import struct, sys
from collections import Counter
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():
dump_path = sys.argv[1]
target_vt = int(sys.argv[2], 0)
obj_size = int(sys.argv[3], 0) if len(sys.argv) > 3 else None
md = MinidumpFile.parse(dump_path)
reader = md.get_reader().get_buffered_reader()
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
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))
region_bufs = []
physobj_addrs = set()
owner_locations = [] # (va, base, off, buf)
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.add(base + off)
elif v == target_vt:
owner_locations.append((base + off, base, off, buf))
print(f"physobj instances: {len(physobj_addrs)}")
print(f"owner instances (vt 0x{target_vt:08x}): {len(owner_locations)}")
if not owner_locations:
return
MAX_WINDOW = obj_size if obj_size else 0x400
pfields = Counter()
nonzero_per_owner = []
for ova, base, off, buf in owner_locations:
# Determine tight window: stop at next image pointer or fixed obj_size
if obj_size:
stop = off + obj_size
else:
stop = off + 4
limit = min(len(buf), off + MAX_WINDOW)
while stop < limit:
v = struct.unpack_from("<I", buf, stop)[0]
if is_image(v):
break
stop += 4
nphys = 0
for fo in range(0, stop - off, 4):
v = struct.unpack_from("<I", buf, off + fo)[0]
if v in physobj_addrs:
pfields[fo] += 1
nphys += 1
nonzero_per_owner.append((ova, nphys, stop - off))
sizes = Counter(s for _, _, s in nonzero_per_owner)
print(f"\ninstance-size distribution (top 10 by count):")
for sz, cnt in sizes.most_common(10):
print(f" size~0x{sz:03x} count={cnt}")
print(f"\nphysobj-ptr field offsets within owner (top 20):")
for fo, cnt in pfields.most_common(20):
print(f" +0x{fo:03x} count={cnt}")
nz = [n for _, n, _ in nonzero_per_owner if n > 0]
if nz:
print(f"\nowners with >=1 physobj: {len(nz)} / {len(owner_locations)}")
print(f"avg physobj/owner: {sum(nz)/len(nz):.2f}")
print(f"total physobj-edges: {sum(nz)}")
cnts = Counter(n for _, n, _ in nonzero_per_owner)
print(f"\nphysobjs-per-owner distribution:")
for n, cnt in sorted(cnts.most_common(15)):
print(f" {n} physobjs: {cnt} owners")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,125 @@
"""position_array_inspect.py <pid> -- for arrays of Positions (96.6%
of all hits), find the bytes immediately BEFORE the array start and
immediately AFTER the array end. This is where the array allocator's
header would live, or where the containing DArray/SmartArray descriptor
points.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
POSITION_VT = 0x00797910
SIZEOF_POSITION = 72
ACMIN, ACMAX = 0x00400000, 0x00900000
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 scan(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
# For each "array head" (Position with no Position 72 bytes before),
# collect: (array_len, bytes 16 BEFORE first position).
# For each "array tail" (Position with no Position 72 bytes after),
# collect: bytes 16 AFTER end of position.
array_lens = Counter()
head_minus_4 = Counter()
head_minus_8 = Counter()
head_minus_12 = Counter()
head_minus_16 = Counter()
array_size_bytes = Counter()
total_arrays = 0
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
pos_offs = []
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
pos_offs.append(off)
pos_set = set(pos_offs)
# find array heads
for off in pos_offs:
if (off - SIZEOF_POSITION) in pos_set:
continue # not a head
# Walk forward to find the tail
tail = off
length = 1
while (tail + SIZEOF_POSITION) in pos_set:
tail += SIZEOF_POSITION
length += 1
if length < 2:
continue # not really an array
total_arrays += 1
array_lens[length] += 1
array_size_bytes[length * SIZEOF_POSITION] += 1
# Bytes BEFORE head
if off >= 16:
m4 = struct.unpack_from("<I", data, off - 4)[0]
m8 = struct.unpack_from("<I", data, off - 8)[0]
m12 = struct.unpack_from("<I", data, off - 12)[0]
m16 = struct.unpack_from("<I", data, off - 16)[0]
head_minus_4[m4] += 1
head_minus_8[m8] += 1
head_minus_12[m12] += 1
head_minus_16[m16] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"PID {pid}: {total_arrays} Position arrays found")
print()
print("Top array lengths (positions per array):")
for length, n in array_lens.most_common(15):
print(f" len={length:>5} count={n:>6} bytes/array={length*SIZEOF_POSITION:>7}")
print()
print("Top values at offset -4 of array head (likely refcount/size/flags):")
for v, n in head_minus_4.most_common(8):
print(f" {v:#010x} count={n}")
print()
print("Top values at offset -8 of array head:")
for v, n in head_minus_8.most_common(8):
print(f" {v:#010x} count={n}")
print()
print("Top values at offset -12 of array head:")
for v, n in head_minus_12.most_common(8):
print(f" {v:#010x} count={n}")
print()
print("Top values at offset -16 of array head:")
for v, n in head_minus_16.most_common(8):
print(f" {v:#010x} count={n}")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(scan(int(sys.argv[1])))

View file

@ -0,0 +1,154 @@
"""position_heap_solo_scan.py <pid>
Variant of position_host_scan that filters to ONLY Positions which
look like standalone heap allocations (vs embedded into a bigger
struct).
Heuristic for "standalone heap allocation":
- Bytes right before the Position vtable have a typical heap-block
header pattern (small DWORD = allocation size, then a possibly-NULL
block-id, then padding/flags).
- OR there is a ref-counted wrapper vtable at offset -8 (the
PositionPropertyValue pattern).
- The +96..+128 region after the Position is either NULL/heap-free
(suggesting nothing follows the Position in the same block).
Goal: classify Positions into:
(A) embedded in a larger object host vtable found at offset -4
through -16 (the Position is just a field of e.g. a PhysicsObj
or NetBlob).
(B) inside a ref-counted property wrapper vtable at -8
(PositionPropertyValue layout).
(C) heap-solo at offset 0 allocated as `new Position(...)`
directly, no enclosing object.
(D) part of an array another Position vtable 96 bytes away.
The leaking class is whichever bucket dominates the delta between
heavy-looter and idle character.
"""
import ctypes
import ctypes.wintypes as wt
import struct
import sys
from collections import Counter, defaultdict
POSITION_VT = 0x00797910
ACCLIENT_MIN = 0x00400000
ACCLIENT_MAX = 0x00900000
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 classify(data, off):
"""Return (bucket, evidence_value). bucket in {'array','ppv',
'embedded','solo','unknown'}."""
# array: Position vtable 96 bytes earlier or later
if off >= 96:
prev = struct.unpack_from("<I", data, off - 96)[0]
if prev == POSITION_VT:
return ('array_prev', prev)
if off + 96 + 4 <= len(data):
nxt = struct.unpack_from("<I", data, off + 96)[0]
if nxt == POSITION_VT:
return ('array_next', nxt)
# ppv: vtable at off-8, refcount-shaped dword at off-4
if off >= 8:
m8 = struct.unpack_from("<I", data, off - 8)[0]
m4 = struct.unpack_from("<I", data, off - 4)[0]
if ACCLIENT_MIN <= m8 <= ACCLIENT_MAX and 0 < m4 < 0x100000:
return ('ppv', m8)
# embedded: any acclient code-range vtable at offsets -32..-4
if off >= 32:
for d in range(-32, 0, 4):
v = struct.unpack_from("<I", data, off + d)[0]
if ACCLIENT_MIN <= v <= ACCLIENT_MAX:
return ('embedded', v)
# solo: nothing identifying
return ('solo', 0)
def scan(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
bucket_counts = Counter()
embedded_vt_counts = Counter()
ppv_vt_counts = Counter()
array_off_counts = Counter()
total = 0
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:
total += 1
b, ev = classify(data, off)
bucket_counts[b] += 1
if b == 'embedded':
embedded_vt_counts[ev] += 1
elif b == 'ppv':
ppv_vt_counts[ev] += 1
elif b.startswith('array'):
array_off_counts[b] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"PID {pid}: {total} Position instances scanned")
print()
print("Bucket distribution:")
for b, n in bucket_counts.most_common():
pct = 100.0 * n / max(1, total)
print(f" {b:>12}: {n:>7} ({pct:5.1f}%)")
print()
print("Top 'embedded' host vtables (Position is a member of class):")
for v, n in embedded_vt_counts.most_common(15):
print(f" 0x{v:08x} count={n:>6}")
print()
print("Top 'ppv' wrapper vtables (Position inside ref-counted holder):")
for v, n in ppv_vt_counts.most_common(10):
print(f" 0x{v:08x} count={n:>6}")
print()
print("Array adjacency:")
for b, n in array_off_counts.most_common():
print(f" {b}: {n}")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(scan(int(sys.argv[1])))

140
tools/position_host_scan.py Normal file
View file

@ -0,0 +1,140 @@
"""position_host_scan.py <pid>
For every Position instance found in a live PID:
- Read 64 bytes before and 128 bytes after the vtable pointer.
- Look at offsets -32, -28, -24, -20, -16, -12, -8, -4 (might be
vtables of host containers / property wrappers / list nodes).
- Look at offsets +96 (the byte right after Position ends), +100,
+104, +108 (vtables of "next instance in the same container").
- For any DWORD in those slots that falls in acclient.exe code/.rdata
range (0x00400000..0x007FFFFF), bucket it.
- Print top vtables seen at each offset.
This is the diagnostic that breaks open which OWNER class is
heap-allocating Positions but never freeing them.
"""
import ctypes
import ctypes.wintypes as wt
import struct
import sys
from collections import Counter, defaultdict
POSITION_VT = 0x00797910
ACCLIENT_MIN = 0x00400000
ACCLIENT_MAX = 0x00900000 # generous upper bound for .text/.rdata
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
# Offsets relative to Position vtable address. Positive = into Position;
# negative = backwards into containing header.
OFFSETS = [-32, -28, -24, -20, -16, -12, -8, -4,
+96, +100, +104, +108, +112, +116, +120, +124]
def scan(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
# Histogram offset -> Counter(value)
hist = defaultdict(Counter)
total = 0
# Track Position-at-N density: how many Positions hit at exact
# stride 96 from a prior Position (suggests array/list of Positions).
stride_hits = Counter()
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
# Find all Position vtable hits in this region first
pos_offs_in_region = []
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
pos_offs_in_region.append(off)
for off in pos_offs_in_region:
total += 1
# context
for delta in OFFSETS:
target = off + delta
if 0 <= target <= len(data) - 4:
v = struct.unpack_from("<I", data, target)[0]
# only record if it looks like a code pointer (vtable)
if ACCLIENT_MIN <= v <= ACCLIENT_MAX:
hist[delta][v] += 1
else:
# also track NULL specifically
if v == 0:
hist[delta][0] += 1
# Stride: if Position at off and another at off+96, +192 etc.
pos_set = set(pos_offs_in_region)
for off in pos_offs_in_region:
for k_stride in (96, 192, 288):
if off + k_stride in pos_set:
stride_hits[k_stride] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"PID {pid}: {total} Position instances scanned")
print()
print("Top non-zero vtables at each offset relative to Position vt:")
for delta in OFFSETS:
c = hist[delta]
non_null = [(v, n) for v, n in c.items() if v != 0]
non_null.sort(key=lambda x: -x[1])
null_n = c.get(0, 0)
if not non_null and null_n == 0:
continue
print(f"\n offset {delta:+4d}: (null={null_n}/{total} = "
f"{100.0*null_n/max(1,total):.1f}%)")
for v, n in non_null[:6]:
pct = 100.0 * n / total
print(f" 0x{v:08x} count={n:>6} ({pct:.1f}%)")
print()
print("Position-at-stride hits (suggests array/list of Positions):")
for s, n in stride_hits.most_common():
print(f" stride {s}B: {n} adjacent Position pairs")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(scan(int(sys.argv[1])))

108
tools/position_host_v2.py Normal file
View file

@ -0,0 +1,108 @@
"""position_host_v2.py <pid> -- Position is 72 bytes (not 96).
For each Position vt hit:
- Detect array adjacency at stride 72.
- Look at offsets -56..-4 for host vtables.
- Bucket: (array-element, ppv-wrapper, host-embedded, solo).
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
POSITION_VT = 0x00797910
SIZEOF_POSITION = 72 # vt + objcell_id + Frame(64)
ACMIN, ACMAX = 0x00400000, 0x00900000
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 classify(data, off, pos_set):
# array adjacency at stride 72
if off >= SIZEOF_POSITION and (off - SIZEOF_POSITION) in pos_set:
return ('array_inner', None)
if (off + SIZEOF_POSITION) in pos_set:
return ('array_head', None)
# Look for any acclient code-range vtable in -56..-4
candidate_offs = list(range(-56, 0, 4))
found = None
for d in candidate_offs:
if off + d < 0: continue
v = struct.unpack_from("<I", data, off + d)[0]
if ACMIN <= v <= ACMAX:
# check vtable plausibility — slot 0 should point to a code-page
# we can't read .text from this script easily — keep just the vtable
found = (d, v)
break
if found:
return ('embedded', found)
return ('solo', None)
def scan(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
buckets = Counter()
host_at_off = Counter() # (off, vtable) -> count
total = 0
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
pos_offs = []
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
pos_offs.append(off)
pos_set = set(pos_offs)
for off in pos_offs:
total += 1
bucket, ev = classify(data, off, pos_set)
buckets[bucket] += 1
if bucket == 'embedded':
host_at_off[ev] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"PID {pid}: {total} Position instances")
for b, n in buckets.most_common():
pct = 100.0 * n / total
print(f" {b:>15}: {n:>7} ({pct:5.1f}%)")
print()
print("Top (offset, host-vtable) combos for 'embedded' bucket:")
for (off, vt), n in host_at_off.most_common(20):
pct = 100.0 * n / total
print(f" off={off:+4d} vt=0x{vt:08x} count={n:>6} ({pct:.2f}%)")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(scan(int(sys.argv[1])))

View file

@ -0,0 +1,100 @@
"""position_sample_dump.py <pid> [count=10]
For a few Position hits, print the surrounding bytes raw so we can
visually inspect the layout. Goal: see if there's a heap-block
header pattern we missed.
"""
import ctypes
import ctypes.wintypes as wt
import struct
import sys
POSITION_VT = 0x00797910
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 hexdump(addr, data, prefix=""):
for i in range(0, len(data), 16):
chunk = data[i:i+16]
h = " ".join(f"{b:02x}" for b in chunk)
a = "".join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
dws = " ".join(f"{struct.unpack_from('<I', chunk, j)[0]:08x}"
for j in range(0, min(16, len(chunk) - (len(chunk) % 4)), 4))
print(f"{prefix}0x{addr+i:08x}: {h:<47} | {dws} | {a}")
def main():
pid = int(sys.argv[1])
want = int(sys.argv[2]) if len(sys.argv) > 2 else 10
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess err={ctypes.get_last_error()}"); return 1
shown = 0
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)) and shown < want:
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
# find positions in this region
pos_offs = []
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
pos_offs.append(off)
# sample first few
for off in pos_offs[:3]:
if shown >= want:
break
shown += 1
va_pos = mbi.BaseAddress + off
region_kb = mbi.RegionSize // 1024
print(f"\n=== Position #{shown} @ VA 0x{va_pos:08x} "
f"(region 0x{mbi.BaseAddress:08x} {region_kb}KB, "
f"in-region offset 0x{off:x}, "
f"positions-in-region={len(pos_offs)}) ===")
# dump 64 bytes BEFORE position vt
lo = max(0, off - 64)
hexdump(mbi.BaseAddress + lo, data[lo:off], prefix=" before ")
print(" POSITION:")
hexdump(mbi.BaseAddress + off, data[off:off + 128], prefix=" pos+ctx ")
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000 or shown >= want:
break
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(main())

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)

175
tools/probe_260k_holders.py Normal file
View file

@ -0,0 +1,175 @@
"""probe_260k_holders.py <pid>
Walk all 260KB private-RW regions in target process. For each, scan first
few hundred bytes (the "header") and check:
- First DWORD: is it a pointer into d3d9.dll's image range? (= a vtable
pointer for a d3d9-managed object)
- First 32 DWORDs: any pointers back into the process's heap?
- Find any pointer in the process's heap that points to this region's base.
Goal: determine whether the 260KB blocks are
(a) live d3d9-managed objects (have a d3d9 vtable at offset 0)
(b) raw backing buffers with no AC-side holder pointers
(c) AC-held buffers with at least one pointer from AC's heap"""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
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, 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 rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print("OpenProcess fail"); sys.exit(2)
# Build a list of all committed RW regions and their (base, size, type).
regions = []
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
sz = mbi.RegionSize
if mbi.State == 0x1000 and (mbi.Protect & 0xFF) in (0x04, 0x40):
regions.append((base, sz, mbi.Type, mbi.Protect))
next_addr = base + sz
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
# Find image ranges for d3d9.dll via PSAPI
psapi = ctypes.windll.psapi
psapi.EnumProcessModulesEx.argtypes = [wt.HANDLE, ctypes.POINTER(wt.HMODULE),
wt.DWORD, ctypes.POINTER(wt.DWORD), wt.DWORD]
psapi.EnumProcessModulesEx.restype = wt.BOOL
psapi.GetModuleFileNameExA.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.c_char_p, wt.DWORD]
psapi.GetModuleFileNameExA.restype = wt.DWORD
class MODULEINFO(ctypes.Structure):
_fields_ = [("lpBaseOfDll", ctypes.c_void_p),
("SizeOfImage", wt.DWORD),
("EntryPoint", ctypes.c_void_p)]
psapi.GetModuleInformation.argtypes = [wt.HANDLE, wt.HMODULE, ctypes.POINTER(MODULEINFO), wt.DWORD]
psapi.GetModuleInformation.restype = wt.BOOL
# Need a handle with QUERY + READ for psapi. Re-open with extra rights.
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h2 = k.OpenProcess(0x410, False, pid) # PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
if not h2: h2 = h
d3d9_lo, d3d9_hi = 0, 0
ac_image_lo, ac_image_hi = 0, 0
needed = wt.DWORD(0)
hmods = (wt.HMODULE * 1024)()
if psapi.EnumProcessModulesEx(h2, hmods, ctypes.sizeof(hmods), ctypes.byref(needed), 0x03):
n = needed.value // ctypes.sizeof(wt.HMODULE)
name = ctypes.create_string_buffer(260)
info = MODULEINFO()
for i in range(n):
psapi.GetModuleFileNameExA(h2, hmods[i], name, 260)
nm = name.value.decode(errors='replace').lower()
if not (psapi.GetModuleInformation(h2, hmods[i], ctypes.byref(info), ctypes.sizeof(info))):
continue
base = info.lpBaseOfDll or 0
sz = info.SizeOfImage
if 'd3d9' in nm:
d3d9_lo = base; d3d9_hi = base + sz
elif nm.endswith('\\acclient.exe') or nm.endswith('/acclient.exe'):
ac_image_lo = base; ac_image_hi = base + sz
else:
print(f"EnumProcessModulesEx failed err={ctypes.GetLastError()}", file=sys.stderr)
print(f"d3d9.dll range: 0x{d3d9_lo:08x} - 0x{d3d9_hi:08x}")
print(f"acclient image: 0x{ac_image_lo:08x} - 0x{ac_image_hi:08x}")
# Find the 260KB-sized regions (size = 266240 bytes exactly, or "near 260KB").
TARGET_SIZE = 266240 # 256K + 4K
candidates = [(b, s) for (b, s, t, p) in regions if s == TARGET_SIZE and (t & 0x20000)]
print(f"\nFound {len(candidates)} regions of exactly {TARGET_SIZE} bytes (260KB) "
f"in private RW.\n")
# For up to N candidates, classify
N = min(20, len(candidates))
print(f"Sampling first {N} for content + holder counts:")
# Pre-flatten all heap RW regions to a list of (base, data) for the holder-scan.
# That's expensive. Limit total bytes to 200 MB so we don't OOM.
print(" - loading heap regions for holder-scan (capped at 200 MB)...")
total_loaded = 0
heap_blob = []
MAX_BYTES = 200 * 1024 * 1024
for (b, s, t, p) in regions:
if total_loaded + s > MAX_BYTES: break
if s > 64 * 1024 * 1024: continue # skip huge regions
if not (t & 0x20000): continue
data = rd(h, b, s)
if data is not None:
heap_blob.append((b, data))
total_loaded += s
print(f" loaded {total_loaded/1024/1024:.1f} MB across {len(heap_blob)} regions")
def count_holders(target_va, max_count=5):
"""Return list of (holder_va, surrounding_hex) where target_va appears
as a DWORD-aligned pointer in heap memory."""
tb = struct.pack('<I', target_va)
hits = []
for (base, data) in heap_blob:
off = 0
while True:
off = data.find(tb, off)
if off < 0: break
if (off & 3) == 0:
hits.append((base + off, data[max(0, off-8):off+8].hex(' ')))
if len(hits) >= max_count: return hits
off += 4
return hits
vtable_d3d9 = 0
vtable_other = 0
no_vtable_at_0 = 0
holder_counts = []
for (b, s) in candidates[:N]:
head = rd(h, b, 32)
if head is None:
print(f" 0x{b:08x}: rd fail")
continue
first = struct.unpack('<I', head[:4])[0]
is_d3d9_vt = d3d9_lo <= first < d3d9_hi
is_ac_vt = ac_image_lo <= first < ac_image_hi
if is_d3d9_vt: vtable_d3d9 += 1
elif is_ac_vt or first != 0: vtable_other += 1
else: no_vtable_at_0 += 1
holders = count_holders(b, max_count=3)
holder_counts.append(len(holders))
cat = "d3d9-vtable" if is_d3d9_vt else ("ac-vtable" if is_ac_vt else (
"non-zero" if first != 0 else "zero"))
print(f" 0x{b:08x}: first_dword=0x{first:08x} ({cat}) "
f"holders={len(holders)}", end='')
for hv, ctx in holders[:1]:
print(f" ex@0x{hv:08x}", end='')
print()
print(f"\nSummary of {N} sampled 260KB regions:")
print(f" with d3d9-vtable at offset 0: {vtable_d3d9}")
print(f" with other non-zero at offset 0: {vtable_other}")
print(f" with zero at offset 0: {no_vtable_at_0}")
total_holders = sum(holder_counts)
print(f" total pointers TO these regions from heap: {total_holders}")
print(f" regions with >=1 holder: {sum(1 for c in holder_counts if c > 0)}")
print(f" regions with 0 holders: {sum(1 for c in holder_counts if c == 0)}")
k.CloseHandle(h)

Some files were not shown because too many files have changed in this diff Show more