Files
OpenMaidEngine/tools/frida/find_global_by_sequence.py
gamer147 102fe1d021 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>
2026-07-06 20:48:22 -04:00

142 lines
5.9 KiB
Python

#!/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())