- tools/convert_agf.py (AGF->BMP stills), tools/frida/ (capture harness + README; Frida 17.15.3) - docs/asset-resolution-re.md: foundational RE steering doc (resId->file; graphics+audio; not machine-verifiable -> Frida ground truth + human oracle). RE plan: parse SYS4INI, crack resId->name, backend render (A2b plan Tasks 3-5), audio, movies. - Finding: SC0000 bg = slot-0 slideshow (res 0x23 first); resolution opaque (CGINIT not a name map, SYS4INI S4IC needs RE, Frida file-I/O noisy/memory-mapped, opening mixes MPEG movies). - slice plan updated; texture ops already engine-driven (Task 1, prior commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
3.0 KiB
Python
89 lines
3.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Attach Frida to the running game and log READS from the graphics archives (DATA2/DATA5*.ALF)
|
|
with offset+size, in order — so we can correlate them with the engine's set-texture sequence.
|
|
|
|
The game opens the .ALF archives at startup (before we can attach) and reads CGs from the
|
|
persistent handles, so CreateFile never fires during play; we hook ReadFile and resolve each
|
|
handle -> path via GetFinalPathNameByHandleW (cached), capturing the read offset.
|
|
|
|
Flow: game running -> run this (attach) -> replay SC0000's opening -> stop this. Log lands in
|
|
build/frida-reads.log as `path<TAB>offset<TAB>size`.
|
|
|
|
Usage: py -3.11 -u -X utf8 tools/frida_capture_graphics.py [process_name] (default AGE.EXE)
|
|
"""
|
|
import frida, sys, time
|
|
from pathlib import Path
|
|
|
|
proc = sys.argv[1] if len(sys.argv) > 1 else "AGE.EXE"
|
|
OUT = Path(__file__).resolve().parents[2] / "build" / "frida-reads.log" # age-reimpl/build/
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
log = open(OUT, "w", encoding="utf-8")
|
|
|
|
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 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;
|
|
}
|
|
|
|
const rf = k32.findExportByName('ReadFile');
|
|
Interceptor.attach(rf, {
|
|
onEnter(args) {
|
|
const p = pathOf(args[0]);
|
|
if (!p || !/(data2|data5)\.alf$|\.agf$/i.test(p)) return; // graphics archives only
|
|
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) {}
|
|
send({path: p, offset: off, size: size});
|
|
}
|
|
});
|
|
send({ready: true});
|
|
"""
|
|
|
|
def on_message(msg, data):
|
|
if msg.get("type") != "send":
|
|
if msg.get("type") == "error":
|
|
print("[frida-error]", msg.get("description"))
|
|
return
|
|
pl = msg["payload"]
|
|
if pl.get("ready"):
|
|
print("[frida] ReadFile hook live — replay the opening now.")
|
|
return
|
|
line = f"{pl['path']}\t{pl['offset']}\t{pl['size']}"
|
|
print("READ", line)
|
|
log.write(line + "\n"); log.flush()
|
|
|
|
try:
|
|
session = frida.attach(proc)
|
|
except frida.ProcessNotFoundError:
|
|
print(f"process '{proc}' not found. AGE-like:",
|
|
[(p.pid, p.name) for p in frida.enumerate_processes() if "age" in p.name.lower()])
|
|
sys.exit(2)
|
|
|
|
script = session.create_script(JS)
|
|
script.on("message", on_message)
|
|
script.load()
|
|
print(f"[frida] attached to {proc}; logging archive reads to {OUT}")
|
|
try:
|
|
while True:
|
|
time.sleep(0.5)
|
|
except KeyboardInterrupt:
|
|
pass
|