134 lines
6.8 KiB
Python
134 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture native ADV text layout, rasterization, reveal, and presentation boundaries.
|
|
|
|
Start the native game at the title screen, arm this probe, then choose New Game and click through
|
|
the first voiced page (SC0000 0x9b2/0x9d3). Output is written to
|
|
``build/native-adv-text-trace.jsonl``. The probe is read-only.
|
|
|
|
py -3.11 -u -X utf8 tools/frida/capture_adv_text_trace.py 45
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
OUT = REPO / "build" / "native-adv-text-trace.jsonl"
|
|
|
|
JS = r"""
|
|
const mod = Process.getModuleByName('AGE.EXE');
|
|
const OFF = {
|
|
operand:0x1b940, op6e:0x1e330, op7a:0x1eba0, op204:0x22a60,
|
|
setCursor:0x530f0, buildText:0x576c0, buildTextBlocking:0x57dc0,
|
|
reveal:0x51220, drawString:0x50150, rasterSimple:0x59d90, rasterCached:0x5b600,
|
|
bind:0x7e870
|
|
};
|
|
const IDX=0x53d14, PC=0x53d2c, CB=0x53d28, STRIDE=0x78;
|
|
let ctx=null, current={codebase:0,offset:-1}, seq=0;
|
|
|
|
function i32(p,o=0){try{return p.add(o).readS32();}catch(e){return null;}}
|
|
function u32(p,o=0){try{return p.add(o).readU32();}catch(e){return null;}}
|
|
function stackP(reg,n){try{return reg.esp.add(4+n*4).readPointer();}catch(e){return ptr(0);}}
|
|
function stackI(reg,n){try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;}}
|
|
function bytes(p,cap=256){
|
|
if(!p || p.isNull()) return [];
|
|
const out=[]; try{for(let i=0;i<cap;i++){const b=p.add(i).readU8();if(b===0)break;out.push(b);}}catch(e){}
|
|
return out;
|
|
}
|
|
function layout(mgr,slot){
|
|
try{
|
|
if(slot===0)slot=i32(mgr,0x4c8);
|
|
const p=mgr.add(0x414+slot*4).readPointer();
|
|
const begin=u32(p,0x30),end=u32(p,0x34),count=(begin&&end)?((end-begin)/0x14):0;
|
|
let last=null;if(count>0){const q=ptr(end-0x14);last=[i32(q,4),i32(q,8),i32(q,12),i32(q,16),i32(q,0)];}
|
|
return {slot:slot,layout:p.toString(),count:count,last:last,origin:[i32(p,0xc),i32(p,0x10)],handleBase:i32(p,0x68),
|
|
revealLimit:i32(p,0x6c),mode:i32(p,0x70),revealIndex:i32(mgr,0x570)};
|
|
}catch(e){return {slot:slot,error:String(e)};}
|
|
}
|
|
function state(extra={}){const c=ctx;return Object.assign({kind:'event',seq:++seq,t:Date.now(),
|
|
codebase:current.codebase,offset:current.offset,
|
|
frameTime:c?u32(c,0xb550):null,runFlags:c?u32(c,0xa0ce4):null,
|
|
charDelay:c?i32(c,0x14e9c):null},extra);}
|
|
function emit(name,extra={}){send(state(Object.assign({name:name},extra)));}
|
|
|
|
Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){
|
|
const c=this.context.ecx;ctx=c;
|
|
try{const idx=c.add(IDX).readS32();if(idx<0||idx>=64)return;
|
|
const pc=c.add(PC+idx*STRIDE).readU32(),cb=c.add(CB+idx*STRIDE).readU32();
|
|
current={codebase:cb>>>0,offset:((pc-cb)>>>2)};}catch(e){}
|
|
}});
|
|
|
|
for(const [name,off] of [['op-0x6e',OFF.op6e],['op-0x7a',OFF.op7a],['op-0x204',OFF.op204]])
|
|
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name);}});
|
|
|
|
Interceptor.attach(mod.base.add(OFF.setCursor),{onEnter(){
|
|
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);
|
|
emit('cursor-set-enter',{slot:this.slot,x:stackI(this.context,1),y:stackI(this.context,2),layout:layout(this.mgr,this.slot)});
|
|
},onLeave(){emit('cursor-set-leave',{layout:layout(this.mgr,this.slot)});}});
|
|
|
|
function hookBuild(off,name){Interceptor.attach(mod.base.add(off),{onEnter(){
|
|
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);this.before=layout(this.mgr,this.slot);
|
|
emit(name+'-enter',{slot:this.slot,textBytes:bytes(stackP(this.context,1)),rubyBytes:bytes(stackP(this.context,2)),
|
|
flags:stackI(this.context,3),layout:this.before});
|
|
},onLeave(){emit(name+'-leave',{layout:layout(this.mgr,this.slot)});}});}
|
|
hookBuild(OFF.buildText,'build-text');hookBuild(OFF.buildTextBlocking,'build-text-blocking');
|
|
|
|
Interceptor.attach(mod.base.add(OFF.reveal),{onEnter(){
|
|
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);emit('glyph-reveal-enter',{layout:layout(this.mgr,this.slot)});
|
|
},onLeave(ret){emit('glyph-reveal-leave',{done:ret.toInt32(),layout:layout(this.mgr,this.slot)});}});
|
|
|
|
Interceptor.attach(mod.base.add(OFF.drawString),{onEnter(){emit('draw-string-worker',{
|
|
surface:stackI(this.context,0),textBytes:bytes(stackP(this.context,1)),x:stackI(this.context,2),y:stackI(this.context,3)});}});
|
|
for(const [name,off] of [['raster-simple',OFF.rasterSimple],['raster-cached',OFF.rasterCached]])
|
|
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name,{surface:stackI(this.context,0),x:stackI(this.context,1),y:stackI(this.context,2)});}});
|
|
Interceptor.attach(mod.base.add(OFF.bind),{onEnter(){const h=stackI(this.context,0);if(h===0xe678||h>=0xe678&&h<0xe800)
|
|
emit('bind-text-object',{handle:h,slot:stackI(this.context,1),src:[stackI(this.context,2),stackI(this.context,3),stackI(this.context,4),stackI(this.context,5)],
|
|
dst:[stackI(this.context,6),stackI(this.context,7)]});}});
|
|
send({kind:'ready',base:mod.base.toString()});
|
|
"""
|
|
|
|
|
|
def main():
|
|
import frida
|
|
seconds = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 45
|
|
proc = sys.argv[2] if len(sys.argv) > 2 else "AGE.EXE"
|
|
target = int(proc) if proc.isdigit() else proc
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
counts = {}
|
|
with OUT.open("w", encoding="utf-8") as f:
|
|
def on_message(msg, data):
|
|
if msg.get("type") == "error":
|
|
print("[frida-error]", msg.get("description")); return
|
|
if msg.get("type") != "send": return
|
|
row = msg["payload"]
|
|
if row.get("kind") == "ready":
|
|
print(f"[frida] ADV text hooks live @ {row['base']}"); return
|
|
for key in ("textBytes", "rubyBytes"):
|
|
if key in row:
|
|
row[key[:-5]] = bytes(row.pop(key)).decode("cp932", errors="replace")
|
|
f.write(json.dumps(row, ensure_ascii=False) + "\n"); f.flush()
|
|
name = row.get("name", "?"); counts[name] = counts.get(name, 0) + 1
|
|
if name in {"op-0x6e", "op-0x7a", "op-0x204", "cursor-set-enter",
|
|
"draw-string-worker", "build-text-enter", "glyph-reveal-enter",
|
|
"bind-text-object", "surface-lock", "surface-unlock"}:
|
|
print(f" #{row['seq']:05d} off=0x{row['offset']:05x} {name:22s} delay={row['charDelay']}")
|
|
|
|
try:
|
|
session = frida.attach(target)
|
|
except frida.ProcessNotFoundError:
|
|
print("[frida] AGE.EXE not found; leave the native game at the title screen first.")
|
|
return 2
|
|
script = session.create_script(JS); script.on("message", on_message); script.load()
|
|
print(f"[frida] capture armed for {seconds}s. Choose New Game and click through the first voiced page.")
|
|
try: time.sleep(seconds)
|
|
except KeyboardInterrupt: pass
|
|
try: session.detach()
|
|
except Exception: pass
|
|
print(f"[trace] wrote {sum(counts.values())} events -> {OUT}")
|
|
print("[trace] " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
|
return 0 if counts else 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|