Files
BFM-decomp/tools/test_jtbl_parse_config.py
Drew T 7d10fd7e20 fix(jtbl_carve): parse_config now implements its documented contract (md_* c-line corruption)
parse_config's docstring said 'the trailing run of {data,.rodata} pieces after
the last c piece'; its code took data_pieces[0], the first such piece anywhere in
the file. Those agree on 171 configs and disagree on 42: the md_* modules open
with the 154-A leading island - [0x0, .rodata, md_XXX] BEFORE their c piece, so
apply()'s splice lines[:lo] + region + lines[hi:] deleted the c line and wrote the
yaml to disk before the tool errored out for unrelated reasons.

Fixed by deriving the region from the last c piece, plus an R43 guard that refuses
outright if any c piece lands inside the window apply() rewrites wholesale.

Also, correcting what S58's blanket refusal had lumped together:
  * md_* (42 configs) was the whole corruption class.
  * main was never in it - its pieces are already [all c ..., data, .rodata, data].
    main's real defects were the config PATH (there is no config/splat.main.yaml,
    it is splat.us.exe.yaml) and the FILE BASE: the EXE has a 0x800 header, so the
    delta is 0x8000F800, not the yaml's first vram: 0x80010000, and with the naive
    value payload_word silently read 0x800 early. Both fixed; the base comes from
    the single derivation in family_remap.vram_of (R33).

The cfg_path class refusal is lifted and replaced by an operation-level one: a carve
whose table lies below the data region is in the leading island, which a tail carve
cannot reach, so build_carve refuses and names the island-split lane (R43).

tools/test_jtbl_parse_config.py proves all three, read-only:
  NC-1 regression 171/171 configs byte-unchanged by the fix
  NC-2 defect     42/42 md_* lose a c line under the historical derivation; 42/42
                  keep every c line under the fix
  NC-3 main       cfg=splat.us.exe.yaml base=0x8000F800, region starts after the last c
2026-08-24 10:42:11 -06:00

