Files
BFM-decomp/tools/argcheck.py
T

211 lines
11 KiB
Python

#!/usr/bin/env python3
"""argcheck — every call site whose in-scope declaration is NARROWER than the callee's real definition.
WHY THIS EXISTS (P36 S102, the burst of 20). Six T7 agents, working independently on different functions and never
seeing each other's results, each reached score 0 by restoring an argument the source had dropped: a7 (a cast at the
call), a8 (a widened function-pointer-array prototype), a11 (a dispatch-table call given its object pointer), a12 (a
call given the value it loads), a13 (a definition given its two parameters), a25 (a call re-issued at the callee's real
four-argument arity). In every one the register pin was hired to fake the instruction the missing argument would have
emitted, and the mechanisms were different each time — combine's `added_sets_2` gate (`combine.c:1458`), a copy
preference applied ahead of first-fit (`global.c:1535/1589` → `:997-1030`) whose argument copy degenerates into a
self-move deleted at `toplev.c:3142`, reorg's liveness half (`reorg.c:3374`, the use added to
`CALL_INSN_FUNCTION_USAGE` at `:428`) — but the CLASS is one, and no candidate generator can reach it: every family in
`delever.recipe_candidates` rewrites statements and declarations, none edits a call's ARGUMENT LIST.
So this is a static filter, not a search. It answers, for the whole tree at once, the question each of those agents had
to answer by hand with one grep: does this call site's declaration agree with the callee's real definition?
WHAT IT REPORTS, and what the scope column does and does not mean. The scope says which FIX is available, not whether
the body can be banked:
* ANY scope — the CAST route is bankable body-only, because the cast lives inside the definition: agent a7 closed
`func_8013BC7C` with `((void (*)(void *))f)(p)` against a FILE-scope `(void)` declaration, and a25 re-issued a call
at the callee's real four-argument arity the same way.
* BLOCK scope — the declaration itself can also be widened in the same edit as the body (a8's
`extern void (*D_80192FD0[])(u8 *);`), which is the more readable spelling.
* FILE scope — widening the DECLARATION belongs to the types phase, because gcc 2.7.2 rejects a conflicting
block-scope redeclaration outright; three such are already recorded by name (`func_80136824`, `func_80168828`,
`func_80157D20`).
KNOWN BLIND SPOT, measured against the six agent cracks this was built from. It finds a7, a12 and a25 and it CANNOT find
a13: `func_80157D20` is declared `(void)` in 131 of its 138 defining sites AND defined `(void)`, so no declaration
disagrees with any definition — the missing parameters exist only in the target's bytes, where the prologue copies two
argument registers. A declaration comparison is blind to a definition that is uniformly wrong; only the residual sees
those. So this census is a lower bound on the class, and the residual remains the oracle.
The real arity is read from the DEFINITION, wherever it lives: a `.c` under `src/`, or a shared body header under
`src/shared/` (P35's one-source-per-function shape). A definition is the ground truth; a declaration is a claim.
"""
import argparse, collections, json, pathlib, re, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lever_census as lc
REPO = pathlib.Path(__file__).resolve().parent.parent
NAME = r"[A-Za-z_]\w*"
DEF = re.compile(r"^[A-Za-z_][\w \t*]*?\b(" + NAME + r")\s*\(([^;{]*)\)\s*\{", re.M)
DECL = re.compile(r"\bextern\s+[A-Za-z_][\w \t*]*?\b(" + NAME + r")\s*\(([^;)]*)\)\s*;")
def arity(params):
"""The number of declared parameters. `void` and an empty list are both 0, but they are NOT the same claim: an empty
list is K&R "unspecified", which gcc accepts against any call, while `(void)` is a positive assertion of none."""
p = params.strip()
if p == "" or p == "void":
return 0
depth, n = 0, 1
for ch in p:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
elif ch == "," and depth == 0:
n += 1
return n
RET = re.compile(r"^([A-Za-z_][\w \t*]*?)\b" + NAME + r"\s*\(")
def definitions():
"""{name: (arity, params, where, ret)} from every DEFINITION in the tree. The return type comes with it because the
CAST route — the one that stays body-only at either scope — needs the whole signature, not just the arity."""
out = {}
for f in sorted(list((REPO / "src").glob("**/*.c")) + list((REPO / "src" / "shared").glob("**/*.h"))):
rel = str(f.relative_to(REPO))
try:
txt = f.read_text(errors="surrogateescape")
except OSError:
continue
masked = lc.sc.mask_text(txt)
for m in DEF.finditer(masked):
name, params = m.group(1), m.group(2)
if name in ("if", "for", "while", "switch", "return", "sizeof"):
continue
head = masked[m.start():m.start(1)].strip()
out.setdefault(name, (arity(params), params.strip(), rel, head or "void"))
# K&R definitions — `f(a, b, c)` then `s16 a; s16 b; u8 *c;` then `{`. DEF above requires `) {` and never saw them:
# 505 functions are defined this way and 95 had no other readable definition, so R19, R25 and the census were
# blind to them (found S103 by R25's known-true check: func_8012956C's callee func_801299C8 was absent). The
# params string carries the DEFAULT-PROMOTED types (char/short → int, float → double): that is what a K&R callee
# receives, so a prototype cast built from it stays compatible with the definition.
for m in KR_DEF.finditer(masked):
name, names = m.group(1), [n.strip() for n in m.group(2).split(",")]
if name in ("if", "for", "while", "switch", "return", "sizeof") or name in out:
continue
ptypes = kr_params(names, m.group(3))
head = masked[m.start():m.start(1)].strip()
out.setdefault(name, (len(names), ", ".join(ptypes), rel, head or "int"))
return out
def kr_params(names, decl_block):
"""[type] — the DEFAULT-PROMOTED type of each K&R parameter, in order, read from the declaration block between the
parameter list and the `{` (an undeclared parameter is `int`, as in C89)."""
types = {}
for decl in decl_block.split(";"):
dm = re.match(r"^\s*((?:(?:unsigned|signed|const|struct|union|volatile)\s+)*[A-Za-z_]\w*)\s*(.*)$", decl.strip())
if not dm:
continue
for part in dm.group(2).split(","):
pm = re.match(r"^\s*(\**)\s*([A-Za-z_]\w*)\s*$", part)
if pm:
types[pm.group(2)] = (dm.group(1) + (" " + pm.group(1) if pm.group(1) else "")).strip()
out = []
for n in names:
t = types.get(n, "int")
if "*" not in t and re.fullmatch(r"(?:unsigned\s+|signed\s+)?(?:s8|u8|s16|u16|char|short)(?:\s+int)?", t):
t = "int"
elif t == "float":
t = "double"
out.append(t)
return out
KR_DEF = re.compile(r"^[A-Za-z_][\w \t*]*?\b(" + NAME + r")\s*\(\s*(" + NAME + r"(?:\s*,\s*" + NAME + r")*)\s*\)\s*\n"
r"((?:[ \t]*[A-Za-z_][^;{}()]*;[ \t]*\n)+)\s*\{", re.M)
def scan(defs, only_needed_argpins=True):
"""[row] — one row per narrower-than-real declaration, with the scope that decides whether it is bankable."""
rows = []
for f in sorted(list((REPO / "src").glob("**/*.c")) + list((REPO / "src" / "shared").glob("**/*.h"))):
rel = str(f.relative_to(REPO))
try:
txt = f.read_text(errors="surrogateescape")
except OSError:
continue
if "extern" not in txt:
continue
masked = lc.sc.mask_text(txt)
lines = masked.split("\n")
# the bodies of this TU, and which of them hold a NEEDED pin on an argument register
argpin_fns = set()
if "!FAKE" in txt:
for s in lc.walk_file(txt, rel, rel.endswith(".h"))["sites"]:
if s["cls"] in "AB" and s["kind"] == "pin" and s.get("detail") in ("$4", "$5", "$6", "$7"):
argpin_fns.add(s.get("fn"))
spans = [(m.group(1), masked[:m.start()].count("\n") + 1,
masked[:m.end()].count("\n") + 1) for m in DEF.finditer(masked)]
def fn_at(ln):
for name, a, _ in spans:
if a <= ln:
last = name
else:
break
return last if spans and spans[0][1] <= ln else None
depth = 0
for i, l in enumerate(lines):
for m in DECL.finditer(l):
callee, params = m.group(1), m.group(2)
real = defs.get(callee)
if real is None:
continue
dec = arity(params)
if dec >= real[0]:
continue
if params.strip() == "" and real[0] > 0:
kind = "K&R-empty" # accepts any call; not itself an error, but m2c then calls it with none
else:
kind = "narrow"
holder = fn_at(i + 1)
rows.append(dict(tu=rel, line=i + 1, callee=callee, declared=dec, real=real[0],
real_params=real[1], defined_in=real[2], scope="block" if depth > 0 else "file",
kind=kind, in_fn=holder,
argpin=bool(holder and holder in argpin_fns)))
depth += l.count("{") - l.count("}")
depth = max(depth, 0)
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--json", help="write the rows here")
ap.add_argument("--argpin-only", action="store_true", help="only bodies holding a NEEDED $4-$7 pin")
a = ap.parse_args()
defs = definitions()
rows = scan(defs)
if a.argpin_only:
rows = [r for r in rows if r["argpin"]]
by_callee = collections.Counter(r["callee"] for r in rows)
by_scope = collections.Counter(r["scope"] for r in rows)
by_kind = collections.Counter(r["kind"] for r in rows)
pinned = [r for r in rows if r["argpin"]]
print(f"argcheck: {len(defs)} definitions read; {len(rows)} declaration(s) narrower than the definition, "
f"over {len(by_callee)} callee(s)")
print(f" by scope: {dict(by_scope)} (the CAST route is body-only at either scope; only widening the "
f"DECLARATION needs block scope)")
print(f" by kind: {dict(by_kind)}")
print(f" in a body that still holds a NEEDED $4-$7 pin: {len(pinned)} row(s) in "
f"{len({(r['tu'], r['in_fn']) for r in pinned})} body/bodies — the targeted draw")
for c, n in by_callee.most_common(12):
ex = next(r for r in rows if r["callee"] == c)
print(f" {c:16s} {n:5d} site(s) declared {ex['declared']} vs real {ex['real']} ({ex['defined_in']})")
if a.json:
pathlib.Path(a.json).write_text(json.dumps(rows, indent=1))
print(f" rows -> {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())