Files
OpenMaidEngine/tools/frida/find_globals_base.py
gamer147 a61c0c9abd 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

184 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Locate the game's INT-GLOBAL array in memory by a known-value signature scan, so we can
read VM globals live (e.g. G[0x62424] = the CG resId) -- see docs/asset-resolution-re.md step 2.
The `*INIT` scripts write thousands of known constants to known global-int addresses at boot:
`mov (global-int ADDR) IMM`. Our VM addresses globals as a flat int array, so at runtime the
game holds `int_globals[ADDR]` at `B + ADDR*4` for some base B. We build a signature of those
(ADDR, value) pairs, find a long CONTIGUOUS run as a rare multi-dword anchor, `Memory.scan` for
it, and verify each candidate B against many scattered pairs. Unique high-match B = the array.
This unlocks runtime global observation generally (the roadmap's VM-validation cornerstone):
read `resId` (G[0x62424]) directly at each CG load, and watch scope-selector globals.
build: py -3.11 -X utf8 tools/frida/find_globals_base.py --build-sig
scan: py -3.11 -u -X utf8 tools/frida/find_globals_base.py <pid>
"""
import collections
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SIG = REPO / "build" / "globals-signature.json"
sys.path.insert(0, str(REPO / "tools"))
MOV, T_GINT, T_IMM = 0x55, 3, 0
INIT_SCRIPTS = ["EBINIT", "ITINIT", "SKINIT", "CGINIT"]
def build_signature():
import paths, sys4load
pairs = collections.defaultdict(collections.Counter)
for name in INIT_SCRIPTS:
p = paths.GAME_DIR / f"{name}.BIN"
if not p.exists():
p = paths.DATA1 / f"{name}.BIN"
if not p.exists():
continue
for ins in sys4load.load(p).instructions:
if (ins.opcode == MOV and len(ins.args) >= 2
and ins.args[0][0] == T_GINT and ins.args[1][0] == T_IMM):
pairs[ins.args[0][1]][ins.args[1][1]] += 1
# stable, distinctive, single-write addresses
single = {a: next(iter(vc)) for a, vc in pairs.items()
if len(vc) == 1 and vc.most_common(1)[0][1] == 1
and 8 < next(iter(vc)) < 0x7fffffff}
# longest contiguous run (addr, addr+1, ...) -> rare multi-dword anchor
addrs = sorted(single)
best = (None, 0)
i = 0
while i < len(addrs):
j = i
while j + 1 < len(addrs) and addrs[j + 1] == addrs[j] + 1:
j += 1
if j - i + 1 > best[1]:
best = (addrs[i], j - i + 1)
i = j + 1
anchor_addr, anchor_len = best
anchor = [(anchor_addr + k, single[anchor_addr + k]) for k in range(anchor_len)]
# scattered verification pairs spread across the address range
spread = addrs[::max(1, len(addrs) // 120)][:120]
verify = [(a, single[a]) for a in spread]
SIG.parent.mkdir(parents=True, exist_ok=True)
SIG.write_text(json.dumps({"anchor_addr": anchor_addr, "anchor": anchor, "verify": verify},
ensure_ascii=False), encoding="utf-8")
print(f"signature: {len(single)} distinctive pairs; "
f"anchor run @0x{anchor_addr:x} len {anchor_len} ({anchor_len*4} bytes); "
f"{len(verify)} verify pairs -> {SIG.relative_to(REPO)}")
print("anchor values:", [v for _, v in anchor[:12]])
return 0
JS_TEMPLATE = r"""
const ANCHOR_ADDR = __ANCHOR_ADDR__;
const ANCHOR_VALS = __ANCHOR_VALS__; // consecutive int32 values at ANCHOR_ADDR..
const VERIFY = __VERIFY__; // [[addr,val],...]
// build the anchor byte pattern (little-endian int32 each)
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 pattern = ANCHOR_VALS.map(u32le).join(' ');
function verifyBase(B){
let ok=0, tot=0;
for(const pv of VERIFY){
tot++;
try { if(B.add(pv[0]*4).readU32() === (pv[1]>>>0)) ok++; } catch(e){}
}
return {ok:ok, tot:tot};
}
const ranges = Process.enumerateRanges('rw-').filter(r=>r.size >= 1024*1024);
let best=null;
// robustness ladder: long anchor is rare but fragile to any changed value; short prefixes
// catch it if a value moved. Each candidate is confirmed by the 120 scattered verify pairs.
const LENS = [ANCHOR_VALS.length, 64, 16, 4].filter((v,i,a)=>v<=ANCHOR_VALS.length && a.indexOf(v)===i);
for(const L of LENS){
const pat = ANCHOR_VALS.slice(0,L).map(u32le).join(' ');
let hits=0;
ranges.forEach(function(r){
let matches; try { matches = Memory.scanSync(r.base, r.size, pat); } catch(e){ return; }
if(matches.length > 8000) return; // too common at this length, skip
hits += matches.length;
matches.forEach(function(m){
const B = m.address.sub(ANCHOR_ADDR*4);
const v = verifyBase(B);
if(v.ok >= 30 && (!best || v.ok>best.ok))
best = {base: B.toString(), ok:v.ok, tot:v.tot, anchor_at: m.address.toString(), anchor_len:L};
});
});
send({phase:'scan', anchor_len:L, hits:hits, found: !!best});
if(best) break;
}
if(best){
// read G[0x62424] (the CG resId) as a sanity value
let resid=null; try{ resid = ptr(best.base).add(0x62424*4).readU32(); }catch(e){}
best.G_62424 = resid;
send({phase:'found', best:best});
} else {
send({phase:'notfound'});
}
"""
def scan(pid):
import frida
if not SIG.exists():
raise SystemExit("no signature; run --build-sig first")
sig = json.loads(SIG.read_text(encoding="utf-8"))
js = (JS_TEMPLATE
.replace("__ANCHOR_ADDR__", str(sig["anchor_addr"]))
.replace("__ANCHOR_VALS__", json.dumps([v for _, v in sig["anchor"]]))
.replace("__VERIFY__", json.dumps(sig["verify"])))
out = {}
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"]
ph = pl.get("phase")
if ph == "scan":
print(f"[scan] anchor prefix {pl['anchor_len']} dwords: {pl['hits']} raw hits"
+ (" -> base confirmed" if pl.get("found") else ""))
elif ph == "found":
b = pl["best"]
out["base"] = b["base"]
print(f"[FOUND] int-global base = {b['base']} (verify {b['ok']}/{b['tot']} pairs, "
f"anchor @ {b['anchor_at']})")
print(f" G[0x62424] (CG resId right now) = {b['G_62424']} (0x{b['G_62424']:x})"
if b.get("G_62424") is not None else " G[0x62424] unreadable")
elif ph == "notfound":
print("[scan] anchor pattern not found in any heap range (globals not resident, "
"wrong element size, or array not yet populated)")
dev = frida.get_local_device()
target = int(pid) if str(pid).isdigit() else pid
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()
if "base" in out:
print(f"\nint-global base found: {out['base']}. Next: hook the loader and read "
f"G[0x62424] there for definitive (resId,name) pairs.")
return 0
def main():
if "--build-sig" in sys.argv:
return build_signature()
pid = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE")
return scan(pid)
if __name__ == "__main__":
sys.exit(main())