Files
OpenMaidEngine/tools/correlate_scope.py
gamer147 a61c0c9abd 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

149 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Correlate the VM's set-texture(resId) trace with the game's Frida load order to LOCALIZE the
asset-resolution scope selector (docs/asset-resolution-re.md step 2, scope-selector hunt).
Inputs:
build/settex-<SCENE>.json our VM's ordered set-texture(resId) trace + bytecode offsets
(produce with: py -3.11 -X utf8 tools/vm0.py --settex <SCENE>)
build/frida-load-order-result.json the game's ordered (name, file_number=resId) loads
(produce with tools/frida/capture_load_order.py --analyze)
build/asset-index.json for segmenting DATA2 into packages
Method: `resId == file_number`, and each loaded file belongs to exactly one DATA2 "package"
(a monotonic-file_number run). So the game's load order is a readout of the active package over
time. We greedily align each Frida load to the next same-resId set-texture in the VM trace, which
pins it to a bytecode offset. Where the active package CHANGES between consecutive loads, some
instruction in the bytecode span between their offsets flipped the scope -- the scope selector.
This report lists the aligned loads, flags package transitions, and points at the byte spans to
inspect (dump them with the disassembly in build/disasm/<SCENE>.asm).
Usage: py -3.11 -X utf8 tools/correlate_scope.py <SCENE> (e.g. SC0000)
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import paths
def packages():
"""Segment DATA2 (directory order) into monotonic-file_number runs; return name->pkg index."""
idx = json.loads((paths.BUILD / "asset-index.json").read_text(encoding="utf-8"))
d2 = [f for f in idx["files"] if f["archive"] == "DATA2.ALF"]
name2pkg, pkg, prev = {}, 0, None
for f in d2:
fn = f["file_number"]
if prev is not None and fn <= prev:
pkg += 1
name2pkg[f["name"]] = pkg
prev = fn
return name2pkg
# boilerplate ops to hide when dumping the between-loads span (leave the structural/effectful ones)
NOISE = {"mov", "add", "sub", "mul", "div", "mod", "and", "or", "sar", "shl", "xor",
"eq", "ne", "lt", "lte", "gr", "gre", "jcc", "jmp", "set-string",
"show-text", "end-text-line", "wait-for-input", "stmt-begin", "stmt-end",
"block-mark", "cond-block", "label-def", "line-id?", "comment", "set-font",
"lookup-array", "lookup-array-2d", "stmt-desc?", "bit-set", "bit-reset",
"check-bit", "text-param?", "gfx-geom?", "count?", "resolve-handle?",
"create-texture", "draw-texture", "draw-string", "draw?", "draw-blit?"}
def disasm_map(scene):
"""offset(int) -> stripped disasm line, from build/disasm/<SCENE>.asm."""
path = paths.BUILD / "disasm" / f"{scene}.asm"
out = {}
if not path.exists():
return out
for ln in path.read_text(encoding="utf-8").splitlines():
s = ln.strip()
if s[:2] == "0x" and ":" in s:
off = int(s.split(":", 1)[0], 16)
out[off] = s.split(":", 1)[1].strip()
return out
def dump_span(trace, ta, tb, dis):
"""Print the significant (non-NOISE) ops executed between trace indices ta..tb."""
seen = 0
for t in range(ta, min(tb, len(trace))):
off = int(trace[t], 16)
line = dis.get(off, "")
mnem = line.split()[0] if line else ""
if mnem and mnem not in NOISE:
print(f" {trace[t]:>8} {line}")
seen += 1
if not seen:
print(" (no structural/effectful ops in span — only boilerplate)")
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("-")]
if not args:
raise SystemExit(__doc__)
scene = args[0].upper().removesuffix(".BIN")
settex_path = paths.BUILD / f"settex-{scene}.json"
loads_path = paths.BUILD / "frida-load-order-result.json"
if not settex_path.exists():
raise SystemExit(f"missing {settex_path.name} — run: tools/vm0.py --settex {scene}")
if not loads_path.exists():
raise SystemExit(f"missing {loads_path.name} — run tools/frida/capture_load_order.py --analyze")
sx = json.loads(settex_path.read_text(encoding="utf-8"))
vm, trace = sx["settex"], sx.get("trace", []) # [{i,off,resId,slot,trace_i}]
loads = json.loads(loads_path.read_text(encoding="utf-8"))["load_order"] # [{name,file_number}]
name2pkg = packages()
dis = disasm_map(scene)
# greedy align: each Frida load -> next same-resId set-texture in VM order
aligned, j, unmatched = [], 0, 0
for ld in loads:
fn = ld["file_number"]
k = j
while k < len(vm) and vm[k]["resId"] != fn:
k += 1
if k < len(vm):
aligned.append({"vm": vm[k], "resId": fn, "name": ld["name"], "pkg": name2pkg.get(ld["name"])})
j = k + 1
else:
aligned.append({"vm": None, "resId": fn, "name": ld["name"], "pkg": name2pkg.get(ld["name"])})
unmatched += 1
print(f"scene {scene}: {len(vm)} VM set-textures, {len(loads)} Frida loads, "
f"{len(loads)-unmatched} aligned ({unmatched} unmatched)\n")
print(f"{'off':>8} {'resId':>5} {'pkg':>4} name")
prev = None
transitions = []
for a in aligned:
off = a["vm"]["off"] if a["vm"] else None
flag = ""
if prev is not None and a["pkg"] != prev["pkg"]:
flag = f" <<< PACKAGE {prev['pkg']} -> {a['pkg']}"
transitions.append((prev, a))
print(f"{str(off):>8} {a['resId']:>5} {str(a['pkg']):>4} {a['name']}{flag}")
prev = a
print()
if not transitions:
print("No package transitions in this capture — play deeper into the scene to cross one.")
return 0
print(f"{len(transitions)} package transition(s). Significant ops executed across each "
f"(the scope selector should be here):\n")
for pa, pb in transitions:
print(f" === pkg {pa['pkg']} ({pa['name']}) -> pkg {pb['pkg']} ({pb['name']}) ===")
if pa["vm"] and pb["vm"]:
dump_span(trace, pa["vm"]["trace_i"], pb["vm"]["trace_i"], dis)
else:
print(" (unaligned — cannot pin the span)")
print()
return 0
if __name__ == "__main__":
sys.exit(main())