diff --git a/tools/frida/README.md b/tools/frida/README.md
index 819b678..3da5d14 100644
--- a/tools/frida/README.md
+++ b/tools/frida/README.md
@@ -21,6 +21,14 @@ Prereq: `py -3.11 -m pip install frida` (core only — `frida-tools` CLI is not
## Tools
+- **`dump_engine.py`** — ★ dumps the **unpacked engine code** from the live process for offline static
+ RE (native op handlers). Both on-disk images are the same packed binary (`SYS4AB.BIN` = XOR-0xFF of
+ `AGE.EXE`), so the real handler code exists only in memory: the `AGE.EXE` module (some code unpacked
+ in-place, e.g. the AGF decoder `+0x74f1f`) plus the main VM interpreter in a large per-run heap `r-x`
+ region (~30 MB, nonstable base). Attach → it enumerates ranges, dumps the module image + every r-x
+ range ≥ 1 MB (chunked) → `build/engine-dump/{manifest.json,range_.bin}`, and prints the
+ landmark bytes at `AGE.EXE+0x74f1f` to validate. Then disassemble (capstone) and locate a handler
+ (e.g. `0x215` @ Kelebek VA `0x421160`) via the opcode dispatch table. Attach by pid.
- **`capture_load_order.py`** — ★ the working asset-resolution capture. Hooks `ReadFile` on
`DATA2.ALF`; each asset load starts with header reads **at its exact archive offset**, so exact-start
reads give the clean per-asset **load order** (→ names via `build/asset-index.json`). `--analyze`
diff --git a/tools/frida/dump_engine.py b/tools/frida/dump_engine.py
new file mode 100644
index 0000000..a683754
--- /dev/null
+++ b/tools/frida/dump_engine.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""Dump the UNPACKED engine code from the live game so we can statically disassemble native op
+handlers (docs/vm-mapping-plan.md appendix; docs/global-memory-re.md).
+
+Why: both on-disk engine images are the SAME packed binary — SYS4AB.BIN decrypts (XOR-0xFF) to
+AGE.EXE byte-for-byte, and AGE.EXE's code sections are entropy-8.00 packed garbage. The real handler
+code exists only after the packer runs, in memory: AGE.EXE has some unpacked code in-place (e.g. the
+AGF decoder at AGE.EXE+0x74f1f) AND the main VM interpreter runs from a large per-run heap r-x region
+(~30 MB, nonstable base). So to RE a handler (e.g. 0x215 @ Kelebek VA 0x421160) we dump these regions
+live, then disassemble offline and locate the handler via the opcode dispatch table.
+
+Flow: launch the game (via `AGE Patch.exe`, JP locale) to the title -> attach -> this dumps -> exit.
+Output: build/engine-dump/manifest.json + range_.bin per dumped region (build/ is gitignored).
+
+Usage: py -3.11 -u -X utf8 tools/frida/dump_engine.py [pid|AGE.EXE]
+"""
+import json
+import sys
+import time
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+OUTDIR = REPO / "build" / "engine-dump"
+
+# Dump every r-x range (code), plus the AGE.EXE module image in full. rw- ranges are only listed in
+# the manifest (native object-manager state; snapshot later if a handler needs it). CHUNK keeps each
+# frida message small; 2 MB is comfortably under the default limits.
+CHUNK = 2 * 1024 * 1024
+
+JS = r"""
+const CHUNK = %d;
+
+function ranges(prot){ return Process.enumerateRanges(prot).map(r => ({
+ base: r.base.toString(), size: r.size, prot: r.protection,
+ file: r.file ? r.file.path : null })); }
+
+function dump(baseStr, size, tag){
+ const base = ptr(baseStr);
+ for (let off = 0; off < size; off += CHUNK){
+ const n = Math.min(CHUNK, size - off);
+ let buf;
+ try { buf = Memory.readByteArray(base.add(off), n); }
+ catch(e){ send({kind:'gap', base:baseStr, off:off, n:n, err:''+e}); continue; }
+ send({kind:'chunk', base:baseStr, off:off, n:n, tag:tag}, buf);
+ }
+ send({kind:'done', base:baseStr, size:size, tag:tag});
+}
+
+const mod = Process.getModuleByName('AGE.EXE');
+const rx = ranges('r-x');
+const rw = ranges('rw-');
+
+// landmark check: the AGF decoder is documented at AGE.EXE+0x74f1f (stable, unpacked in-place)
+let landmark = null;
+try { landmark = Memory.readByteArray(mod.base.add(0x74f1f), 16); } catch(e){}
+
+send({kind:'manifest',
+ age_base: mod.base.toString(), age_size: mod.size,
+ rx: rx, rw: rw}, landmark);
+
+// dump targets: the AGE.EXE module image, plus every r-x range >= 1 MB (the unpacked heap code)
+dump(mod.base.toString(), mod.size, 'age-module');
+for (const r of rx){
+ if (r.base === mod.base.toString()) continue; // module already covered
+ if (r.size >= 1*1024*1024) dump(r.base, r.size, 'rx-heap');
+}
+send({kind:'alldone'});
+"""
+
+
+def capture(proc):
+ import frida
+ OUTDIR.mkdir(parents=True, exist_ok=True)
+ files = {} # base -> open file handle
+ manifest = {}
+ done = {"flag": False}
+
+ def fh(base):
+ if base not in files:
+ files[base] = open(OUTDIR / f"range_{int(base, 16):08x}.bin", "wb")
+ return files[base]
+
+ def on_message(msg, data):
+ if msg.get("type") == "error":
+ print("[frida-error]", msg.get("description")); return
+ if msg.get("type") != "send":
+ return
+ pl = msg["payload"]
+ k = pl.get("kind")
+ if k == "manifest":
+ manifest.update(pl)
+ lm = data.hex(" ") if data else "(unreadable)"
+ print(f"[frida] AGE.EXE base={pl['age_base']} size=0x{pl['age_size']:x}")
+ print(f"[frida] landmark AGE.EXE+0x74f1f = {lm}")
+ print(f"[frida] r-x ranges: {len(pl['rx'])} rw- ranges: {len(pl['rw'])}")
+ for r in pl["rx"]:
+ mark = " <-- dump" if (r["size"] >= 1 << 20 or r["base"] == pl["age_base"]) else ""
+ print(f" r-x {r['base']} size=0x{r['size']:x} {r.get('file') or ''}{mark}")
+ elif k == "chunk":
+ f = fh(pl["base"]); f.seek(pl["off"]); f.write(data)
+ elif k == "gap":
+ print(f" [gap] {pl['base']}+0x{pl['off']:x} n=0x{pl['n']:x} {pl['err']}")
+ elif k == "done":
+ print(f"[frida] dumped {pl['tag']} {pl['base']} (0x{pl['size']:x} bytes)")
+ elif k == "alldone":
+ done["flag"] = True
+
+ target = int(proc) if str(proc).isdigit() else proc
+ try:
+ session = frida.attach(target)
+ except (frida.ProcessNotFoundError, frida.ServerNotRunningError):
+ procs = frida.get_local_device().enumerate_processes()
+ hits = [(p.pid, p.name) for p in procs if "age" in p.name.lower()]
+ print("[frida] AGE.EXE not found. Running AGE-like processes:", hits or "(none)")
+ print(" Launch the game (AGE Patch.exe, JP locale) to the title, then re-run.")
+ return 2
+
+ script = session.create_script(JS % CHUNK)
+ script.on("message", on_message)
+ script.load()
+ print(f"[frida] attached to {proc}; dumping engine code -> {OUTDIR}")
+ try:
+ for _ in range(600): # up to ~60 s; exits early on alldone
+ if done["flag"]:
+ break
+ time.sleep(0.1)
+ except KeyboardInterrupt:
+ pass
+ for f in files.values():
+ f.close()
+ if manifest:
+ (OUTDIR / "manifest.json").write_text(
+ json.dumps({k: v for k, v in manifest.items() if k != "type"},
+ ensure_ascii=False, indent=1), encoding="utf-8")
+ print(f"\n[frida] wrote {len(files)} range file(s) + manifest.json to {OUTDIR}")
+ print(" Next: disassemble (capstone) and locate the 0x215 handler via the dispatch table.")
+ return 0 if done["flag"] else 1
+
+
+if __name__ == "__main__":
+ proc = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE")
+ sys.exit(capture(proc))