Implement native ADV retained text

This commit is contained in:
gamer147
2026-07-10 22:46:27 -04:00
parent 8bee45f483
commit 34db35903b
14 changed files with 401 additions and 38 deletions

View File

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

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