Implement native-backed SC0000 SFX lifecycle

This commit is contained in:
gamer147
2026-07-11 02:15:17 -04:00
parent 377987b5e0
commit 99ce351041
16 changed files with 413 additions and 64 deletions

View File

@@ -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'),

View 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())