Files
BFM-decomp/tools/difficulty.py
T
Drew T 8ffb7f0607 feat(phase-7): -O0 split + 4 matches (42 real) + libcd.h — session B checkpoint
- per-file -O0 mechanism: split text c-subseg into boot@-O0 (src/boot.c) + 800@-O2
  via splat resegmentation + Makefile target-specific CC1FLAGS; the boot/main/
  game-mode-dispatch module (0x80010000-0x800123F0) is -O0, not the pinned -O2
  (per-module compiler mixing, SETUP §5.5). Regression-gated byte-identical.
- 4 byte-matches (38->42 real, make check BYTE-IDENTICAL throughout): GameModeDispatch
  (-O0 register-ptr far member), DebugMenuHandler (-O0 reserved-slot local), CdQueueBusy
  (-O2 if/else order + branch polarity), CdReadRequest (-O2 early-return fall-through)
- PsyQ infra: include/psyq/libcd.h (CdlLOC/CdlFILE from the .gdt) + 4 named symbols
  (CdSearchFile/CdPosToInt/CdIntToPos/VSync) in symbols.us.txt; func_* stubs renamed
- loader cluster non-jtbl COMPLETE (6 matched + 2 drafted): NON_MATCHING drafts of
  LoaderInitFileTable + ResourceLoadStateMachine (logically faithful, residuals in-source)
- tooling: progress.py/difficulty.py fixed for the multi-file split (glob src/*.c, all
  asm dirs, skip forward-decls); deterministic; 42 real / 4 NM
- flywheel: matching-cookbook §6 (-O0 detection + idioms), §7 (PsyQ types/symbols), T4
- build byte-identical 143dbb89f34491258bbc27810d0a12ec8b43a8dd from a full clean cycle
2026-06-14 19:37:05 -06:00

99 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Difficulty-ranked inventory of UNMATCHED functions — the harvest queue for matching.
Ghidra-free: parses each unmatched stub's asm/nonmatchings/800/<name>.s for size, control
flow, jump-table presence, and call count, then ranks easiest-first. Jump-table functions
score high (they need the deferred rodata-island workflow, Task 2'). Writes the actionable
easy queue to docs/difficulty.md and the full CSV to .run/difficulty.csv.
Usage: tools/difficulty.py [TOP] (TOP = how many easy rows in the md digest, default 120)
"""
import re, sys, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
SRCS = sorted((ROOT / "src").glob("*.c")) # every c-segment (src/boot.c, src/800.c, ...)
ASM_ROOT = ROOT / "asm" / "nonmatchings" # per-segment subdirs (boot/, 800/, ...)
MD = ROOT / "docs" / "difficulty.md"
CSV = ROOT / ".run" / "difficulty.csv"
def find_s(name):
"""Locate <name>.s in any asm/nonmatchings/<seg>/ subdir."""
for p in sorted(ASM_ROOT.glob(f"*/{name}.s")):
return p
return None
INSTR = re.compile(r'^\s*/\*\s*[0-9A-Fa-f]+\s+([0-9A-Fa-f]+)\s+[0-9A-Fa-f]+\s*\*/\s+([a-z][a-z0-9.]*)')
BRANCH = re.compile(r'^b(eq|ne|gez|gtz|lez|ltz|nez|eqz|c1t|c1f|gezal|ltzal)?z?$')
def is_data_blob(txt):
return 'glabel' not in txt and 'jlabel' not in txt and 'dlabel' in txt
def unmatched_stubs():
"""INCLUDE_ASM names that are real functions (exclude data-blobs) and not inside NON_MATCHING."""
out = []
for src in SRCS:
lines = src.read_text().split('\n'); n = len(lines); i = 0
while i < n:
s = lines[i].strip()
if s.startswith('#ifdef NON_MATCHING'):
while i < n and not lines[i].strip().startswith('#endif'):
i += 1
i += 1; continue
m = re.match(r'INCLUDE_ASM\("[^"]+",\s*(\w+)\)', s)
if m:
out.append(m.group(1))
i += 1
return out
def analyze(name):
p = find_s(name)
if p is None: return None
txt = p.read_text()
if is_data_blob(txt): return None # not a function
nins = branches = ncalls = 0; last_vaddr = None; jtbl = False
if 'jtbl_' in txt: jtbl = True
for ln in txt.splitlines():
m = INSTR.match(ln)
if not m: continue
nins += 1; last_vaddr = int(m.group(1), 16); mn = m.group(2)
if mn == 'jal': ncalls += 1
elif mn == 'j': branches += 1
elif mn == 'jr' and '$ra' not in ln: jtbl = True # indirect jump = switch/jumptable
elif BRANCH.match(mn): branches += 1
score = nins + 3*branches + 25*(1 if jtbl else 0) + 2*ncalls
return dict(name=name, nins=nins, branches=branches, ncalls=ncalls,
jtbl=jtbl, leaf=(ncalls == 0), score=score)
def main():
top = int(sys.argv[1]) if len(sys.argv) > 1 else 120
rows = [r for r in (analyze(n) for n in unmatched_stubs()) if r]
rows.sort(key=lambda r: (r['score'], r['name']))
CSV.parent.mkdir(exist_ok=True)
CSV.write_text("name,score,nins,branches,ncalls,jtbl,leaf\n" +
"".join(f"{r['name']},{r['score']},{r['nins']},{r['branches']},"
f"{r['ncalls']},{int(r['jtbl'])},{int(r['leaf'])}\n" for r in rows))
trivial = sum(1 for r in rows if r['nins'] <= 5)
leaves = sum(1 for r in rows if r['leaf'] and not r['jtbl'])
jtbls = sum(1 for r in rows if r['jtbl'])
md = ["# Unmatched difficulty inventory (generated by tools/difficulty.py — harvest queue)",
"",
f"unmatched functions : {len(rows)}",
f"trivial (<=5 ins) : {trivial}",
f"non-jtbl leaves : {leaves} (best harvest targets)",
f"jump-table funcs : {jtbls} (deferred — need the rodata-island workflow, Task 2')",
"",
f"## Easiest {min(top, len(rows))} unmatched (score asc) — the work queue",
"| score | name | nins | br | calls | jtbl | leaf |",
"|---|---|---|---|---|---|---|"]
for r in rows[:top]:
md.append(f"| {r['score']} | {r['name']} | {r['nins']} | {r['branches']} | "
f"{r['ncalls']} | {'Y' if r['jtbl'] else '-'} | {'Y' if r['leaf'] else '-'} |")
MD.write_text("\n".join(md) + "\n")
print("\n".join(md[:9]))
print(f"... full table -> {MD.relative_to(ROOT)} (top {top}), CSV -> {CSV.relative_to(ROOT)}")
if __name__ == "__main__":
main()