Files
BFM-decomp/tools/sync_tu_decls.py
T
Drew T 454d3878bc fix(sync_tu_decls): a definition is a declaration; a gate refusal is not a verdict
Two defects, both found by driving the last two self_decl_tu drafts to a bank.

1. tu_decl looked only for an `extern … sym …;` line, so when the clashing
   symbol is a function the TU DEFINES it stopped with

       stopping: func_8005E480 clashes with the TU itself but src/800c3.c has
       no `extern` line to copy.

   though the authoritative spelling was in the definition's own header at
   src/800c3.c:916. This was the terminal blocker of BOTH remaining drafts
   (func_8005E3AC on func_8005E480, func_8005E79C on func_8005E804). The
   definition is now preferred over an extern when both exist — it is the one
   cc1 checks every other declaration against. Banked func_8005E3AC in one
   round. Checked against known-true cases before being trusted: definition
   path on func_8005E480/func_8005E804, extern path still verbatim on
   func_8005D734, absent symbol still None.

2. gate_main refuses outright on a dirty src/ or a red baseline and never
   reaches a per-draft opinion. The round loop matched neither DROP_RE nor
   COMPILE_RE in that output and fell through to "no declaration conflict
   named; stopping after 0 sync(s)" — reporting a HARNESS refusal as a property
   of the DRAFT (R40). Measured on func_8005E79C, whose gate was refused
   because the bank one command earlier had left src/ uncommitted. The refusal
   is now surfaced and exits 3.
2026-09-03 18:56:21 -06:00

194 lines
10 KiB
Python

