feat: scene-entry state capture -> auto-seed for single-scene runs
capture_global_writes.py hooks vm_operand_write@0x425fb0 (plaintext values, before the obfuscated store — sidesteps the shelved flat-scan problem) and folds the engine's global-int writes into a GameSession snapshot. --spawn captures from boot (packer-aware: polls until the code unpacks, then attaches — AGE.EXE unpacks in-place so a spawn-time hook hits packed bytes; also kills the spawned pid if setup fails so no suspended orphan). Age.Cli 'trace ... --state <snap>' runs a scene from the captured state. Validated: a real boot->new-game->SC0000 capture (34008 globals incl. G[0x6c1]=1) seeds the VM to match the engine's ENTIRE opening (542 ops, no non-realignable fork) with zero manual seeding. Residual: a 2-op color detour (0x202/0x203) unfixed by state = a real branch diff to chase. Engine 81/81, diff 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <file>: 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);
|
||||
|
||||
199
tools/frida/capture_global_writes.py
Normal file
199
tools/frida/capture_global_writes.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user