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

195 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Phase 2 of the native resId->filename crack (see docs/asset-resolution-re.md step 2a).
Phase 1 (locate_resource_load.py) found the stable asset-load call chain in AGE.EXE:
AGE.EXE+0x16d5d7 -> AGE.EXE+0x74f1f -> AGE.EXE+0x397b -> ReadFile
This hooks the two upper frames and, at entry, dumps their arguments (raw dwords + any
ASCII a pointer argument targets), tagged with thread id. It also keeps the ReadFile ->
offset -> asset-name resolver. Interleaving the two streams lets us pair each load call
with the asset it produced and find which argument carries the resId (== 37 for EV052CA,
39 EV052DA, 43 EV052DC, 46 EV052DB) or a pointer to the SYS4INI entry (name / file_number).
Once identified, that argument IS the resId->name mapping at the source. Read-only.
Flow: game running -> this attaches -> replay the opening CGs -> Ctrl-C -> --analyze.
Output: build/frida-resid-args.jsonl (ordered events).
"""
import json
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "frida-resid-args.jsonl"
INDEX = REPO / "build" / "asset-index.json"
HANDLER_OFFSETS = [0x74f1f] # phase 1 chain; 0x16d5d7 is a return addr (not a callable entry)
def data2_offsets():
idx = json.loads(INDEX.read_text(encoding="utf-8"))
return {f["offset"]: (f["name"], f["file_number"])
for f in idx["files"] if f["archive"] == "DATA2.ALF"}
JS_TEMPLATE = r"""
const OFFSETS = new Set(__OFFSETS__);
const HANDLERS = __HANDLERS__;
const PS = Process.pointerSize;
const age = Process.getModuleByName('AGE.EXE');
// --- load-handler arg dumps ---
function scanAscii(base, len) { // printable ASCII runs (>=4) as "hexoff:text"
const out = [];
try {
const u = new Uint8Array(base.readByteArray(len));
let start = -1;
for (let i = 0; i <= u.length; i++) {
const c = i < u.length ? u[i] : 0;
if (c >= 0x20 && c < 0x7f) { if (start < 0) start = i; }
else { if (start >= 0 && i - start >= 4)
out.push(start.toString(16) + ':' + String.fromCharCode.apply(null, u.subarray(start, i)));
start = -1; }
}
} catch (e) {}
return out;
}
HANDLERS.forEach(function(off) {
const addr = age.base.add(off);
Interceptor.attach(addr, {
onEnter(args) {
const sp = this.context.sp, ebp = this.context.ebp;
const raw = [], cargs = [];
for (let i = 1; i <= 8; i++) { // [sp+PS*i] = this fn's arg i
try { raw.push(sp.add(PS * i).readPointer().toString()); } catch (e) { raw.push('0x0'); }
}
for (let i = 2; i <= 9; i++) { // [ebp+PS*i] = CALLER's args (ebp is caller's at entry)
try { cargs.push(ebp.add(PS * i).readPointer().toString()); } catch (e) { cargs.push('0x0'); }
}
// scan the loader context object (arg1) and any pointer arg's target for ASCII (filenames?)
const ctx = scanAscii(ptr(raw[0]), 0x400);
const argStr = {};
raw.concat(cargs).forEach(function(v, k) {
const s = scanAscii(ptr(v), 0x48);
if (s.length) argStr[k] = s;
});
send({kind: 'call', off: '0x' + off.toString(16), tid: this.threadId,
ret: '0x' + this.returnAddress.sub(age.base).toString(16),
args: raw, cargs: cargs, ctx: ctx, argStr: argStr});
}
});
});
// --- ReadFile -> asset-start resolver (ground-truth name per load) ---
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 key = h.toString(); let v = cache[key]; if (v !== undefined) return v;
let p = null; try { const buf = Memory.alloc(1040);
const n = GetFinalPathNameByHandleW(h, buf, 519, 0);
if (n > 0 && n < 519) p = buf.readUtf16String(); } catch (e) {} cache[key] = p; return p; }
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 (!OFFSETS.has(off)) return;
send({kind: 'read', tid: this.threadId, offset: off});
}
});
send({ready: true});
"""
def analyze():
if not OUT.exists():
raise SystemExit(f"no log: {OUT}")
import re
recs = [json.loads(l) for l in OUT.read_text(encoding="utf-8").splitlines() if l.strip()]
calls = [r for r in recs if r["kind"] == "call"]
reads = [r for r in recs if r["kind"] == "read"]
print(f"{len(calls)} decode calls, {len(reads)} reads\n")
# every ASCII string the hook surfaced (ctx object + pointer-arg targets)
def strings_of(c):
out = list(c.get("ctx", []))
for v in c.get("argStr", {}).values():
out.extend(v)
return [s.split(":", 1)[1] for s in out]
seen = {}
for c in calls:
for s in strings_of(c):
seen[s] = seen.get(s, 0) + 1
evlike = {s: n for s, n in seen.items() if re.search(r"EVM?\d|\.AGF|AGF", s, re.I)}
print(f"distinct ASCII strings surfaced: {len(seen)}")
print(f"asset-name-like strings ({len(evlike)}):")
for s, n in sorted(evlike.items(), key=lambda kv: -kv[1])[:60]:
print(f" x{n:<4} {s!r}")
if not evlike:
print(" (none — filename not in the context object; try the caller frame / go one level up)")
print("\n top non-EV strings (for orientation):")
for s, n in sorted(seen.items(), key=lambda kv: -kv[1])[:20]:
print(f" x{n:<4} {s!r}")
return 0
def capture(proc):
import frida
offs = data2_offsets()
js = (JS_TEMPLATE
.replace("__OFFSETS__", json.dumps(sorted(offs.keys())))
.replace("__HANDLERS__", json.dumps(HANDLER_OFFSETS)))
OUT.parent.mkdir(parents=True, exist_ok=True)
log = open(OUT, "w", encoding="utf-8")
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] resId-arg hooks live — replay the opening CGs now."); return
log.write(json.dumps(pl, ensure_ascii=False) + "\n"); log.flush()
if pl["kind"] == "read":
name, fn = offs.get(pl["offset"], ("?", -1))
print(f"READ {name} (fn={fn})")
else:
import re
names = [s.split(":", 1)[1] for s in pl.get("ctx", [])
if re.search(r"EVM?\d|\.AGF", s.split(":", 1)[1], re.I)]
if names:
print(f" call ret=AGE.EXE+{pl['ret']} names={names}")
target = int(proc) if proc.isdigit() else proc
try:
session = frida.attach(target)
except frida.ProcessNotFoundError:
procs = frida.get_local_device().enumerate_processes()
print(f"'{proc}' not found. 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}; hooks at AGE.EXE+{[hex(h) for h in HANDLER_OFFSETS]}; log -> {OUT}")
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
print("\n[frida] stopped. Now: py -3.11 -X utf8 tools/frida/capture_resid_args.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())