Implement native ADV retained text
This commit is contained in:
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
|
||||
INFERRED: dict[int, dict] = {
|
||||
0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'),
|
||||
0x7a: dict(name='text-param?', category='adv', noop=False, confidence='med', source='inference', summary='3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param'),
|
||||
0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'),
|
||||
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'),
|
||||
|
||||
133
tools/frida/capture_adv_text_trace.py
Normal file
133
tools/frida/capture_adv_text_trace.py
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture native ADV text layout, rasterization, reveal, and presentation boundaries.
|
||||
|
||||
Start the native game at the title screen, arm this probe, then choose New Game and click through
|
||||
the first voiced page (SC0000 0x9b2/0x9d3). Output is written to
|
||||
``build/native-adv-text-trace.jsonl``. The probe is read-only.
|
||||
|
||||
py -3.11 -u -X utf8 tools/frida/capture_adv_text_trace.py 45
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
OUT = REPO / "build" / "native-adv-text-trace.jsonl"
|
||||
|
||||
JS = r"""
|
||||
const mod = Process.getModuleByName('AGE.EXE');
|
||||
const OFF = {
|
||||
operand:0x1b940, op6e:0x1e330, op7a:0x1eba0, op204:0x22a60,
|
||||
setCursor:0x530f0, buildText:0x576c0, buildTextBlocking:0x57dc0,
|
||||
reveal:0x51220, drawString:0x50150, rasterSimple:0x59d90, rasterCached:0x5b600,
|
||||
bind:0x7e870
|
||||
};
|
||||
const IDX=0x53d14, PC=0x53d2c, CB=0x53d28, STRIDE=0x78;
|
||||
let ctx=null, current={codebase:0,offset:-1}, seq=0;
|
||||
|
||||
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 stackP(reg,n){try{return reg.esp.add(4+n*4).readPointer();}catch(e){return ptr(0);}}
|
||||
function stackI(reg,n){try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;}}
|
||||
function bytes(p,cap=256){
|
||||
if(!p || p.isNull()) return [];
|
||||
const out=[]; try{for(let i=0;i<cap;i++){const b=p.add(i).readU8();if(b===0)break;out.push(b);}}catch(e){}
|
||||
return out;
|
||||
}
|
||||
function layout(mgr,slot){
|
||||
try{
|
||||
if(slot===0)slot=i32(mgr,0x4c8);
|
||||
const p=mgr.add(0x414+slot*4).readPointer();
|
||||
const begin=u32(p,0x30),end=u32(p,0x34),count=(begin&&end)?((end-begin)/0x14):0;
|
||||
let last=null;if(count>0){const q=ptr(end-0x14);last=[i32(q,4),i32(q,8),i32(q,12),i32(q,16),i32(q,0)];}
|
||||
return {slot:slot,layout:p.toString(),count:count,last:last,origin:[i32(p,0xc),i32(p,0x10)],handleBase:i32(p,0x68),
|
||||
revealLimit:i32(p,0x6c),mode:i32(p,0x70),revealIndex:i32(mgr,0x570)};
|
||||
}catch(e){return {slot:slot,error:String(e)};}
|
||||
}
|
||||
function state(extra={}){const c=ctx;return Object.assign({kind:'event',seq:++seq,t:Date.now(),
|
||||
codebase:current.codebase,offset:current.offset,
|
||||
frameTime:c?u32(c,0xb550):null,runFlags:c?u32(c,0xa0ce4):null,
|
||||
charDelay:c?i32(c,0x14e9c):null},extra);}
|
||||
function emit(name,extra={}){send(state(Object.assign({name:name},extra)));}
|
||||
|
||||
Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){
|
||||
const c=this.context.ecx;ctx=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){}
|
||||
}});
|
||||
|
||||
for(const [name,off] of [['op-0x6e',OFF.op6e],['op-0x7a',OFF.op7a],['op-0x204',OFF.op204]])
|
||||
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name);}});
|
||||
|
||||
Interceptor.attach(mod.base.add(OFF.setCursor),{onEnter(){
|
||||
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);
|
||||
emit('cursor-set-enter',{slot:this.slot,x:stackI(this.context,1),y:stackI(this.context,2),layout:layout(this.mgr,this.slot)});
|
||||
},onLeave(){emit('cursor-set-leave',{layout:layout(this.mgr,this.slot)});}});
|
||||
|
||||
function hookBuild(off,name){Interceptor.attach(mod.base.add(off),{onEnter(){
|
||||
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);this.before=layout(this.mgr,this.slot);
|
||||
emit(name+'-enter',{slot:this.slot,textBytes:bytes(stackP(this.context,1)),rubyBytes:bytes(stackP(this.context,2)),
|
||||
flags:stackI(this.context,3),layout:this.before});
|
||||
},onLeave(){emit(name+'-leave',{layout:layout(this.mgr,this.slot)});}});}
|
||||
hookBuild(OFF.buildText,'build-text');hookBuild(OFF.buildTextBlocking,'build-text-blocking');
|
||||
|
||||
Interceptor.attach(mod.base.add(OFF.reveal),{onEnter(){
|
||||
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);emit('glyph-reveal-enter',{layout:layout(this.mgr,this.slot)});
|
||||
},onLeave(ret){emit('glyph-reveal-leave',{done:ret.toInt32(),layout:layout(this.mgr,this.slot)});}});
|
||||
|
||||
Interceptor.attach(mod.base.add(OFF.drawString),{onEnter(){emit('draw-string-worker',{
|
||||
surface:stackI(this.context,0),textBytes:bytes(stackP(this.context,1)),x:stackI(this.context,2),y:stackI(this.context,3)});}});
|
||||
for(const [name,off] of [['raster-simple',OFF.rasterSimple],['raster-cached',OFF.rasterCached]])
|
||||
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name,{surface:stackI(this.context,0),x:stackI(this.context,1),y:stackI(this.context,2)});}});
|
||||
Interceptor.attach(mod.base.add(OFF.bind),{onEnter(){const h=stackI(this.context,0);if(h===0xe678||h>=0xe678&&h<0xe800)
|
||||
emit('bind-text-object',{handle:h,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)]});}});
|
||||
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 45
|
||||
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)
|
||||
counts = {}
|
||||
with OUT.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] ADV text hooks live @ {row['base']}"); return
|
||||
for key in ("textBytes", "rubyBytes"):
|
||||
if key in row:
|
||||
row[key[:-5]] = bytes(row.pop(key)).decode("cp932", errors="replace")
|
||||
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 {"op-0x6e", "op-0x7a", "op-0x204", "cursor-set-enter",
|
||||
"draw-string-worker", "build-text-enter", "glyph-reveal-enter",
|
||||
"bind-text-object", "surface-lock", "surface-unlock"}:
|
||||
print(f" #{row['seq']:05d} off=0x{row['offset']:05x} {name:22s} delay={row['charDelay']}")
|
||||
|
||||
try:
|
||||
session = frida.attach(target)
|
||||
except frida.ProcessNotFoundError:
|
||||
print("[frida] AGE.EXE not found; leave the native game at the title screen first.")
|
||||
return 2
|
||||
script = session.create_script(JS); script.on("message", on_message); script.load()
|
||||
print(f"[frida] capture armed for {seconds}s. Choose New Game and click through the first voiced page.")
|
||||
try: time.sleep(seconds)
|
||||
except KeyboardInterrupt: pass
|
||||
try: session.detach()
|
||||
except Exception: 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