Files
BFM-decomp/tools/cast_call_sites.py
T
Drew T f4502f11bb fix(phase-26a): A3c — the recovery passes were reconciling 95% of drafts against the WRONG TU
FIRST CONSUMER MIGRATION onto the cdecl oracle — and the compiler taught me two things I had
wrong, one of which reopens a wall that has been closed since Phase 15.

1. cdecl.compatible() — "will cc1 accept these two declarations of one name?"
   The predicate four tools each half-implement and get wrong: norm_sig / _norm_type collapse the
   int family to ONE token, so a SIGNEDNESS change reads as "already compatible" and gets no
   rewrite -- while cc1 REJECTS that redeclaration. Right about codegen, wrong about the front end,
   which never reaches codegen.

2. THE ADJUDICATOR MUST BE THE COMPILER THAT COMPILES YOUR CODE (cookbook §51g LAW 9).
   I wrote the rules from the C standard, then let a compiler judge. It contradicted me -- and then
   the RIGHT compiler contradicted the first one. Three different answers:

       declarations in one TU        | standard | modern gcc | gcc-2.7.2 cc1
       typedef int X;  twice         | error    | ACCEPTS    | ERROR
       extern u16 X; + volatile u16 X| error    | error      | ACCEPTS
       void X(s16);  then  void X(); | error    | error      | ACCEPTS
       void X();     then  void X(s16)| error   | error      | ERROR

   --compat now adjudicates with tools/bin/gcc-2.7.2-psx/cc1, the front end that actually
   arbitrates the build: 1,485/1,485 live corpus pairs agree, 0 disagree, 0 skipped.

3. THE PRIZE: the Phase-15 narrow-param wall rests on a false premise.
   The no-prototype rule is ORDER-DEPENDENT. `void X(s16); void X();` COMPILES; only the reverse
   fails. Phase 15 closed "the 159 arity/narrow-param conflicts" as "no clean deterministic fix --
   it is simply C's default-promotion rule". cc1 does not enforce that rule in the direction the
   wall assumed. Four three-line probes, 90 seconds, zero tokens. -> A10 RE-TEST TARGET.
   Probe the compiler for FACTS; read its source only for LEVERS; byte-validate both. (We read
   gcc-papermario for five phases believing it was 2.7.2. It was 2.8.1.)

4. THE MIGRATION: cast_call_sites canonicalized 95.1% of drafts against a TU that would never
   compile them. `--src-file` is an OPTIONAL HAND-PASSED flag defaulting to src/<ov>/<ov>.c, and no
   caller knows about the Phase-26 _jr_<ADDR> carves: ov_SC01_077 has 263 open stubs across 12 TUs
   and only 13 are in the main .c -- while harvest_verify (A3) correctly splices into the real one.
   Now DERIVED from corpus.stubs() (the INCLUDE_ASM line is self-describing), with the canonical map
   derived from cdecl.tu_scope() (cpp -- so macro-injected DEFINE_func_* decls are finally visible).
   Callee-conflict repair reach: 8 -> 58 of 196 drafts (7x).

