mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
69d83f7b61
Drew: we do want C correctness on all funcs, and log it for the story and the chart. - decl_repair --apply rewrote 3,439 units and repaired 16,759 declarations. check-all: 218 passed, 0 failed of 218. lever_census --check: 26,456 pin/asm sites, 0 UNMARKED — unchanged, as expected: this pass fixed TRUTH, not levers. - only the free set was touched: a declaration is repaired when every call to that function in the unit already passes the arguments, so the code was right and only the promise was wrong. Calls that pass too few remain R19's population, where the argument must be chosen and the bytes decide. - tools/readability_progress.py: the Gen3 series beside docs/levers.md, because levers are only one way the source is untrue. It counts lying call declarations (split by the K&R-empty and (void) forms, and how many sit in a body still holding an argument-register pin) and raw cast dereferences against struct member reads — the struct debt. Each row carries its date and commit so the chart is generated, never typed (R75). docs/readability.md renders it. First row after the repair: 94,001 lying declarations over 1,564 callees (86,701 (), 7,300 (void)), 461 in 314 pinned bodies; 414,148 raw cast dereferences against 173,286 struct member reads. - dictionary rows for decl_repair and readability_progress; kit corpus regenerated; tool_census --check OK.
215 lines
11 KiB
Python
215 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""decl_repair — widen every call declaration that lies, and measure on the BYTES which repairs are free.
|
|
|
|
THE QUESTION (Drew, P36 S102). `argcheck` found 110,478 declarations narrower than the callee's real definition, over
|
|
1,912 callees. Only a few hundred of those sit in a body that still holds a register pin; the rest are in functions that
|
|
already match byte-for-byte. Those do not block anything — but they are wrong C: a call declared to take nothing that
|
|
really takes a pointer compiles and matches through the calling convention, not because the declaration is true, and
|
|
every one of them is a lie the names and types phases would inherit.
|
|
|
|
WHY THIS IS NOT HOUSEKEEPING. Repairing a declaration can CHANGE THE BYTES — that is exactly why it removes pins
|
|
elsewhere. So the repair is not free by assumption, it is free or not by measurement, one translation unit at a time:
|
|
|
|
* IDENTICAL — the widened declarations produce the same object. The repair is FREE and can be applied with the gate as
|
|
its proof.
|
|
* DIFFERS — this unit's code depends on the false declaration. That is the interesting list, not the failure list:
|
|
each one is a body quietly compensating for a wrong signature, and some of them are pins waiting to come off.
|
|
* COMPILE-ERROR — the widened form conflicts with something else in the unit (a second declaration, a K&R definition).
|
|
|
|
Nothing is written into the tree: each candidate is compiled from a scratch copy of the unit through that unit's own
|
|
recipe, and compared against the baseline snapshot the fleet gate cannot wipe. Run it while agents are working.
|
|
"""
|
|
import argparse, collections, concurrent.futures as cf, json, os, pathlib, re, subprocess, sys
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
import argcheck, delever_oracle as oracle, lever_census as lc
|
|
|
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
SCRATCH = REPO / ".run" / "P36" / "declrepair"
|
|
DECL = re.compile(r"(\bextern\s+)([A-Za-z_][\w \t*]*?)\b([A-Za-z_]\w*)(\s*\()([^;)]*)(\)\s*;)")
|
|
|
|
|
|
CALLSITE = re.compile(r"(?<![\w.>])([A-Za-z_]\w*)\s*\(")
|
|
|
|
|
|
def call_arities(masked, callee):
|
|
"""[n] — how many arguments each call to `callee` in this unit actually passes."""
|
|
out = []
|
|
for m in re.finditer(r"(?<![\w.>])" + re.escape(callee) + r"\s*\(", masked):
|
|
depth, j = 0, m.end() - 1
|
|
while j < len(masked):
|
|
if masked[j] == "(":
|
|
depth += 1
|
|
elif masked[j] == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
j += 1
|
|
if j >= len(masked):
|
|
continue
|
|
args = masked[m.end():j].strip()
|
|
out.append(0 if args == "" else args.count(",") + 1)
|
|
return out
|
|
|
|
|
|
def widen(text, defs):
|
|
"""(new text, [(callee, declared, real)]) — the declarations this unit can HONESTLY repair.
|
|
|
|
A declaration is only rewritten when EVERY call to that function in this unit ALREADY passes at least the real
|
|
number of arguments. That is the free-correctness set: the code already does the right thing and only the promise is
|
|
wrong, which is what m2c leaves behind when it emits `extern void f();` and then calls `f(x)`.
|
|
|
|
The first version of this tool widened every narrower declaration and left the call sites alone, which is not a
|
|
repair at all — a prototype that demands an argument the call does not pass is a hard error, so 1,766 of the first
|
|
3,250 units could not compile BY CONSTRUCTION and not one was IDENTICAL. A measurement that cannot come out any
|
|
other way is not a measurement (R40: clear the instrument before believing its subject). Calls that pass too few
|
|
arguments are the OTHER population — R19's, where the argument has to be chosen and the bytes decide.
|
|
|
|
The RETURN TYPE is never touched: this repairs arity, not results."""
|
|
masked = lc.sc.mask_text(text)
|
|
fixed = []
|
|
|
|
def sub(m):
|
|
callee, params = m.group(3), m.group(5)
|
|
real = defs.get(callee)
|
|
if real is None:
|
|
return m.group(0)
|
|
dec = argcheck.arity(params)
|
|
if dec >= real[0]:
|
|
return m.group(0)
|
|
calls = call_arities(masked, callee)
|
|
if not calls or any(n < real[0] for n in calls):
|
|
return m.group(0) # a call still passes too few: R19's job, not this one
|
|
fixed.append((callee, dec, real[0]))
|
|
return m.group(1) + m.group(2) + m.group(3) + m.group(4) + real[1] + m.group(6)
|
|
|
|
return DECL.sub(sub, text), fixed
|
|
|
|
|
|
_SEC_CACHE = {}
|
|
|
|
|
|
def sections(objpath, tag):
|
|
"""The bytes of `.text`, `.rodata` and `.data` — the CODEGEN, without the object's embedded source path.
|
|
|
|
A whole-object comparison cannot work here: the candidate is compiled from a scratch copy, so its file name is
|
|
recorded in the object and every candidate DIFFERS by construction. The first fleet run of this tool returned
|
|
0 IDENTICAL of 3,634 units for exactly that reason and I believed it for one report — the negative control that
|
|
would have caught it in a second (compile the UNCHANGED text through the same path and require IDENTICAL) is now
|
|
part of every unit's judgement below (R39). With the path excluded, the control's sections match exactly."""
|
|
key = (str(objpath), tag)
|
|
if key in _SEC_CACHE:
|
|
return _SEC_CACHE[key]
|
|
out = b""
|
|
for sec in (".text", ".rodata", ".data"):
|
|
dst = SCRATCH / "_sec" / (re.sub(r"\W+", "_", str(objpath)) + tag + sec)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
r = subprocess.run(["mipsel-linux-gnu-objcopy", "-O", "binary", f"--only-section={sec}",
|
|
str(objpath), str(dst)], capture_output=True)
|
|
out += dst.read_bytes() if (r.returncode == 0 and dst.exists()) else b""
|
|
_SEC_CACHE[key] = out
|
|
return out
|
|
|
|
|
|
def compile_to(rel, rec, text, tag):
|
|
"""(object path | None, err) — `text` compiled from a scratch copy of the unit through its own recipe."""
|
|
d = SCRATCH / re.sub(r"\W+", "_", rel)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
scratch_src = d / os.path.basename(rec["src"])
|
|
scratch_src.write_text(text if rel == rec["src"] else
|
|
(REPO / rec["src"]).read_text(errors="surrogateescape"), errors="surrogateescape")
|
|
pipeline = rec["pipeline"].replace(" " + rec["src"], " " + scratch_src.as_posix(), 1) \
|
|
.replace("-Iinclude", f"-Iinclude -I{os.path.dirname(rec['src'])}", 1)
|
|
data, dt, err = oracle.compile_obj(dict(rec, pipeline=pipeline, src=scratch_src.as_posix()), None,
|
|
tag=tag + "_" + re.sub(r"\W+", "_", rel))
|
|
if data is None:
|
|
return None, err
|
|
op = d / (tag + ".o")
|
|
op.write_bytes(data)
|
|
return op, ""
|
|
|
|
|
|
def judge(rel, rec, defs):
|
|
try:
|
|
raw = (REPO / rel).read_text(errors="surrogateescape")
|
|
except OSError as e:
|
|
return dict(tu=rel, verdict="UNREADABLE", err=str(e)[:120], fixed=0)
|
|
if "extern" not in raw:
|
|
return None
|
|
new, fixed = widen(raw, defs)
|
|
if not fixed:
|
|
return None
|
|
basep = oracle.baseline_path(rec["obj"])
|
|
if not pathlib.Path(basep).exists():
|
|
return dict(tu=rel, verdict="NO-BASELINE", fixed=len(fixed), callees=[], err="")
|
|
base = sections(basep, "base")
|
|
# R39, in the tool: the UNCHANGED text must reproduce the baseline through this same path, or the verdict below is
|
|
# about the harness rather than the repair.
|
|
ctl, cerr = compile_to(rel, rec, raw, "ctl")
|
|
if ctl is None or sections(ctl, "ctl") != base:
|
|
return dict(tu=rel, verdict="CONTROL-FAILED", fixed=len(fixed), callees=[], err=cerr[:160])
|
|
cand, err = compile_to(rel, rec, new, "cand")
|
|
if cand is None:
|
|
v = "COMPILE-CRASH" if err.startswith("CRASH:") else "COMPILE-ERROR"
|
|
else:
|
|
v = "IDENTICAL" if sections(cand, "cand") == base else "DIFFERS"
|
|
return dict(tu=rel, verdict=v, fixed=len(fixed),
|
|
callees=sorted({c for c, _, _ in fixed})[:8], err=err[:160] if cand is None else "")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("-j", "--jobs", type=int, default=8)
|
|
ap.add_argument("--limit", type=int)
|
|
ap.add_argument("--json", default=".run/P36/engine/decl_repair.json")
|
|
ap.add_argument("--apply", action="store_true",
|
|
help="write the repairs into the tree, ONLY for units this run judged IDENTICAL (each unit is "
|
|
"re-judged here, never trusted from a previous run's json — the tree may have moved)")
|
|
a = ap.parse_args()
|
|
defs = argcheck.definitions()
|
|
by_src = oracle.recipes_by_src(oracle.load_recipes()["recipes"])
|
|
units = [(rel, recs[0]) for rel, recs in sorted(by_src.items()) if recs]
|
|
if a.limit:
|
|
units = units[:a.limit]
|
|
print(f"decl_repair: {len(units)} translation unit(s) with a recipe; {len(defs)} definitions; -j{a.jobs}", flush=True)
|
|
rows, n = [], 0
|
|
with cf.ThreadPoolExecutor(max_workers=a.jobs) as ex:
|
|
for r in ex.map(lambda u: judge(u[0], u[1], defs), units):
|
|
n += 1
|
|
if r:
|
|
rows.append(r)
|
|
if n % 250 == 0:
|
|
c = collections.Counter(x["verdict"] for x in rows)
|
|
print(f" {n}/{len(units)} judged; {dict(c)}", flush=True)
|
|
if a.apply:
|
|
# Only a unit THIS RUN judged IDENTICAL is written, and it is written from the same `widen` output the judgement
|
|
# compiled — never re-derived, never taken from an earlier json (R42's cousin: the tree may have moved under it).
|
|
n_w = n_d = 0
|
|
for r in rows:
|
|
if r["verdict"] != "IDENTICAL":
|
|
continue
|
|
path = REPO / r["tu"]
|
|
raw = path.read_text(errors="surrogateescape")
|
|
new, fixed = widen(raw, defs)
|
|
if not fixed or new == raw:
|
|
continue
|
|
path.write_text(new, errors="surrogateescape")
|
|
n_w += 1
|
|
n_d += len(fixed)
|
|
print(f"decl_repair --apply: {n_w} unit(s) rewritten, {n_d} declaration(s) repaired — now run the fleet gate")
|
|
c = collections.Counter(x["verdict"] for x in rows)
|
|
free = sum(x["fixed"] for x in rows if x["verdict"] == "IDENTICAL")
|
|
held = sum(x["fixed"] for x in rows if x["verdict"] == "DIFFERS")
|
|
print(f"decl_repair: {len(rows)} unit(s) carry a lying declaration; verdicts {dict(c)}")
|
|
print(f" FREE to repair (the object is byte-identical with the declarations widened): "
|
|
f"{c.get('IDENTICAL', 0)} unit(s), {free} declaration(s)")
|
|
print(f" the object CHANGES (the code depends on the false declaration): "
|
|
f"{c.get('DIFFERS', 0)} unit(s), {held} declaration(s)")
|
|
pathlib.Path(a.json).write_text(json.dumps(rows, indent=1))
|
|
print(f" rows -> {a.json}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|