Files
BFM-decomp/tools/scope_data_externs.py
T
Drew T efec1b9b71 fix(phase-30 S47-A1): asm-label aliases must never be dropped by §8d; +148 members
scope_data_externs §8d drops the draft's decl of any symbol the TU already declares at file scope.
It keys on the SYMBOL, but a §37 asm-label ALIAS binds a DIFFERENT C identifier to that symbol:
the TU declares `D_801851BC`, it does NOT declare `tbl_D_80187044`. Dropping the alias left the
body referencing an undeclared name, which cc1 reports with no `error:` prefix — so the sweep
classified all 132 siblings as CC1-FAIL(no-diagnostic), i.e. as a codegen wall.

The bitter part: the alias exists PRECISELY BECAUSE the TU declares that symbol with a conflicting
type (a `void (*[])(void)` dispatch table vs this function's 20-byte-stride view). The drop rule
fired on exactly the declarations written to survive it. Why 1 of 2 died was fully determined:
tbl_D_80187048's symbol is not in the TU, so it demoted normally.

Fix: is_asm_alias() — an alias is demoted into the body, never dropped (the identifiers differ, so
it cannot collide with the TU's decl). Control-tested 6 ways incl. self-labels and plain externs.

Measured: func_80132018 3/135 -> 135/135; full re-sweep +16 more. Total +148 members.
R22 clean-fleet 213 passed / 0 failed of 213. tools-health OK, dedup-check 1949/0.
Fleet 96.11 -> 96.15% fn-count, 87.8 -> 87.9% distinct; stubs 14,120 -> 13,972 = -148 (2nd oracle).

CORRECTION TO MY OWN CLAIM (R14): after the probe I said the 58% aggregate was concealing a broad
problem. The re-sweep refuted it — only 16 more banks fleet-wide. The alias class really was one
family; the first read ("outlier") was right and the correction was wrong.

875 sweep failures classified: 231 PLUMBING-other, 141 DIFF (real divergence, only 16%),
136 `conflicting types for cdFileLocTable` (ONE symbol — biggest single class left),
77 CC1-FAIL(no-diagnostic), 26 memcpy, 12 D_80114F24, 11 D_800AE620, 9 D_800183E0.

STILL UNFIXED, and the most dangerous instrument left: the sweep's failure classifier greps for
`error:`, which gcc-2.7.2 never emits on hard errors. Every hard error therefore reads
CC1-FAIL(no-diagnostic). That is how a missing declaration looked like a codegen wall across 132
functions. rtu_match was fixed for this at T0(b); this classifier was not.
2026-08-10 18:50:48 -06:00

206 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""scope_data_externs.py — place a templated body's DATA externs at the scope the target TU can accept
(Phase 26, cookbook §8d). The missing recovery step that unblocks the heavy-jr ×N family sweeps.
THE BUG IT FIXES (byte-proven on func_8015AE2C -> ov_SC01_000)
-------------------------------------------------------------
`family_remap.gather_externs` carries the exemplar's decls for every symbol the body references and
prepends them at FILE scope (family_remap.py, remap_hseq). For a per-location DATA symbol that the
target TU declares only at BLOCK scope — inside its OWN later functions, loosely typed — that carried
file-scope decl ESTABLISHES A GLOBAL DECLARATION the TU never had, and every later block-scope `extern`
of that symbol must now agree with it. They don't (the engine is loosely typed), so gcc-2.7.2 rejects
the TU. The asymmetry, both halves byte-proven by the whole-binary gate:
BLOCK(int) -> BLOCK(struct Ent *) -> FILE(void *) ... builds [ OK ] (the region, stub state)
FILE(void *) -> BLOCK(int) -> ... conflicting types for `D_801812A4' (ERROR)
Example: ov_SC01_000's `D_801812A4` is a fn-ptr dispatch table declared FOUR incompatible ways in one
region — `(int)` and `(struct Ent_8015CD20 *)` at block scope inside func_8015C128 / func_8015CD20, then
`(void *)` at file scope. That compiles. Prepend the body's `extern void (*D_801812A4[])(void *);` above
them and it does not.
THE FIX
-------
Emit the carried extern at BLOCK scope (inside the function body) whenever the TU has NO file-scope decl
of that symbol above the insertion point. Then it declares no global, nothing below it can conflict, and
the TU's own decl environment is preserved exactly. This is the §8c principle ("splitting a TU means
rebuilding its declaration environment") applied to templating: a carried decl must not CHANGE the
environment — and it restores fidelity, because the original source declares these symbols at block
scope in precisely the same way (m2c/Ghidra emit per-function externs there).
BYTE-NEUTRAL: an `extern` declaration emits no code, and moving it changes neither the symbol nor the
declared type, so every access keeps its opcode. Only name lookup moves. The whole-binary byte-gate
(G3/P9) remains the sole arbiter — a wrong placement just fails the gate and the sibling reverts.
NEVER WORSE THAN THE STATUS QUO: if the symbol DOES have a file-scope decl above, we leave the line
alone (identical spelling = a legal duplicate; a differing spelling is the §41 reconcile class and is
an error at file scope either way, so demoting could not have saved it — the `reconciled` stage is the
fallback there).
SCOPE: `D_` data externs only. Function externs stay at file scope so `cast_call_sites` /
`canon_sig_reconcile` (which parse file-scope decls) keep working on them; gcc tolerates mismatched
function decls with a pedwarn, so they are not the failing class.
Usage (library — the sweep calls fix() directly; ×N per family, so no subprocess):
from scope_data_externs import fix
body, moved = fix(body, tu_text, insert_pos, "func_8015AE2C")
CLI (diagnostics):
tools/scope_data_externs.py --body .run/_repro_body.c --tu src/ov_SC01_000/ov_SC01_000_jr_8015AE2C.c \
--func func_8015AE2C [--out fixed.c]
"""
import argparse
import re
import sys
# a col-0 (file-scope) `extern ...;` on one line. Leading whitespace => block scope, which is what we
# emit and never need to re-place.
FILE_EXTERN_RE = re.compile(r'^extern\b[^;{}\n]*;', re.M)
DATA_SYM_RE = re.compile(r'\bD_[0-9A-Fa-f]{6,8}\b')
# any `extern ...;` on one line, at ANY indentation (file OR block scope)
ANY_EXTERN_RE = re.compile(r'^\s*extern\b[^;{}\n]*;')
def _file_scope_data_syms(text):
"""Every D_ symbol declared by a col-0 `extern` in `text`. The symbol a decl DECLARES is its first
D_ token — true for both `extern u8 D_x[];` and the fn-ptr-array form `extern void (*D_x[])(void *);`
(the form reconcile_decls' regex cannot parse, which is why that tool silently skipped this class)."""
out = set()
for m in FILE_EXTERN_RE.finditer(text):
d = DATA_SYM_RE.search(m.group(0))
if d:
out.add(d.group(0))
return out
def _body_open_brace(body, func):
r"""Index just past the `{` that opens `func`'s body — i.e. where block-scope decls go. Scans forward
from the signature to the first `{` at depth 0 of the *statement* (so it handles the ANSI form with
the brace on the signature line, the ANSI form with the brace on its own line, AND the K&R form whose
param decls sit between the signature and the `{`). The first cut of this used `^\s*\{\s*$` — own-line
braces only — and silently no-op'd on every ANSI draft (the same silent-skip disease as the four
catalogued in §40/§8d; caught because the h_seq re-sweep banked 0/780)."""
sig = re.search(rf'^[^\n]*\b{re.escape(func)}\s*\(', body, re.M)
if not sig:
return None
i = body.find('{', sig.end())
if i < 0:
return None
j = body.find('\n', i)
return (j + 1) if j >= 0 else (i + 1)
_ASM_LABEL_RE = re.compile(r'__asm__\s*\(\s*"([^"]+)"\s*\)')
def is_asm_alias(ln):
"""True for a §37/§84 ASM-LABEL ALIAS: `extern T ident… __asm__("SYM");` with ident != SYM.
THE DROP BELOW MUST NOT REACH THESE (Phase 30 S47, byte-witnessed on func_80132018).
The "TU already declares it above us -> drop ours" rule keys on the SYMBOL, but an alias binds a
DIFFERENT C IDENTIFIER to that symbol. The TU's `extern void (*D_801851BC[])(void);` declares
`D_801851BC`; it does NOT declare `tbl_D_80187044`. Dropping the alias therefore leaves the body
referencing an undeclared name — `tbl_D_80187044' undeclared` — which cc1 reports with no
`error:` prefix, so the sweep classified all 132 siblings as CC1-FAIL(no-diagnostic).
The bitter part: the alias exists PRECISELY BECAUSE the TU declares that symbol with a
conflicting type (a `void (*[])(void)` dispatch table vs this function's 20-byte-stride view).
So the drop rule fired on exactly the declarations written to survive it, and undid the
workaround. An alias can never collide with the TU's declaration — the identifiers differ — so
it is demoted into the body like any other, never dropped."""
m = _ASM_LABEL_RE.search(ln)
if not m:
return False
ids = re.findall(r'[A-Za-z_]\w*', ln[:m.start()])
return bool(ids) and ids[-1] != m.group(1)
def fix(body, tu_text, insert_pos, func):
"""Demote the body's file-scope DATA externs that the TU does not already declare at file scope
above `insert_pos`. Returns (new_body, [moved symbols]). A no-op (body unchanged) when nothing
qualifies, when the body declares no data externs, or when the opening brace can't be located."""
above = _file_scope_data_syms(tu_text[:insert_pos])
demote = [] # (line_text, sym)
dropped = [] # syms the TU ALREADY declares above us — redundant, see below
keep_lines = []
for ln in body.split('\n'):
# BLOCK-SCOPE TOO, not just col-0 (Phase 29 SESSION-22). §8d demotes these externs on the way
# in, so by the time a sibling draft is STAGED they are already indented — and a col-0-only
# scan therefore saw nothing to do on exactly the drafts that needed it. C still requires a
# block-scope `extern` to agree with a file-scope declaration in scope, so a redundant
# redeclaration conflicts at ANY scope; the drop below must reach both.
if ANY_EXTERN_RE.match(ln) and not FILE_EXTERN_RE.match(ln):
d = DATA_SYM_RE.search(ln)
if d and d.group(0) in above and not is_asm_alias(ln):
dropped.append(d.group(0))
continue
keep_lines.append(ln)
continue
if FILE_EXTERN_RE.match(ln): # col-0 extern (match => anchored at col 0)
d = DATA_SYM_RE.search(ln)
if d:
# An ASM-LABEL ALIAS is demoted, never dropped: its C identifier is not the symbol,
# so the TU's declaration does not cover it (see is_asm_alias).
if d.group(0) not in above or is_asm_alias(ln):
demote.append((ln, d.group(0)))
continue # drop from the file-scope preamble
# THE TU ALREADY DECLARES IT ABOVE US -> DROP OURS ENTIRELY (Phase 29 SESSION-22).
# Keeping it was the SC07-quartet blocker: `conflicting types for D_800AF634` in
# ov_SC07_006/007/010/011, which is a type-IDENTITY collision no type-STRING compare
# can see. Both sides read `S_AF634 []` — but the templated body carries its OWN
# block-scope `typedef struct {…} S_AF634;` while the TU's declaration comes from a
# MACRO-INJECTED one (§8c), so the two `S_AF634`s are DISTINCT TYPES with one name
# and cc1 rejects the redeclaration. (This is also why `cdecl.compatible` cannot
# catch it and correctly answers "compatible".)
#
# A redeclaration we do not emit cannot collide — with anything, identity or type —
# and the TU's own declaration is in scope and authoritative for the body. Strictly
# better than the alternative, which was a hard compile error; the whole-binary
# byte-gate remains the arbiter if the TU's type implies a different access.
dropped.append(d.group(0))
continue
keep_lines.append(ln)
if not demote and not dropped:
return body, []
stripped = '\n'.join(keep_lines)
if not demote: # drops only — no block to place
return stripped, dropped
at = _body_open_brace(stripped, func)
if at is None: # can't place them safely -> leave the body alone
return body, []
block = ''.join(f' {ln}\n' for ln, _ in demote)
return stripped[:at] + '\n' + block + stripped[at:], [s for _, s in demote] + dropped
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--body', required=True, help='the remapped body (file-scope externs prepended)')
ap.add_argument('--tu', required=True, help='the target TU (.c) the body is spliced into')
ap.add_argument('--func', required=True)
ap.add_argument('--at', type=int, default=None,
help='char offset of the insertion point (default: the INCLUDE_ASM stub for --func)')
ap.add_argument('--out')
a = ap.parse_args()
body, tu = open(a.body).read(), open(a.tu).read()
pos = a.at
if pos is None:
m = re.search(rf'INCLUDE_ASM\("[^"]*",\s*{re.escape(a.func)}\);', tu)
if not m:
sys.exit(f'no INCLUDE_ASM stub for {a.func} in {a.tu} (pass --at)')
pos = m.start()
new, moved = fix(body, tu, pos, a.func)
print(f'file-scope data syms above the insertion point: {len(_file_scope_data_syms(tu[:pos]))}')
print(f'demoted to block scope: {len(moved)} {moved}')
if a.out:
open(a.out, 'w').write(new)
print(f'wrote {a.out}')
if __name__ == '__main__':
main()