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

179 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Phase 1 of the native resId->filename crack: LOCATE the resource-load / set-texture
handler in the running game by back-tracing every asset-open (see docs/asset-resolution-re.md
step 2, option a).
Idea: to issue a ReadFile at an asset's exact archive offset the game must have *already*
resolved resId -> (archive, offset) inside its native load-by-id / set-texture (op 0x1f9)
handler. So at each asset-start read, a stack backtrace passes straight through that handler.
We backtrace only on reads whose offset EXACTLY equals a DATA2 asset offset (from
build/asset-index.json), resolve the offset -> asset name, and log module-relative frames.
Frames are AGE.EXE-relative (base subtracted) so they're stable across runs despite ASLR.
Offline, `aggregate` ranks the AGE.EXE return addresses that recur across the MOST distinct
assets: the load-by-id / set-texture handler is the frame common to every asset-open. That
address (module+offset) becomes the hook target for phase 2 (read the resId argument ->
definitive resId->name table). No game state is modified.
Flow (see tools/frida/README.md):
1. Launch the game (via `AGE Patch.exe`) to the title.
2. py -3.11 -u -X utf8 tools/frida/locate_resource_load.py
3. Start a new game so the SC0000 opening auto-plays; let a dozen CGs load.
4. Ctrl-C. Then: py -3.11 -X utf8 tools/frida/locate_resource_load.py --aggregate
Output: build/frida-resource-bt.jsonl ({name, offset, size, frames:[...]} per asset-open).
"""
import json
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2] # age-reimpl/
OUT = REPO / "build" / "frida-resource-bt.jsonl"
INDEX = REPO / "build" / "asset-index.json"
def load_data2_offsets():
"""{offset -> name} for DATA2.ALF from the asset index (asset-start detector + resolver)."""
idx = json.loads(INDEX.read_text(encoding="utf-8"))
return {f["offset"]: f["name"] for f in idx["files"] if f["archive"] == "DATA2.ALF"}
def aggregate():
"""Rank AGE.EXE-relative frames by how many DISTINCT assets they appear under."""
if not OUT.exists():
raise SystemExit(f"no capture log: {OUT} (run the capture first)")
from collections import defaultdict
assets_per_frame = defaultdict(set) # frame-string -> set(asset names)
depth_of_frame = defaultdict(list) # frame-string -> [stack depths]
n = 0
for ln in OUT.read_text(encoding="utf-8").splitlines():
if not ln.strip():
continue
rec = json.loads(ln)
n += 1
for depth, fr in enumerate(rec.get("frames", [])):
if fr.startswith("AGE.EXE+"): # ignore kernel32/ntdll/CRT frames
assets_per_frame[fr].add(rec["name"])
depth_of_frame[fr].append(depth)
distinct_assets = {rec["name"] for rec in
(json.loads(l) for l in OUT.read_text(encoding="utf-8").splitlines() if l.strip())}
print(f"{n} asset-open events over {len(distinct_assets)} distinct assets")
print("AGE.EXE frames ranked by distinct-asset coverage (handler = covers ~all):")
ranked = sorted(assets_per_frame.items(), key=lambda kv: (-len(kv[1]), fr_depth(depth_of_frame[kv[0]])))
for fr, assets in ranked[:25]:
d = depth_of_frame[fr]
print(f" {fr:<22} assets={len(assets):<3} avg_depth={sum(d)/len(d):.1f}")
print("\nThe handler is the frame covering the most distinct assets at a shallow, stable depth.")
print("Hook it in phase 2 to read the resId argument.")
return 0
def fr_depth(depths):
return sum(depths) / len(depths)
JS_TEMPLATE = r"""
const OFFSETS = new Set(__OFFSETS__); // exact DATA2 asset-start offsets
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;
}
function frameStr(addr) {
const m = Process.findModuleByAddress(addr);
if (!m) return addr.toString();
return m.name + '+0x' + addr.sub(m.base).toString(16);
}
const rf = k32.findExportByName('ReadFile');
Interceptor.attach(rf, {
onEnter(args) {
const p = pathOf(args[0]);
if (!p || !/data2\.alf$/i.test(p)) return;
const size = args[2].toInt32();
if (size > 4096) return; // header reads only (asset-start burst)
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; // only exact asset-start offsets
let frames = [];
try {
frames = Thread.backtrace(this.context, Backtracer.ACCURATE).map(frameStr);
} catch (e) {
try { frames = Thread.backtrace(this.context, Backtracer.FUZZY).map(frameStr); } catch (e2) {}
}
send({offset: off, size: size, frames: frames});
}
});
send({ready: true});
"""
def capture(proc):
import frida
offsets = load_data2_offsets()
js = JS_TEMPLATE.replace("__OFFSETS__", json.dumps(sorted(offsets.keys())))
OUT.parent.mkdir(parents=True, exist_ok=True)
log = open(OUT, "w", encoding="utf-8")
seen = set()
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] backtrace hook live — start a new game; let the opening load CGs.")
return
name = offsets.get(pl["offset"], "?")
rec = {"name": name, "offset": pl["offset"], "size": pl["size"], "frames": pl["frames"]}
log.write(json.dumps(rec, ensure_ascii=False) + "\n"); log.flush()
if name not in seen:
seen.add(name)
age = [f for f in pl["frames"] if f.startswith("AGE.EXE+")]
print(f"OPEN {name} ({len(pl['frames'])} frames, {len(age)} in AGE.EXE)")
for f in pl["frames"][:8]:
print(" " + f)
target = int(proc) if proc.isdigit() else proc
try:
session = frida.attach(target)
except frida.ProcessNotFoundError:
procs = frida.get_local_device().enumerate_processes() # frida 17.x: device method
print(f"process '{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}; {len(offsets)} DATA2 asset offsets loaded; log -> {OUT}")
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
print("\n[frida] stopped. Now: py -3.11 -X utf8 tools/frida/locate_resource_load.py --aggregate")
return 0
def main():
if "--aggregate" in sys.argv:
return aggregate()
proc = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE")
return capture(proc)
if __name__ == "__main__":
sys.exit(main())