Files
BFM-decomp/tools/draft_prechecks.py
T
Drew T dc4412b1de fix(verbatim_to_stub): refuse a §179-C epilogue-less fragment
A function with no `jr $ra` of its own falls into a sibling's shared
epilogue. gcc-2.7.2 has no sibcall/tail-merge pass and appends an epilogue to
every C function it compiles, so no C spelling can ever match — converting one
to an INCLUDE_ASM stub just puts an unbankable target into the drawable
frontier.

I did exactly that to six functions in src/800c.c, on a `rows == 1` filter
that meant "the manifest listed one row", not "this is an independent
function" — ignoring the DECOMPILE-AS-PARENT disposition whose entire meaning
is "this row is a FRAGMENT". Three drafting agents then rediscovered §179-C
from scratch, one citing the cookbook line that names its own target.

The symptom is one grep, so nobody should pay an agent to find it again.

TWO THINGS THIS COST, both caught only by testing a known-true case:
  * the first version read the function's .s — but splat stops emitting <fn>.s
    for a verbatim body, so it had nothing to read and returned False: inert
    for precisely the case it guards. It now reads the verbatim block itself.
  * my first negative control was CloseEvent, a libapi trampoline that
    genuinely has no `jr $ra` — a "false positive" that was the correct
    answer. Re-controlled on VectorNormal (verbatim, has jr $ra, guard stays
    silent) vs func_80047E58 (verbatim, no jr $ra, guard fires).

Census of main's verbatim blocks: 37 have jr $ra, 100 do not.
2026-09-03 15:02:50 -06:00

192 lines
9.7 KiB
Python

