mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
b89fcc2edc
THE WORST DEFECT IN THE AUDIT IS NOT IN A SCANNER. It is one default argument in the CALLER of a
scanner we had already fixed.
# tools/gate_stage.py:315
summary = run_gate(a.drafts, binary=b, src=a.src or f"src/{b}/{b}.c", ...) # ALWAYS the main .c
`src` RESTRICTS the byte-gate to ONE translation unit, and _gate1 does `if src: cmd += ["--src", src]`
-- always truthy. A3 had just taught harvest_verify to DERIVE each draft's home TU *when --src is
omitted*, lifting the byte-gate's reach from 4.9% to 100%. gate_stage NEVER OMITS IT. The fix was
neutralised by its own caller's default, and the PRIMARY BANKING PATH -- every wave, the grinder, the
orchestrator, bulk_harvest -- remained structurally unable to bank 250 of ov_SC01_077's 263 stubs.
WHY IT SURVIVED 26 PHASES: harvest_verify cannot splice a draft whose stub is not in the TU it was
pointed at, so the draft never verifies -- and is then logged as near/failed, i.e. AS A MATCHING
PROBLEM. The wave reports a poor close-rate; the function goes to the backlog as a compiler residual.
A tool that CANNOT bank a function is indistinguishable, in every log this project keeps,
from a function that CANNOT BE banked.
PROOF, same draft / same gate / same second: gate_stage rejected func_80129C40; harvest_verify run
directly (no --src) VERIFIED it byte-identical and banked it.
AND A COUNTING BUG THAT HID THE HIDING (gate_stage:261): when match_one says MATCH but the whole-binary
gate rejects, the record is logged status="near" and THE COUNTER IS NEVER INCREMENTED. A 63-draft run
printed `banked 0, near 0, failed 0` -- three zeros that do not sum to 63 -- for phases. Nobody ever
added them up. (The number was not wrong. It was ABSENT.)
ALSO FIXED, sig_unify (the same disease, one level down): it SILENTLY DROPPED 190 of 196 drafts (97%).
`cur_stubs` was read from the main .c (13 of 263 stubs), so any draft whose stub lives in a _jr_ carve
hit `if fn not in cur_stubs: continue` -- dropped BEFORE THE WRITE: never copied to --out, never gated,
never logged, while the summary printed "drafts unified: 6" and read like success. THIS IS GATE_STAGE'S
STAGE-2 RECOVERY -- the pass whose whole job is to rescue the stage-1 failures -- and it has been a
no-op for nearly every draft it was meant to save. Now: TU derived per draft (corpus.stubs), canon
derived from cdecl.tu_scope (cpp -- macro-injected decls finally visible), and _keep() so an
already-acceptable decl is left alone (the §19 "sig_unify regresses canonical drafts" failure mode).
Reach: 6 -> 196 drafts; callee-externs rewritten 2 -> 90; own def-sig 2 -> 86.
MEASURED, all three consumers migrated (196 never-banked drafts):
near 5 -> 116 failed 190 -> 17
=> 173 of 190 "failures" were PLUMBING, not codegen: now compiling and SCORED instead of invisible.
THE PRIZE (measured, not claimed): the backlog holds 1,588 entries at closeness==0 -- body byte-exact
per match_one, whole-binary gate rejected. 1,215 have been banked since by other paths. 373 ARE STILL
OPEN STUBS WHOSE BODIES ARE ALREADY BYTE-EXACT, sitting in a ledger that calls them unrecoverable.
⚠ THE HARVEST ITSELF IS NOT IN THIS COMMIT, AND IS NOT CLAIMED (P9). Gating the 63 ov_SC01_077 ones
dragged `dedup_propagate --auto-from --recover` behind it; it ran >1h and hit its timeout -- its
first-ever run over the FULL corpus (A6/A7 unblocked the 407 files it could never see). It MUTATES THE
TREE BEFORE IT GATES, so the kill left 859 files + engine_core.h (+544 lines) written and UN-GATED with
the registry never updated. R22 on that tree: 44 passed / 92 FAILED -> `git checkout -- src/ config/`,
fleet restored to 136/136. Nothing lost (H4: the tree was clean, so the revert was one command).
Two real lessons, recorded: dedup_propagate is NOT crash-safe and must never run under a timeout it can
hit; and a 63-draft experiment must not drag an unbounded fleet-wide propagation behind it.
R22 clean-fleet after revert: 136 passed, 0 failed of 136. src/ and config/ clean.
cookbook §51g LAW 11: A FIX IS NOT LANDED UNTIL ITS CALLER STOPS OVERRIDING IT. After fixing a
scanner, grep every call site and ask whether a caller's default re-disables it. An audit that stops
at the callee is half an audit.
275 lines
12 KiB
Python
275 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Unify a harvest draft's FULL signature set (callee externs AND the draft's own definition
|
|
signature) to the banked-canonical set — the recovery pass for the standalone-MATCH residual.
|
|
|
|
Phase 15 / T6 (cookbook §14c, extends canon_draft_decls). The dominant gate-failure for the
|
|
call-heavy shared core is a TU-level signature conflict on a draft whose BODY is already
|
|
byte-correct (it passes `match_one`). `canon_draft_decls` rewrites the draft's *callee* extern
|
|
lines to canonical, but it never touches the draft's OWN definition signature — and a probe
|
|
(30/30 sampled standalone-MATCH failures = type conflicts, 0 false-positives) showed the dominant
|
|
conflict is exactly that: the function is *defined* `void`/`s16`/... here, but already
|
|
*extern-declared* `s32` by the banked callers in the same TU -> `conflicting types for func_X`.
|
|
|
|
This rewrites, for each draft:
|
|
(1) every `extern ...;` line for a func/data symbol -> the banked-canonical declaration
|
|
(the canon_draft_decls behaviour), and
|
|
(2) the draft's OWN `<ret> func_X(<params>) {` definition header -> the canonical RETURN type
|
|
and canonical PARAM TYPES (names taken from the draft so the body still resolves), with an
|
|
arity-mismatch fallback that rewrites only the return type.
|
|
|
|
Codegen-neutrality is NOT assumed (widening a narrow return is byte-neutral; a param-type change
|
|
may not be) — the whole-binary `harvest_verify` byte-gate remains the sole arbiter (G3/P9): a
|
|
unification that changes the body's bytes just fails the gate and reverts. Never a wrong match.
|
|
|
|
Usage:
|
|
tools/sig_unify.py --overlay ov_SC01_077 --in .run/drafts-X --out .run/drafts-X-uni
|
|
"""
|
|
import argparse, os, re, glob, importlib.util
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
'ght', os.path.join(REPO, 'tools/gen_harvest_targets.py'))
|
|
_ght = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_ght)
|
|
|
|
DATA_DECL_RE = re.compile(
|
|
r'extern\s+([A-Za-z_][\w\s\*]*?\bD_[0-9A-Fa-f]+\s*(?:\[\s*\])?)\s*;')
|
|
DRAFT_EXTERN_LINE_RE = re.compile(r'^[ \t]*extern\b[^;]*;[ \t]*$', re.M)
|
|
# m2c emits callee prototypes WITHOUT `extern`: `<type> func_X(<params>); /* extern */`.
|
|
# Canonicalize these too, else they keep m2c's GUESSED signature and `conflicting types` in the
|
|
# whole-binary build (the body is usually byte-correct; only the decl conflicts). Phase-16 fix.
|
|
PROTO_DECL_RE = re.compile(
|
|
r'^[ \t]*[A-Za-z_][\w \t\*]*\bfunc_[0-9A-Fa-f]+\s*\([^;{]*\)\s*;[ \t]*(?:/\*[^\n]*\*/)?[ \t]*$', re.M)
|
|
|
|
|
|
def sym_of(decl):
|
|
m = re.search(r'\b(func_[0-9A-Fa-f]+|D_[0-9A-Fa-f]+)\b', decl)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def collect_data_decls(paths):
|
|
out = {}
|
|
for p in paths:
|
|
if not os.path.exists(p):
|
|
continue
|
|
for m in DATA_DECL_RE.finditer(open(p).read()):
|
|
decl = re.sub(r'\s+', ' ', m.group(1)).strip()
|
|
s = sym_of(decl)
|
|
if s:
|
|
out.setdefault(s, f'extern {decl};')
|
|
return out
|
|
|
|
|
|
def split_top_commas(s):
|
|
"""split a parameter list on top-level commas (respects nested parens/brackets)."""
|
|
out, depth, cur = [], 0, ''
|
|
for ch in s:
|
|
if ch in '([':
|
|
depth += 1
|
|
elif ch in ')]':
|
|
depth -= 1
|
|
if ch == ',' and depth == 0:
|
|
out.append(cur)
|
|
cur = ''
|
|
else:
|
|
cur += ch
|
|
if cur.strip():
|
|
out.append(cur)
|
|
return out
|
|
|
|
|
|
def param_type(p):
|
|
"""'s32 a0'->'s32'; 's32 *a0'->'s32 *'; 'struct Foo *x'->'struct Foo *'; 's32'->'s32'."""
|
|
p = p.strip()
|
|
m = re.match(r'^(.*?)([A-Za-z_]\w*)\s*$', p, re.S)
|
|
if not m:
|
|
return p
|
|
head = m.group(1).strip()
|
|
return head if head else p # nothing before the last ident -> unnamed type
|
|
|
|
|
|
def param_name(p):
|
|
p = p.strip()
|
|
m = re.search(r'([A-Za-z_]\w*)\s*$', p)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def parse_canon_sig(decl):
|
|
"""'extern s32 func_X(s32 *a0, s32 a1);' -> ('s32', ['s32 *','s32'])."""
|
|
m = re.match(r'extern\s+(.*?)\b(func_[0-9A-Fa-f]+)\s*\((.*)\)\s*;\s*$', decl.strip(), re.S)
|
|
if not m:
|
|
return None
|
|
ret = re.sub(r'\s+', ' ', m.group(1)).strip()
|
|
params = m.group(3).strip()
|
|
if params in ('', 'void'):
|
|
ptypes = []
|
|
else:
|
|
ptypes = [param_type(x) for x in split_top_commas(params)]
|
|
return ret, ptypes
|
|
|
|
|
|
def rewrite_def(txt, fn, canon):
|
|
"""rewrite the draft's own definition header to the canonical return/param types."""
|
|
if fn not in canon:
|
|
return txt, False
|
|
sig = parse_canon_sig(canon[fn])
|
|
if not sig:
|
|
return txt, False
|
|
cret, cptypes = sig
|
|
# match the definition header: `<ret> func_X(<params>) {` (params have no nested parens here)
|
|
rx = re.compile(r'(?P<lead>(?:^|\n)[ \t]*)(?P<ret>[A-Za-z_][\w \t\*]*?)\s*\b'
|
|
+ re.escape(fn) + r'\s*\((?P<params>[^()]*)\)\s*\{', re.S)
|
|
m = rx.search(txt)
|
|
if not m:
|
|
return txt, False
|
|
dparams_raw = m.group('params').strip()
|
|
if dparams_raw in ('', 'void'):
|
|
dnames = []
|
|
else:
|
|
dnames = [param_name(x) for x in split_top_commas(dparams_raw)]
|
|
if len(cptypes) == len(dnames) and all(dnames):
|
|
newparams = ', '.join(f'{t} {n}' for t, n in zip(cptypes, dnames)) if dnames else 'void'
|
|
elif len(cptypes) > len(dnames):
|
|
# arity mismatch, canonical declares MORE params than the draft body uses: ADOPT the
|
|
# canonical param types+count (the DEF-side loose-typing wall — Phase 20/21). Keep the
|
|
# draft's names where they exist (body still resolves); synth names for the extras — unused,
|
|
# they sit in $a0..$a3, free at -O2. The whole-binary byte-gate reverts anything not byte-exact.
|
|
names = [(dnames[i] if i < len(dnames) and dnames[i] else f'_arg{i}') for i in range(len(cptypes))]
|
|
newparams = ', '.join(f'{t} {n}' for t, n in zip(cptypes, names))
|
|
else: # canonical has FEWER params than the draft -> return type only
|
|
newparams = dparams_raw if dparams_raw else 'void'
|
|
new_hdr = f'{m.group("lead")}{cret} {fn}({newparams})\n{{'
|
|
if re.sub(r'\s+', ' ', new_hdr).strip() == re.sub(r'\s+', ' ', m.group(0)).strip():
|
|
return txt, False
|
|
return txt[:m.start()] + new_hdr + txt[m.end():], True
|
|
|
|
|
|
_cdecl = _load('cdecl', 'tools/cdecl.py') if '_load' in dir() else None
|
|
|
|
|
|
def _tu_for(overlay, fn):
|
|
"""The TU that holds this function's stub — DERIVED (corpus.stubs); None if it is not an open
|
|
stub anywhere (already banked, or not ours)."""
|
|
try:
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location('corpus', os.path.join(REPO, 'tools/corpus.py'))
|
|
c = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(c)
|
|
st = c.stubs(overlay).get(int(fn[5:], 16))
|
|
return os.path.join(REPO, st.path) if st else None
|
|
except Exception:
|
|
return os.path.join(REPO, f'src/{overlay}/{overlay}.c')
|
|
|
|
|
|
def _canon_for(tu):
|
|
"""{symbol: 'extern <canonical decl>;'} — DERIVED from what cc1 sees in THIS TU (cpp), so the
|
|
macro-injected `DEFINE_func_*` externs are finally visible. The old union of three raw-text
|
|
scanners (collect_define_sigs + collect_inline_sigs + collect_extern_sigs) could not see them,
|
|
and its DATA_DECL_RE was blind to fn-ptr / sized-array / multi-declarator decls (44 of 646
|
|
symbols in this very corpus) — so the recovery pass silently did nothing for exactly the symbols
|
|
that were failing."""
|
|
import cdecl as cd
|
|
out = {}
|
|
for name, d in cd.tu_scope(tu).items():
|
|
if d.storage == 'typedef':
|
|
continue
|
|
out[name] = d.declaration(storage='extern')
|
|
return out
|
|
|
|
|
|
def _keep(draft_line, canon_decl):
|
|
"""Leave the draft's decl alone when cc1 would accept it beside the TU's. sig_unify's known
|
|
failure mode is REGRESSING already-correct drafts (§19/§25: 'canon-first, sig_unify FALLBACK'),
|
|
and a rewrite that changes nothing semantic can still perturb codegen. Rewrite only when the
|
|
front end would actually reject the pair."""
|
|
try:
|
|
import cdecl as cd
|
|
a = cd.parse(canon_decl)[0]
|
|
b = cd.parse(draft_line.strip())[0]
|
|
return cd.compatible(a, b)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument('--overlay', default='ov_SC01_077')
|
|
ap.add_argument('--src-file', dest='src_file', default=None,
|
|
help='overlay SPLIT .c the drafts target (e.g. ov_SC01_077_a.c / _o0.c); default = '
|
|
'the main .c. Reads cur_stubs + inline/extern canonical sigs from THIS file so '
|
|
'split-file drafts are NOT dropped (they are stubs in the split, not the main .c) '
|
|
'and get the def-side arity-adopt recovery. engine_core.h is always included.')
|
|
ap.add_argument('--in', dest='indir', required=True)
|
|
ap.add_argument('--out', dest='outdir', required=True)
|
|
args = ap.parse_args()
|
|
|
|
override = os.path.join(REPO, args.src_file) if args.src_file else None
|
|
os.makedirs(os.path.join(REPO, args.outdir), exist_ok=True)
|
|
|
|
n = ext_rw = def_rw = passthru = 0
|
|
_canon_cache = {}
|
|
for p in sorted(glob.glob(os.path.join(REPO, args.indir, '*.c'))):
|
|
fn = os.path.basename(p)[:-2]
|
|
txt = open(p).read()
|
|
|
|
# WHICH TU? DERIVED (Phase 26-A, §51g LAW 10). `--src-file` was an optional hand-passed flag
|
|
# defaulting to src/<ov>/<ov>.c, and `cur_stubs` was read from THAT file — so a draft whose
|
|
# stub lives in a Phase-26 `_jr_<ADDR>` carve was not in the set and hit
|
|
# if fn not in cur_stubs: continue
|
|
# which dropped it BEFORE THE WRITE. It never reached --out, was never gated, was never
|
|
# logged; the summary just printed a smaller "drafts unified" and read like success.
|
|
# MEASURED: 190 of 196 drafts (97%) silently vanished — and this is gate_stage's STAGE-2
|
|
# RECOVERY, the pass whose whole job is to rescue the stage-1 failures. It has been a no-op
|
|
# for almost every draft it was meant to save.
|
|
# corpus.stubs() knows which TU holds each stub (the INCLUDE_ASM line is self-describing),
|
|
# so the derivation IS the check and the silent drop is now structurally impossible.
|
|
tu = override or _tu_for(args.overlay, fn)
|
|
if tu is None: # not an open stub anywhere -> nothing to unify
|
|
open(os.path.join(REPO, args.outdir, os.path.basename(p)), 'w').write(txt)
|
|
passthru += 1 # PASSED THROUGH and COUNTED, never dropped
|
|
continue
|
|
if tu not in _canon_cache:
|
|
_canon_cache[tu] = _canon_for(tu)
|
|
canon = _canon_cache[tu]
|
|
ext_changed = False
|
|
|
|
def repl(m):
|
|
nonlocal ext_changed
|
|
line = m.group(0)
|
|
s = sym_of(line)
|
|
if s and s in canon and not _keep(line, canon[s]):
|
|
new = canon[s]
|
|
if re.sub(r'\s+', ' ', new).strip() != re.sub(r'\s+', ' ', line).strip():
|
|
ext_changed = True
|
|
return re.match(r'^[ \t]*', line).group(0) + new
|
|
return line
|
|
|
|
txt = DRAFT_EXTERN_LINE_RE.sub(repl, txt)
|
|
|
|
def repl_proto(m):
|
|
nonlocal ext_changed
|
|
line = m.group(0)
|
|
s = sym_of(line)
|
|
if s and s != fn and s in canon and not _keep(line, canon[s]):
|
|
new = canon[s]
|
|
if re.sub(r'\s+', ' ', new).strip() != re.sub(r'\s+', ' ', line).strip():
|
|
ext_changed = True
|
|
return re.match(r'^[ \t]*', line).group(0) + new
|
|
return line
|
|
|
|
txt = PROTO_DECL_RE.sub(repl_proto, txt)
|
|
txt, def_changed = rewrite_def(txt, fn, canon)
|
|
open(os.path.join(REPO, args.outdir, os.path.basename(p)), 'w').write(txt)
|
|
n += 1
|
|
ext_rw += ext_changed
|
|
def_rw += def_changed
|
|
|
|
print(f'drafts in: {n + passthru}; unified: {n}; passed through (not an open stub): {passthru}; '
|
|
f'TUs: {len(_canon_cache)}')
|
|
print(f' callee-externs rewritten in {ext_rw}, OWN def-sig rewritten in {def_rw}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|