Reads cur_ctx_index/frame_pc/frame_codebase from the engine ctx per executed op and emits (codebase, offset=(pc-codebase)/4) to build/engine-optrace.jsonl. Recon result: the tick hook (0x410fb0) does NOT expose ctx via ecx (0 entries); the operand hook (0x41b940) is the working capture (100% of offsets land on valid SC0000 instruction starts). Writes a tracer-live.flag so the capture can be gated on hooks-installed before the scene loads (else the entry burst is missed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
7.0 KiB
Python
166 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Engine op-path tracer for the differential offset-path oracle (docs/engine-re.md).
|
|
|
|
Attaches to the running game and, per executed op, reads the current coroutine's PC + codebase from the
|
|
engine ctx and emits (codebase, offset=(pc-codebase)/4) to build/engine-optrace.jsonl in execution order.
|
|
Diffed against the VM's trace (Age.Cli trace --trace-json) by tools/diff_optrace.py — the first divergence
|
|
is the opcode/branch we modeled wrong.
|
|
|
|
READ-ONLY (Interceptor + memory reads; no patching), following probe_frame_cadence.py / capture_gfx_objects.py.
|
|
Two selectable hooks (Task 1 recon gate decides which is safe):
|
|
--hook tick -> adv_interpreter_tick 0x410fb0 (one op per tick; clean 1:1 signal). CModule on this
|
|
path crashed the game before; PLAIN-JS here is untested -> the recon gate proves it.
|
|
--hook operand -> vm_operand_fetch 0x41b940 (PROVEN-safe, probe_frame_cadence.py uses it). Fires
|
|
per-operand, so we dedupe consecutive same-pc; misses zero-operand ops (markers/no-ops,
|
|
which don't branch) -> acceptable for a control-flow diff.
|
|
|
|
py -3.11 -u -X utf8 tools/frida/trace_engine_ops.py [--hook tick|operand] [seconds] [proc]
|
|
|
|
Let the deterministic opening auto-advance to the first wait-for-input, then stop (Ctrl-C or the timeout).
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
OUT = REPO / "build" / "engine-optrace.jsonl"
|
|
LIVE = REPO / "build" / "tracer-live.flag" # touched once hooks are installed; removed on exit
|
|
|
|
TICK_OFF = 0x10fb0 # adv_interpreter_tick (0x410fb0); one op per tick, thiscall ecx=ctx
|
|
OPFETCH_OFF = 0x1b940 # vm_operand_fetch (0x41b940); per-operand, thiscall ecx=ctx (PROVEN-safe)
|
|
IDX_OFF = 0x53d14 # cur_ctx_index
|
|
PC_BASE = 0x53d2c # frame_pc ; +idx*0x78 = current instruction pointer
|
|
CB_BASE = 0x53d28 # frame_codebase; +idx*0x78 = base pointer of the running script's code
|
|
PC_STRIDE = 0x78
|
|
|
|
JS = r"""
|
|
const HOOK_OFF=%d, IDX_OFF=%d, PC_BASE=%d, CB_BASE=%d, PC_STRIDE=%d, DEDUPE=%d;
|
|
const mod = Process.getModuleByName('AGE.EXE');
|
|
let buf = [], total = 0, lastPc = -1;
|
|
const FLUSH_N = 2000;
|
|
|
|
function flush(){ if (buf.length){ send({kind:'batch', rows: buf}); buf = []; } }
|
|
|
|
Interceptor.attach(mod.base.add(HOOK_OFF), {
|
|
onEnter(args){
|
|
const ctx = this.context.ecx; // thiscall: ecx = engine ctx
|
|
let idx, pc, cb;
|
|
try {
|
|
idx = ctx.add(IDX_OFF).readS32();
|
|
if (idx < 0 || idx >= 64) return;
|
|
pc = ctx.add(PC_BASE + idx*PC_STRIDE).readU32();
|
|
cb = ctx.add(CB_BASE + idx*PC_STRIDE).readU32();
|
|
} catch(e){ return; }
|
|
if (DEDUPE && pc === lastPc) return; // operand mode: one entry per instruction
|
|
lastPc = pc;
|
|
const off = (pc - cb) >>> 2; // byte delta / 4 = bytecode word index
|
|
buf.push([cb >>> 0, off]);
|
|
total++;
|
|
if (buf.length >= FLUSH_N) flush();
|
|
}
|
|
});
|
|
|
|
setInterval(flush, 200); // flush the tail so nothing is lost on detach
|
|
send({kind:'ready', base: mod.base.toString(), hook: '0x'+HOOK_OFF.toString(16), total: 0});
|
|
rpc.exports = { stats(){ return total; }, flush(){ flush(); return total; } };
|
|
"""
|
|
|
|
|
|
def capture(hook, seconds, proc):
|
|
import frida
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
hook_off = TICK_OFF if hook == "tick" else OPFETCH_OFF
|
|
dedupe = 1 if hook == "operand" else 0
|
|
js = JS % (hook_off, IDX_OFF, PC_BASE, CB_BASE, PC_STRIDE, dedupe)
|
|
|
|
count = {"rows": 0}
|
|
f = open(OUT, "w", encoding="utf-8")
|
|
|
|
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"]
|
|
if pl.get("kind") == "ready":
|
|
print(f"[frida] hook live @ base {pl['base']} + {pl['hook']} (mode={hook})"); return
|
|
if pl.get("kind") == "batch":
|
|
for cb, off in pl["rows"]:
|
|
f.write(json.dumps({"codebase": cb, "offset": off}) + "\n")
|
|
count["rows"] += len(pl["rows"])
|
|
|
|
target = int(proc) if str(proc).isdigit() else proc
|
|
try:
|
|
session = frida.attach(target)
|
|
except frida.ProcessNotFoundError:
|
|
procs = frida.get_local_device().enumerate_processes()
|
|
print("[frida] not found. .exe processes:",
|
|
[(p.pid, p.name) for p in procs if p.name.lower().endswith(".exe")])
|
|
f.close(); return 2
|
|
|
|
crashed = {"v": False}
|
|
session.on("detached", lambda reason, *a: crashed.update(v=(reason != "application-requested")) or
|
|
print(f"[frida] session detached: {reason}"))
|
|
script = session.create_script(js)
|
|
script.on("message", on_message)
|
|
script.load() # synchronous: the Interceptor is installed before this returns
|
|
LIVE.write_text("live", encoding="utf-8") # signal to the orchestrator: hooks are live, safe to trigger
|
|
print(f"[frida] attached to {proc}; capturing {seconds}s ({hook} hook). HOOK LIVE — trigger now.")
|
|
print(" >>> let the opening auto-advance to the first wait-for-input. <<<")
|
|
try:
|
|
for _ in range(seconds):
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
print("[frida] stopped early (Ctrl-C).")
|
|
try:
|
|
script.exports_sync.flush() # final flush of the tail
|
|
time.sleep(0.3)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
session.detach()
|
|
except Exception:
|
|
pass
|
|
f.close()
|
|
try:
|
|
LIVE.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
print(f"\n[trace] wrote {count['rows']} entries -> {OUT}")
|
|
if crashed["v"]:
|
|
print("[GATE] the hook DESTABILIZED the game (unexpected detach). If this was --hook tick, "
|
|
"re-run with --hook operand (proven-safe).")
|
|
if count["rows"] == 0:
|
|
print("[GATE] no entries captured — the hook never fired (game idle / wrong hook / ecx!=ctx). "
|
|
"Ensure the game is EXECUTING the opening while capturing.")
|
|
return 3
|
|
# quick shape: distinct codebases + top few by volume
|
|
from collections import Counter
|
|
c = Counter()
|
|
with open(OUT, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
c[json.loads(line)["codebase"]] += 1
|
|
print(f"[trace] distinct codebases: {len(c)}")
|
|
for cb, n in c.most_common(8):
|
|
print(f" 0x{cb:08x}: {n} ops")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
hook = "tick"
|
|
if "--hook" in args:
|
|
hook = args[args.index("--hook") + 1]
|
|
rest = [a for a in args if not a.startswith("-") and a not in ("tick", "operand")]
|
|
seconds = int(rest[0]) if rest and rest[0].isdigit() else 20
|
|
proc = next((a for a in rest if not a.isdigit()), "AGE.EXE")
|
|
if hook not in ("tick", "operand"):
|
|
print("usage: trace_engine_ops.py [--hook tick|operand] [seconds] [proc]"); return 1
|
|
return capture(hook, seconds, proc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|