"""Static pre-checks that turn a BUILD-time draft failure into a GENERATION-time skip.
Every class here was measured as a real failure in the S50 autodraft runs, and every one is
decidable without compiling anything. That is the whole point: a build spent to learn something a
regex knows is a build wasted, and at ~1 min/group over ~100 groups the waste is the run.
1. ARITY DISAGREEMENT — `too few arguments to function 'func_80146C3C'`.
The destination TU already declares a callee with a different parameter count than the seed's
body passes. Neither emitting the seed's decl (conflicting types) nor relying on the
destination's (too few arguments) can work; the member needs a real edit.
2. UNDEFINED DATA — `undefined reference to 'D_801E9C44'`.
The draft references a symbol nothing in the DESTINATION binary defines. The seed's own data
symbol survived into the draft because it sits at no relocation slot symbol_map covers.
"""
import glob
import os
import re
def _decl_arity(text, sym):
"""Parameter count of the first declaration/definition of `sym` in `text`, or None."""
m = re.search(rf'^[ \t]*(?:extern\s+)?[A-Za-z_][\w \*]*\b{re.escape(sym)}\s*\(([^;{{)]*)\)',
text, re.M)
if not m:
return None
args = m.group(1).strip()
if args == '':
return None # C89: `f()` declares UNSPECIFIED parameters, not zero — never a conflict
if args == 'void':
return 0
return len([a for a in args.split(',') if a.strip()])
def _call_arity(body, sym):
"""Argument count at the first CALL of `sym` in `body`, or None. Depth-aware so a nested call
or a comma inside a cast does not miscount."""
m = re.search(rf'\b{re.escape(sym)}\s*\(', body)
if not m:
return None
depth, args, cur = 0, [], ''
for ch in body[m.end() - 1:]:
if ch == '(':
depth += 1
if depth == 1:
continue
elif ch == ')':
depth -= 1
if depth == 0:
args.append(cur)
break
if depth == 1 and ch == ',':
args.append(cur)
cur = ''
continue
if depth >= 1:
cur += ch
return len([a for a in args if a.strip()])
def arity_conflicts(body, dest_text, syms, self_name=None):
"""Symbols the body CALLS with an argument count the destination's own declaration refuses."""
bad = []
for s in syms:
if not s.startswith('func_') or (self_name and s.lower() == self_name.lower()):
continue # the member's own definition is not a call to itself
d, c = _decl_arity(dest_text, s), _call_arity(body, s)
# CONSERVATIVE by measurement: flag only when the destination declares NAMED parameters and
# the body passes fewer. Every true positive had that shape (`dest decl 1 vs call 0`) and
# every false positive the other one (`dest decl 0 vs call 1`) — where the `0` comes from a
# `(void)` my parser reads out of a declaration it should not have matched. A pre-check that
# throws away good drafts is worse than one that lets a few builds fail.
if d is not None and c is not None and d > c:
bad.append(f"{s}(dest decl {d} vs call {c})")
return bad
_DEF_CACHE = {}
def defined_in_binary(binary, sym):
"""True if `sym` is defined anywhere in the destination binary — its asm (a dlabel/glabel, or
a symbol file entry) or its C. Cached per binary: the asm scan is the expensive part."""
key = binary
if key not in _DEF_CACHE:
defs = set()
for p in glob.glob(f"asm/{binary}/**/*.s", recursive=True):
for m in re.finditer(r'^(?:dlabel|glabel|jlabel)\s+([A-Za-z_]\w*)', open(p).read(), re.M):
defs.add(m.group(1))
for p in glob.glob(f"config/symbols.{binary}*.txt"):
for m in re.finditer(r'^([A-Za-z_]\w*)\s*=', open(p).read(), re.M):
defs.add(m.group(1))
_DEF_CACHE[key] = defs
return sym in _DEF_CACHE[key]
def undefined_data(binary, dest_text, syms, self_name=None, body=""):
"""Referenced symbols nothing in the destination binary defines, and the destination TU does
not define locally either.
A symbol the DRAFT ITSELF defines is not undefined — its own function name above all, which
has no `glabel` left in asm precisely BECAUSE it is now C. Skipping that was 38 of 38 false
positives in the negative control."""
out = []
for s in syms:
if self_name and s.lower() == self_name.lower():
continue
if re.search(rf'^[^\n=;]*\b{re.escape(s)}\b[^\n;]*\)\s*\{{', body, re.M) or \
re.search(rf'^[ \t]*(?:const\s+|static\s+)*[A-Za-z_][\w \*]*\b{re.escape(s)}\b'
rf'(?:\s*\[[^\]]*\])?\s*=', body, re.M):
continue
if defined_in_binary(binary, s):
continue
if re.search(rf'^[ \t]*(?:const\s+|static\s+)*[A-Za-z_][\w \*]*\b{re.escape(s)}\b[^\n;]*[=;]',
dest_text, re.M):
continue
out.append(s)
return out
def is_verbatim_asm_draft(text, fn):
"""True if `text` is the target's own assembly, not a decompile of it (P31 S76).
THE DEFECT THIS CATCHES, and why no byte gate can. `tools/asm_verbatim.py` emits a §265
verbatim body -- the function's own .s pasted into a file-scope `__asm__` string. Those files
sit in the same draft directories as real C drafts and are named `<fn>.c` like everything else.
Score one with match_one and it prints MATCH, truthfully: a raw asm blob assembles to the bytes
it was copied from. Substitute one and the whole-binary gate goes GREEN, also truthfully. The
function is then reported BANKED while nothing whatsoever has been decompiled.
S75 hit this once (an agent submitted the verbatim block as its own "decompile"; only an
adversarial reader caught it) and recorded "No byte gate can" catch it. That is true of the
BYTE check and false of a slate-load refusal, which is why this lives here: the verbatim form
is trivially decidable from the text (R43 -- refuse the input, never mishandle it).
S76 hit it again, from the other direction and at scale: nine functions were converted from
verbatim bodies to stubs precisely so they could be decompiled, then "banked" from stored
drafts that were the same verbatim blocks -- a round trip through the gate that moved
progress.py by exactly zero and read as 9 of 9 MATCH. The instrument was right and the claim
was wrong.
A draft is verbatim iff a file-scope `__asm__` names this function as a .ent/.globl/label AND
no C definition of it exists. Both halves matter: a real draft may legitimately carry a small
inline `__asm__`, and a verbatim body may omit .globl for a static."""
if '__asm__' not in text:
return False
f = re.escape(fn)
# The escaped forms are what the emitters write INTO a C string: `.ent\tfoo\n` is backslash-t,
# not a tab. Match both the escaped and the literal spellings -- five S76 censuses of this
# class disagreed with each other until both were handled.
asm_names = re.search(rf'\.(?:ent|globl)(?:\\t|[ \t])+{f}\b', text) or \
re.search(rf'"\s*{f}:\s*(?:\\n|$)', text)
if not asm_names:
return False
c_def = re.search(rf'^[A-Za-z_][\w \*]*\b{f}\s*\([^;]*\)\s*(?:/\*.*?\*/\s*)?\{{', text, re.M | re.S)
return not c_def
def is_epilogueless_fragment(asm_path=None, asm_text=None):
"""True if this function has NO `jr $ra` of its own — cookbook §179-C (P31 S76).
THE CLASS. A §179-C function ends mid-basic-block, or every exit is a raw `j` into a SIBLING's
label, and the shared `lw $ra / addiu $sp / jr $ra` tail lives in the next symbol. gcc-2.7.2 has
no sibcall/cross-function tail-merge pass and `expand_function_end` appends an epilogue to every
C function it compiles, so NO C spelling exists: every attempt comes out +2 instructions
(a phantom `jr $ra` / `nop`). The only correct form is a file-scope `__asm__`.
WHY IT IS A PRECHECK AND NOT A LESSON (S76). The manifest already marks these DECOMPILE-AS-PARENT
("this row is a FRAGMENT — decompile the unit_entry, NEVER the fragment") and the cookbook §179-C
entry NAMES several of them. I converted six of them to INCLUDE_ASM stubs anyway, on a `rows == 1`
filter that meant "the manifest listed one row", not "this is an independent function" — putting
six unbankable targets into the drawable frontier. Three separate drafting agents then rediscovered
§179-C from scratch, one of them citing the very cookbook line that names its target.
The symptom is one grep over the target's own `.s`, so no one should ever pay an agent to find it
again: a function with no `jr $ra` is not drawable and not convertible, whatever any disposition
field says. Verified on the S76 set: 6 of 6 main `src/800c.c` conversions flagged, 5 of 5 overlay
conversions cleared.
READ THE RIGHT SOURCE. Pass `asm_text` when the function is still a VERBATIM body: splat stops
emitting `<fn>.s` for a function that is not a stub, so a path-only check has nothing to read and
returns False — inert for exactly the case it exists to prevent. Measured S76: the first version
of this guard passed a §179-C fragment straight through for that reason, caught only by testing
it on a case whose answer was already known. The verbatim block IS the assembly; use it."""
if asm_text is None:
try:
with open(asm_path, errors='replace') as fh:
asm_text = fh.read()
except (OSError, TypeError):
return False # cannot read -> not our call to make; the caller's oracle decides
return not any('jr' in ln and '$ra' in ln for ln in asm_text.splitlines())