Files
BFM-decomp/docs/wiki/The-matching-workflow.md
T
Drew T 827295e241 tools+docs(phase-33.5): task 13.5 — the tools audit + the two dictionaries: tools/tool_census.py (two agreeing enumerations of 327 tool files; docstring/SETUP row/consumers/class derived from the tree; the authored half in config/tool_dictionary.tsv — phase · portability · the NEED each tool answers · what · adapts · verdict — with coverage asserted both ways) → docs/tool-index.md (need-keyed, KEEP-GEN, Reference-index row, wiki + how-to pointers), the kit's tools/MANIFEST.md regenerated (header states live 293 + superseded 28 = 321 rows), and the two verbatim corpora in-tree (Drew, confirmed S91): decomp-architect/corpus/tools/<phase>/ (302 copies + 28 superseded pointers + INDEX) and corpus/cookbook/ (the cookbook, its symptom index, the codegen map, a front page stating what transfers per compiler) — sha1-equal to their sources by tool_census --check in tools-health, regenerated by make kit-corpus; kit_lint exempts the corpus dirs (verbatim evidence) but syntax-checks them; G66 (consult the tool dictionary first) + G67 (translate an inherited idiom through its pass) + two memory seeds (34 at install); SETUP Step 6 installs docs/knowledge-corpus.md and checks the manifest against its own stated total; the ops-setup dictionary rows; the intake's Phase 7 cites G66/G67 and Phase 10 + Part C name the raw-cast → declared-symbol step; templates/layout-contract.md (the five-tool probe, a draft for the split). The review under Drew's criterion: 93 no-consumer tools (one Opus agent's draft, verified: 0 defects, every successor live, 0 live consumers, 0 collisions; four one-off verdicts overturned to STILL-NEEDED) → 34 retired by git mv to tools/sunset/ (28 superseded, 6 one-offs; README review table; SETUP rows moved; Archive-index group). Run 4 (fresh throwaway, the final kit): stopped on my Step-6 check (321 vs the live 293) → both sides derived → resumed → PASS 10/10, manifest 56 == 56, 4 commits, guardrails held (the one foreign path was the timeline regenerated by the detached tools-health). tools-health OK; doc_links --strict rc 0; audit_public OK over 6,842 paths; the purge probe PASSED (Phase 34's gate open). decision-log "P33.5 S91" + accelerators "P33.5 S91" banked; log + checkpoint (NEXT = task 14, xHigh, fresh session)
2026-09-07 22:09:15 -06:00

152 lines
13 KiB
Markdown

# The matching workflow — draft → gate → bank
> **Which tool answers which need:** the [tool index](../tool-index.md) lists every tool by the need it answers, with what proved it and what it hard-codes.
## The definition
A function is **matched** when its compiled output is instruction-identical to the original, register allocation
included, *and* the whole binary still hashes to its recorded SHA1 with that function compiled from source (rule G3).
"Functionally equivalent" is never matched; a passing-looking diff is never matched; a closeness score is never matched.
Every other signal in the workflow is a filter feeding that gate, and each filter is blind to something.
## The oracle ladder
| Oracle | What it proves | What it cannot see |
|---|---|---|
| [`tools/match_one.py`](../../tools/match_one.py) — compile ONE function standalone with the pinned triple, mask relocations (`jal`/`j` targets, `%hi`/`%lo` immediates), compare to the target's bytes from splat's `.s` | the instruction **shape** matches | symbol identity (masked), the declarations the real translation unit imposes, the file's optimization level |
| [`tools/rtu_match.py`](../../tools/rtu_match.py) — splice the candidate into the WHOLE split `.c` with every other function's `INCLUDE_ASM` neutralized, compile the real TU | the body matches *in its real declaration context* (types, the canonical-signature layer, file-scope globals) — far closer to the gate than a standalone compile | link-time symbol identity |
| **The whole-binary gate** — substitute the draft, build the binary, compare its SHA1 to `config/check.<alias>.sha` | **everything** | nothing; this is the arbiter |
The two failure modes the table exists to prevent were both observed: a clean standalone MATCH that could not link
(it named a symbol that does not exist — the mask hid it), and a standalone mismatch on every instruction because the
probe compiled at the wrong optimization level. An oracle must state what it cannot see, in its own output.
## The gates
- **Overlays and modules** are gated in parallel, each in its own git worktree, so one carving worker cannot corrupt
another's `asm/` ([`tools/parallel_gate.py`](../../tools/parallel_gate.py); the older per-draft loop is
[`tools/harvest_verify.py`](../../tools/harvest_verify.py), which bisects a batch on failure). A gate that starts on a
dirty tree cannot tell your edits from its own, so the tree must be clean first.
- **The main executable is gated only by a clean rebuild** ([`tools/gate_main.py`](../../tools/gate_main.py)). Its extract
step rewrites the linker script, so an incremental build after a source change produces a *false diff* — this cost the
campaign a night and a written-up "linker defect" that did not exist, until a control with no draft at all reproduced
the same wrong hash. One clean rebuild verifies a whole batch of drafts (about 15 s when the batch is clean), bisecting
when it is not.
- **After any bank, the fleet is verified from clean** — `make clean && make extract-all && make check-all` — never with
a bare incremental `make check-all` (rule R22; an incremental check once reported a false 211/213). Propagation to other
binaries is not itself byte-gated, so a propagating bank is followed by the fleet run.
**Bank = commit immediately** (rule R42): before the next command that can touch `src/` — a gate is one. A gate once
destroyed 61 uncommitted banks. "Banked" is written only from the tool's printed success line (R66), never from the
intent to bank.
## Where drafts come from
1. **Free work first.** A function that already exists byte-identically in another binary is a remap, not a draft
([The dedup engine](The-dedup-engine.md)); a same-shape sibling is a family remap; a stored draft that once matched a
twin is re-verified rather than redrafted. The twin graph changes after every bank, so
[`tools/twin_rescan.py`](../../tools/twin_rescan.py) runs after every gate that banked. Every project has this
zero-token tier; it is run before any paid one.
2. **A scaffold.** m2c over Ghidra's decompilation, seeded with the project's names; useful as a starting shape, rarely
byte-exact on its own for this compiler.
3. **Agents with a card.** The unit handed to a model is a *card*: the target's disassembly and destination file, what
that file already declares, the project-wide declaration consensus for every callee, a matched sibling when one
exists, the function's own past-attempt history, and the lever the atlas believes applies — with where to read
about it. Cards are assembled deterministically at zero token cost
([`tools/claude_wave_packs.py`](../../tools/claude_wave_packs.py)); the procedure, each guard paired with the
measurement that earned it, is [`docs/wave-playbook.md`](../wave-playbook.md). Models are routed by size (a cheap
tier up to ~50 instructions, a mid tier to ~120, the strongest above), and their verdicts are filters, never the gate.
4. **The permuter for near-misses.** [decomp-permuter](../../tools/decomp-permuter/) with the project's relocation-masked
scorer and warm-restart wrapper ([`tools/permuter/`](../../tools/permuter/)): LLM-free, CPU-bound, it randomizes the C
of a close draft to close register-allocation and scheduling residuals. A permuter score is not a closeness until its
diff is read (R63).
5. **Reading the compiler.** For a residual no lever reaches, the answer was found by reading the gcc 2.7.2 pass that
emits it — the codegen map ([`docs/gcc-2.7.2-map/`](../gcc-2.7.2-map/), cookbook §31) names the pass, the residual
pattern and the C-level lever, and the last functions of the project fell to five-line reproducers compiled with
`-da` dumps rather than to more variants of the 500-instruction function ([chapter 07](../how-to-ai-decomp/07-compiler-source.md)).
## Matching by hand
Most of the fleet was matched by agents under the wave machinery; the hand loop is what those agents ran inside, and it
is what a person runs on the residue. It was written down mid-project as a guide for the struct-heavy engine core
(cookbook §16 and §17, the compiler-quirk toolkit); this is the part of it that stayed true.
**Two decompiles, and the assembly as arbiter.** Use both Ghidra's whole-program decompile and m2c's, not one chained
pipeline: Ghidra resolves locals against globals and callee identities from the whole binary, which m2c lacks; m2c
gives the better starting shape. They disagree, and the assembly decides — one made a struct of what the other made
separate globals, and the reuse of one `lui` base settled it. Two mechanics that cost time: the MCP server must be
serving the *overlay* (`tools/ghidra_mcp_start.sh <alias>`, then `/mcp`, R29) and its decompile is asynchronous (poll
the task); and the decompile wrapper hardcodes the main executable, so an overlay is decompiled by calling m2c
directly (`tools/m2c/m2c.py -t mipsel-gcc-c -f <function> --context <ctx> <file.s>`).
**The loop.** Standalone probe (`tools/match_one.py <fn> --c <draft> --asm-subdir asm/<alias>/nonmatchings/<alias>`)
until the shape matches; the real-TU probe; the gate. A red gate is a hash, not a diagnosis — substitute the draft into
a copy of the destination file and read the compiler's first conflict:
`make build BINARY=<alias> 2>&1 | grep -iE 'conflicting|redefin|error'`.
**The five signature moves.** The wall the project first called "fundamental" was a *signature-consistency* problem,
and it dissolved under byte-neutral canonical widening plus the codebase's own cast idioms, applied surgically — a
blanket declarations header broke loose matches (Phase 16); per-callee, byte-gated canonicalisation worked.
1. *Return widening.* A function with no explicit `return` compiles identically as `void` or `s32`, so widening the
canonical return type is free fleet-wide; the mechanizable predicate: a `void` function whose result some call site
uses → widen to `s32` everywhere.
2. *The sign cast at the use site.* Keep the canonical `s16`; write `(u16)D_X` where the assembly loads `lhu`.
3. *The arity cast.* `((ret (*)(argtypes))func_X)(args)` at a call whose arity the canonical signature does not carry.
Its boundary is default argument promotion: pointer arguments cast safely, narrow scalars did not — until the K&R
`s16`-parameter definition form dissolved that wall too (cookbook §43).
4. *Canonical-matching the target's own parameters.* If the consensus signature passes an address as `s32`, take it as
`s32` and access through `*(s32 *)(arg + 0xNN)` — the int-to-pointer cast is byte-neutral.
5. *Function-pointer-table typing.* `extern void (*D_x[])(argtypes);` turns an indirect-dispatch residual class from
"cannot express" into "compiles".
The layer lives in the overlay's own `.c` file, never in the shared header (a reach-1 name would collide across 134
overlays). Two width facts: **types are byte-neutral for matching** — the compiler reads the access width off the
instruction (`lh`, `lbu`, `lw`), not off a struct, so recovered structures help comprehension and not the byte-close —
while **the widths of locals and parameters are not**: a value loaded with `lh` wants an `s32` local, because an `s16`
local adds a redundant `sll`/`sra` re-extension.
**Choosing what to hand-match.** Sorting candidates by *ascending* scaffold mismatch selects for the compiler-quirk
tail (the scaffold already got the structure right, so only a codegen quirk remains); clean closes came from
function-pointer calls, relocation-free few-call bodies, and mis-structured-but-fixable scaffolds. The tractable shape
was ≤ 80 instructions and ≤ 4 calls. The progress metric was function-count-weighted, so a giant banks more bytes for
the same percentage — "fewest largest" has no edge — and declaration work removes friction without unlocking matches:
there was no "magic five that unlock hundreds". Match shared callees before their callers, so the definition fixes the
signature fleet-wide, and watch for the circular callee — a residual that is also a callee in other drafts broke three
of them at once.
**Running it as a wave.** Parallel agents manufacture signature conflicts: one wave's whole gap between standalone
matches (60%) and whole-binary banks (33%) was compile errors — every one a callee declared differently by two drafts
in the same translation unit, and zero codegen mismatches. Hence the order *draft → normalise the declarations
(`tools/sig_unify.py`) → gate*, a gate that batches at chunk size 1 when the failure class is signature conflicts
(a batched bisect blames a good draft for another draft's clash), and a recovery pass that re-normalises the failures
and re-gates. Pre-decompile the targets headlessly rather than through live MCP contention (stop the server, then
`analyzeHeadless … -noanalysis -postScript DecompileFunctions.java`; rename Ghidra's lowercase `FUN_` outputs to the
project's `func_` form; Ghidra's program ids are opaque, so map the alias first). The harness's workflow `args`
channel does not transit arrays — embed the target list in the script. Filter agent scratch (`_try`, `_v2`) to the
canonical draft name before gating, and make "no draft" a valid result: agents told to write a file *only on a
standalone match* diagnosed twelve of thirty quirk walls precisely and correctly wrote nothing. Propagation is itself a
fail-closed byte gate — it authors the shared macro body, registers the group, and its `--auto-from` sweep catches any
earlier inline match never propagated — and a propagated body ships its signature change to every member.
## The knowledge base, and feeding it
Before each match, consult [`docs/matching-cookbook.md`](../matching-cookbook.md) (grep it by symptom through
[`docs/cookbook-index.md`](../cookbook-index.md)); after each match, write the generalizable lesson back into the
cookbook *and* into the tooling, so the next similar function is a one-shot (rule R16). Two laws learned at cost: only
harvest lessons from byte-proven results, and strip a credited lever from the accepted body and recompile before it
enters the base — one in three credited levers was byte-inert.
## Integration is the bottleneck
Once cracking became systematic, the dominant cost was **banking** a byte-correct body past the real translation unit:
declaration conflicts, file placement, symbol spellings, jump-table carves, mixed optimization levels. At one measurement
about 92% of drafts were byte-correct and about 27% banked. The reconcile ladder — declaration sync, callee casts,
canonical-signature reconciliation, carve tools — is the part every project under-budgets;
[chapter 10](../how-to-ai-decomp/10-integration-and-propagation.md) is about it.
## What this means for a contributor today
The frontier is empty; there is nothing left to match. Every change to `src/` — a struct in place of a raw address, a
name in place of `func_80144B9C`, a reshaped loop — is held to the same invariant: `make check BINARY=<alias>` stays
green for every binary the change touches, and the clean fleet run stays 218/218. The gate is what makes readability
work safe: a wrong edit cannot land. [Contributing](Contributing-and-the-no-ROM-policy.md) has the mechanics.