5. AND THE NULL RESULT, REPORTED AS SUCH (P9/R14). Those 58 banked ZERO functions. The historical
   draft tail fails on CODEGEN, not plumbing -- func_801387B8, which the audit blames on a single
   unparsed `[4]`, is really 67/100 instructions off with a $s0/$s1 swap (that claim does not
   reproduce on today's tree). The real gain is narrower and still worth having: 52 drafts moved
   from "won't compile" to "compiles, N instructions off" -- from an INVISIBLE failure that reads as
   a compiler wall into a SCORED near-miss the permuter and the §47/§48 dials can act on. That is
   the audit's thesis, not a bank. THREE times in one session a confirmed mechanism produced a null
   consequence.

Also: my own new audit printed "ALL ORACLES GREEN" while silently skipping 100% of its corpus (a
missing -Isrc). The exact bug class, in the tool written to hunt it. An unadjudicable check is not
a passed check.

  R22 clean-fleet: make clean + extract-all + check-all -> 136 passed, 0 failed of 136
  src/ untouched (0 changes)   make audit-cdecl: green   --compat: 1485/1485
  NEXT: sig_unify + reconcile_decls carry the SAME wrong-TU bug (same --src-file flag).
2026-07-14 12:26:01 -06:00

271 lines
13 KiB
Python

#!/usr/bin/env python3
"""cast_call_sites.py — the §17a-1 per-site function-pointer cast recovery (Phase 20, cookbook §20).
THE wave-at-scale gate cap (the §20 loose-typing call-graph wall). At the reach-134 tail a draft
passes `match_one` (compiles STANDALONE with its own externs, masks jal/%hi/%lo) but FAILS the
whole-binary gate: a callee (e.g. func_80153C74) is DEFINED in this overlay's TU with one signature
(the engine_core.h `DEFINE_func_80153C74()` body `void func_80153C74(s16,s16)`), while the draft
declares it `extern void func_80153C74(int,int)` for its own byte-match -> in-TU
`conflicting types for func_80153C74` (a COMPILE error, not a byte miss).
Every cheaper lever fails here (cookbook §20, all 0): `sig_unify`/`canon_draft_decls` rewrite the
draft's callee extern to the ONE canonical sig but leave the CALL `func_X(a,b)` -> the args get
converted to the canonical (narrow) param types -> DIFFERENT codegen -> byte miss; `fix_arity_callers`
is the caller side, not this; no-proto `void f()` is incompatible with the overlay's narrow def
`void f(s16,s16)`; strip-externs gives implicit-int.
The ONLY fix (§17a-1, byte-proven Phase 18 Step-1): for each conflicting callee, do BOTH —
(1) rewrite the draft's callee DECL line to the canonical (conflict-free, keeps the symbol in
scope as a forward declaration before the call), and
(2) CAST every CALL site to the draft's INTENDED signature:
func_X(a, b) -> ((<dret>(*)(<dptypes>))func_X)(a, b)
x = func_X(a) -> x = ((<dret>(*)(<dptypes>))func_X)(a)
gcc-2.7.2 folds the cast of a known function symbol back to a direct `jal func_X` with the
draft's calling convention, so the body bytes are unchanged — there is no global decl that can
conflict, and the call codegen matches the byte target. (Decl lines are never cast.)
This is a PURE draft-text transform (like sig_unify / canon_resident_calls): the whole-binary
`harvest_verify` byte-gate remains the sole arbiter (G3/P9) — a wrong transform just fails the gate
and reverts. It SUPERSEDES sig_unify for the callee-conflict class (it does sig_unify's extern
canonicalization PLUS the call-site cast). Run it on the canon_resident_calls output (intended
sigs intact); the DEF-side caller-conflict class is still `fix_arity_callers`' job (composes).
Pipeline (cookbook §20):
draft -> canon_resident_calls -> cast_call_sites -> harvest_verify --chunk 1
(then fix_arity_callers for any residual DEF-side `conflicting types ... (void)` caller conflicts)
Usage:
tools/cast_call_sites.py --overlay ov_SC01_077 --in .run/drafts-t6-cn --out .run/drafts-t6-cast
"""
import argparse, os, re, glob, importlib.util
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load(mod, rel):
spec = importlib.util.spec_from_file_location(mod, os.path.join(REPO, rel))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
_ght = _load('ght', 'tools/gen_harvest_targets.py')
_su = _load('su', 'tools/sig_unify.py') # reuse split_top_commas / param_type
_cdecl = _load('cdecl', 'tools/cdecl.py') # THE declaration oracle (Phase 26-A, §51g)
_corpus = _load('corpus', 'tools/corpus.py') # THE corpus oracle — which TU holds a stub
def tu_for(overlay, fn, override=None):
"""The TU this draft will actually be SPLICED INTO — DERIVED from the corpus, not hand-passed.
`--src-file` was an OPTIONAL flag defaulting to `src/<ov>/<ov>.c`. Every caller that did not
know to set it — and none of them know about the Phase-26 `_jr_<ADDR>` carve files — was
therefore canonicalizing drafts against the MAIN .c while `harvest_verify` (fixed in A3)
correctly spliced them into the jr split. The recovery passes were reconciling against a
DIFFERENT TRANSLATION UNIT than the one that would compile the code, and the heavy Phase-26
cores live in exactly those jr files.
The INCLUDE_ASM line is self-describing, and corpus.stubs() reads it. Ask, don't assume."""
if override:
return override
try:
st = _corpus.stubs(overlay).get(int(fn[5:], 16))
if st:
return os.path.join(REPO, st.path)
except Exception:
pass
return os.path.join(REPO, f'src/{overlay}/{overlay}.c')
# A function declaration line (prototype ending in `;`, NOT a definition): optional `extern`,
# a type, func_X, a param list, `;`, optional trailing /* comment */. Matches both the
# `extern void func_X(int,int);` form and the bare `s32 func_X(s32,void*,s32);` m2c form.
DECL_LINE_RE = re.compile(
r'^([ \t]*)(extern\s+)?([A-Za-z_][\w \t\*]*?)\b(func_[0-9A-Fa-f]+)\s*\(([^;{]*)\)\s*;'
r'[ \t]*(?:/\*[^\n]*\*/)?[ \t]*$')
def parse_sig(ret, params):
"""('void', 'int a0, int a1') -> ('void', ['int','int']); '' / 'void' params -> []."""
ret = re.sub(r'\s+', ' ', ret).strip()
params = params.strip()
if params in ('', 'void'):
ptypes = []
else:
ptypes = [_su.param_type(x) for x in _su.split_top_commas(params)]
return ret, ptypes
def norm_sig(ret, ptypes):
"""A spelling-insensitive-ish key for 'do these sigs differ': collapse whitespace, treat the
typedef int aliases as int (s32/int/long are byte-identical params -> casting them is a no-op,
so we must NOT count them as a difference and emit a pointless cast)."""
def canon_t(t):
t = re.sub(r'\s+', ' ', t).strip()
t = re.sub(r'\s*\*', ' *', t).strip()
# int-family aliases collapse (same calling-convention / same width)
t = re.sub(r'\b(s32|u32|int|unsigned int|unsigned|long|unsigned long|u_long)\b', 'int', t)
return t
return canon_t(ret), tuple(canon_t(t) for t in ptypes)
def cast_type(dret, dptypes):
"""'void',['int','int'] -> 'void (*)(int, int)'; 's32',[] -> 's32 (*)(void)'."""
inner = ', '.join(dptypes) if dptypes else 'void'
return f'{dret} (*)({inner})'
def canonical_map(overlay, src_file=None):
"""addr-int -> canonical sig string '<ret> func_X(<params>)' — DERIVED from what cc1 actually
sees in the TU (`cdecl.tu_scope`, i.e. cpp), not scraped out of the raw text.
WHY THIS CHANGED (Phase 26-A, R33; cookbook §51g LAW 7)
------------------------------------------------------
It used to union three raw-text scanners:
collect_extern_sigs([engine_core.h, the .c]) + collect_define_sigs(ec) + collect_inline_sigs(c)
and NONE of them can see a MACRO-INJECTED declaration — an `extern` inside a `DEFINE_func_*`
macro body, which becomes a genuine file-scope declaration of every TU that invokes the macro.
engine_core.h is 23,546 continuation lines inside 1,801 such macros, so this was not an edge case.
The cost, measured: `func_801387B8` (a stub in 134 TUs) calls `func_80138DE0`, which its TU
declares — via a macro expansion — as `s32 (s32, s32, s32)`. The draft declares `s32 (s32)`.
The map returned NOTHING for that callee, so transform() took the
`if addr not in canon: continue # pure stub callee -> no conflict, leave it`
branch — on a premise that was simply false — and the draft died with `conflicting types`,
which reads downstream as an intrinsic compiler wall. cpp answers it exactly, in 54 ms.
"""
c_path = src_file or os.path.join(REPO, f'src/{overlay}/{overlay}.c')
sigs = {}
for name, d in _cdecl.tu_scope(c_path).items():
if d.kind != 'func' or not re.fullmatch(r'func_[0-9A-Fa-f]{8}', name):
continue
sigs[int(name[5:], 16)] = d.declaration(storage='').rstrip(';').strip()
return sigs, c_path
def split_sig_string(s):
"""'void func_X(s16 a0, s16 a1)' -> ('void', 's16 a0, s16 a1') (no extern / no ;)."""
m = re.match(r'(.*?)\bfunc_[0-9A-Fa-f]+\s*\((.*)\)\s*$', s.strip(), re.S)
if not m:
return None
return m.group(1).strip(), m.group(2).strip()
_ABOVE = set() # names the TU declares ABOVE the splice point (set by main/gate_stage)
def _no_conflict(canon_sig, draft_line, fn):
"""Will cc1 accept the TU's declaration of `fn` alongside the draft's? (cdecl.compatible, which
is validated against the REAL gcc-2.7.2 cc1 on 1,485 live corpus pairs — `cdecl.py --compat`.)
This replaces `norm_sig(...) == norm_sig(...)`, which collapsed the int family (s32|u32|int|
unsigned|long) to ONE token and therefore called a SIGNEDNESS change "already compatible" and
emitted no rewrite — while cc1 rejects that redeclaration outright. It was right about codegen
(same width, same load) and wrong about the C FRONT END, which never reaches codegen.
Order matters, and only cc1 could have told us so: a no-prototype decl followed by a
narrow-param prototype CONFLICTS, but the reverse order is ACCEPTED (§51g / the Phase-15
narrow-param wall). So the TU's decl goes first iff it is declared above the splice point.
"""
try:
c = _cdecl.parse(f'extern {canon_sig};')[0]
d = _cdecl.parse(draft_line.strip())[0]
except (_cdecl.CDeclError, IndexError):
return False # unparseable -> be safe, reconcile it
a, b = (c, d) if fn in _ABOVE else (d, c) # TU order
return _cdecl.compatible(a, b)
def transform(text, self_fn, canon):
"""Return (new_text, n_callees_cast). For each callee whose canonical TU sig CONFLICTS with the
draft's intended sig: rewrite the decl line to canonical + cast every call to the intended sig."""
lines = text.split('\n')
# Pass 1: scan decl lines -> intended sigs; decide which callees conflict (need casting).
to_cast = {} # func_Y -> cast_type string (draft's intended sig)
canon_decl = {} # func_Y -> canonical decl line replacement (indentation preserved)
decl_line_idx = {} # func_Y -> set of line indices that are its decl line(s)
for i, ln in enumerate(lines):
m = DECL_LINE_RE.match(ln)
if not m:
continue
indent, _extern, ret, fn, params = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
if fn == self_fn:
continue
addr = int(fn[5:], 16)
if addr not in canon: # pure stub callee: draft's decl is the only
continue # one -> no conflict, leave it (don't cast)
dret, dptypes = parse_sig(ret, params)
csig = split_sig_string(canon[addr])
if not csig:
continue
cret, cptypes = parse_sig(*csig)
if _no_conflict(canon[addr], ln, fn):
continue # cc1 accepts both -> no rewrite, no cast
to_cast[fn] = cast_type(dret, dptypes)
canon_decl[fn] = f'{indent}extern {canon[addr]};'
decl_line_idx.setdefault(fn, set()).add(i)
if not to_cast:
return text, 0
# Pass 2: rebuild. Decl lines of cast-callees -> canonical decl. Body lines -> cast the calls.
call_res = {fn: re.compile(r'\b' + re.escape(fn) + r'\s*\(') for fn in to_cast}
out = []
for i, ln in enumerate(lines):
replaced_decl = None
for fn, idxs in decl_line_idx.items():
if i in idxs:
replaced_decl = fn
break
if replaced_decl is not None:
out.append(canon_decl[replaced_decl]) # the canonical forward decl (no cast here)
continue
if DECL_LINE_RE.match(ln): # some OTHER decl line -> never cast in a decl
out.append(ln)
continue
for fn, cty in to_cast.items(): # body line -> cast every call site
ln = call_res[fn].sub(f'(({cty}){fn})(', ln)
out.append(ln)
return '\n'.join(out), len(to_cast)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--overlay', default='ov_SC01_077')
ap.add_argument('--src-file', dest='src_file', default=None,
help='overlay .c whose local decls the draft must match (default main; use the '
'_a.c/_o0.c split file for drafts that land there)')
ap.add_argument('--in', dest='indir', required=True)
ap.add_argument('--out', dest='outdir', required=True)
a = ap.parse_args()
override = a.src_file and os.path.join(REPO, a.src_file)
os.makedirs(os.path.join(REPO, a.outdir), exist_ok=True)
drafts = touched = total_callees = 0
cache, ncanon = {}, 0
for p in sorted(glob.glob(os.path.join(REPO, a.indir, '*.c'))):
fn = os.path.basename(p)[:-2]
# PER-DRAFT: canonicalize against the TU that will actually compile it (derived), not
# against whatever .c the caller happened to name.
tu = tu_for(a.overlay, fn, override)
if tu not in cache:
cache[tu] = canonical_map(a.overlay, tu)[0]
canon = cache[tu]
ncanon = max(ncanon, len(canon))
# TU order for the no-prototype rule: which of the TU's decls precede this splice point?
# (cdecl caches the cpp run, so this is ~free per draft.)
_ABOVE.clear()
_ABOVE.update(_cdecl.tu_scope(tu, above=fn))
new, k = transform(open(p).read(), fn, canon)
open(os.path.join(REPO, a.outdir, os.path.basename(p)), 'w').write(new)
drafts += 1
if k:
touched += 1
total_callees += k
print(f'canonical sigs: {ncanon} (per-TU, derived); TUs: {len(cache)}; drafts: {drafts}; '
f'cast-recovered: {touched} draft(s), {total_callees} callee(s)')
if __name__ == '__main__':
main()