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

120 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Resolve Frida archive-read offsets -> asset names via build/asset-index.json.
The runtime capture (tools/frida/capture_graphics.py) logs raw ReadFile spans on the
DATA*.ALF archives as `<path>\t<offset>\t<size>` lines. On their own those offsets are
opaque and mixed with OS memory-map paging (the doc's "Frida file-I/O is noisy"). With
the SYS4INI asset index as the answer key we can turn each offset back into the *asset*
it belongs to, and thereby recover the real per-scene **asset load order** — the ground
truth for cracking resId->filename (see docs/asset-resolution-re.md, step 2).
Two read signals per asset (observed): a burst of tiny header reads starting exactly at
the asset's archive offset (delta 0), then one bulk read of the payload. Uniform 0x20000
(131072-byte) reads are memory-map paging and are dropped. We treat a read whose offset
*exactly* equals an index entry's offset as an unambiguous "asset-start" event; the
ordered, de-duplicated sequence of those is the load order.
Usage: py -3.11 -X utf8 tools/resolve_frida_reads.py [reads.log] [-o out.json]
default reads.log = build/frida-reads.log ; default out = build/frida-asset-loads.json
"""
from __future__ import annotations
import bisect
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import paths
PAGING_SIZE = 131072 # 0x20000 uniform memory-map paging reads -> noise
def load_index() -> dict:
idx = json.loads((paths.BUILD / "asset-index.json").read_text(encoding="utf-8"))
by_arc: dict[str, list[tuple[int, int, str]]] = {}
for f in idx["files"]:
by_arc.setdefault(f["archive"], []).append((f["offset"], f["size"], f["name"]))
for a in by_arc:
by_arc[a].sort()
return by_arc
def resolve(by_arc, arc, off):
"""Return (name, entry_offset, size, delta) for the asset whose range holds `off`."""
arr = by_arc.get(arc)
if not arr:
return None
i = bisect.bisect_right(arr, (off, float("inf"), "")) - 1
if i < 0:
return None
o, s, n = arr[i]
return (n, o, s, off - o) if off < o + s else None
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("-")]
out_flag = next((sys.argv[i + 1] for i, a in enumerate(sys.argv) if a == "-o"), None)
log = Path(args[0]) if args else paths.BUILD / "frida-reads.log"
out = Path(out_flag) if out_flag else paths.BUILD / "frida-asset-loads.json"
if not log.exists():
raise SystemExit(f"reads log not found: {log}")
by_arc = load_index()
# exact-offset -> name per archive (asset-start detector)
exact = {a: {o: n for o, _, n in v} for a, v in by_arc.items()}
per_arc: dict[str, int] = {}
paging = unresolved = total = 0
starts = [] # ordered (arc, name) asset-start events (deduped consecutively)
contained = set() # every distinct asset any read touched
for ln in log.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) != 3:
continue
path, off_s, size_s = parts
arc = path.replace("\\", "/").rsplit("/", 1)[-1]
off, size = int(off_s), int(size_s)
total += 1
per_arc[arc] = per_arc.get(arc, 0) + 1
if size == PAGING_SIZE:
paging += 1
continue
hit = resolve(by_arc, arc, off)
if hit is None:
unresolved += 1
continue
name = hit[0]
contained.add((arc, name))
if off in exact.get(arc, {}): # exact asset-start
ev = (arc, exact[arc][off])
if not starts or starts[-1] != ev:
starts.append(ev)
result = {
"source_log": log.name,
"asset_index": "asset-index.json",
"total_reads": total,
"paging_reads_dropped": paging,
"unresolved_reads": unresolved,
"reads_per_archive": per_arc,
"distinct_assets_touched": len(contained),
"load_order_count": len(starts),
"load_order": [{"archive": a, "name": n} for a, n in starts],
}
out.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{log.name}: {total} reads ({paging} paging dropped, {unresolved} unresolved)")
print(f"reads/archive: {per_arc}")
print(f"distinct assets touched: {len(contained)}; "
f"asset-start load order: {len(starts)} events")
for a, n in starts:
print(f" {a:<12} {n}")
print(f"-> {out.relative_to(paths.REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())