157 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""test_jtbl_parse_config.py — the regression + negative control for jtbl_carve.parse_config.
WHY THIS EXISTS (P31 S58/S59). `parse_config`'s docstring said "the trailing run of {data,
.rodata} pieces after the last `c` piece" and its code said `data_pieces[0]` — the first
data/.rodata piece anywhere in the file. Those agree on 171 configs and disagree on 42, because
the md_* modules open with the §154-A LEADING ISLAND `- [0x0, .rodata, md_XXX]` before their `c`
piece. On those, apply()'s splice `lines[:lo] + region + lines[hi:]` DELETED the `c` line and
wrote the yaml to disk before the tool errored out for unrelated reasons. S58 responded with a
blanket md_*/main refusal in cfg_path; S59 fixed the derivation and lifted it.
Three checks, all read-only (no config on disk is touched — the splice is simulated in memory):
NC-1 REGRESSION — on every config that is NOT in the corruption class, the fixed derivation
returns EXACTLY what the historical one returned. The overlay carve pipeline is byte-gated
and green; a "fix" that moves it is a regression, not a fix. The historical derivation is
re-implemented below (`_legacy_region_lo`) precisely so this comparison is possible.
NC-2 THE DEFECT — on every md_* config, the historical derivation is SHOWN to delete a `c`
piece line (the defect is demonstrated, not asserted from the failure signature), and the
fixed one is shown to preserve every `c` line.
NC-3 CLASS FACTS — main resolves to config/splat.us.exe.yaml with file base 0x8000F800 (not the
yaml's first `vram:` 0x80010000, which reads 0x800 early), and main is NOT in the
corruption class: its pieces are already [all c ..., data, .rodata, data].
Run: .venv/bin/python tools/test_jtbl_parse_config.py
"""
import glob
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import jtbl_carve as jc # noqa: E402
REPO = jc.REPO
FAIL = []
def alias_of(cfg):
"""binary alias from a config path — 'config/splat.<alias>.yaml', with the EXE's exception."""
base = os.path.basename(cfg)
return "main" if base == "splat.us.exe.yaml" else base[len("splat."):-len(".yaml")]
def pieces_of(lines):
"""[(line_idx, indent, off, kind, name)] — the same scan parse_config does."""
out = []
for i, ln in enumerate(lines):
m = jc.PIECE_RE.match(ln)
if m:
out.append((i, m.group(1), int(m.group(2), 16), m.group(3), m.group(4)))
return out
def _legacy_region_lo(pieces):
"""THE HISTORICAL DERIVATION (pre-S59), kept only so NC-1 can compare against it.
`data_pieces[0]` — the first data/.rodata piece anywhere in the file, regardless of where the
`c` pieces are."""
data = [p for p in pieces if p[3] in ("data", ".rodata")]
return data[0][0] if data else None
def check(cond, msg):
if not cond:
FAIL.append(msg)
return cond
def main():
cfgs = sorted(glob.glob(os.path.join(REPO, "config/splat.*.yaml")))
legacy_class, held_class = [], []
for cfg in cfgs:
lines = open(cfg).read().splitlines()
pieces = pieces_of(lines)
if not [p for p in pieces if p[3] in ("data", ".rodata")]:
continue # no data region at all — not our subject
c_idxs = [p[0] for p in pieces if p[3] == "c"]
lo_legacy = _legacy_region_lo(pieces)
(legacy_class if (c_idxs and lo_legacy < c_idxs[-1]) else held_class).append(cfg)
print(f"configs with a data region : {len(legacy_class) + len(held_class)}")
print(f" corruption class (md_*) : {len(legacy_class)}")
print(f" contract already held : {len(held_class)}")
check(len(legacy_class) == 42, f"NC: expected 42 configs in the corruption class, got {len(legacy_class)}")
check(all(os.path.basename(c).startswith("splat.md_") for c in legacy_class),
"NC: the corruption class must be exactly the md_* modules")
# ---- NC-1: the fix moves nothing outside the corruption class ------------------------------
moved = []
for cfg in held_class:
ov = alias_of(cfg)
lines = open(cfg).read().splitlines()
lo_legacy = _legacy_region_lo(pieces_of(lines))
lo_new = jc.parse_config(ov)[2]
if lo_legacy != lo_new:
moved.append((ov, lo_legacy, lo_new))
check(not moved, f"NC-1 REGRESSION: the region start moved on {moved}")
print(f"NC-1 regression : {len(held_class)}/{len(held_class)} configs unchanged by the fix")
# ---- NC-2: the defect, demonstrated, and the fix, demonstrated -----------------------------
demonstrated, preserved = 0, 0
for cfg in legacy_class:
ov = alias_of(cfg)
lines = open(cfg).read().splitlines()
pieces = pieces_of(lines)
c_lines = {ln for i, ln in enumerate(lines) if i in {p[0] for p in pieces if p[3] == "c"}}
_, _, lo_new, hi_new, tail_start, _, _, _ = jc.parse_config(ov)
lo_legacy = _legacy_region_lo(pieces)
# apply() splices lines[:lo] + <regenerated region> + lines[hi:]; a `c` line inside the
# replaced window is GONE from the file. hi is the same under both derivations.
legacy_after = set(lines[:lo_legacy]) | set(lines[hi_new:])
new_after = set(lines[:lo_new]) | set(lines[hi_new:])
lost_legacy = c_lines - legacy_after
lost_new = c_lines - new_after
if lost_legacy:
demonstrated += 1
if not lost_new:
preserved += 1
check(lost_legacy, f"NC-2 {ov}: the historical derivation was expected to delete a `c` line, it did not")
check(not lost_new, f"NC-2 {ov}: the FIXED derivation still deletes {sorted(lost_new)}")
# and the leading island must sit OUTSIDE the region the fix will rewrite
island = [p for p in pieces if p[3] == ".rodata" and p[2] == 0]
check(all(p[0] < lo_new for p in island),
f"NC-2 {ov}: the leading island piece is inside the rewritten region")
check(tail_start > 0, f"NC-2 {ov}: tail_start 0x{tail_start:x} — the region still starts at the island")
print(f"NC-2 defect : {demonstrated}/{len(legacy_class)} md_* configs LOSE a `c` line under the "
f"historical derivation; {preserved}/{len(legacy_class)} keep every `c` line under the fix")
# ---- NC-3: main's class facts --------------------------------------------------------------
check(jc.cfg_path("main").endswith("config/splat.us.exe.yaml"),
f"NC-3: main resolved to {jc.cfg_path('main')}")
base = jc.overlay_vram_base("main")
check(base == 0x8000F800, f"NC-3: main file base is 0x{base:x}, expected 0x8000F800")
check(all(alias_of(c) != "main" for c in legacy_class), "NC-3: main must NOT be in the corruption class")
lo_main = jc.parse_config("main")[2]
lines_main = open(jc.cfg_path("main")).read().splitlines()
c_last = max(p[0] for p in pieces_of(lines_main) if p[3] == "c")
check(lo_main > c_last, f"NC-3: main region starts at line {lo_main + 1}, last `c` at {c_last + 1}")
print(f"NC-3 main : cfg=config/splat.us.exe.yaml base=0x{base:x} "
f"region starts line {lo_main + 1} (last `c` line {c_last + 1})")
if FAIL:
print("\nFAILED:")
for f in FAIL:
print(" -", f)
return 1
print("\nALL CHECKS PASS")
return 0
if __name__ == "__main__":
sys.exit(main())