diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index a8285cd..5695ba9 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -235,9 +235,14 @@ if (args[0] == "trace") bool boot = args.Contains("--boot"); var jscripts = Paths.Scripts(); var target = Sys4Loader.Load(jscripts[sceneName.ToUpperInvariant()], table); - var session = new GameSession(); - // optional 0xADDR=VAL seeds — inject pre-scene state the cold --boot misses (e.g. 0x6c1=1, the - // ADV-chrome enable set by the real engine's system boot). Applied before boot so they persist. + // --state : start from a captured scene-entry snapshot (Frida global-write log → + // capture_global_writes.py) — the real engine's full pre-scene state, superseding the partial + // --boot. Otherwise fresh + optional --boot. + int si = Array.IndexOf(args, "--state"); + var session = (si >= 0 && si + 1 < args.Length) + ? GameSession.FromJson(File.ReadAllText(args[si + 1])) + : new GameSession(); + // optional 0xADDR=VAL seeds — inject pre-scene state by hand (e.g. 0x6c1=1). Applied on top. foreach (var s in args.Where(a => a.Contains('=') && a != outPath)) { var kv = s.Split('='); @@ -245,7 +250,7 @@ if (args[0] == "trace") long v = kv[1].StartsWith("0x") ? Convert.ToInt64(kv[1], 16) : long.Parse(kv[1]); session.Seed(k, v); } - if (boot) + if (boot && si < 0) // --state already carries boot state; don't re-run the *INIT prefix foreach (var b in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" }) session.RunScene(Sys4Loader.Load(jscripts[b], table), table, new CaptureHost(), null, provider); var sink = new JsonOffsetTraceSink(target.Name); diff --git a/tools/frida/capture_global_writes.py b/tools/frida/capture_global_writes.py new file mode 100644 index 0000000..7510080 --- /dev/null +++ b/tools/frida/capture_global_writes.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Capture the engine's global-int writes → a scene-entry state snapshot (docs/engine-re.md +"Differential offset-path oracle" → the pre-scene-state seed). + +Hooks the named helper vm_operand_write@0x425fb0 (thiscall: ecx=ctx, [esp+4]=operand index, +[esp+8]=value). The helper receives the PLAINTEXT value before the engine encodes it into the +obfuscated global store (rotate+XOR with the per-session cookie at ctx+0x55120) — which is exactly +why the shelved flat-int32 scans (global-memory-re.md) found nothing, and why hooking the WRITER is +the clean path: no de-obfuscation needed. For each call we replicate the helper's own address math: + + curCtx = *(ctx+0x53d14); framePc = *(ctx+0x53d2c+curCtx*0x78) + opnd = framePc + operand_index*8; type = *(opnd-4); index = *(opnd) ; value = [esp+8] + +and log (codebase, index, value) for type 3 = global-int (type 4=float / 6=string / 9,10,12=locals +skipped for v1). Codebase is recorded so a scene's OWN writes can be excluded to get the true +pre-scene boundary. + + py -3.11 -u -X utf8 tools/frida/capture_global_writes.py --spawn [seconds] # complete (from boot) + py -3.11 -u -X utf8 tools/frida/capture_global_writes.py --attach [seconds] # partial (from now) + +--spawn launches AGE.EXE under Frida so hooks are live before instruction 0 (captures TITLE etc.); +--attach hooks the running game (misses everything before attach). Writes build/global-writes.jsonl +(raw, ordered) + build/scene-entry-state.json (last-write-wins GameSession snapshot, loadable via +`Age.Cli ... --state`). +""" +import json +import sys +import time +from collections import OrderedDict +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +GAME_DIR = REPO.parent / "姫狩りダンジョンマイスター" +AGE_EXE = GAME_DIR / "AGE.EXE" +RAW = REPO / "build" / "global-writes.jsonl" +SNAP = REPO / "build" / "scene-entry-state.json" +LIVE = REPO / "build" / "tracer-live.flag" + +WRITE_OFF = 0x25fb0 # vm_operand_write (0x425fb0) +IDX_OFF = 0x53d14 # cur_ctx_index +PC_BASE = 0x53d2c # frame_pc +CB_BASE = 0x53d28 # frame_codebase +STRIDE = 0x78 + +# vm_operand_write's SEH prologue (from build/engine-dump): push -1; push 0x56a836; mov eax,fs:[0]. +# AGE.EXE is PACKED and unpacks in-place, so at --spawn time these bytes are still packed and Frida +# CANNOT intercept ("unable to intercept function at 00425FB0"). We poll until the real prologue appears +# (unpack done) THEN attach — for --attach (already unpacked) the very first poll matches, a no-op cost. +SIG = "0x6a,0xff,0x68,0x36,0xa8,0x56,0x00,0x64,0xa1,0x00,0x00,0x00,0x00" + +JS = r""" +const WOFF=%d, IDX_OFF=%d, PC_BASE=%d, CB_BASE=%d, STRIDE=%d; +const SIG=[%s]; +const mod = Process.getModuleByName('AGE.EXE'); +let buf=[], total=0, calls=0, installed=false; +function flush(){ if(buf.length){ send({kind:'batch', rows:buf}); buf=[]; } } +function install(){ + if (installed) return true; + let ok=false; + try{ + const b=new Uint8Array(mod.base.add(WOFF).readByteArray(SIG.length)); + ok = SIG.every((v,i)=>b[i]===v); + }catch(e){ ok=false; } + if (!ok) return false; + Interceptor.attach(mod.base.add(WOFF), { + onEnter(args){ + calls++; + const ctx=this.context.ecx, sp=this.context.esp; + try{ + const opndIdx = sp.add(4).readU32(); // param_2 = operand index + const value = sp.add(8).readS32(); // param_3 = plaintext value (signed int) + const curCtx = ctx.add(IDX_OFF).readS32(); + if (curCtx<0 || curCtx>=64) return; + const framePc = ctx.add(PC_BASE + curCtx*STRIDE).readPointer(); + const cb = ctx.add(CB_BASE + curCtx*STRIDE).readU32(); + const opnd = framePc.add(opndIdx*8); + const type = opnd.sub(4).readU32(); + if (type !== 3) return; // v1: global-int only + const index = opnd.readU32(); + buf.push([cb>>>0, index>>>0, value]); + total++; + if (buf.length>=4000) flush(); + }catch(e){ return; } + } + }); + installed = true; + send({kind:'ready', base: mod.base.toString()}); + return true; +} +if (!install()) { const t=setInterval(()=>{ if(install()) clearInterval(t); }, 10); } +setInterval(flush, 200); +setInterval(()=>{ if (installed) send({kind:'stats', calls:calls, total:total}); }, 3000); +rpc.exports = { flush(){ flush(); return total; }, stats(){ return total; } }; +""" % (WRITE_OFF, IDX_OFF, PC_BASE, CB_BASE, STRIDE, SIG) + + +def run(mode, seconds, exe): + import frida + RAW.parent.mkdir(parents=True, exist_ok=True) + raw = open(RAW, "w", encoding="utf-8") + n = {"rows": 0} + + 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": + LIVE.write_text("live", encoding="utf-8") # hook is really installed (post-unpack) now + print(f"[frida] hook INSTALLED @ {pl['base']} + 0x{WRITE_OFF:x} (vm_operand_write)"); return + if pl.get("kind") == "stats": + print(f"[frida] hook fired {pl['calls']}x → {pl['total']} global-int writes captured"); return + if pl.get("kind") == "batch": + for cb, idx, val in pl["rows"]: + raw.write(json.dumps({"codebase": cb, "index": idx, "value": val}) + "\n") + n["rows"] += len(pl["rows"]) + + device = frida.get_local_device() + if mode == "spawn": + print(f"[frida] spawning {exe}\n cwd={GAME_DIR}") + pid = device.spawn([str(exe)], cwd=str(GAME_DIR)) + try: + session = device.attach(pid) + script = session.create_script(JS) + script.on("message", on_message) + script.load() # script loaded; hook installs itself once the code unpacks + device.resume(pid) # MUST reach this, else the game stays suspended (no window) + except Exception: + try: + device.kill(pid) # don't leave a suspended orphan if setup failed + except Exception: + pass + raise + print(f"[frida] spawned pid {pid}, resumed. Waiting for unpack, then the hook self-installs.") + print(f" Capturing {seconds}s — let it boot to TITLE, then click New Game.") + else: + try: + session = frida.attach("AGE.EXE") + except frida.ProcessNotFoundError: + print("[frida] AGE.EXE not running."); raw.close(); return 2 + script = session.create_script(JS) + script.on("message", on_message) + script.load() + print(f"[frida] attached to AGE.EXE. Capturing {seconds}s (partial — misses pre-attach writes).") + + try: + for _ in range(seconds): + time.sleep(1) + except KeyboardInterrupt: + print("[frida] stopped early (Ctrl-C).") + try: + script.exports_sync.flush(); time.sleep(0.3) + except Exception: + pass + try: + session.detach() + except Exception: + pass + raw.close() + try: + LIVE.unlink() + except OSError: + pass + + # Build the snapshot (last-write-wins) + a per-codebase breakdown so a scene's own writes are visible. + globals_lastwin = OrderedDict() + per_cb = {} + with open(RAW, encoding="utf-8") as f: + for line in f: + r = json.loads(line) + globals_lastwin[r["index"]] = r["value"] + per_cb.setdefault(r["codebase"], set()).add(r["index"]) + snap = {"Globals": {str(k): v for k, v in globals_lastwin.items()}, "Strings": {}} + SNAP.write_text(json.dumps(snap), encoding="utf-8") + + print(f"\n[capture] {n['rows']} global-int writes; {len(globals_lastwin)} distinct globals") + print(f"[capture] raw -> {RAW}") + print(f"[capture] snap -> {SNAP} (GameSession shape, load via --state)") + print(f"[capture] codebases writing globals: {len(per_cb)}") + for cb, idxs in sorted(per_cb.items(), key=lambda kv: -len(kv[1]))[:8]: + print(f" 0x{cb:08x}: {len(idxs)} distinct globals") + # spot-check a couple of the flags the oracle flagged + for probe in (0x6c1, 0x6d3, 0x62451): + if probe in globals_lastwin: + print(f"[capture] G[0x{probe:x}] = {globals_lastwin[probe]}") + return 0 + + +def main(): + a = sys.argv[1:] + mode = "spawn" if "--spawn" in a else "attach" + exe = next((a[i + 1] for i, x in enumerate(a) if x == "--exe" and i + 1 < len(a)), AGE_EXE) + secs = next((int(x) for x in a if x.isdigit()), 60) + return run(mode, secs, exe) + + +if __name__ == "__main__": + sys.exit(main())