Implement native-backed SC0000 SFX lifecycle
This commit is contained in:
102
tools/frida/capture_sfx_trace.py
Normal file
102
tools/frida/capture_sfx_trace.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture native SC0000 SFX op/worker/DirectSound timing (read-only).
|
||||
|
||||
Start at the title, arm this probe, then choose New Game. The opening reaches the first
|
||||
load/start pair at 0xc29 without input. Output: build/native-sfx-trace.jsonl.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
OUT = REPO / "build" / "native-sfx-trace.jsonl"
|
||||
TMP = REPO / "build" / "native-sfx-trace.tmp.jsonl"
|
||||
LIVE = REPO / "build" / "sfx-tracer-live.flag"
|
||||
|
||||
JS = r"""
|
||||
const mod=Process.getModuleByName('AGE.EXE');
|
||||
const OFF={operand:0x1b940,b4:0x201d0,b5:0x20210,b6:0x20250,c2:0x204c0,d9:0x16da0,
|
||||
load:0x82500,start:0x825d0,release:0x82600,decode:0x83360,destroy:0x831a0,
|
||||
dsStart:0x84270,fadeArm:0x64830,fadeTick:0x64960};
|
||||
const IDX=0x53d14,PC=0x53d2c,CB=0x53d28,STRIDE=0x78;
|
||||
let ctx=null,current={codebase:0,offset:-1},seq=0; const hooked={};
|
||||
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 sp(reg,n){try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;}}
|
||||
function pp(p,o=0){try{return p.add(o).readPointer();}catch(e){return ptr(0);}}
|
||||
function emit(name,x={}){send(Object.assign({kind:'event',seq:++seq,t:Date.now(),name,
|
||||
codebase:current.codebase,offset:current.offset,runFlags:ctx?u32(ctx,0xa0ce4):null},x));}
|
||||
function mgrState(m,ch){return {manager:m.toString(),channel:ch,resource:i32(m,0x4bc+ch*4),
|
||||
object:pp(m,0x5a4+ch*4).toString()};}
|
||||
function install(){
|
||||
Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){ctx=this.context.ecx;try{const n=i32(ctx,IDX);
|
||||
if(n<0||n>=64)return;const pc=u32(ctx,PC+n*STRIDE),cb=u32(ctx,CB+n*STRIDE);
|
||||
current={codebase:cb>>>0,offset:((pc-cb)>>>2)};}catch(e){}}});
|
||||
for(const n of ['b4','b5','b6','c2','d9']) Interceptor.attach(mod.base.add(OFF[n]),{onEnter(){emit('op-0x'+n);}});
|
||||
function hookDs(obj,ch){if(!obj||obj.isNull())return;const buf=pp(obj,0x40c);if(buf.isNull())return;
|
||||
const vt=pp(buf),key=vt.toString();if(hooked[key])return;hooked[key]=true;
|
||||
for(const [name,slot] of [['play',12],['set-position',13],['set-volume',15],['set-pan',16],['stop',18]]){
|
||||
const fn=pp(vt,slot*4),k=fn.toString();if(hooked[k])continue;hooked[k]=true;
|
||||
Interceptor.attach(fn,{onEnter(args){emit('ds-'+name,{channel:ch,buffer:args[0].toString(),arg1:args[1].toInt32(),arg2:args[2].toInt32(),arg3:args[3].toInt32()});}});
|
||||
} emit('ds-hooks',{channel:ch,object:obj.toString(),buffer:buf.toString(),vtable:vt.toString()});}
|
||||
Interceptor.attach(mod.base.add(OFF.load),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);this.res=sp(this.context,1);
|
||||
emit('sfx-load-enter',Object.assign({resourceArg:this.res},mgrState(this.m,this.ch)));},onLeave(ret){const s=mgrState(this.m,this.ch);
|
||||
emit('sfx-load-leave',Object.assign({ret:ret.toInt32()},s));hookDs(ptr(s.object),this.ch);}});
|
||||
Interceptor.attach(mod.base.add(OFF.start),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);this.mode=sp(this.context,1);
|
||||
const s=mgrState(this.m,this.ch);emit('sfx-start-enter',Object.assign({mode:this.mode},s));hookDs(ptr(s.object),this.ch);},
|
||||
onLeave(ret){emit('sfx-start-leave',Object.assign({ret:ret.toInt32()},mgrState(this.m,this.ch)));}});
|
||||
Interceptor.attach(mod.base.add(OFF.release),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);emit('sfx-release',mgrState(this.m,this.ch));}});
|
||||
Interceptor.attach(mod.base.add(OFF.decode),{onEnter(){this.m=this.context.ecx;this.ch=sp(this.context,0);emit('sfx-decode-enter',{manager:this.m.toString(),channel:this.ch,byteLength:sp(this.context,1),fileSlot:sp(this.context,2)});},
|
||||
onLeave(){const obj=pp(this.m,0x5a4+this.ch*4);hookDs(obj,this.ch);}});
|
||||
Interceptor.attach(mod.base.add(OFF.destroy),{onEnter(){emit('sfx-buffer-destroy',{manager:this.context.ecx.toString(),channel:sp(this.context,0)});}});
|
||||
Interceptor.attach(mod.base.add(OFF.dsStart),{onEnter(){this.obj=this.context.ecx;hookDs(this.obj,i32(this.obj,0x408));emit('ds-start-worker-enter',{object:this.obj.toString(),buffer:pp(this.obj,0x40c).toString(),preloaded:i32(this.obj,0x245c),playing:i32(this.obj,0x2460),loop:i32(this.obj,0x2464)});},
|
||||
onLeave(ret){emit('ds-start-worker-leave',{object:this.obj.toString(),ret:ret.toInt32(),preloaded:i32(this.obj,0x245c),playing:i32(this.obj,0x2460),loop:i32(this.obj,0x2464)});}});
|
||||
Interceptor.attach(mod.base.add(OFF.fadeArm),{onEnter(){emit('audio-fade-arm',{object:this.context.ecx.toString(),target:sp(this.context,0),step:sp(this.context,1),current:i32(this.context.ecx,0x420)});}});
|
||||
Interceptor.attach(mod.base.add(OFF.fadeTick),{onEnter(){emit('audio-fade-tick',{object:this.context.ecx.toString(),ticks:sp(this.context,0),progress:i32(this.context.ecx,0x418),current:i32(this.context.ecx,0x420),target:i32(this.context.ecx,0x424)});}});
|
||||
send({kind:'ready',base:mod.base.toString()});
|
||||
}
|
||||
install();
|
||||
"""
|
||||
|
||||
def main():
|
||||
import frida
|
||||
args = sys.argv[1:]
|
||||
numbers = [int(a) for a in args if a.isdigit()]
|
||||
seconds = numbers[0] if numbers else 30
|
||||
target = numbers[1] if len(numbers)>1 else "AGE.EXE"
|
||||
OUT.parent.mkdir(parents=True,exist_ok=True); counts={}
|
||||
try:
|
||||
session=frida.attach(target)
|
||||
except frida.ProcessNotFoundError:
|
||||
print("[frida] AGE.EXE not found; prior trace preserved")
|
||||
return 2
|
||||
with TMP.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] SFX hooks armed @ {row['base']}"); LIVE.write_text("live",encoding="utf-8"); return
|
||||
f.write(json.dumps(row,ensure_ascii=False)+"\n"); f.flush(); n=row.get("name","?");counts[n]=counts.get(n,0)+1
|
||||
if n.startswith("op-") or n in {"sfx-load-enter","sfx-start-enter","sfx-release","ds-play","ds-stop","ds-set-volume","ds-set-pan","audio-fade-arm"}:
|
||||
print(f" #{row['seq']:04d} off=0x{row['offset']:05x} {n} ch={row.get('channel','-')} res={row.get('resourceArg',row.get('resource','-'))}")
|
||||
try:
|
||||
script=session.create_script(JS);script.on("message",on_message);script.load()
|
||||
except Exception:
|
||||
raise
|
||||
print(f"[frida] ARMED for {seconds}s -- start New Game when the title appears.")
|
||||
try: time.sleep(seconds)
|
||||
except KeyboardInterrupt: pass
|
||||
try: session.detach()
|
||||
except Exception: pass
|
||||
try: LIVE.unlink()
|
||||
except OSError: pass
|
||||
if counts:
|
||||
TMP.replace(OUT)
|
||||
else:
|
||||
try: TMP.unlink()
|
||||
except OSError: pass
|
||||
print(f"[trace] wrote {sum(counts.values())} events -> {OUT if counts else '(prior trace preserved)'}")
|
||||
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