mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
d2b48b7680
Promotes the proven probe (commit:1266) into a real tool, and validates it FIRST-TRY on a
fresh overlay.
tools/o0_subsplit.py <ov> --lo <vram> --hi <vram>:
- derives the range's contents from the SOURCE ANCHORS (overlay_src_split.parse_overlay_c:
`asm` = unmatched stub, `define`/`def`/`nonmatch` = already matched), NEVER from an asm
scan -- a matched fn emits no .s, which is exactly the blindness that made the range look
like a clean contiguous run (SS126 / SS124's shape);
- computes the -O0 bound as (address range MINUS already-matched bodies) and emits ONE
sub-region per maximal run of unmatched anchors (K matched islands => K+1 regions);
- names each `<ov>_o0<letter>` picking free suffixes, so the widened Makefile glob selects
them; refuses loudly if it runs out or if the range spans >1 object or is already -O0;
- honours the one-carve-per-region law (forces a cut at every already-banked jr in the
object) and reuses jr_isolate_all's plan/build_new_config/ascending-unique validation
verbatim, so carve-repoint + source-repartition stay on the proven path;
- warns (does not refuse) when a stub in an -O0 run lacks the frame-pointer prologue --
the byte-gate is the arbiter, not the heuristic.
VALIDATION on ov_SC03_015 (untouched by the manual probe): the tool independently derived the
SAME structure found by hand on ov_SC03_014 -- 2 matched -O2 islands (func_80184440,
func_801848E4), 2 -O0 regions (8 + 7 fns), same 5 cuts. Sub-split -> BYTE-IDENTICAL. Then 3
drafts, each global DERIVED FROM THAT OVERLAY'S OWN ASM (%hi operand) rather than copied:
3/3 match_one --o0 MATCH (22 ins), 3/3 through the whole-binary gate.
BANKED this commit: func_801846E4 / func_8018473C / func_80184794 in ov_SC03_015 (6 across
the two overlays now). The other 24 stubs in the region are undrafted -- the route makes them
DRAFTABLE (they were un-bankable at any effort before); drafting them is crack-wave work.
R22 CLEAN-FLEET: extract-all 139/139 (+main); check-all 140 passed, 0 failed of 140.
cookbook SS126 (the address-range-is-not-an-optimization-region law + the probe ladder).
184 lines
8.4 KiB
Python
184 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""P30 T2 — the CARVE WITHIN A CARVE: route an `-O0` address range inside an `-O2` object.
|
|
|
|
Some `-O0` regions sit INSIDE an object that is otherwise `-O2` (a jr split, or the main body).
|
|
gcc-2.7.2 has no per-function optimize pragma, so opt level is per FILE (§116): the range must be
|
|
cut into its own object(s) and named so the Makefile's `-O0` wildcard selects it.
|
|
|
|
WHAT THIS TOOL EXISTS TO GET RIGHT (cookbook §126, byte-proven):
|
|
**An address range is not an optimization region.** A range selected by address contains everything
|
|
in it — including already-MATCHED functions, whose bodies expand from engine_core.h as
|
|
`DEFINE_func_*()` instantiations (or sit inline) and are compiled `-O2`. Flipping the FILE to `-O0`
|
|
recompiles those too and they stop matching. So the `-O0` bound is
|
|
|
|
(address range) MINUS (already-matched bodies)
|
|
|
|
and a range with K interleaved matched functions needs **K+1** `-O0` sub-regions, not one.
|
|
|
|
The contents are derived from the SOURCE ANCHORS (`overlay_src_split.parse_overlay_c`: `asm` =
|
|
unmatched stub, `define`/`def`/`nonmatch` = matched), NEVER from an asm-file scan — a matched
|
|
function emits no `.s`, so a scan of `asm/**/*.s` is structurally blind to exactly the bodies that
|
|
break the flip. That blindness is what made the range look like a clean contiguous run (§124's shape).
|
|
|
|
The split itself reuses `jr_isolate_all` verbatim (plan / build_new_config / the ascending-unique
|
|
validation / repoint_overlays_mk): region naming there is purely positional, so only the cut list is
|
|
injected. That keeps source-repartition and `.rodata` carve-repoint semantics on the proven path,
|
|
including the one-carve-per-region law (every already-banked jr in the object must also be a cut).
|
|
|
|
The whole-binary byte-gate remains the sole arbiter (G3/P9). After running this:
|
|
make extract BINARY=<ov> && make build BINARY=<ov> -> must be BYTE-IDENTICAL
|
|
(the split is byte-neutral by construction; a divergence means a bound is wrong), then bank drafts
|
|
into the `_o0*` files and gate again, then a full R22 (config changed => T2 blast radius).
|
|
|
|
Usage:
|
|
tools/o0_subsplit.py <ov> --lo 0x80183CF0 --hi 0x80184920 [--dry-run]
|
|
"""
|
|
import argparse
|
|
import os
|
|
import re
|
|
import string
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import jr_isolate_all as J # noqa: E402
|
|
import overlay_src_split as oss # noqa: E402
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
MATCHED_KINDS = {"define", "def", "nonmatch"} # already carry real C => must stay -O2
|
|
|
|
|
|
def free_letters(ov, n):
|
|
"""n unused `<ov>_o0<letter>` suffixes (b is the whale's, so start at c and skip taken ones)."""
|
|
taken = {m.group(1) for m in
|
|
(re.fullmatch(rf"{re.escape(ov)}_o0(\w?)\.c", f)
|
|
for f in os.listdir(os.path.join(REPO, "src", ov))) if m}
|
|
out = []
|
|
for ch in string.ascii_lowercase[2:]: # c, d, e, ...
|
|
if ch not in taken:
|
|
out.append(ch)
|
|
if len(out) == n:
|
|
return out
|
|
sys.exit(f"o0_subsplit: ran out of free _o0<letter> suffixes for {ov}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("ov")
|
|
ap.add_argument("--lo", required=True, help="first vram of the -O0 range (inclusive)")
|
|
ap.add_argument("--hi", required=True, help="first vram AFTER the -O0 range (exclusive)")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
a = ap.parse_args()
|
|
ov, lo, hi = a.ov, int(a.lo, 16), int(a.hi, 16)
|
|
|
|
p = J.plan(ov)
|
|
|
|
def obj_of(v):
|
|
for s, e, nm, li, ind in p["obj_ranges"]:
|
|
if s <= v and (e is None or v < e):
|
|
return nm
|
|
return None
|
|
|
|
nm = obj_of(lo)
|
|
if nm is None or obj_of(max(lo, hi - 1)) != nm:
|
|
sys.exit(f"o0_subsplit: [{a.lo}, {a.hi}) does not lie inside ONE code object "
|
|
f"(lo in {nm}, hi-1 in {obj_of(max(lo, hi - 1))})")
|
|
if nm.endswith(J.O0_SUFFIX) or re.search(r"_o0\w?$", nm):
|
|
sys.exit(f"o0_subsplit: {nm} is ALREADY an -O0 object — nothing to route.")
|
|
|
|
# ---- derive the range's contents FROM THE SOURCE (never an asm scan) --------------------
|
|
srcpath = os.path.join(REPO, f"src/{ov}/{nm}.c")
|
|
_hdr, items = oss.parse_overlay_c(open(srcpath).read(), oss.load_ov_syms(ov))
|
|
inrange = sorted([it for it in items if it[0] is not None and lo <= it[0] < hi],
|
|
key=lambda it: it[0])
|
|
if not inrange:
|
|
sys.exit(f"o0_subsplit: no addressed source anchors in [{a.lo}, {a.hi}) of {nm}")
|
|
|
|
stubs = [it for it in inrange if it[2] == "asm"]
|
|
matched = [it for it in inrange if it[2] in MATCHED_KINDS]
|
|
print(f"o0_subsplit {ov}: object {nm}")
|
|
print(f" range 0x{lo:08X}..0x{hi:08X}: {len(inrange)} anchors "
|
|
f"= {len(stubs)} unmatched stub(s) + {len(matched)} ALREADY-MATCHED (must stay -O2)")
|
|
for ad, nam, kind, _t in matched:
|
|
print(f" -O2 island: 0x{ad:08X} {nam} [{kind}]")
|
|
if not stubs:
|
|
sys.exit("o0_subsplit: every anchor in the range is already matched — nothing to route.")
|
|
|
|
# ---- maximal runs of consecutive UNMATCHED anchors = the -O0 sub-regions ----------------
|
|
runs, cur = [], []
|
|
for it in inrange:
|
|
if it[2] == "asm":
|
|
cur.append(it)
|
|
else:
|
|
if cur:
|
|
runs.append(cur)
|
|
cur = []
|
|
if cur:
|
|
runs.append(cur)
|
|
|
|
letters = free_letters(ov, len(runs))
|
|
cuts, renames = set(), {}
|
|
for run, ch in zip(runs, letters):
|
|
start = run[0][0]
|
|
after = next((it[0] for it in inrange if it[0] > run[-1][0]), hi)
|
|
cuts.add(start)
|
|
cuts.add(after) # close the region (the next anchor, or hi)
|
|
renames[start] = f"o0{ch}"
|
|
print(f" -O0 region {ov}_o0{ch}: 0x{start:08X}..0x{after:08X} ({len(run)} fns)")
|
|
|
|
# sanity (this scan IS valid for stubs — only MATCHED fns lack a .s): every -O0 candidate
|
|
# should carry the frame-pointer prologue. Warn, don't refuse: the byte-gate is the arbiter.
|
|
import corpus
|
|
nofp = []
|
|
for run in runs:
|
|
for ad, nam, _k, _t in run:
|
|
ap_ = corpus.asm_path(ov, nam)
|
|
if ap_ and os.path.exists(os.path.join(REPO, ap_)):
|
|
txt = open(os.path.join(REPO, ap_)).read()
|
|
if not re.search(r'addu\s+\$fp,\s*\$sp,\s*\$zero', txt):
|
|
nofp.append(nam)
|
|
if nofp:
|
|
print(f" ⚠ {len(nofp)} stub(s) in the -O0 runs lack the frame-pointer prologue "
|
|
f"({nofp[:5]}{'…' if len(nofp) > 5 else ''}) — check the bounds; gate decides.")
|
|
|
|
# ---- the one-carve-per-region law: every already-banked jr here must also be cut --------
|
|
_alljr, banked = J.jr_inventory(ov)
|
|
banked_here = [x for x in banked if obj_of(x) == nm]
|
|
allcuts = sorted(cuts | set(banked_here))
|
|
print(f" banked jr in object: {[hex(x) for x in banked_here]} (forced cuts)")
|
|
print(f" final cuts: {[hex(c) for c in allcuts]}")
|
|
|
|
_orig = J.subseg_name
|
|
J.subseg_name = lambda o, vram: (f"{o}_{renames[vram]}" if vram in renames else _orig(o, vram))
|
|
p["per_obj"] = {nm: allcuts}
|
|
cfg_lines, new_files, carve_renames = J.build_new_config(ov, p)
|
|
|
|
# jr_isolate_all's fail-loud ascending/unique validation, verbatim
|
|
code_re = re.compile(r'^\s*- \[(0x[0-9A-Fa-f]+), c, (\w+)\]')
|
|
seen_off, seen_nm = -1, set()
|
|
for ln in cfg_lines:
|
|
m = code_re.match(ln)
|
|
if not m:
|
|
continue
|
|
off, n2 = int(m.group(1), 16), m.group(2)
|
|
if off <= seen_off or n2 in seen_nm:
|
|
sys.exit(f"o0_subsplit: REFUSING corrupt config — [{hex(off)}, {n2}] "
|
|
f"{'out of order' if off <= seen_off else 'duplicate'}")
|
|
seen_off, seen_nm = off, seen_nm | {n2}
|
|
|
|
print(f" -> {len(new_files)} region .c files; carve repoints: {carve_renames or '(none)'}")
|
|
if a.dry_run:
|
|
print(" [dry-run] nothing written.")
|
|
return
|
|
open(os.path.join(REPO, f"config/splat.{ov}.yaml"), "w").write("\n".join(cfg_lines) + "\n")
|
|
for path, content in new_files.items():
|
|
open(path, "w").write(content)
|
|
J.repoint_overlays_mk(carve_renames, dry=False)
|
|
print(f" wrote config + region files + overlays.mk.\n"
|
|
f" NEXT: make extract BINARY={ov} && make build BINARY={ov} -> must be BYTE-IDENTICAL "
|
|
f"(the split is byte-neutral by construction; a divergence means a bound is wrong).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|