feat(a2b): asset-RE tooling + findings; render blocked on asset resolution

- 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>
This commit is contained in:
gamer147
2026-07-06 16:36:17 -04:00
parent d2d798bbac
commit b92e815850
5 changed files with 240 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
#!/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