139 lines
6.4 KiB
Python
139 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Correlate SC0000 bytecode offsets with native retained-state presentation.
|
|
|
|
This read-only Frida probe records the narrow boundary needed to distinguish object mutation from
|
|
displayed output: draw/color workers, object composition, surface-command consumption, gfx frame render,
|
|
command-queue clear, and the D3D9 Present count. Each event carries the most recent VM codebase/word
|
|
offset plus the native frame clock and dirty/queue fields.
|
|
|
|
Start the game at the title screen, start this probe, then choose New Game so the hook sees SC0000 from
|
|
entry. Stop after the first dialogue page:
|
|
|
|
py -3.11 -u -X utf8 tools/frida/capture_presentation_trace.py 30
|
|
|
|
Output: build/native-presentation-trace.jsonl. No values are patched and the game is never slowed.
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
OUT = REPO / "build" / "native-presentation-trace.jsonl"
|
|
LIVE = REPO / "build" / "presentation-tracer-live.flag"
|
|
|
|
JS = r"""
|
|
const mod = Process.getModuleByName('AGE.EXE');
|
|
const OFF = {
|
|
operand: 0x1b940, bind: 0x7e870, colorAnim: 0x7ea00, colorStatic: 0x7e9b0,
|
|
render: 0x820b0, commands: 0x7fbc0, composite: 0x7f650, clear: 0x7cb10
|
|
};
|
|
const IDX=0x53d14, PC=0x53d2c, CB=0x53d28, STRIDE=0x78;
|
|
let ctx=null, current={codebase:0,offset:-1}, seq=0, presentCount=0, d3dHooked=false;
|
|
|
|
function i32(p,o){ try{return p.add(o).readS32();}catch(e){return null;} }
|
|
function u32(p,o){ try{return p.add(o).readU32();}catch(e){return null;} }
|
|
function state(extra={}) {
|
|
const c=ctx;
|
|
return Object.assign({kind:'event',seq:++seq,t:Date.now(),codebase:current.codebase,
|
|
offset:current.offset,presents:presentCount,frameTime:c?u32(c,0xb550):null,
|
|
dirty:c?i32(c,0xb558):null,commandDirty:c?i32(c,0xb560):null,
|
|
commandCount:c?i32(c,0x41c):null},extra);
|
|
}
|
|
function emit(name,extra={}){ send(state(Object.assign({name:name},extra))); }
|
|
function stackI(reg,n){ try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;} }
|
|
|
|
function hookD3D(c) {
|
|
if (d3dHooked) return;
|
|
d3dHooked=true;
|
|
let d3d; try{d3d=Process.getModuleByName('d3d9.dll');}catch(e){emit('d3d-missing');return;}
|
|
const lo=d3d.base, hi=d3d.base.add(d3d.size), inside=p=>p.compare(lo)>=0&&p.compare(hi)<0;
|
|
let best=null,bestN=0;
|
|
for(let off=0;off<0x200000;off+=4){
|
|
let obj,vt; try{obj=c.add(off).readPointer();vt=obj.readPointer();}catch(e){continue;}
|
|
if(obj.isNull()||!inside(vt))continue;
|
|
let n=0; for(let s=0;s<120;s++){let fn;try{fn=vt.add(s*4).readPointer();}catch(e){break;}
|
|
if(inside(fn))n++;else if(s>3)break;}
|
|
if(n>bestN){bestN=n;best=vt;}
|
|
}
|
|
if(!best||bestN<60){emit('d3d-device-not-found',{methodRun:bestN});return;}
|
|
const fn=best.add(17*4).readPointer();
|
|
Interceptor.attach(fn,{onEnter(){presentCount++;}});
|
|
emit('d3d-present-hooked',{address:fn.toString(),methodRun:bestN});
|
|
}
|
|
|
|
Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){
|
|
const c=this.context.ecx; ctx=c; hookD3D(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){}
|
|
}});
|
|
|
|
Interceptor.attach(mod.base.add(OFF.bind),{onEnter(){emit('bind-draw',{
|
|
handle:stackI(this.context,0),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)]});}});
|
|
Interceptor.attach(mod.base.add(OFF.colorAnim),{onEnter(){emit('color-anim',{
|
|
handle:stackI(this.context,0),delay:stackI(this.context,1),duration:stackI(this.context,2),argb:stackI(this.context,3)>>>0});}});
|
|
Interceptor.attach(mod.base.add(OFF.colorStatic),{onEnter(){emit('color-static',{
|
|
handle:stackI(this.context,0),mode:stackI(this.context,1),argb:stackI(this.context,2)>>>0});}});
|
|
Interceptor.attach(mod.base.add(OFF.commands),{onEnter(){emit('surface-commands-enter');},onLeave(){emit('surface-commands-leave');}});
|
|
Interceptor.attach(mod.base.add(OFF.render),{onEnter(){ctx=this.context.ecx;emit('render-enter');},onLeave(){emit('render-leave');}});
|
|
Interceptor.attach(mod.base.add(OFF.clear),{onEnter(){emit('queue-clear-enter');},onLeave(){emit('queue-clear-leave');}});
|
|
Interceptor.attach(mod.base.add(OFF.composite),{onEnter(args){
|
|
const h=args[0].toUInt32();
|
|
if(h>=0xcb00 && h<=0xd400) emit('composite',{handle:h});
|
|
}});
|
|
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 30
|
|
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)
|
|
f = OUT.open("w", encoding="utf-8")
|
|
counts = {}
|
|
|
|
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] presentation hooks live @ {row['base']}"); return
|
|
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 {"bind-draw", "color-anim", "color-static", "render-enter", "render-leave",
|
|
"surface-commands-enter", "queue-clear-enter"}:
|
|
print(f" #{row['seq']:05d} off=0x{row['offset']:05x} {name:22s} "
|
|
f"h={('0x%x' % row['handle']) if row.get('handle') is not None else '-':>8s} "
|
|
f"present={row['presents']} q={row['commandCount']}")
|
|
|
|
try:
|
|
session = frida.attach(target)
|
|
except frida.ProcessNotFoundError:
|
|
print("[frida] AGE.EXE not found; leave the native game at the title screen first.")
|
|
f.close(); return 2
|
|
script = session.create_script(JS); script.on("message", on_message); script.load()
|
|
LIVE.write_text("live", encoding="utf-8")
|
|
print(f"[frida] capture armed for {seconds}s. Choose New Game now; stop after the first page.")
|
|
try:
|
|
time.sleep(seconds)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
try: session.detach()
|
|
except Exception: pass
|
|
f.close()
|
|
try: LIVE.unlink()
|
|
except OSError: 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())
|