Implement native-backed SC0000 SFX lifecycle
This commit is contained in:
@@ -9,7 +9,11 @@ INFERRED: dict[int, dict] = {
|
||||
0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'),
|
||||
0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'),
|
||||
0x97: dict(name='hotspot-reg?', category='input', noop=True, confidence='med', source='inference', summary='companion register-hotspot / set-widget-action (argc5: v1 v2 1 1 <action-id>; NO code targets)'),
|
||||
0xb6: dict(name='snd-ctrl?', category='audio', noop=False, confidence='low', source='inference', summary='1 imm; self-chains, 0x41D family near play-sound-effect/0xb5 — sound channel/volume/stop control'),
|
||||
0xb4: dict(name='sfx-load', category='audio', noop=False, confidence='high', source='investigation', summary="(resource_id)(channel) — synchronously resolve/open the scene-manifest asset and replace the channel's decoded sound buffer without starting playback. Native manager supports channels 0..12; SC0000 uses 0..9."),
|
||||
0xb5: dict(name='sfx-start', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback.'),
|
||||
0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'),
|
||||
0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.'),
|
||||
0xd9: dict(name='clear-run-state-0x1000', category='control', noop=True, confidence='high', source='investigation', summary='Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result.'),
|
||||
0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."),
|
||||
0x1bc: dict(name='block-mark', category='marker', noop=True, confidence='high', source='inference', summary='zero-arg; follows jcc/mov, precedes mov/ret — block boundary'),
|
||||
0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'),
|
||||
|
||||
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