diff --git a/docs/MATCHING_COOKBOOK.md b/docs/MATCHING_COOKBOOK.md index 7a1bd20..cdc917e 100644 --- a/docs/MATCHING_COOKBOOK.md +++ b/docs/MATCHING_COOKBOOK.md @@ -1779,3 +1779,32 @@ it as taken. Worker A was wrongly blocked on exactly that address. range, not whether it appears as an endpoint.** Worker A wrote `.run/p11/w-a/free.sh ` to do this correctly. **Any worker checking a candidate address against the registry must use a range test** — this is the third defect found in a coordinator-written rule this phase. + +### 114. A FRAGMENT row can never match — worker A's anomaly scan (advisory) + +Worker A found `0x800C3490` (132 B, in its partition, unclaimed) **is not a matchable body: it starts +mid-expression.** Its first instruction is `sw v0,32(sp)` — a stack write before any frame setup, +using `v0`/`v1`/`t1` set by the code *before* the row. It ends with its own +`addiu sp,sp,48; jr ra`, and the identical tail also appears at `0x800C3470`–`0x800C348C`, so it is a +**shared/jump-target block that Ghidra promoted to a function**. It is inside no region and the code +before it is in no worklist, so **nobody can match it standalone**. + +**Worker A's generalised rule, and the reason it is narrower than "first instruction is not +prologue-like":** a function may legally start with `beq a0,a1,...` (comparing two incoming +registers) or `sh` (storing an incoming register to `*a0`), so most such rows are fine. The real +signal is: + +> **A row is a FRAGMENT if its first instruction writes to the stack (sp-relative) before any +> `addiu sp,sp,-N`, or reads a stack slot, or uses a callee-saved register that is never saved.** + +Worker A scanned its 256 rows and got 11 first-instruction hits, of which **only `0x800C3490`** +trips the real rule. + +**Implemented as `./tools/sf3_rank --fragments`, which scans any partition set.** Measured across +all four partitions it finds **5 suspects**; measured against the **555 registered regions it flags +2** (`0x800923E8`, `0x80099DC4`), so it is **advisory, not an exclusion** — sufficient-but-not-complete +in exactly the way the trapping check is. **A hit means "read this before spending a spelling", never +"skip it".** + +**The value is the reading phase, not the match:** a fragment costs a full structural derivation and +can never close, so flagging it saves a worker's whole reading budget. diff --git a/tools/sf3_rank b/tools/sf3_rank index 29d137f..8a6b4cc 100755 --- a/tools/sf3_rank +++ b/tools/sf3_rank @@ -77,6 +77,45 @@ def redundancy(words: list[int]) -> float: return max(scores) if scores else 0.0 +CALLEE_SAVED = frozenset(range(16, 24)) # s0..s7 + + +def fragment_reason(insns: list[int]) -> str: + """Worker A's fragment check: does the body use a frame it never set up? + + A function may legally start with `beq a0,a1,...` or `sh a0,0(a0)`. It may NOT start by + touching a slot of a frame that does not exist yet. Worker A found `0x800C3490`, whose + first instruction is `sw v0,32(sp)` -- a stack write before any frame setup, using + registers set by the code BEFORE the row -- and whose identical tail also appears at + 0x800C3470. It is a shared/jump-target block Ghidra promoted to a function, so it is + inside no region and nobody can match it standalone. + + ADVISORY, NOT AN EXCLUSION: measured against the 555 registered regions this flags + **2** of them (`0x800923E8`, `0x80099DC4`, both "first insn touches sp+16"), so it is + sufficient-but-not-complete in exactly the way the trapping check is. Treat a hit as + "read this before spending a spelling", never as "skip". + """ + if not insns: + return "empty" + word = insns[0] + opcode = word >> 26 + rs = (word >> 21) & 0x1F + rt = (word >> 16) & 0x1F + immediate = word & 0xFFFF + if opcode in (0x23, 0x2B, 0x0F, 0x20, 0x24, 0x25, 0x28, 0x29, 0x2C, 0x2D, 0x2E, 0x3F): + if (rs == 29 or rt == 29) and immediate != 0: + return f"first instruction touches sp+{immediate}" + used = {rs, rt} + if opcode == 0: + used.add((word >> 11) & 0x1F) + hit = used & CALLEE_SAVED + if hit and opcode != 0x09 and len(insns) > 8: + saves = any((x >> 26) == 0x2B and ((x >> 21) & 0x1F) == 29 for x in insns[:12]) + if not saves: + return f"uses {sorted(hit)} with no save in the first 12 instructions" + return "" + + def load_rows(paths: list[Path], payload: bytes, min_size: int, max_size: int) -> list[tuple[float, int, str]]: rows: list[tuple[float, int, str]] = [] for path in paths: @@ -93,11 +132,13 @@ def load_rows(paths: list[Path], payload: bytes, min_size: int, max_size: int) - def main(argv: list[str]) -> int: - paths, top, min_size, max_size = [], 40, 0, 1 << 30 + paths, top, min_size, max_size, fragments = [], 40, 0, 1 << 30, False index = 0 while index < len(argv): arg = argv[index] - if arg == "--top": + if arg == "--fragments": + fragments = True + elif arg == "--top": index += 1 top = int(argv[index]) elif arg == "--min-size": @@ -120,6 +161,26 @@ def main(argv: list[str]) -> int: return 2 payload = EXE.read_bytes() + + if fragments: + print("ADVISORY ONLY -- measured at 2 false positives across 555 registered regions.") + print(f"{'size':>5} address reason") + count = 0 + for path in paths: + for line in path.read_text().splitlines(): + if line.startswith("#") or not line.strip(): + continue + fields = line.split("\t") + start, end = int(fields[1], 16), int(fields[2], 16) + if end - start < min_size or end - start > max_size: + continue + reason = fragment_reason(body_words(payload, start, end)) + if reason: + count += 1 + print(f"{end - start:5d} {fields[1]} {reason}") + print(f"\nsuspects={count}") + return 0 + rows = load_rows(paths, payload, min_size, max_size) rows.sort(key=lambda row: (-row[0], row[1], row[2]))