mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-02 16:00:27 -04:00
122 lines
11 KiB
Markdown
122 lines
11 KiB
Markdown
# 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 submodule `tools/decomp-permuter` (commit `b44b0622`,
|
|
> 2024-12-30); the PR was written against upstream `main` at `41bd0bfc` (2026-09-05, 39 commits later) and lives on the
|
|
> branch `reloc-masked-scorer` of the fork `Druthulu/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`](../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/masked_scorer.py) + [`tools/permuter/run_masked.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_80132784` read as 384
|
|
instructions instead of 400); `-z` keeps 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 a `j` takes is the difference between
|
|
`break` and `return` (`ov_SC03_118:func_801825EC`, one word). The internal-`j` target is compared relative to the
|
|
function start (P31 T1).
|
|
- **Keep the opcode even at a masked slot.** A mask of 0 at a `j`/`jal` position swallowed whatever the other side held
|
|
(a `j` vs a `bne` scored 0 one way and 1 the other, P31 S65).
|
|
- **`R_MIPS_PC16` is masked like `HI16`** (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`'s `field_matches_any_symbol` returns `"." in field` (`src/scorer.py`, the two branches
|
|
> for mips and arm32). Splitters such as splat name symbols `func_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_regex` setting (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 wherever `field_matches_any_symbol`
|
|
> decides "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`](../tools/permuter_ils.py) over [`tools/p16_permute.py`](../tools/p16_permute.py):
|
|
|
|
```bash
|
|
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) `stdout` is 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 through `rtu_match` and 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.py` turns the plateau
|
|
log into a classified corpus and `tools/residual_class.py` decides each class from the bytes; the track is described in
|
|
`docs/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.
|