mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
3a886b9652
FIVE independently-MATCHed ov_SC06_029 bodies were rejected by a `parse error before '#'` in a file
the GATE ITSELF generates, at a line no draft contains. The isolation emitted, into the §8b carried
decl layer:
extern #define CALL_80185C6C ((void *(*)(s32, s32))func_80185C6C) extern void func_8012C218();
CAUSE. Every peeler in the TU-split chain asked `line.strip().startswith("/*")`, which is blind to a
comment a construct opens MID-LINE and wraps. The declaration ends at its `;` BEFORE the `/*`, so
the caller resumed on the comment's PROSE with in_block=False — and the prose is hostile: `(s32,s32)`
closes a depth-0 paren, `seen_header` latches, and every later `;` reads as a K&R parameter
declaration, so one "construct" swallowed the whole preamble. `parse_overlay_c` then anchored a
`def` on a pure declaration run and `def_proto` rendered it as that definition's implied prototype.
A SECOND defect rode along: `_file_scope_decls` hoisted such a col-0 line VERBATIM, unterminated
`/*` included, so the carried layer opened a comment that silently ate the next two declarations —
a dropped file-scope decl is a silent byte-changer. Building the guard exposed a THIRD: `_strip`
tested for `/*` before stripping `//`, so `// … src/*/*.c` (7 lines in 5 sources) opened a phantom
block comment and blanked everything to the next `*/`.
FIX: one derived comment-state oracle, `comment_open_at()` (R33) — per line, does it BEGIN inside a
block comment — consulted by parse_overlay_c, def_proto, split_src_region.parse and
jr_isolate_all._file_scope_decls (which also truncates a hoisted decl at an unterminated `/*`).
`_strip` now lexes left to right. `parse_overlay_c` RAISES (R43) when a wrapped comment closes with
code after the `*/`, because that construct could never anchor — 0 occurrences fleet-wide.
MEASURED, not assumed:
* the shape occurs 238 times across 193 tracked .c files; 153 are col-0 hoistable declarations in
150 files — every one a binary whose next isolation would have carried a broken decl layer.
* A/B over all 4,188 tracked sources, old parser vs new: round-trip identity 4188/4188 both ways;
exactly 2 files' item lists change, each losing one PHANTOM def and gaining nothing; malformed
implied prototypes 999 -> 984; 0 refusals.
* negative control BEFORE any edit: ov_SC06_029 extract+build -j+check BYTE-IDENTICAL b7b0d4ae.
* with the fix, gate_stage banked 5 of 6 drafts, counted from the SOURCE; the 6th
(func_80184084) is the separate CARVE-REFUSED class.
The 984 residual malformed prototypes are a DIFFERENT pre-existing trigger (col-0 lines gluing
declarations to DEFINE_func_*() invocations); 4 still carry a `#` and survive only because it lands
in a dropped segment. Named in §437, deliberately not fixed here.
Cookbook §437 + a SETUP.md tooling-ledger row for comment_open_at (parse_overlay_c may now raise).
The banks themselves are NOT in this commit: the agent's worktree predated func_8017F9C0's bank, so
adopting its TU verbatim would have destroyed one. They get re-gated against HEAD with these tools.
280 lines
13 KiB
Python
280 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Split a splat src/*.c at vram boundaries, preserving matched C and NON_MATCHING blocks (H5).
|
|
|
|
When a text subsegment is resegmented (e.g. to swap a library region's stubs for real objects),
|
|
the existing curated .c must be trimmed and its matched functions relocated — splat will NOT
|
|
overwrite an existing .c, so a stale .c would place functions at the wrong addresses. This tool
|
|
partitions the file's top-level items (INCLUDE_ASM stubs, real C functions, and #ifdef
|
|
NON_MATCHING blocks) by each item's vram:
|
|
|
|
trim <src.c> <lo> <hi> <move.frag>
|
|
rewrite <src.c> keeping only items with vram < lo (the header is preserved verbatim);
|
|
items in [lo, hi) are dropped (superseded by the linked objects); real-C items with
|
|
vram >= hi are written to <move.frag> for injection into the post-region file.
|
|
|
|
inject <dst.c> <move.frag>
|
|
in the freshly splat-generated <dst.c>, replace each moved function's INCLUDE_ASM stub
|
|
with its real C body (matched by function name).
|
|
|
|
Item vram comes from a func_XXXXXXXX name, else a name looked up in config/symbols.us.txt.
|
|
Brace matching is naive (counts {}); the build's SHA1 check is the backstop if an item with
|
|
string-literal braces is mis-split.
|
|
"""
|
|
import os
|
|
import re, sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from overlay_src_split import comment_open_at # ONE comment-state model for the whole split chain (R33)
|
|
|
|
SYMS_PATH = "config/symbols.us.txt"
|
|
|
|
|
|
def load_syms():
|
|
s = {}
|
|
for ln in open(SYMS_PATH):
|
|
m = re.match(r"(\w+)\s*=\s*0x([0-9A-Fa-f]+)", ln)
|
|
if m:
|
|
s[m.group(1)] = int(m.group(2), 16)
|
|
return s
|
|
|
|
|
|
_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
|
# A declarator keyword is never the symbol (mirrors gate_main._NOT_A_SYMBOL).
|
|
_NOT_A_SYMBOL = {"void", "char", "short", "int", "long", "float", "double", "signed", "unsigned",
|
|
"const", "volatile", "struct", "union", "enum", "static", "extern", "register",
|
|
"typedef", "s8", "u8", "s16", "u16", "s32", "u32", "s64", "u64", "f32", "f64",
|
|
"if", "for", "while", "switch", "return", "else", "do"}
|
|
|
|
|
|
def item_name(text):
|
|
"""The declared name of a top-level item, read from CODE only.
|
|
|
|
COMMENTS ARE STRIPPED FIRST (P31 S72). The definition regex runs with re.M over the WHOLE item,
|
|
and an item begins with its leading comment block — so a comment containing any parenthesised
|
|
token (`harvest_verify(...)`, `func_x()`, even prose with brackets) matched BEFORE the real
|
|
definition below it. The name then failed to resolve to an address, `coalesce` treated a real
|
|
FUNCTION as an address-less preamble and merged it into its neighbour, and `inject` reported
|
|
9 functions whose stubs it could not find — while their bodies sat in the file, carried inside
|
|
another item. Two bugs with one cause: a pattern that scans prose as if it were code."""
|
|
m = re.search(r"INCLUDE_ASM\([^,]+,\s*(\w+)\)", text)
|
|
if m:
|
|
return m.group(1)
|
|
code = _COMMENT.sub("", text)
|
|
code = re.sub(r"^\s*//[^\n]*$", "", code, flags=re.M)
|
|
# MATCH THE DEFINITION, NOT A DECLARATION ABOVE IT (P31 S72; the §192 class, which
|
|
# `gate_main.sym_of` fixed for itself and this tool never got). `parse` folds a run of leading
|
|
# `extern` lines into the following function's item, and the old pattern matched the FIRST
|
|
# line: `extern void (*D_80196184[])(void);` returned the name "void", which resolves to no
|
|
# address, so `coalesce` treated a REAL FUNCTION as a preamble and merged it into its
|
|
# neighbour. The body then landed inside another item while its own stub survived — 26
|
|
# functions in one overlay with BOTH a definition and a stub, i.e. duplicate symbols at
|
|
# assembly time. A definition ends in `{`; a declaration ends in `;`. Anchor on that, and
|
|
# never accept a type keyword as the name.
|
|
# LEADING WHITESPACE IS ALLOWED: agent-written bodies are sometimes indented at top level, and
|
|
# a column-0 anchor made ` void func_8018B410(u8 *a0) {` invisible — the item then had no
|
|
# name, no address, and was merged into its neighbour as if it were a preamble. Indented
|
|
# CONTROL FLOW cannot be mistaken for a definition because `_NOT_A_SYMBOL` excludes
|
|
# if/for/while/switch/do, and a call statement ends in `;` rather than `{`.
|
|
for m in re.finditer(r"^[ \t]*[A-Za-z_][\w \t\*]*?\b(\w+)\s*\([^;{]*\)\s*\{", code, re.M):
|
|
if m.group(1) not in _NOT_A_SYMBOL:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def item_addr(text, syms):
|
|
name = item_name(text)
|
|
if name:
|
|
m = re.match(r"func_([0-9A-Fa-f]{8})$", name)
|
|
if m:
|
|
return int(m.group(1), 16)
|
|
if name in syms:
|
|
return syms[name]
|
|
return None
|
|
|
|
|
|
def parse(src):
|
|
"""Return (header, [item_text, ...]) splitting top-level items."""
|
|
lines = src.split("\n")
|
|
n = len(lines)
|
|
# SAME BLINDNESS, SAME CURE as overlay_src_split (P31 S74, cookbook §437): the comment peel
|
|
# below only recognises a comment that STARTS a line, so a construct whose TRAILING `/*` note
|
|
# WRAPS (`extern void f(); /* note` + continuation) left this loop treating the comment's prose
|
|
# as an item. `comment_open_at` is the one derived model of the file's comment state (R33).
|
|
opens = comment_open_at(lines)
|
|
i = 0
|
|
while i < n and (lines[i].startswith("#include") or lines[i].strip() == ""):
|
|
i += 1
|
|
header = "\n".join(lines[:i]).rstrip("\n")
|
|
items = []
|
|
while i < n:
|
|
if lines[i].strip() == "":
|
|
i += 1
|
|
continue
|
|
start = i
|
|
# leading line/block comments belong to the following item
|
|
while i < n and (opens[i] or lines[i].lstrip().startswith("//")
|
|
or lines[i].lstrip().startswith("/*")
|
|
or lines[i].lstrip().startswith("*")):
|
|
if lines[i].lstrip().startswith("/*") and "*/" not in lines[i]:
|
|
while i < n and "*/" not in lines[i]:
|
|
i += 1
|
|
i += 1
|
|
if i >= n:
|
|
break
|
|
l = lines[i]
|
|
if l.startswith("#ifdef NON_MATCHING"):
|
|
while i < n and not lines[i].startswith("#endif"):
|
|
i += 1
|
|
i += 1
|
|
elif l.lstrip().startswith("INCLUDE_ASM"):
|
|
i += 1
|
|
else:
|
|
depth = 0
|
|
seen = False
|
|
while i < n:
|
|
depth += lines[i].count("{") - lines[i].count("}")
|
|
if "{" in lines[i]:
|
|
seen = True
|
|
i += 1
|
|
if seen and depth <= 0:
|
|
break
|
|
items.append("\n".join(lines[start:i]))
|
|
return header, items
|
|
|
|
|
|
def _norm_ws(s):
|
|
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
|
|
def is_real_c(text):
|
|
return "INCLUDE_ASM" not in text or text.startswith("#ifdef NON_MATCHING")
|
|
|
|
|
|
def coalesce(items, syms):
|
|
"""Merge address-LESS top-level items into the item that follows them. -> ([item], n_merged).
|
|
|
|
WHY (P31 S72). `parse` yields one item per top-level construct, and `trim` demanded an address
|
|
for every one. But an overlay `.c` is full of constructs that HAVE no address — a hoisted
|
|
typedef block, a per-function `extern` run, a `/* HOISTED (P31 S54) ... */` banner — so `trim`
|
|
exited with "cannot resolve address of item" and `jr_isolate` has been BLOCKED on it since
|
|
Phase 26, which is why the overlays that need a subseg split never got one.
|
|
|
|
The fix is the model, not the regex: those constructs are a PREAMBLE belonging to the function
|
|
BELOW them (that is where the author put them, and it is what makes a leading `extern` block
|
|
travel with the body that uses it). So they are not separate items at all — merge each run
|
|
forward into the next addressed item, and the "one item = one address" invariant holds again.
|
|
A trailing run at EOF has nothing below it and attaches to the previous item instead.
|
|
|
|
NOT PERFECT, AND DELIBERATELY SO: a declaration used by functions on BOTH sides of the cut
|
|
follows the first one and the other side loses it. That is the same 57-crossing-declarations
|
|
problem main's split hit, and the answer there is the answer here — let the COMPILER enumerate
|
|
what is missing and lift those few into a shared header (cookbook §431). This function reports
|
|
how many preambles it moved so that risk is visible rather than silent.
|
|
|
|
RETURNS (addr, name, text) TRIPLES, not text. The address and name are captured from the
|
|
ADDRESSED item BEFORE the merge and carried; re-deriving them from the merged text is wrong,
|
|
because `item_name` scans from the top and would match the preamble's prose instead of the
|
|
function below it — which is exactly how the first version of this failed, on the very
|
|
`/* HOISTED ... */` banner it was written to handle.
|
|
"""
|
|
out, buf, merged = [], [], 0
|
|
for it in items:
|
|
a = item_addr(it, syms)
|
|
if a is None:
|
|
buf.append(it); continue
|
|
text = "\n\n".join(buf + [it]) if buf else it
|
|
merged += len(buf); buf = []
|
|
out.append((a, item_name(it), text)) # name from the ITEM, not the merged text
|
|
if buf: # trailing preamble: nothing below it, so it stays with the last item
|
|
if out:
|
|
a, nm, text = out[-1]
|
|
out[-1] = (a, nm, "\n\n".join([text] + buf)); merged += len(buf)
|
|
else:
|
|
return [], 0 # a file with NO addressed item at all -> caller refuses
|
|
return out, merged
|
|
|
|
|
|
def trim(srcpath, lo, hi, movepath):
|
|
syms = load_syms()
|
|
header, items = parse(open(srcpath).read())
|
|
triples, merged = coalesce(items, syms)
|
|
if not triples:
|
|
sys.exit(f"trim: {srcpath} has no address-bearing top-level item — refusing (R43).")
|
|
if merged:
|
|
print(f"trim: attached {merged} address-less preamble item(s) (comments / extern / typedef "
|
|
f"runs) to the function below them — see coalesce(); if the build then reports a "
|
|
f"missing declaration, lift that one into a shared header (§431).")
|
|
keep, drop, move = [], 0, []
|
|
for a, nm, it in triples:
|
|
if a < lo:
|
|
keep.append(it)
|
|
elif a < hi:
|
|
drop += 1
|
|
else:
|
|
if is_real_c(it):
|
|
move.append((a, nm, it))
|
|
open(srcpath, "w").write(header + "\n\n" + "\n\n".join(keep) + "\n")
|
|
move.sort()
|
|
with open(movepath, "w") as f:
|
|
f.write("\n\n".join(it for _, _, it in move) + "\n")
|
|
print(f"trim {srcpath}: kept {len(keep)} (<0x{lo:X}), dropped {drop} stubs/superseded "
|
|
f"[0x{lo:X},0x{hi:X}), moved {len(move)} real-C funcs (>=0x{hi:X}) -> {movepath}")
|
|
for a, nm, _ in move:
|
|
print(f" move 0x{a:08X} {nm}")
|
|
|
|
|
|
def inject(dstpath, movepath):
|
|
_, moved = parse(open(movepath).read())
|
|
triples, _ = coalesce(moved, load_syms()) # same model on the way back in
|
|
by_name = {nm: it for _a, nm, it in triples if nm}
|
|
dst = open(dstpath).read()
|
|
done = []
|
|
for name, body in by_name.items():
|
|
# replace the whole stub line (optionally wrapped) for this function
|
|
pat = re.compile(r"^INCLUDE_ASM\([^,]+,\s*" + re.escape(name) + r"\);\s*$", re.M)
|
|
if pat.search(dst):
|
|
dst = pat.sub(lambda m: body, dst, count=1)
|
|
done.append(name)
|
|
# A TRIVIAL FUNCTION HAS NO STUB TO REPLACE, AND THAT IS NOT A FAILURE (P31 S72).
|
|
# splat emits an EMPTY function (`jr $ra; nop`) as real C directly rather than as an
|
|
# INCLUDE_ASM stub, so the freshly-generated destination already DEFINES it — measured here:
|
|
# 251 stubs and 9 real-C definitions, and inject hard-exited on all 9 while the bodies it
|
|
# wanted to write were already present and textually identical. Accept that case, but only
|
|
# after PROVING equivalence: a destination definition that DIFFERS from the moved one is a
|
|
# genuine conflict and still fails, because silently keeping the wrong body is how a split
|
|
# produces a binary that builds and is not byte-identical.
|
|
already, conflict = [], []
|
|
for name in [n for n in by_name if n not in done]:
|
|
dm = re.search(r"^[A-Za-z_][^\n]*\b%s\s*\([^)]*\)\s*\{[^}]*\}" % re.escape(name),
|
|
dst, re.M)
|
|
if not dm:
|
|
conflict.append((name, "no stub and no definition in the destination"))
|
|
elif _norm_ws(dm.group(0)) == _norm_ws(by_name[name]):
|
|
already.append(name)
|
|
else:
|
|
conflict.append((name, "destination defines it DIFFERENTLY"))
|
|
open(dstpath, "w").write(dst)
|
|
print(f"inject {dstpath}: replaced {len(done)} stubs with matched C: {done}")
|
|
if already:
|
|
print(f"inject: {len(already)} function(s) already present in the destination and textually "
|
|
f"IDENTICAL (splat emits trivial/empty functions as C, not as a stub): {already}")
|
|
if conflict:
|
|
sys.exit("inject: " + "; ".join(f"{n} — {why}" for n, why in conflict))
|
|
|
|
|
|
def main():
|
|
global SYMS_PATH
|
|
args = sys.argv[1:]
|
|
if "--symbols" in args: # Phase 9: per-binary symbol file (default = the EXE's)
|
|
k = args.index("--symbols"); SYMS_PATH = args[k + 1]; del args[k:k + 2]
|
|
if len(args) >= 5 and args[0] == "trim":
|
|
trim(args[1], int(args[2], 0), int(args[3], 0), args[4])
|
|
elif len(args) == 3 and args[0] == "inject":
|
|
inject(args[1], args[2])
|
|
else:
|
|
sys.exit(__doc__)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|