feat(assets): solve asset resolution (SYS4INI per-scene section manifest)
resId -> files[section_base(scene) + resId]. SYS4INI's file list is sectioned, one per scene (SCxxxx.BIN + its cross-archive asset manifest); file_number is the index within the section. Unified for set-texture, play-bgm, play-voice. Fully static/general -> no per-scene capture. - tools/parse_sys4ini.py: SYS4INI (S4IC422, LZSS) -> build/asset-index.json - tools/resolve_asset.py: sections + (scene,resId) resolver -> build/asset-sections.json - validated: 97% structural, SC0000 17/17 vs Frida, 586/595 captured loads - opcodes.toml: set-texture/create/draw-texture, play-bgm/voice enriched (frida-grounded) - Frida tooling (capture_load_order all-archive, correlate_scope, ...) + vm0 --settex - docs: asset-resolution-re (step2 SOLVED), global-memory-re (shelved), tools-reference Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
tools/frida/find_global_by_sequence.py
Normal file
141
tools/frida/find_global_by_sequence.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Locate the VM int-global G[0x62424] (the CG resId) in memory by a differential value scan --
|
||||
the confirmed-anchor bootstrap for runtime global observation (docs/asset-resolution-re.md step 2).
|
||||
|
||||
G[0x62424] is unambiguously a VM global-int (`mov (global-int 0x62424) ...`), and because
|
||||
resId == SYS4INI file_number, each DATA2 asset-start ReadFile tells us the EXACT value it holds
|
||||
at that instant (the asset's file_number). So we self-drive a Cheat-Engine-style scan: on the
|
||||
first CG load, scan the heap for int32 == fn; on each later load, keep only candidates that now
|
||||
equal the new fn. The monotonic distinct opening sequence (35,37,39,43,46,122,129,...) collapses
|
||||
the set to G[0x62424] in a few steps -- no native-copy contamination, no manual timing.
|
||||
|
||||
Once found, its address anchors the VM int-global store; from there we derive the address->memory
|
||||
mapping and read any global live.
|
||||
|
||||
Flow: title screen -> attach -> start new game -> advance the opening SLOWLY (one CG at a time).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
INDEX = REPO / "build" / "asset-index.json"
|
||||
|
||||
|
||||
BG_MIN_SIZE = 500_000 # backgrounds are big AGFs (~1MB); portraits/sprites are far smaller
|
||||
|
||||
|
||||
def data2_off2fn():
|
||||
"""{offset: file_number} for BACKGROUND-sized DATA2 assets only (excludes sprite/portrait
|
||||
loads, which churn G[0x62424]/other resId globals between background changes)."""
|
||||
idx = json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
return {f["offset"]: f["file_number"] for f in idx["files"]
|
||||
if f["archive"] == "DATA2.ALF" and f["size"] >= BG_MIN_SIZE}
|
||||
|
||||
|
||||
JS_TEMPLATE = r"""
|
||||
const OFF2FN = __OFF2FN__; // {offset: file_number == resId}
|
||||
const k32 = Process.getModuleByName('kernel32.dll');
|
||||
const GetFinalPathNameByHandleW = new NativeFunction(
|
||||
k32.findExportByName('GetFinalPathNameByHandleW'), 'uint32', ['pointer','pointer','uint32','uint32']);
|
||||
const SetFilePointer = new NativeFunction(
|
||||
k32.findExportByName('SetFilePointer'), 'uint32', ['pointer','int32','pointer','uint32']);
|
||||
const NUL = ptr(0); const cache = {};
|
||||
function pathOf(h){ const k=h.toString(); let v=cache[k]; if(v!==undefined) return v; let p=null;
|
||||
try{ const b=Memory.alloc(1040); const n=GetFinalPathNameByHandleW(h,b,519,0);
|
||||
if(n>0&&n<519) p=b.readUtf16String(); }catch(e){} cache[k]=p; return p; }
|
||||
|
||||
function u32le(v){const b=[v&0xff,(v>>>8)&0xff,(v>>>16)&0xff,(v>>>24)&0xff];
|
||||
return b.map(x=>('0'+x.toString(16)).slice(-2)).join(' ');}
|
||||
|
||||
const CAP = 600000;
|
||||
function scanValue(v){
|
||||
const out=[]; const pat=u32le(v);
|
||||
const ranges=Process.enumerateRanges('rw-');
|
||||
for(const r of ranges){
|
||||
let m; try{ m=Memory.scanSync(r.base, r.size, pat); }catch(e){ continue; }
|
||||
for(const x of m){ out.push(x.address); if(out.length>=CAP) return out; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isStack(a){ // heuristic: self-referential / return-addr neighbourhood
|
||||
const r=Process.findRangeByAddress(a);
|
||||
return r && r.size < 0x200000; // small rw- region = likely a stack
|
||||
}
|
||||
let cands=null, lastOff=-1, step=0;
|
||||
function keepEq(v){ cands=cands.filter(a=>{ try{ return a.readU32()===(v>>>0); }catch(e){ return false; } }); }
|
||||
|
||||
Interceptor.attach(k32.findExportByName('ReadFile'), {
|
||||
onEnter(args){
|
||||
const p=pathOf(args[0]); if(!p || !/data2\.alf$/i.test(p)) return;
|
||||
const size=args[2].toInt32(); if(size>4096) return;
|
||||
const ov=args[4]; let off=-1;
|
||||
try{ off = ov.isNull()? SetFilePointer(args[0],0,NUL,1) : ov.add(8).readU32(); }catch(e){}
|
||||
if(!(off in OFF2FN)) return;
|
||||
if(off===lastOff) return; lastOff=off;
|
||||
const fn=OFF2FN[off];
|
||||
if(cands===null){ cands=scanValue(fn); }
|
||||
else { keepEq(fn); }
|
||||
step++;
|
||||
send({step:step, phase:'load', fn:fn, count:cands.length});
|
||||
// STABILITY FILTER: 900ms later (during the pause before the next click) the global still
|
||||
// holds fn, but transient stack copies have been overwritten -> drop them.
|
||||
setTimeout(function(){
|
||||
if(cands===null) return;
|
||||
keepEq(fn);
|
||||
send({step:step, phase:'stable', fn:fn, count:cands.length,
|
||||
addrs: cands.length<=12 ? cands.map(a=>({a:a.toString(), stack:isStack(a)})) : []});
|
||||
}, 1400);
|
||||
}
|
||||
});
|
||||
send({ready:true});
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
import frida
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
proc = args[0] if args else "AGE.EXE"
|
||||
js = JS_TEMPLATE.replace("__OFF2FN__", json.dumps(data2_off2fn()))
|
||||
|
||||
def on_message(msg, data):
|
||||
if msg.get("type") == "error":
|
||||
print("[frida-error]", msg.get("description")); return
|
||||
if msg.get("type") != "send":
|
||||
return
|
||||
pl = msg["payload"]
|
||||
if pl.get("ready"):
|
||||
print("[frida] scan hook live — advance the opening one CG at a time, pausing ~1.5s each.")
|
||||
return
|
||||
ph = pl.get("phase")
|
||||
print(f"[step {pl['step']} {ph:>6}] resId={pl['fn']} (0x{pl['fn']:x}) candidates={pl['count']}")
|
||||
if ph == "stable" and pl.get("addrs"):
|
||||
for e in pl["addrs"]:
|
||||
print(f" {e['a']} {'(stack)' if e['stack'] else '<== STABLE global candidate'}")
|
||||
stable = [e for e in pl["addrs"] if not e["stack"]]
|
||||
if 0 < len(stable) <= 3:
|
||||
print(" >>> stable non-stack survivors — likely G[0x62424].")
|
||||
|
||||
dev = frida.get_local_device()
|
||||
target = int(proc) if str(proc).isdigit() else proc
|
||||
try:
|
||||
session = frida.attach(target)
|
||||
except frida.ProcessNotFoundError:
|
||||
print("AGE-like:", [(p.pid, p.name) for p in dev.enumerate_processes() if "age" in p.name.lower()])
|
||||
return 2
|
||||
script = session.create_script(js)
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
print(f"[frida] attached to {proc}; narrowing G[0x62424] by the resId sequence.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user