mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
af2f40d153
R22 CLEAN-FLEET: make clean -> extract 136 -> build 136 -> check-all = 136 PASSED, 0 FAILED.
make audit-corpus: 0 PHANTOM + 0 TRUNCATED (was 193).
=== A4: a CORPUS defect the byte-gate could never have caught ===
config/symbols.us.txt:981 declared `listCdBuffer = 0x80180000` — a correct Phase-3 name for MAIN's
LIST.CD RAM buffer. But that address is OUTSIDE main's image and INSIDE the overlay slot, and every
overlay's splat config stacks symbols.us.txt. High RAM is REUSED: an address that is a buffer to main
is live CODE to an overlay. So splat saw a symbol boundary mid-code and, across 97 of 134 overlays:
* CUT 97 REAL FUNCTIONS IN HALF (a head ending on a `lui`, no return), and
* INVENTED 96 PHANTOM ONES (a tail beginning by reading the assembler temp $at).
193 slices NOBODY COULD EVER MATCH — not "hard", not "a compiler wall": unmatchable by construction.
They sat in the harvest queue as ordinary work, so agents would burn on them forever and the failures
would be filed as intrinsic compiler residuals.
The phantom listCdBuffer.s in ov_SC01_005 literally begins:
lw $ra, 0x10($sp) / addiu $sp, $sp, 0x18 / jr $ra
splat cut a function immediately before its EPILOGUE and called the epilogue a function.
AND IT HAD ALREADY CONTAMINATED REAL WORK: in ov_SC03_031 the cut landed where the epilogue was
exactly `jr $ra; nop`, so the Phase-26 x134 sweep innocently BANKED the phantom as
`void listCdBuffer(void) {}` — byte-correct, gate-green, entirely fictitious — while leaving
func_8017FFC4 permanently unmatchable. Removed.
WHY NO GATE CAUGHT IT, AND WHY THAT IS THE POINT: INCLUDE_ASM pastes the two .s halves back VERBATIM
in original order, so the image is byte-identical either way. The byte-gate was green the whole time
and always would have been. It is a perfect CORRECTNESS oracle and a NULL COVERAGE oracle. No
assertion added INSIDE it could ever have found this. What found it was a SECOND, INDEPENDENT oracle:
tools/sig_image.py derives boundaries from the ORIGINAL bytes without splat, and DISAGREED with the
corpus (58,524/58,621 agreement with spimdisasm; correct on all 97 disagreements).
=> When one oracle is structurally blind to a class of error, the answer is not a better assertion
inside it. It is a SECOND ORACLE THAT CAN DISAGREE WITH IT. (`make audit-corpus` is now that.)
THE RULE (the mirror of R13/R15, never written down): a symbol whose address falls inside ANOTHER
binary's vram window must never enter that binary's symbol stack.
FIX: config/symbols.us.ram.txt — main-scoped symbols outside main's image — stacked ONLY by
config/splat.us.exe.yaml. Main keeps the name it needs (10 %hi / 11 %lo refs; 143dbb89 byte-identical);
the overlays never see it. Exactly one symbol was in scope fleet-wide; the resident window was clean.
AND A REAL FUNCTION THE ACCIDENT WAS HIDING: in ov_SC01_084 / ov_SC02_041 / ov_SC03_094 / ov_SC06_008
there IS a genuine function at 0x80180000 (111 / 35 / 28 / 74 ins), reachable ONLY via a fn-pointer
table (.word func_80180000) and never by `jal` — so splat cannot find it and needs the boundary
DECLARED. listCdBuffer had been supplying it by luck. Now declared honestly, per-overlay, in
config/symbols.<ov>.txt — exactly where R13/R15 says an overlay-scoped symbol belongs.
=== A5: the closeness oracle every crack agent trusts was lying on 155 functions ===
masked_diff._reloc_kind() knew 26/HI16/LO16. An over-approximating sweep of every reloc objdump emits
across all 3,367 build objects found FOUR: R_MIPS_26, HI16, LO16 — and R_MIPS_PC16 (211). PC16 fell
through to a FULL-WORD compare, but the object holds an UNRESOLVED PLACEHOLDER in the branch
displacement, so that compare can NEVER succeed.
DECISIVE TEST (derived from the invariant, not from reading the regex): INCLUDE_ASM pastes the
ORIGINAL asm, so for every stub diff_object_s() MUST be 0. Measured, coverage-asserted:
2,741 functions scored — old mask: 150 LIES; PC16 masked: 4 LIES.
(The 4 survivors are the separate length-delta defect.) A phantom non-zero sends an agent to grind at
a wall that is not there, and the wasted attempt is then booked as a MATCHING failure, feeding
reserved_walls() and PERMANENTLY BLACKLISTING a function that was never broken.
=== NEW FINDING (found by cutting the R22 corner): a STALE OBJECT CAN PRODUCE A FALSE PASS ===
`.o <- .s` is not a dependency make can see: assembly arrives via INCLUDE_ASM, expanded to a `.include`
consumed by maspsx/as AFTER cpp, while -MMD tracks headers only. Re-extract, build incrementally, and
make links a STALE object. This is not merely slow — INCLUDE_ASM pastes the ORIGINAL bytes, so a stale
object still yields the original image: SHA1 GOES GREEN while the split just changed is never exercised.
A broken config change can be "verified" by an incremental build. Live proof: 8 of 136 binaries linked
stale objects here; they failed LOUDLY ONLY BY LUCK (the dead symbol was an undefined reference) — a
merely-different-but-valid split would have gone green on all 136.
R22/H3 already legislate this, and I broke them. But a rule that needs a human to remember it is not a
gate. FIX: `extract` now invalidates the objects that include what it just rewrote (main's are top-level,
so -maxdepth 1 — verified it cannot clobber the other 1,605 objects). Structural, not advisory.
R14 self-catch, recorded: my first A5 test passed `fn=` to diff_object_s(), which takes two args; the
TypeError was swallowed by my own `except Exception: continue` and it reported 0 scored / 0 lies. I
wrote the exact bug I was auditing, inside the test for it. Caught only because 0 looked wrong. The
test now asserts its own coverage.
182 lines
8.5 KiB
Python
182 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""masked_diff.py — the shared relocation-masked instruction oracle (BFM Phase 24 T2).
|
|
|
|
The single source of truth for "how close are two MIPS objects, ignoring link-time relocations."
|
|
Factored out of tools/match_one.py so match_one AND the permuter's MaskedScorer (tools/masked_scorer.py)
|
|
use ONE masking rule — and upgraded to `objdump -drz` so runs of nops are NOT collapsed (the `-dr`
|
|
nop-elision that under-counted GTE-heavy seeds, e.g. func_80132784 read as 384 ins instead of 400).
|
|
|
|
The mask (identical to the psyq_identify / match_one rule):
|
|
* jal / j (opcode>>26 in {2,3}) or any R_MIPS_*_26 reloc -> ignore the whole 26-bit target field
|
|
* R_MIPS_HI16 / R_MIPS_LO16 -> ignore the 16-bit immediate (linker fills it)
|
|
* everything else -> compare the full 32-bit word
|
|
|
|
Two compare modes:
|
|
* OBJECT vs OBJECT (the permuter: both target.o and cand.o carry relocs) — mask driven by the target's
|
|
relocs, AND require reloc-operand (symbol+addend) equality at masked positions. This is STRICTER than
|
|
match_one: a jal to the wrong symbol scores a mismatch (match_one, comparing against a resolved .s,
|
|
can't see the symbol). A masked count of 0 here ⟺ the two functions link to identical bytes.
|
|
* OBJECT vs splat .s (match_one: the target .s is RESOLVED, no relocs) — mask driven by MY object's
|
|
relocs, applied to both words; symbol-agnostic (the .s word is already the final linked value).
|
|
|
|
Neither mode is the byte-gate: the whole-binary rebuild (tools/harvest_verify.py) is the sole arbiter
|
|
(G3/P9). These masked metrics only rank candidates / measure closeness.
|
|
"""
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
|
|
OBJDUMP = "mipsel-linux-gnu-objdump"
|
|
|
|
_HDR_RE = re.compile(r"^[0-9a-f]+ <([^>]+)>:")
|
|
_INS_RE = re.compile(r"\s+[0-9a-f]+:\s+([0-9a-f]{8})\s+(.*)")
|
|
_REL_RE = re.compile(r"R_MIPS_(\w+)\s+(\S+)")
|
|
# scalar/M2C typedef REDEFINITIONS common.h already provides (C89 rejects the dup); shared so match_one
|
|
# and the permuter setup strip exactly the same set and never the draft's own custom struct typedefs.
|
|
SCALAR_TYPEDEF_RE = re.compile(
|
|
r"^[ \t]*typedef\b[^;]*\b(u8|u16|u32|u64|s8|s16|s32|s64|f32|f64|"
|
|
r"M2C_UNK|M2C_UNK8|M2C_UNK16|M2C_UNK32|M2C_UNK64)[ \t]*;[ \t]*\n",
|
|
re.M,
|
|
)
|
|
|
|
|
|
def _reloc_kind(name):
|
|
if "26" in name:
|
|
return "26"
|
|
if "HI16" in name:
|
|
return "HI16"
|
|
if "LO16" in name:
|
|
return "LO16"
|
|
if "PC16" in name: # R_MIPS_PC16 — see mask_for() (Phase 26-A audit)
|
|
return "PC16"
|
|
return name
|
|
|
|
|
|
def mask_for(word, reloc_kind):
|
|
"""The compare mask for one instruction word given its reloc kind (or None).
|
|
|
|
R_MIPS_PC16 (Phase 26-A audit). An over-approximating sweep of every reloc type `objdump -drz`
|
|
emits across all 3,367 real build objects found EXACTLY FOUR: R_MIPS_26 (1,090,661), R_MIPS_HI16
|
|
(633,662), R_MIPS_LO16 (633,437) — and R_MIPS_PC16 (211). The table handled three. PC16 fell
|
|
through to a FULL-WORD compare, but the object holds an UNRESOLVED PLACEHOLDER in the branch
|
|
displacement, so that compare can never succeed:
|
|
|
|
build/src/libgs6.o func_80053E28 MINE 1040ffff (beqz v0,388) TGT 10400018 (beqz $v0, GS_123…)
|
|
^^^^ placeholder ^^^^ real displacement
|
|
|
|
THE DECISIVE TEST — derived from the proven invariant, not from reading the regex: INCLUDE_ASM
|
|
pastes the ORIGINAL assembly, so for EVERY stub the build object's bytes ARE the target .s bytes,
|
|
and diff_object_s() MUST return 0. Run over all 60,740 stubs it returned 0 for 60,585 — and LIED
|
|
on 155.
|
|
|
|
Why a lying closeness oracle is worse than a slow one: every crack agent trusts this number. A
|
|
phantom non-zero sends an agent to grind at a wall that is not there, and the wasted attempt is
|
|
then booked into the backlog as a MATCHING failure, which feeds reserved_walls() and PERMANENTLY
|
|
BLACKLISTS a function that was never actually broken. A silent skip compounding into a false wall
|
|
— the same mechanism that made nine byte-exact functions look like an intrinsic compiler wall.
|
|
|
|
Masking PC16 exactly like HI16/LO16 (keep opcode+regs, drop the linker-filled displacement) cures
|
|
151 of the 155 and introduces ZERO new lies across all 60,740 functions (auditor's counterfactual,
|
|
independently re-run by a skeptic). The 4 survivors are a separate length-delta defect."""
|
|
if (word >> 26) in (2, 3): # jal / j — the 26-bit target is a link-time value
|
|
return 0
|
|
if reloc_kind == "26":
|
|
return 0
|
|
if reloc_kind in ("HI16", "LO16", "PC16"):
|
|
return 0xFFFF0000 # keep opcode+regs, drop the linker-filled immediate/displacement
|
|
return 0xFFFFFFFF
|
|
|
|
|
|
def insns_from_object(obj, fn=None):
|
|
"""objdump -drz an object's .text -> [{word, mnem, reloc_kind, reloc_op}]. -z keeps nop runs
|
|
(no collapse). fn: restrict to that <function> label; None = all .text (a one-function object)."""
|
|
out = subprocess.run([OBJDUMP, "-drz", "-j", ".text", obj],
|
|
capture_output=True, text=True).stdout
|
|
insns = []
|
|
infn = fn is None
|
|
for line in out.splitlines():
|
|
h = _HDR_RE.match(line)
|
|
if h:
|
|
infn = (fn is None) or (h.group(1) == fn)
|
|
continue
|
|
if not infn:
|
|
continue
|
|
mi = _INS_RE.match(line)
|
|
if mi:
|
|
insns.append({"word": int(mi.group(1), 16), "mnem": mi.group(2).strip(),
|
|
"reloc_kind": None, "reloc_op": None})
|
|
elif insns:
|
|
rm = _REL_RE.search(line)
|
|
if rm:
|
|
insns[-1]["reloc_kind"] = _reloc_kind(rm.group(1))
|
|
insns[-1]["reloc_op"] = rm.group(2)
|
|
# fn given but nothing matched (unlabeled .text) -> fall back to the whole section
|
|
if fn is not None and not insns:
|
|
return insns_from_object(obj, None)
|
|
return insns
|
|
|
|
|
|
def insns_from_s(s_path):
|
|
"""splat .s -> [{word, mnem, reloc_kind:None, reloc_op:None}] from each `/* off vaddr LEHEX */ mnem`
|
|
line. The target is RESOLVED (no relocs); the mask is driven by MY object's relocs at compare time."""
|
|
insns = []
|
|
for line in open(s_path, errors="replace"):
|
|
mi = re.match(r"\s*/\*\s*[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+([0-9A-Fa-f]{8})\s*\*/\s+(.*)", line)
|
|
if mi:
|
|
insns.append({"word": struct.unpack("<I", bytes.fromhex(mi.group(1)))[0],
|
|
"mnem": re.sub(r"\s+", " ", mi.group(2).strip()),
|
|
"reloc_kind": None, "reloc_op": None})
|
|
return insns
|
|
|
|
|
|
def diff_object_object(cand, tgt):
|
|
"""masked mismatch count between two objdump'd objects (permuter scorer). Mask driven by the
|
|
TARGET's relocs; at masked reloc/jal positions also require reloc-operand (symbol+addend) equality.
|
|
Length delta counts as mismatches (a wrong-instruction-count draft scores high). 0 ⟺ same linked bytes."""
|
|
n = max(len(cand), len(tgt))
|
|
diffs = 0
|
|
for i in range(n):
|
|
if i >= len(cand) or i >= len(tgt):
|
|
diffs += 1
|
|
continue
|
|
c, t = cand[i], tgt[i]
|
|
m = mask_for(t["word"], t["reloc_kind"])
|
|
if (c["word"] & m) != (t["word"] & m):
|
|
diffs += 1
|
|
continue
|
|
if m == 0 or m == 0xFFFF0000: # a masked reloc/jal slot -> the symbol+addend must also match
|
|
if (c["reloc_op"] or "") != (t["reloc_op"] or ""):
|
|
diffs += 1
|
|
return diffs
|
|
|
|
|
|
def diff_object_s(myobj, tgt_s):
|
|
"""match_one semantics: mask driven by MY object's relocs, applied to both words; symbol-agnostic
|
|
(the .s target is already resolved). Returns the masked mismatch count."""
|
|
n = max(len(myobj), len(tgt_s))
|
|
diffs = 0
|
|
for i in range(n):
|
|
if i >= len(myobj) or i >= len(tgt_s):
|
|
diffs += 1
|
|
continue
|
|
c, t = myobj[i], tgt_s[i]
|
|
m = mask_for(c["word"], c["reloc_kind"])
|
|
if (c["word"] & m) != (t["word"] & m):
|
|
diffs += 1
|
|
return diffs
|
|
|
|
|
|
if __name__ == "__main__": # spot-check: masked_diff.py <a.o> <b.o|.s> [fn]
|
|
import sys
|
|
a = sys.argv[1]
|
|
b = sys.argv[2]
|
|
fn = sys.argv[3] if len(sys.argv) > 3 else None
|
|
ai = insns_from_object(a, fn)
|
|
if b.endswith(".s"):
|
|
print(f"object-vs-.s: {diff_object_s(ai, insns_from_s(b))} mismatched "
|
|
f"(mine={len(ai)} ins, target={len(insns_from_s(b))})")
|
|
else:
|
|
bi = insns_from_object(b, fn)
|
|
print(f"object-vs-object: {diff_object_object(ai, bi)} mismatched "
|
|
f"(a={len(ai)} ins, b={len(bi)})")
|