Fix native retained presentation batching
This commit is contained in:
138
tools/frida/capture_presentation_trace.py
Normal file
138
tools/frida/capture_presentation_trace.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user