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>
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""
|
|
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()
|