feat(frida): capture_gfx_objects + finding — drift is state-divergence

Live capture (esi=engine ctx via operand-fetch, poll object-record array
[esi+0x53d64] stride 120). KEY FINDING: through the full real opening, the record
array holds only 3 persistent UI objects — NO CG objects. The real game does NOT
draw opening CGs via the 0x212-0x21a positioned-object path our headless VM uses;
with state it takes a different (direct) branch. So the bg/sprite drift is a
STATE-DIVERGENCE artifact of the unseeded headless VM, not a missing native op —
the fix is the Phase B state/choices flow (makes label_12649 take the if-branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-07 00:04:49 -04:00
parent fbc78b0c00
commit 16fe754422

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Capture the native graphics object-manager state, to RE the slot/geometry the engine assigns each
object (the ground truth behind the drift; docs/phase-a-slice-plan.md A2b).
Root cause recap: our VM stubs 0x215 (native gfx-object query) so every draw collapses onto slot 0.
The real per-object slot/geometry lives in a native object-record array inside the engine context.
Robust anchoring: individual gfx handler entry addresses (Kelebek) are approximate (mid-instruction),
so hooking them is unreliable. Instead we hook the ONE confirmed-firing address — the operand-fetch
helper at module +0x1b940 (thiscall; ecx = the engine context 'esi') — grab the context pointer once,
then POLL the object-record array directly: [esi + 0x53d64], stride 120 (0x78) bytes/object, command-
type field at record+0x24, current index at [esi + 0x53d14]. No dependence on fragile handler offsets.
Decode offline + correlate with `Age.Cli gfx SC0000.BIN` (objects load EV049AA/EV052*/BG030A/... with
slots 4..13 per the 0x3239 record table). Poll snapshots over time show how records change per scene.
Flow: attach while a scene is up (or replay the opening) -> polls ~2/s -> Ctrl-C -> build/gfx-objects.jsonl.
Usage: py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid|AGE.EXE] [seconds]
"""
import json
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "gfx-objects.jsonl"
OPFETCH_OFF = 0x1b940 # operand-fetch helper (call 0x41b940) — confirmed firing; ecx = context
REC_BASE = 0x53d64 # object-record array offset within the engine context (esi)
REC_STRIDE = 120 # bytes per object record
IDX_OFF = 0x53d14 # current object index
N_RECORDS = 20 # poll the first N object records
JS = r"""
const mod = Process.getModuleByName('AGE.EXE');
const OPFETCH = mod.base.add(%d);
const REC_BASE = %d, REC_STRIDE = %d, IDX_OFF = %d, N = %d;
let ctx = null;
const h = Interceptor.attach(OPFETCH, {
onEnter(args){
if (ctx === null){ ctx = this.context.ecx; send({kind:'ctx', esi: ctx.toString()}); }
}
});
function poll(){
if (ctx === null){ send({kind:'wait'}); return; }
let idx = -1, recs = null;
try { idx = ctx.add(IDX_OFF).readS32(); } catch(e){}
try { recs = ctx.add(REC_BASE).readByteArray(N * REC_STRIDE); } catch(e){}
send({kind:'snap', idx:idx}, recs);
}
setInterval(poll, 450);
send({kind:'ready', base: mod.base.toString(), opfetch: OPFETCH.toString()});
"""
def main():
import frida
args = [a for a in sys.argv[1:] if not a.startswith("-")]
proc = args[0] if args else "AGE.EXE"
secs = int(args[1]) if len(args) > 1 else 20
OUT.parent.mkdir(parents=True, exist_ok=True)
log = open(OUT, "w", encoding="utf-8")
st = {"n": 0, "ctx": None}
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 == "ready":
print(f"[frida] hooked operand-fetch @ {pl['opfetch']}; grabbing context + polling records.")
elif k == "ctx":
st["ctx"] = pl["esi"]; print(f"[frida] engine context esi = {pl['esi']}")
elif k == "wait":
pass
elif k == "snap":
rec = {"idx": pl["idx"], "esi": st["ctx"], "records_hex": data.hex() if data else None}
log.write(json.dumps(rec) + "\n"); log.flush()
st["n"] += 1
# quick view: current idx + that record's first 40 bytes
if data and 0 <= pl["idx"] < N_RECORDS:
r = data[pl["idx"]*REC_STRIDE:(pl["idx"]+1)*REC_STRIDE]
print(f" snap#{st['n']} idx={pl['idx']} rec[:40]={r[:40].hex(' ')}")
else:
print(f" snap#{st['n']} idx={pl['idx']} (no rec)")
target = int(proc) if str(proc).isdigit() else proc
try:
session = frida.attach(target)
except (frida.ProcessNotFoundError, frida.ServerNotRunningError):
hits = [(p.pid, p.name) for p in frida.get_local_device().enumerate_processes()
if p.name.upper().startswith("AGE")]
print("[frida] AGE.EXE not found. AGE* processes:", hits or "(none)")
return 2
script = session.create_script(JS % (OPFETCH_OFF, REC_BASE, REC_STRIDE, IDX_OFF, N_RECORDS))
script.on("message", on_message)
script.load()
print(f"[frida] attached to {proc}; polling {secs}s -> {OUT}")
time.sleep(secs)
print(f"\n[frida] done. {st['n']} snapshots -> {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())