11 KiB
The permuter in BFM-decomp, the relocation-masked scorer, the ILS warm restart, and the upstream PR
Status (P33 E5, 2026-09-07). The recipe this project ran decomp-permuter with, written for two readers: a BFM maintainer (what
tools/permuter/does and why) and the upstream project (the PR that carries the scorer and a USAGE note on restarts,tools/permuter/upstream/0001-reloc-masked-scorer.patch, plus the issue text for the symbol-wildcard defect). The permuter itself is the pinned submoduletools/decomp-permuter(commitb44b0622, 2024-12-30); the PR was written against upstreammainat41bd0bfc(2026-09-05, 39 commits later) and lives on the branchreloc-masked-scorerof the forkDruthulu/decomp-permuter.
1. Why the stock scorer could not reach zero here
decomp-permuter scores a candidate by diffing symbolised objdump rows against the target's and charging penalties
(register 5, reordering 60, insertion/deletion 100, …). On MIPS it recognises "this field is a relocation, ignore the
name" only when the field contains a . (src/scorer.py, field_matches_any_symbol: return "." in field), true of
.text+0x34-style references, false of every symbol this project's splitter names (func_80012345, D_80054C10).
Every relocation slot that differed by name was therefore a register penalty the search could never reduce, and the
score floated on a floor: func_80176D94 sat at base ≈225 / best ≈210 / never 0 while a byte-exact answer existed
(Phase 24 T2). A random walk with no gradient to zero diverges.
2. The relocation-masked scorer (what the PR carries)
Compare the two objects' instruction words, masking exactly the fields the linker fills in, read from the
target's relocation records, never guessed from a lui pattern, and require the relocation operands to agree there:
| Target's relocation | Mask | Plus |
|---|---|---|
R_MIPS_26 (a j/jal to a symbol) |
keep the 6-bit opcode, mask the 26-bit target | same symbol+addend |
HI16/LO16/PC16/LITERAL/GPREL16/GOT16/CALL16 |
keep opcode + rs + rt, mask the low 16 bits | same symbol+addend |
R_MIPS_26 against .text (a j to a label inside the function) |
as above | the target compared relative to the function start, two candidates that jump to different labels differ |
| none | the full 32-bit word | registers, constants and branch offsets all count |
score = mismatching positions + |length difference|, one unit per instruction; 0 exactly when the candidate links
to the same bytes. The in-tree form is tools/masked_diff.py (shared with match_one and
rtu_match, so the closeness an agent reads is the closeness the permuter optimises) rebound over the pinned permuter
by tools/masked_scorer.py + tools/permuter/run_masked.py
without editing the submodule. Four of its rules were bought with byte evidence and are in the PR too:
-drz, not-dr. objdump's default nop-elision under-counted GTE-heavy functions (func_80132784read as 384 instructions instead of 400);-zkeeps the runs.- The 26-bit field is masked only when the assembler left it to the linker. A short-circuit on the opcode once
dropped every
j .L…to a local label from the comparison, and which label ajtakes is the difference betweenbreakandreturn(ov_SC03_118:func_801825EC, one word). The internal-jtarget is compared relative to the function start (P31 T1). - Keep the opcode even at a masked slot. A mask of 0 at a
j/jalposition swallowed whatever the other side held (ajvs abnescored 0 one way and 1 the other, P31 S65). R_MIPS_PC16is masked likeHI16(Phase 26-A): the object holds an unresolved placeholder in the branch displacement, so a full-word compare could never succeed; masking it cured 151 of 155 false non-zeros across 60,740 stubs and introduced none.
Upstream PR (PR-1). tools/permuter/upstream/0001-reloc-masked-scorer.patch holds the two commits on the branch
reloc-masked-scorer (regenerable: clone upstream, git am the patch; it applies onto 41bd0bfc). The first adds src/reloc_scorer.py (RelocMaskedScorer, a Scorer subclass
so Permuter/Candidate are untouched), --score-mode {mnemonic,reloc-masked} and a score_mode settings key
(default mnemonic = unchanged behaviour), a USAGE/README/example-settings note, a refusal with -J (remote evaluators
use the default scorer), and test/test_reloc_scorer.py, ten tests on an embedded objdump -drz listing of a small
gcc 2.7.2 object (link-time fields masked; symbol, register, opcode, branch and length differences each counted;
internal jump targets compared; the scorer end to end through objdump_command), needing no cross toolchain. The
second is a USAGE note on restarting from the best candidate (the generic half of §4). Verified at the commit: black --check clean on the touched files; mypy at upstream's own baseline (5 pre-existing
errors, none added); the suite's only failure is upstream's test_perm, which needs mips-linux-gnu-gcc; and the
real permuter run end to end with the mode on compiled objects: base == target → base score 0 → --stop-on-zero;
and the floor, reproduced in miniature, a 5-instruction function with two xor operands swapped in the base:
reloc-masked base score 4 (the debug diff names the four operand differences) and score 0 at iteration 256, under
40 s with -j4; the default scorer read the same base as 3,585 and sat at 3,420 twenty iterations later
(.run/P33/permuter-e2e/, settings.toml objdump_command = "mipsel-linux-gnu-objdump -drz -m mips:4300").
Submitted (2026-09-07): the PR is https://github.com/simonlindholm/decomp-permuter/pull/213 (from the fork's
reloc-masked-scorer branch) and the symbol-regex issue below is https://github.com/simonlindholm/decomp-permuter/issues/214.
Outcome (2026-09-07): CLOSED, not merged. the maintainer closed PR #213 and issue #214 on 2026-09-07 without merge: field_matches_any_symbol is intentional (it covers temporary .text+0x… names), a name mismatch between C and asm should be fixed by renaming the symbol in the target asm (splat's symbol files), and a nonzero optimal score is acceptable in the interactive workflow; he also said the issue text read as LLM-generated and felt disrespectful of his time. What stands: the scorer stays a local tool (tools/permuter/run_masked.py, the tracked patch as its record); his alternative — make the target asm carry the same symbol names as the C via splat's symbol files, so a name difference surfaces as the real error it is — is untested here and is recorded as a Gen3-adjacent tooling item, not adopted. The lesson on authorship is in docs/decision-log.md (P33 S89) and the failure museum.
3. The issue for upstream (filed as #214; closed 2026-09-07 with the PR): a configurable symbol regex
Title: MIPS scorer: field_matches_any_symbol recognises a relocation only when the field contains "."
On MIPS,
Scorer.score'sfield_matches_any_symbolreturns"." in field(src/scorer.py, the two branches for mips and arm32). Splitters such as splat name symbolsfunc_80012345/D_80054C10, no dot, so a relocation slot whose symbol differs between target and candidate is charged as a register difference (PENALTY_REGALLOC) that no permutation can remove. The score then floats on a floor above zero even when a byte-exact candidate exists; we measured base ≈225 / best ≈210 / never 0 on a 225-instruction function that later matched.Proposal: a
symbol_regexsetting (and--symbol-regex), defaulting to the current behaviour (\.), that projects can set to their symbol shape (e.g.^(func|D|jtbl)_[0-9A-Fa-f]{8}), used whereverfield_matches_any_symboldecides "this field is a symbol". Independent of, and complementary to, the relocation-masked scoring mode in PR-1 - that mode sidesteps the question by reading relocation records instead of symbol text, but the default scorer deserves the fix too.
4. The ILS warm restart (a usage recipe, ours to keep)
A cold permuter run on a pinned seed plateaus at the base score over ~10k iterations. Warm-restarting base.c from
the best byte-waypoint each cycle, with a fresh seed, descends where cold stalls: func_80148094 72 → 36 over ~8
restarts, the big drops coming from fresh restarts, not from continuing a plateaued run (Phase 24 T7 §G). The wrapper
is tools/permuter_ils.py over tools/p16_permute.py:
python3 tools/permuter_ils.py func_80148094 --draft .run/t7b/close/func_80148094.c \
--asm-subdir asm/ov_SC01_077/nonmatchings/ov_SC01_077 --klass REGALLOC --cycles 10 --secs 180 --j 12
Its guards, each bought with a session (P31 S79/S80, cookbook §493–§495): (1) register pins are hidden before the
permuter parses the seed, register … __asm__("$16") is carried in a base64 pragma so cc1 still binds the register
while pycparser sees plain C, and a waypoint's source.c (which the permuter decodes back) is re-hidden before the
next cycle, or every cycle after the first is a parser refusal that reads as "unchanged" (func_80020DA4: 1 real cycle
- 7 silent no-ops); (2) a refused cycle aborts the run with the reason, not-judged is not a verdict (R61); (3) the
seed's header comment is stripped before
cpp -P(a*/-less comment made the permuter no-op silently on every commented giant draft); (4)stdoutis flushed, eight parallel runs once showed empty logs for twenty minutes (R55); (5) a score-0 winner is a candidate, not a bank: intermediate waypoints can be semantically divergent (the permuter rewrites stores for byte proximity), so the winner goes throughrtu_matchand the whole-binary gate.
The measured limits, so the tool is used where it pays: the permuter's problem on this project was targeting, not a
missing transform (decision log 2026-07-21), pointed at the right seed with the right scorer it closed
register-allocation and scheduling residuals; it never closed a structural residual (a wrong loop shape, a missing
idiom), which is the reader's job (residual_class, cookbook §66d: alternate a random search with a byte-verified idiom
and let the residual class decide whose turn it is). And a masked "1" is not a closeness until its diff is read (R63).
5. Related
- Cookbook §3 (the harness), §42/§45 (the F-band cracks where pins beat the permuter), §66d–§66d-5 (the permuter⇄reader loop), §137 (REGALLOC-PERM as a two-compile arithmetic problem), §493–§495 (the S80 repairs).
- Mine the permuter's failures, not just its wins (the offline-automatic endgame):
tools/autopsy.pyturns the plateau log into a classified corpus andtools/residual_class.pydecides each class from the bytes; the track is described indocs/how-to-ai-decomp/03-bootstrap-order.md, Phase 2b (first proposed in the July hindsight study's §7, archived). docs/how-to-ai-decomp/07-compiler-source.md, when the residual is the compiler's, read the pass instead.