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>
179 lines
7.4 KiB
Python
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())
|