#!/usr/bin/env python3
"""sync_tu_decls.py — bank a draft the gate refuses by copying the TU's OWN declarations into it.
WHY THIS EXISTS (P31 S76). The dominant reason a byte-correct draft does not bank is not codegen —
it is that the draft and its destination TU spell a shared symbol differently, and gcc-2.7.2 rejects
the redeclaration. `gate_main`'s pre-check already NAMES the offending symbol and which side it kept:
DROP func_8005EC00: D_800729DC clashes with the TU itself in src/800c3.c
kept=('u32','') this=('void*','')
That is a complete instruction. The TU holds the authoritative spelling (it is what the rest of the
binary compiles against); the draft's guess does not. So the fix needs no judgement at all: copy the
TU's `extern` line verbatim into the draft, re-gate, and repeat for whatever it names next.
MEASURED. Done by hand this session it banked `func_8005EB28` in one round and `func_8005EC00` in
two — both had been stuck across multiple slates, and both are byte-identical afterwards. The
conflicts are usually a CASCADE: banking one function gives the TU a real definition, which then
contradicts the stale `extern` every later draft in that TU still carries. Every bank changes the
declaration environment for every draft that follows it.
WHAT IT DOES NOT DO. It only resolves conflicts where the TU already declares the symbol. A draft
whose OWN signature disagrees with the TU (`self_decl_tu` — the TU declares the function being
banked) needs `cast_self_callers --sync-decls`, because the call SITES must change too. This tool
refuses that class loudly rather than mangling it (R43).
THE BYTE GATE REMAINS THE SOLE ARBITER (G3/P9): every round ends in a real gate run, and a draft that
stops conflicting but does not match is reported as a mismatch, never as a bank.
tools/sync_tu_decls.py --binary main --fn func_8005EC00 --draft <path> [--rounds 6] [--apply]
"""
import argparse
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = os.path.join(REPO, ".venv/bin/python")
if not os.path.exists(PY):
PY = sys.executable
DROP_RE = re.compile(r"DROP \S+: (?P<sym>[A-Za-z_]\w*) clashes with (?P<side>the TU itself|an earlier draft)"
r" in (?P<tu>\S+)")
# The gate reports a conflict TWO ways and this tool must read both (P31 S76): the slate-load
# pre-check emits `DROP … clashes with …`, but a conflict only the compiler sees arrives after the
# build as `COMPILE conflict on `SYM' at FILE:LINE`. Handling only the first left five drafts
# looking unrecoverable when their blocker was the same class, one symbol deeper.
COMPILE_RE = re.compile(r"COMPILE conflict on `(?P<sym>[A-Za-z_]\w*)' at (?P<tu>[^:]+):\d+")
# A DEFINITION IS A DECLARATION (P31 S77). The first version looked only for an `extern` line, so
# when the clashing symbol is a function the TU DEFINES — the common case once a TU has been banked
# into — it reported "no `extern` line to copy" and stopped, though the authoritative spelling was
# right there in the definition's own header. Measured: this was the terminal blocker of BOTH
# remaining self_decl_tu drafts (func_8005E3AC on func_8005E480, func_8005E79C on func_8005E804,
# each defined in src/800c3.c). The definition is preferred over an extern when both exist: it is
# the one cc1 checks every other declaration against.
DEF_HEAD = r"^[ \t]*((?:[A-Za-z_][\w]*[ \t\*]+)+?)%s[ \t]*\(([^;{)]*)\)[ \t]*\{"
def tu_decl(tu_path, sym):
"""The TU's authoritative spelling of `sym` — its DEFINITION header if it has one, else its
own `extern … sym …;` line, rendered as an `extern` declaration."""
try:
text = open(os.path.join(REPO, tu_path), errors="replace").read()
except OSError:
return None
m = re.search(DEF_HEAD % re.escape(sym), text, re.M)
if m:
ret = " ".join(m.group(1).split())
params = " ".join(m.group(2).split()) or "void"
return "extern %s %s(%s);" % (ret, sym, params)
pat = re.compile(r"^\s*extern\b[^;\n]*\b%s\b[^;\n]*;\s*$" % re.escape(sym), re.M)
m = pat.search(text)
return m.group(0).strip() if m else None
def replace_decl(text, sym, decl):
"""Swap the draft's declaration of `sym` for the TU's. Returns (text, changed)."""
pat = re.compile(r"^\s*extern\b[^;\n]*\b%s\b[^;\n]*;\s*$" % re.escape(sym), re.M)
if not pat.search(text):
return text, False
return pat.sub(decl, text, count=1), True
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--binary", required=True)
ap.add_argument("--fn", required=True)
ap.add_argument("--draft", required=True)
ap.add_argument("--rounds", type=int, default=6)
ap.add_argument("--apply", action="store_true",
help="actually gate (and therefore bank on success); without it, only report "
"which declarations would be synced")
a = ap.parse_args()
if a.binary != "main":
sys.exit("sync_tu_decls: only `main` is supported — main is the binary whose gate names the "
"conflicting symbol. For an overlay use gate_stage + cast_self_callers (R43: "
"refusing an input this tool cannot handle rather than mishandling it).")
# A DECLARATION FIX CANNOT RESCUE A BODY THAT DIFFERS (P31 S76). Five of the first sixteen
# candidates compiled once their declarations were synced and then failed the byte gate — they
# were NEARs all along (closeness 12-89), and a `CC1-FAIL` classification only says the
# declaration blocked COMPILATION, never that the body underneath is correct. Check the body
# first so a gate is not spent learning what match_one already knows (R37: probe before costing).
# PASS --asm-subdir OR THE ORACLE JUDGES A DIFFERENT FUNCTION (§238). match_one defaults to
# `asm/resident/nonmatchings/resident` and says so in a warning; without the subdir this guard
# scored the wrong target, returned no verdict, and let a NEAR through — the exact hazard the
# guard exists to catch, in the guard itself. Derive it from the stub oracle.
sys.path.insert(0, os.path.join(REPO, "tools"))
import corpus
_hit = [v for v in corpus.stubs(a.binary).values() if v.symbol == a.fn]
if not _hit:
sys.exit("sync_tu_decls: %s is not an open stub in %s — nothing to bank." % (a.fn, a.binary))
_mo = subprocess.run([PY, os.path.join(REPO, "tools/match_one.py"), a.fn, "--c", a.draft,
"--json", "--asm-subdir", _hit[0].asm_dir],
cwd=REPO, capture_output=True, text=True)
try:
_cl = json.loads(_mo.stdout.strip().splitlines()[-1]).get("closeness")
except Exception:
_cl = None
if _cl not in (0, None):
sys.exit("sync_tu_decls: REFUSED — %s is a NEAR (closeness %s). Syncing declarations makes "
"it COMPILE, not MATCH; the byte gate would reject it anyway. Fix the body first."
% (a.fn, _cl))
work = os.path.join(REPO, ".run", "sync_tu_decls", a.fn)
os.makedirs(work, exist_ok=True)
draft = os.path.join(work, a.fn + ".c")
with open(a.draft, errors="replace") as fh:
open(draft, "w").write(fh.read())
slate = os.path.join(work, "slate.json")
json.dump([{"fn": a.fn, "draft": draft}], open(slate, "w"))
synced = []
for rnd in range(1, a.rounds + 1):
r = subprocess.run([PY, os.path.join(REPO, "tools/gate_main.py"), slate]
+ (["--apply"] if a.apply else []),
cwd=REPO, capture_output=True, text=True,
env={**os.environ, "GATE_MAIN_MAX_STEPS": "6"})
out = (r.stdout or "") + (r.stderr or "")
if re.search(r"^BANKED [1-9]", out, re.M):
print("BANKED %s after %d declaration sync(s): %s"
% (a.fn, len(synced), ", ".join(synced) or "none"))
return 0
# A GATE REFUSAL IS NOT A VERDICT (P31 S77, R40/R49). gate_main refuses outright on a dirty
# src/ or a red baseline and never reaches a per-draft opinion. Falling through to "no
# declaration conflict named" reported that refusal as a property of the DRAFT — measured on
# func_8005E79C, whose gate was refused because the bank one command earlier had left src/
# uncommitted, and which read as "the body is the problem". Surface it instead.
refusal = re.search(r"^(gate_main: .*UNCOMMITTED changes|\*\*\* BASELINE RED.*)", out, re.M)
if refusal:
print("gate REFUSED to run — this says NOTHING about %s:" % a.fn, file=sys.stderr)
print(" " + refusal.group(1).strip()[:160], file=sys.stderr)
return 3
m = DROP_RE.search(out) or COMPILE_RE.search(out)
if not m:
# No conflict left to fix — the residual is a real mismatch or another class entirely.
tail = [l for l in out.splitlines()
if re.search(r"BANKED|STILL MISMATCHED|COMPILE conflict|REFUSED", l)]
print("no declaration conflict named; stopping after %d sync(s)." % len(synced))
for l in tail[:3]:
print(" ", l.strip())
return 1
sym, tu = m.group("sym"), m.group("tu")
side = m.groupdict().get("side") or "the TU itself"
if sym == a.fn:
print("REFUSED: the conflict is on %s ITSELF (self_decl_tu) — the TU declares the "
"function being banked, so the CALL SITES must change too. That is "
"cast_self_callers --sync-decls, not this tool." % sym)
return 2
decl = tu_decl(tu, sym)
if not decl:
print("stopping: %s clashes with %s but %s has no `extern` line to copy."
% (sym, side, tu))
return 1
txt, changed = replace_decl(open(draft, errors="replace").read(), sym, decl)
if not changed:
print("stopping: the draft has no declaration of %s to replace." % sym)
return 1
open(draft, "w").write(txt)
synced.append(sym)
print("round %d: %s -> %s" % (rnd, sym, decl))
print("gave up after %d rounds (%d synced)." % (a.rounds, len(synced)))
return 1
if __name__ == "__main__":
sys.exit(main())