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

117 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Static, general asset resolver: (scene, resId) -> asset file. Solves asset resolution
(docs/asset-resolution-re.md) with NO runtime capture.
Mechanism (proven): SYS4INI's file list is organized into SECTIONS, one per scene -- each is a
`SCxxxx.BIN` script entry followed by that scene's asset MANIFEST (all assets it references, across
every archive and type: EV/BG/CS/AE event & sprite graphics, OGG/WAV audio, ...). `file_number` is
the 0-based index within the section. So a bytecode resId resolves as:
resId -> files[ section_base(scene) + resId ]
where section_base(scene) is the start of the SYS4INI section containing the scene's script.
This is the same rule for set-texture(resId), play-bgm(id), play-voice(id) -- one unified manifest.
Validated: 97% of files fit `fn == position - section_base`; SC0000's opening resolves 17/17 vs
Frida ground truth; 586/595 captured loads across all sections satisfy `files[base+fn] == name`.
Usage:
py -3.11 -X utf8 tools/resolve_asset.py --build # emit build/asset-sections.json
py -3.11 -X utf8 tools/resolve_asset.py <SCENE> [resId] # resolve one, or dump the manifest
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import paths
def load_index():
return json.loads((paths.BUILD / "asset-index.json").read_text(encoding="utf-8"))["files"]
def sections(files):
"""Split the SYS4INI file list into sections at each file_number reset (fn <= prev).
Returns (section_start_per_position[list], sections[list of (start, end, scene_name|None])."""
base_of, secs, start, prev = [], [], 0, -1
for i, f in enumerate(files):
if f["file_number"] <= prev:
secs.append((start, i - 1))
start = i
base_of.append(start)
prev = f["file_number"]
secs.append((start, len(files) - 1))
# attach the scene script (SCxxxx.BIN) that owns each section, if any
out = []
for s, e in secs:
scene = next((files[k]["name"] for k in range(s, e + 1)
if re.match(r"SC\d+\.BIN$", files[k]["name"])), None)
out.append({"start": s, "end": e, "scene": scene})
return base_of, out
def scene_base(files, base_of, scene):
key = scene.upper()
if not key.endswith(".BIN"):
key += ".BIN"
pos = next((i for i, f in enumerate(files) if f["name"].upper() == key), None)
if pos is None:
raise SystemExit(f"scene not in SYS4INI: {scene}")
return base_of[pos]
def resolve(files, base, resid):
p = base + resid
return files[p] if 0 <= p < len(files) else None
def main() -> int:
files = load_index()
base_of, secs = sections(files)
if "--build" in sys.argv:
scene_bases = {}
for sec in secs:
if sec["scene"]:
scene_bases[sec["scene"].removesuffix(".BIN")] = sec["start"]
out = paths.BUILD / "asset-sections.json"
out.write_text(json.dumps(
{"note": "SYS4INI sections; resolve resId -> files[section_base + resId]. "
"See docs/asset-resolution-re.md.",
"section_count": len(secs),
"scene_base": scene_bases,
"sections": secs}, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{len(secs)} sections, {len(scene_bases)} scenes -> {out.relative_to(paths.REPO)}")
return 0
args = [a for a in sys.argv[1:] if not a.startswith("-")]
if not args:
raise SystemExit(__doc__)
scene = args[0]
base = scene_base(files, base_of, scene)
sec = next(s for s in secs if s["start"] == base)
print(f"{scene}: section [{sec['start']}..{sec['end']}] base {base} "
f"({sec['end']-sec['start']+1} entries)")
if len(args) > 1:
resid = int(args[1], 0)
f = resolve(files, base, resid)
print(f" resId {resid} -> {f['archive']} {f['name']} (offset {f['offset']}, size {f['size']})"
if f else f" resId {resid} -> out of range")
return 0
# dump the scene's manifest (graphics + audio), skipping the leading script entry
print(" manifest (resId -> asset):")
for p in range(base, sec["end"] + 1):
resid = p - base
f = files[p]
print(f" {resid:>4} {f['archive'][:5]} {f['name']}")
return 0
if __name__ == "__main__":
sys.exit(main())