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>
This commit is contained in:
119
tools/resolve_frida_reads.py
Normal file
119
tools/resolve_frida_reads.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user