Files
OpenMaidEngine/tools/frida/capture_load_order.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

169 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Recover the game's per-asset LOAD ORDER across ALL archives (docs/asset-resolution-re.md).
Assets are typed by name prefix and split across archives: DATA1 = CS/CP/CB/CA/BG/... (ADV
sprites, map sprites, battle & icon portraits, backgrounds), DATA2 = EV/EVM event CGs,
DATA5 = movies. Resolution is per-(archive, type): a set-texture(resId, slot) picks a type via
the slot, and resId indexes within that type. So we must watch EVERY DATA*.ALF, not just DATA2.
Hooks ReadFile on all DATA*.ALF; each asset load begins with header reads at its exact archive
offset, so exact-start reads give the ordered load list -> resolved to (archive, name, prefix,
file_number) via build/asset-index.json. Feed the result to tools/correlate_scope.py to align
with the VM's set-texture(resId) trace and learn the type/scope rule.
Flow: game running -> attach -> replay a scene -> Ctrl-C -> --analyze.
Output: build/frida-load-order.jsonl, build/frida-load-order-result.json.
"""
import json
import re
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "frida-load-order.jsonl"
INDEX = REPO / "build" / "asset-index.json"
def prefix(name):
m = re.match(r"([A-Za-z]+)", name)
return m.group(1) if m else "?"
def index_by_archive():
"""{archive: {offset: (name, file_number, prefix)}} and sorted offset lists for containment."""
idx = json.loads(INDEX.read_text(encoding="utf-8"))
exact, arr = {}, {}
for f in idx["files"]:
a = f["archive"]
exact.setdefault(a, {})[f["offset"]] = (f["name"], f["file_number"], prefix(f["name"]))
arr.setdefault(a, []).append((f["offset"], f["size"], f["name"], f["file_number"]))
for a in arr:
arr[a].sort()
return exact, arr
JS = r"""
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; }
Interceptor.attach(k32.findExportByName('ReadFile'), {
onEnter(args){
const p = pathOf(args[0]); if(!p) return;
const m = /DATA(\d)\.ALF$/i.exec(p); if(!m) return;
const size = args[2].toInt32();
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 < 0) return;
send({kind:'read', archive:'DATA'+m[1]+'.ALF', offset: off, size: size});
}
});
send({ready:true});
"""
def analyze():
if not OUT.exists():
raise SystemExit(f"no log: {OUT}")
import bisect
exact, arr = index_by_archive()
recs = [json.loads(l) for l in OUT.read_text(encoding="utf-8").splitlines() if l.strip()]
reads = [r for r in recs if r.get("kind") == "read"]
per_arc = {}
for r in reads:
per_arc[r["archive"]] = per_arc.get(r["archive"], 0) + 1
print(f"{len(reads)} reads across archives: {per_arc}\n")
def contain(a, off):
v = arr.get(a)
if not v:
return None
i = bisect.bisect_right(v, (off, float("inf"), "", 0)) - 1
if i < 0:
return None
o, s, n, fn = v[i]
return (n, fn) if off < o + s else None
# exact-start order (unambiguous), plus containment first-touch (fuller, noisier)
exact_order, contain_order, seen_e, seen_c = [], [], set(), set()
for r in reads:
a, off = r["archive"], r["offset"]
if off in exact.get(a, {}):
name, fn, pfx = exact[a][off]
if name not in seen_e:
seen_e.add(name); exact_order.append({"archive": a, "name": name, "file_number": fn, "prefix": pfx})
hit = contain(a, off)
if hit and hit[0] not in seen_c:
seen_c.add(hit[0])
contain_order.append({"archive": a, "name": hit[0], "file_number": hit[1], "prefix": prefix(hit[0])})
print(f"=== exact-start load order ({len(exact_order)}) ===")
for e in exact_order:
print(f" {e['archive'][:5]} {e['prefix']:<4} {e['name']:<14} fn={e['file_number']} (resId 0x{e['file_number']:x})")
print(f"\n=== containment first-touch ({len(contain_order)}, includes prefetch noise) ===")
for e in contain_order:
print(f" {e['archive'][:5]} {e['prefix']:<4} {e['name']:<14} fn={e['file_number']}")
res = {"load_order": exact_order, "containment_order": contain_order,
"reads_per_archive": per_arc}
(REPO / "build" / "frida-load-order-result.json").write_text(
json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8")
print("\n-> build/frida-load-order-result.json")
return 0
def capture(proc):
import frida
OUT.parent.mkdir(parents=True, exist_ok=True)
log = open(OUT, "w", encoding="utf-8")
exact, _ = index_by_archive()
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] ReadFile hook live on ALL DATA*.ALF — replay the scene now."); return
log.write(json.dumps(pl, ensure_ascii=False) + "\n"); log.flush()
if pl.get("kind") == "read":
hit = exact.get(pl["archive"], {}).get(pl["offset"])
if hit:
print(f"LOAD {pl['archive'][:5]} {hit[2]:<4} {hit[0]} fn={hit[1]}")
target = int(proc) if str(proc).isdigit() else proc
try:
session = frida.attach(target)
except frida.ProcessNotFoundError:
procs = frida.get_local_device().enumerate_processes()
print("AGE-like:", [(p.pid, p.name) for p in procs 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}; logging all-archive reads -> {OUT}")
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
print("\n[frida] stopped. Now: py -3.11 -X utf8 tools/frida/capture_load_order.py --analyze")
return 0
def main():
if "--analyze" in sys.argv:
return analyze()
proc = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE")
return capture(proc)
if __name__ == "__main__":
sys.exit(main())