mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
a8ae190fa3
- src/shared/clearTbl40.h: CLEAR_TBL40 macro = the matched byte-clear loop body, authored ONCE; instantiated at both func_80037004 and func_80037334 in src/800.c (one source -> two vrams) - matched first try; clean rebuild main -> 143dbb89 BYTE-IDENTICAL WITH the shared C AND WITHOUT it (INCLUDE_ASM stub fallback) -> dual invariant proven (R22 clean rebuilds, both states) - config/dedup.us.yaml: I0_clearTbl40 group registered (h_exact a0744d60…); dedup_integrate --check validates it; negative tests (corrupt hash / wrong vram) fail-closed (exit 1) - tools/progress.py: count dedup-shared members as REAL via the registry (the macro form isn't a parseable function def); REAL 52 -> 54, byte-identical 50.24% -> 50.33%; honest measurement (P9) - tools/dedup_integrate.py: display vram in hex in diagnostics - the machinery half of the Phase-11 milestone is proven on the byte-verified EXE
229 lines
11 KiB
Python
229 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""BFM matching-progress report (authoritative; supersedes the Phase-6 PhaseEnd estimate).
|
|
|
|
Classifies every matchable function across all src/*.c and prints a deterministic summary
|
|
(also written to docs/progress.md). Ghidra-free. The REAL count is the number the
|
|
Gen1-exit "≥25 matched functions" bar counts (splat-auto empties do NOT count).
|
|
|
|
Usage:
|
|
tools/progress.py # print summary + write docs/progress.md
|
|
tools/progress.py --audit # also verify every empty no-op's asm is exactly {jr,nop}
|
|
tools/progress.py --check # also hash build/us/SLUS_007.26 vs config/check.us.sha
|
|
tools/progress.py --binary <alias> # report a non-default binary (default: main = the EXE)
|
|
"""
|
|
import re, sys, hashlib, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
|
|
# Per-binary report config (Phase 9). `main` = the retail EXE; its paths are the originals
|
|
# (no-op default). A second binary (Phase 10) adds an entry — its build/check + src/asm tree;
|
|
# the overlay src/asm subtree LAYOUT is a Phase-10 decision, not invented here.
|
|
BINARIES = {
|
|
"main": dict(build="build/us/SLUS_007.26", check="config/check.us.sha",
|
|
src="src", asm="asm/nonmatchings", out="docs/progress.md"),
|
|
"resident": dict(build="build/resident/resident", check="config/check.resident.sha",
|
|
src="src/resident", asm="asm/resident/nonmatchings", out="docs/progress.resident.md"),
|
|
}
|
|
BINARY = next((sys.argv[i + 1] for i, x in enumerate(sys.argv)
|
|
if x == "--binary" and i + 1 < len(sys.argv)), "main")
|
|
if BINARY not in BINARIES:
|
|
sys.exit(f"progress.py: unknown --binary '{BINARY}' (known: {', '.join(BINARIES)})")
|
|
_cfg = BINARIES[BINARY]
|
|
SRCS = sorted((ROOT / _cfg["src"]).glob("*.c")) # every c-segment (src/boot.c, src/800.c, ...)
|
|
ASM_ROOT = ROOT / _cfg["asm"] # per-segment subdirs (boot/, 800/, ...)
|
|
OUT = ROOT / _cfg["out"]
|
|
BUILD = ROOT / _cfg["build"]
|
|
CHECK = ROOT / _cfg["check"]
|
|
MAKEFILE = ROOT / "Makefile"
|
|
|
|
def linked_subsegs():
|
|
"""Library subsegments swapped to real PsyQ objects at build time = the 5th positional arg
|
|
of each `psyq_integrate.py` call in the Makefile (e.g. `libcd1,libcd2`). Parsing the Makefile
|
|
keeps it the SINGLE source of truth: a new library integration adds its psyq_integrate call and
|
|
its stubs are auto-counted LINKED (no separate manifest to drift). Stubs in these subsegs build
|
|
byte-identically from the real SDK objects when present, and from the committed asm fallback
|
|
otherwise (fresh clone) — so they are LINKED regardless of local .run/obj40 state (a property of
|
|
the project, not the machine).
|
|
|
|
Scoped to the active binary: every psyq_integrate call lives inside the Makefile's
|
|
`ifeq ($(BINARY),main)` block (PsyQ library linking is the EXE's layout — Phase 8), so only
|
|
`main` has LINKED subsegs. A second binary (e.g. resident) has none. When a future binary gains
|
|
its own gated integration, parse that gate here instead of the main-only shortcut."""
|
|
if BINARY != "main":
|
|
return set()
|
|
if not MAKEFILE.exists():
|
|
return set()
|
|
txt = MAKEFILE.read_text()
|
|
# stub lists may be passed inline (`libcd1,libcd2`) or via a make var (`$(LIBGTE_STUBS)` for the
|
|
# long multi-block ones) — collect `NAME := <comma,list>` defs so either form resolves.
|
|
mvars = dict(re.findall(r'^(\w+)\s*:=\s*([A-Za-z0-9_,]+)\s*$', txt, re.M))
|
|
segs = set()
|
|
# leading --flag value pairs (Phase 9: --vram-base/--exe/--symbols) precede the 4 positionals
|
|
# (elf_dir ld_path objdir syms_ld); the 5th positional is the stub list captured below.
|
|
for m in re.finditer(r'psyq_integrate\.py(?:\s+--\S+\s+\S+)*\s+\S+\s+\S+\s+\S+\s+\S+\s+(\S+)', txt):
|
|
arg = m.group(1)
|
|
vm = re.fullmatch(r'\$\((\w+)\)', arg)
|
|
if vm:
|
|
arg = mvars.get(vm.group(1), '')
|
|
segs.update(s for s in arg.split(',') if re.fullmatch(r'[A-Za-z0-9_]+', s))
|
|
return segs
|
|
|
|
LINKED_SEGS = linked_subsegs()
|
|
|
|
def dedup_members(binary):
|
|
"""Function names matched-once-and-shared via config/dedup.us.yaml for this binary. They are
|
|
hand-matched byte-identical C, but instantiated from a shared body (a macro in src/shared/),
|
|
so classify()'s function-definition scan does NOT recognize the macro form — count them REAL
|
|
via the registry instead (the source of truth for code shares, Phase 11). Returns a set."""
|
|
p = ROOT / "config/dedup.us.yaml"
|
|
if not p.exists():
|
|
return set()
|
|
try:
|
|
import yaml
|
|
data = yaml.safe_load(p.read_text()) or {}
|
|
return {m["name"] for g in (data.get("groups") or [])
|
|
for m in (g.get("members") or []) if m.get("binary") == binary}
|
|
except Exception:
|
|
return set()
|
|
|
|
def find_s(name):
|
|
"""Locate <name>.s in any asm/nonmatchings/<seg>/ subdir (segments: boot, 800, ...)."""
|
|
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]')
|
|
|
|
def strip_comments(s):
|
|
s = re.sub(r'/\*.*?\*/', '', s, flags=re.S)
|
|
return re.sub(r'//[^\n]*', '', s)
|
|
|
|
def is_data_blob(name):
|
|
"""A .s with a code label (glabel/jlabel) is a function; data-only (dlabel, no code) is a blob."""
|
|
p = find_s(name)
|
|
if p is None:
|
|
return False
|
|
txt = p.read_text()
|
|
return ('glabel' not in txt and 'jlabel' not in txt and 'dlabel' in txt)
|
|
|
|
def asm_is_trivial(name):
|
|
"""True iff the function's asm is exactly {jr, nop} (the empty-no-op shape splat emits void{} for)."""
|
|
p = find_s(name)
|
|
if p is None:
|
|
return None
|
|
mnem = []
|
|
for ln in p.read_text().splitlines():
|
|
m = re.match(r'^\s*/\*\s*[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s*\*/\s+([a-z0-9.]+)', ln)
|
|
if m:
|
|
mnem.append(m.group(1))
|
|
return set(mnem) <= {'jr', 'nop'} and len(mnem) <= 2
|
|
|
|
SIG = re.compile(r'^\s*[A-Za-z_][\w \t\*]*\b([A-Za-z_]\w*)\s*\(')
|
|
|
|
def classify():
|
|
real, empty, nonmatching, stubs, blobs, linked = [], [], [], [], [], []
|
|
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'):
|
|
blk = []
|
|
while i < n and not lines[i].strip().startswith('#endif'):
|
|
blk.append(lines[i]); i += 1
|
|
i += 1
|
|
m = re.search(r'INCLUDE_ASM\("[^"]+",\s*(\w+)\)', '\n'.join(blk))
|
|
if m: nonmatching.append(m.group(1))
|
|
continue
|
|
m = re.match(r'INCLUDE_ASM\("([^"]+)",\s*(\w+)\)', s)
|
|
if m:
|
|
seg, fn = m.group(1).rstrip('/').rsplit('/', 1)[-1], m.group(2)
|
|
if is_data_blob(fn):
|
|
blobs.append(fn)
|
|
elif seg in LINKED_SEGS: # subseg swapped to a real PsyQ object (byte-identical)
|
|
linked.append(fn)
|
|
else:
|
|
stubs.append(fn)
|
|
i += 1; continue
|
|
if s.startswith('INCLUDE_RODATA'):
|
|
i += 1; continue
|
|
fm = SIG.match(lines[i])
|
|
if fm and '(' in lines[i]:
|
|
# Definition ({ ... }) vs forward declaration (ends ;)? Scan to the first { or ;.
|
|
# extern/prototype lines (e.g. `extern s32 CdQueueBusy(void);`) are NOT functions.
|
|
j = i; kind = None
|
|
while j < n:
|
|
c = strip_comments(lines[j])
|
|
br = c.find('{'); sm = c.find(';')
|
|
if br != -1 and (sm == -1 or br < sm): kind = 'def'; break
|
|
if sm != -1: kind = 'decl'; break
|
|
j += 1
|
|
if kind != 'def':
|
|
i = j + 1; continue # skip the declaration
|
|
start = i; depth = 0; opened = False
|
|
while i < n:
|
|
c = strip_comments(lines[i]); depth += c.count('{') - c.count('}')
|
|
if '{' in c: opened = True
|
|
i += 1
|
|
if opened and depth <= 0: break
|
|
body = '\n'.join(lines[start:i])
|
|
a, b = body.index('{'), body.rindex('}')
|
|
(real if strip_comments(body[a+1:b]).strip() else empty).append(fm.group(1))
|
|
continue
|
|
i += 1
|
|
return real, empty, nonmatching, stubs, blobs, linked
|
|
|
|
def main():
|
|
audit = '--audit' in sys.argv
|
|
check = '--check' in sys.argv
|
|
real, empty, nonmatching, stubs, blobs, linked = classify()
|
|
# Code-shared functions (dedup.us.yaml) are REAL byte-identical matches whose macro-instantiated
|
|
# form classify() doesn't parse — fold them in (dedup-safe) so the count stays honest (P9).
|
|
shared = sorted(dedup_members(BINARY) - set(real))
|
|
real = sorted(set(real) | set(shared))
|
|
matchable = len(real) + len(empty) + len(nonmatching) + len(stubs) + len(linked)
|
|
byteident = len(real) + len(linked) + len(empty) # all byte-identical in the build
|
|
|
|
out = []
|
|
out.append("# BFM matching progress (generated by tools/progress.py — authoritative)")
|
|
out.append("")
|
|
out.append(f"REAL substantive matches : {len(real):5d} <- the Gen1-exit >=25 bar counts THIS")
|
|
if shared:
|
|
out.append(f" (of which dedup-shared : {len(shared):5d} one body -> N sites, config/dedup.us.yaml)")
|
|
out.append(f"LINKED real PsyQ objects : {len(linked):5d} <- byte-identical via linked SDK objects")
|
|
out.append(f"NON_MATCHING (near-miss) : {len(nonmatching):5d}")
|
|
out.append(f"splat-auto empty no-ops : {len(empty):5d}")
|
|
out.append(f"INCLUDE_ASM stubs : {len(stubs):5d}")
|
|
out.append(f"data blobs (excluded) : {len(blobs):5d}")
|
|
out.append("-" * 40)
|
|
out.append(f"matchable functions : {matchable:5d}")
|
|
out.append(f"REAL / matchable : {len(real)} / {matchable} = {100*len(real)/matchable:.2f}%")
|
|
out.append(f"byte-identical/ matchable: {byteident} / {matchable} = {100*byteident/matchable:.2f}% (REAL+LINKED+empties)")
|
|
out.append("")
|
|
out.append("LINKED subsegs: " + " ".join(sorted(LINKED_SEGS)) + f" ({len(linked)} fns)")
|
|
out.append("REAL matches: " + " ".join(sorted(real)))
|
|
out.append("NON_MATCHING: " + " ".join(sorted(nonmatching)))
|
|
|
|
if BUILD.exists():
|
|
h = hashlib.sha1(BUILD.read_bytes()).hexdigest()
|
|
want = CHECK.read_text().split()[0] if CHECK.exists() else ""
|
|
out.append("")
|
|
out.append(f"build SHA1: {h} ({'byte-identical' if h == want else 'MISMATCH'})")
|
|
|
|
if audit:
|
|
bad = [n for n in empty if asm_is_trivial(n) is False]
|
|
out.append("")
|
|
out.append(f"empties audit: {len(empty)-len(bad)}/{len(empty)} genuine jr;nop"
|
|
+ (f" !! SUSPICIOUS: {bad}" if bad else " (all clean)"))
|
|
|
|
text = "\n".join(out) + "\n"
|
|
print(text, end="")
|
|
OUT.write_text(text)
|
|
|
|
if check:
|
|
import subprocess
|
|
sys.exit(subprocess.run(["make", "-C", str(ROOT), "check"]).returncode)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|