tools(phase-33): E5 — the permuter upstream PR branch (reloc-masked-scorer: src/reloc_scorer.py RelocMaskedScorer as a Scorer subclass, --score-mode {mnemonic,reloc-masked} + score_mode setting, -J refusal, docs, test/test_reloc_scorer.py 10/10 on an embedded gcc-2.7.2 listing; black clean, mypy at upstream's baseline; the real permuter: a swapped-operand base 4 -> 0 in 256 iterations where the default scorer read 3,585) tracked as tools/permuter/upstream/0001-reloc-masked-scorer.patch (git am clean onto upstream main 41bd0bfc); docs/permuter-ils.md (why the stock scorer floats, the scorer + PR, the symbol_regex issue text for PR-2, the ILS warm-restart recipe and its five guards); SETUP row + P33 E5 section; doc_links default; log + checkpoint (NEXT = E6); Drew pushes the branch to his fork and opens the PR + issue

This commit is contained in:
Drew T
2026-09-07 11:29:35 -06:00
parent 9c4d32d651
commit bf002f84d2
5 changed files with 841 additions and 22 deletions
+30
View File
@@ -766,6 +766,7 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo
| | `tools/wiki_sync.sh [--push] [--wiki-url URL]` | **(P33 F3)** Render into `.run/wiki/render/` → clone or fast-forward `BFM-decomp.wiki.git` under `.run/wiki/` → REPLACE its pages with the rendered set (the repo is the source of truth; a page edited on GitHub is overwritten) → `git status --short`; **`--push` is Drew's (R6)**: commit + push. Before the wiki repo exists (after the flip AND the first page created in the GitHub UI) the dry run renders and lists (exit 0) and `--push` refuses (exit 2). |
| | `tools/gccmap_cites.py [--dry-run \| --check \| --verify \| --controls \| --explain] [--retag]` | **(P33 E3)** Tag every `file.c:NNN` cite in `docs/gcc-2.7.2-map/*.md` with the source tree its line number belongs to — `[2.7.2]` (the vanilla subset), `[2.8.1 pm]` (gcc-papermario), `[repo]` — derived from the trees (quoted snippets, identifiers with function extents and nearest distance, the author's cues; a contradiction fails loudly; ties → `cite_overrides.tsv` → cue → 2.7.2); writes in place, idempotent, never silently changes an existing tag. `--check` is textual (every cite tagged, no stale override) and runs in `make tools-health` + CI; the others need both reference trees and refuse without them. |
| | `tools/xsig/xsig.py sign-s \| sign-objdump \| cross \| verify \| selftest` | **(P33 E4; Phase-21 origin)** Relocation-masked per-function signatures for CROSS-PROJECT code identification: mask `j`/`jal` targets and HI16/LO16 immediates (from `%hi`/`%lo` operands or `objdump -dr` records), keep opcodes/registers/constants/branches; `cross` joins two JSONL sets on `sig` with a coverage line; `verify` prints the instruction-by-instruction diff (opcode/register/immediate/length). Self-contained (stdlib, MIT, own README + LICENSE + `tests/` from a game-free fixture compiled at two link addresses with `--emit-relocs`); `tests/test_xsig.py` (8) in `make tools-health` + CI. The standalone repo copy is prepared under `.run/P33/xsig-repo/` (Drew creates + pushes `Druthulu/xsig`). |
| | `tools/permuter/upstream/0001-reloc-masked-scorer.patch` | **(P33 E5)** The upstream PR as a `git format-patch` (one commit against `simonlindholm/decomp-permuter` main `41bd0bfc`, 2026-09-05): `src/reloc_scorer.py` (`RelocMaskedScorer`, a `Scorer` subclass), `--score-mode {mnemonic,reloc-masked}` + the `score_mode` settings key, docs, `test/test_reloc_scorer.py` (10, no cross toolchain). Regenerate the branch: clone upstream, `git am` the patch (proven clean). The scratch clone `.run/P33/permuter-upstream/` holds branch `reloc-masked-scorer` under the noreply identity; **Drew pushes it to his fork and opens the PR** (`docs/permuter-ils.md` §2). |
| | `tools/objdiff_report.py [--in docs/progress.json] [--out report.json]` | **(P33 D3)** progress.json → objdiff's report format (report.proto v2, snake_case — validated with `objdiff-cli` 3.8.1 `report changes`): one unit per binary (code = instructions × 4, functions byte-identical / matchable, metadata complete), categories `game-code` and `linked-sony-objects` (functions only). `.github/workflows/progress.yml` runs it on every push (no rebuild — the committed JSON) and uploads the artifact **`SLUS_007.26_report`** for decomp.dev (Drew registers at decomp.dev/manage/new after the flip). |
| | `tools/frogress_upload.py [--push --project bfm --version us]` | **(P33 D3)** stdlib; `--dry-run` is the default (prints the payload); `--push` POSTs `{"api_key","entries":[{git_hash,timestamp,categories:{default:{measures…}}}]}` to `progress.deco.mp/data/<project>/<version>/` with `FROGRESS_API_SECRET` from the environment (never a file). frogress projects are admin-created — Drew requests the slug + key after the flip. |
| | `tools/public_rewrite/` (P33 C1) | **The history-rewrite package** (`docs/public-flip-runbook.md` §3 is the operating table). `common.py` (shared: the purge rules, the DERIVED content-hash sets, identities from the log, the one hash regex, a persistent `cat-file --batch`) · `hash_dict.py [--write-mailmap]` (every commit OBJECT → `commit:NNNN` / twin / orphan; prefix index 7..40; asserts 0 ambiguous; records content-hash collisions as excluded; writes the scratch mailmap) · `scrub.py --test \| --sample \| --file` (THE scrub: hash tokens, addresses → noreply, trailer lines in messages; 12 known-true cases; the HEAD sample with git's own object lookup as the independent oracle) · `gate_scan.py --all\|--refs … [--worktree] [--expect-fail FIXTURE]` (paths ever touched × purge rules; every reachable blob's content sha1 × the ROM set; 5 byte signatures; 50 MiB; emits `rom_blob_ids.txt` = hits ∪ every blob ever under a purge path; the fixture `expected_offenders.txt` is the R39 negative control) · `run_filter.py [--sample]` (the git-filter-repo 2.47.0 module-API run inside the scratch bare clone; refuses elsewhere) · `verify_rewrite.py --old --new` (the pairwise proof) · `build_commit_map.py [--out]` (`docs/commit-map.tsv`, asserted free of old hashes) · `resolve_tokens.py [--check] [--map]` (tokens → shortest unique ≥9-char new abbreviations at the tip) · `absent_scan.py [--repo] [--tree]` (nothing old anywhere) · `probe_github.sh [--after-flip]` (Drew's purge probe). Scratch (`.run/public_rewrite/`, never committed): `dict.json`, `mailmap`, `rom_blob_ids.txt`, `old-to-new.tsv`, `repo.git`, the bundle. · `probe_github.sh [--after-flip]` (Drew's daily post-purge probe, C10: 33 sampled old shas via `gh api` + a fetch; **S88, R57:** the fetch runs in a throwaway bare repo under `.run/public_rewrite/` with `--filter=blob:none --depth=1`, never in the working repo — a successful fetch of an old sha imports its purged closure, which the S87/S88 runs did (5.97 GiB unreachable) — and it ends with a self-check naming any sampled old commit the working repo still holds + the gc recipe) |
@@ -1145,6 +1146,35 @@ fills fast). Nothing is leaking — but the host does not get the memory back on
`tools/ghidra_*.sh` are repo-relative (`BFM_GHIDRA_PROJ` overrides the project dir; `ghidra_mcp_verify.sh <addr> <name>
[PROG]`); Makefile `GHIDRA_PROJ := $(or $(BFM_GHIDRA_PROJ),$(CURDIR)/ghidra)`.
### P33 E5 (S88, 2026-09-07) — the permuter upstream PR branch + `docs/permuter-ils.md`
- **The PR (PR-1).** `RelocMaskedScorer` in `src/reloc_scorer.py` — the in-tree `masked_diff.diff_object_object` rule
(mask from the TARGET's relocation records: `R_MIPS_26` → opcode only; the 16-bit-immediate relocs → opcode+rs+rt; a `j`
against `.text` compared relative to the function start; everything else the full word; symbol+addend equality at
masked slots; `score = mismatches + |Δlength|`, 0 iff link-identical) written for upstream `main` at `41bd0bfc` (39
commits past our pin `b44b0622`; `Scorer.__init__` gained `ign_branch_targets` + `objdump_command`, which is why
`tools/permuter/run_masked.py`'s rebind targets the PIN, not HEAD). A `Scorer` subclass (no change to `Permuter` /
`Candidate`); `--score-mode {mnemonic,reloc-masked}` + `score_mode` in settings.toml (default unchanged); refused with
`-J` (`src/net/evaluator.py` builds a stock `Scorer`, `net/client.py` reads `scorer.stack_differences/algorithm` — the
subclass carries both); MIPS only (raises otherwise); USAGE/README/example_settings notes.
- **Verified at the commit:** `test/test_reloc_scorer.py` 10/10 on an embedded `objdump -drz` listing of the xsig
fixture object (real gcc 2.7.2 output, no game bytes; the end-to-end test feeds the scorer through
`objdump_command="cat"` with a 20-byte MIPS ELF header in front of the listing — `get_arch` reads ident + e_machine);
`black --check` clean on the touched files; `mypy` = upstream's baseline (5 pre-existing errors: `toml` stubs,
`Levenshtein`, `helpers.py` Any — none added; checked by stashing); `./run-tests.sh`: only `test_perm` fails (needs
`mips-linux-gnu-gcc`, absent here); **the real permuter end to end** on `.run/P33/permuter-e2e/` (our `compile.sh`,
`settings.toml` `objdump_command = "mipsel-linux-gnu-objdump -drz -m mips:4300"` — upstream looks for
`mips-linux-gnu-objdump`): base == target → base score 0 → `--stop-on-zero`; a swapped-operand `other()` (5 ins) →
reloc-masked base 4 → **score 0 at iteration 256** (<40 s, `-j4`) while the DEFAULT scorer read the same base as **3,585**
(3,420 after 20 iterations) — the floor in miniature. A dev venv (`.venv-dev/`, mypy 2.3.1 + black 26.5.1 +
toml + pycparser) lives in the scratch clone, never in the project venv.
- **PR-2 / the issue:** the MIPS symbol wildcard `"." in field` (`scorer.py:68/72`) — issue text in `docs/permuter-ils.md`
§3 (a configurable `symbol_regex`). **The recipe:** §4 of the same doc — the ILS warm restart and its five guards
(hidden pins re-hidden per cycle, refused-cycle abort, comment strip, flushed stdout, winner ≠ bank).
- **Drew:** fork upstream, `git -C .run/P33/permuter-upstream push -u fork reloc-masked-scorer`, open the PR (the commit
message is the description), then file the issue. The patch file is the tracked copy (R20); the scratch clone is
regenerable from it.
### P33 E4 (S88, 2026-09-07) — xsig packaged: `tools/xsig/` + the standalone repo, from the Phase-21 `.run/xdedup/xsig.py`
- **What it is.** The Phase-21 cross-project dedup probe's signature library (`.run/xdedup/xsig.py`, 2026-06-25) plus the
driver scripts around it (`sign_bfm.py`, `sign_xeno.py`, `cross.py`, `analyze.py`) folded into ONE stdlib file with a CLI
+122
View File
@@ -0,0 +1,122 @@
# 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,
> `tools/permuter/upstream/0001-reloc-masked-scorer.patch`, and 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).
## 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` = one commit on the branch
`reloc-masked-scorer` of the scratch clone `.run/P33/permuter-upstream/` (regenerable: `git clone` upstream, `git am`
the patch — proven to apply onto `41bd0bfc`). It 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.
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"`).
**What Drew does:** push the branch to his fork and open the PR against `simonlindholm/decomp-permuter`:
```bash
git -C .run/P33/permuter-upstream remote add fork https://github.com/Druthulu/decomp-permuter.git # after forking
git -C .run/P33/permuter-upstream push -u fork reloc-masked-scorer
# then "Compare & pull request" on GitHub; the commit message is the PR description
```
## 3. The issue for upstream (PR-2, or an issue first): 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).
- `docs/hindsight-study.md` §7 — mine the permuter's failures, not just its wins (the offline-automatic endgame).
- `docs/how-to-ai-decomp/07-compiler-source.md` — when the residual is the compiler's, read the pass instead.
+57 -21
View File
@@ -81,7 +81,7 @@ one-time snapshot, `CLAUDE.md` gains "never `git clean -x`" (R20 amendment propo
- [x] **F3** wiki + how-to-ai-decomp + `wiki_sync.sh` — Max (FULL, Drew 2026-09-07) — see Log 2026-09-07 F3
- [x] **E3** gcc-2.7.2 map README + `gccmap_cites.py` — xHigh — see Log 2026-09-07 E3
- [x] **E4** xsig packaging — xHigh — see Log 2026-09-07 E4
- [ ] **E5** permuter upstream PR branch — Max
- [x] **E5** permuter upstream PR branch — Max (FULL, Drew 2026-09-07) — see Log 2026-09-07 E5
- [ ] **E6** drafter write-up — Max
- [ ] **E1** decomp.me preset (after the flip) — xHigh
- [ ] **E2** Archipelago outreach (after the flip) — xHigh
@@ -534,22 +534,51 @@ Mid-phase rules check after every 4 completed tasks (P6). Commit banked artifact
initial commit — **Drew creates `Druthulu/xsig` on GitHub (EMPTY) and pushes** (`git -C .run/P33/xsig-repo remote add
origin … && git push -u origin main`). `make tools-health` re-run with the new line: **`tools-health: OK — sigs fresh; corpus(+resident) + cdecl + binaries + report(lint+dedup) + cookbook-index all green.` (EXIT=0)**
(`.run/P33/e4_tools_health.log`). Commit: see below.
- **2026-09-07 (S88, Max) — E5 the permuter upstream PR branch + `docs/permuter-ils.md` — IN FULL (Drew's call).**
Scratch clone of `simonlindholm/decomp-permuter` at `main` `41bd0bfc` (2026-09-05; 39 commits past our pin `b44b0622`) →
branch **`reloc-masked-scorer`**, one commit under the noreply identity: `src/reloc_scorer.py` (`RelocMaskedScorer`, a
`Scorer` subclass — the in-tree `masked_diff` rule: mask from the TARGET's relocation records, `R_MIPS_26` → opcode only,
the 16-bit-immediate relocs → opcode+rs+rt, a `j` against `.text` compared relative to the function start, everything
else the full word, symbol+addend equality at masked slots, `score = mismatches + |Δlength|`, 0 iff link-identical;
MIPS only), `--score-mode {mnemonic,reloc-masked}` + the `score_mode` settings key (default unchanged), a refusal with
`-J` (remote evaluators build a stock `Scorer`), `--debug` per-instruction diff, USAGE/README/example_settings notes,
`test/test_reloc_scorer.py` (10 tests on an embedded `objdump -drz` listing of the xsig fixture object — real gcc 2.7.2
output, no game bytes; the end-to-end test feeds `objdump_command="cat"` a file with a 20-byte MIPS ELF header ahead of
the listing, since `get_arch` reads ident + e_machine). **Verified at the commit:** 10/10; `black --check` clean;
`mypy` = upstream's baseline (5 pre-existing: `toml` stubs, `Levenshtein`, `helpers.py` Any — none added, checked by
stashing); `./run-tests.sh`: only upstream's `test_perm` fails (needs `mips-linux-gnu-gcc`); **the real permuter end to
end** (`.run/P33/permuter-e2e/`: our `compile.sh`, `settings.toml` with `objdump_command = "mipsel-linux-gnu-objdump
-drz -m mips:4300"` because upstream looks for `mips-linux-gnu-objdump`): base == target → base score 0 →
`--stop-on-zero`; **the floor in miniature** — `other()` (5 ins) with its `xor` operands swapped: reloc-masked base 4 →
**score 0 at iteration 256** (<40 s, `-j4`; the winner restored `(a * 3) ^ (b >> 2)`), while the DEFAULT scorer read the
same base as **3,585** and sat at 3,420 after 20 iterations. Two gotchas fixed on the way (both caught by the tests /
mypy before the commit): a `Match`-typed loop variable shadowed by the instruction loop (mypy union-attr ×3); the
stand-in object needing a real ELF header. **The tracked copy (R20):** `tools/permuter/upstream/
0001-reloc-masked-scorer.patch` (`git format-patch`; proven to `git am` cleanly onto upstream `main`; the one whitespace
warning — a doubled newline at USAGE.md's end — fixed and the commit amended). A dev venv (`.venv-dev/`: mypy 2.3.1,
black 26.5.1, toml, pycparser) lives in the scratch clone, never in the project venv. **`docs/permuter-ils.md`** (NEW; in
`doc_links`' default set, `--strict` PASS): §1 why the stock scorer floats (the `"." in field` wildcard), §2 the scorer +
the PR + the four byte-bought rules (`-drz`, the internal-`j` target, keep-the-opcode, PC16) + Drew's push/PR commands,
§3 the issue text for PR-2 (a configurable `symbol_regex`), §4 the ILS warm-restart recipe and its five guards
(`func_80148094` 72 → 36 over ~8 restarts; hidden pins re-hidden per cycle; refused-cycle abort R61; comment strip;
flushed stdout R55; winner ≠ bank), §5 related. SETUP: row + the P33 E5 section (R21). **Drew:** fork upstream,
`git -C .run/P33/permuter-upstream push -u fork reloc-masked-scorer`, open the PR (the commit message is the
description), file the issue. Commit: see below.
## 🛑 SESSION CHECKPOINT — A1–A5 ✓, B1–B9/C3 ✓, C1–C9 ✓, D1–D5 ✓, F1–F3 ✓, E3 ✓, E4 ✓ (31 of 41); C10 IN PROGRESS ON DREW'S SIDE; NEXT = E5 (2026-09-07, written by session 4555f4e4 "S88" at the E4 close; SUPERSEDES the earlier blocks)
## 🛑 SESSION CHECKPOINT — A1–A5 ✓, B1–B9/C3 ✓, C1–C9 ✓, D1–D5 ✓, F1–F3 ✓, E3 ✓, E4 ✓, E5 ✓ (32 of 41); C10 IN PROGRESS ON DREW'S SIDE; NEXT = E6 (2026-09-07, written by session 4555f4e4 "S88" at the E5 close; SUPERSEDES the earlier blocks)
### 0. How to use this block
You are a FRESH SESSION that has read `PROJECT_CONTEXT.md`, `phase-ends/DIGEST.md`, `PhaseEnd_Phase30/31/32.md` and this file,
and nothing else (R64). Replay this block verbatim, state phase / done / NEXT / effort, list the rules from the digest
(R1–R73), then WAIT for Drew. **NEXT = E5** (Max, ≈1 session — the permuter upstream PR branch; it is the plan's last
named CUT CANDIDATE: ask Drew whether E5 runs in full, trimmed to the issue + `docs/permuter-ils.md`, or is cut — F3 and E3
ran in full). Rebuild the harness task list (40 items, R28) marking A1–A5, B1–B9/C3, C1–C9, D1–D5, F1–F3, E3, E4 completed and
C10 in progress. **Every commit cites NEW
(R1–R73), then WAIT for Drew. **NEXT = E6** (Max, ≈0.5–1 session — the drafter write-up `docs/matching-drafter-pipeline.md`;
no cut candidates remain: F3, E3, E4, E5 all ran in full). Rebuild the harness task list (40 items, R28) marking A1–A5,
B1–B9/C3, C1–C9, D1–D5, F1–F3, E3, E4, E5 completed and C10 in progress. **Every commit cites NEW
hashes only** (the history was rewritten; `docs/commit-map.tsv` maps ordinals → new hashes; the scratch `.run/public_rewrite/`
holds the old ones and stays until the probe passes). **Never `git clean -x`** (CLAUDE.md fail-safe).
### 1. Where we are
**Phase 33 — 100% verification + the public flip + Gen2 exit.** Gate 1 approved 2026-09-06 (plan mode, Max). The approved plan
is VERBATIM at the end of this file — its Blocks E–G paragraphs are the specs for what remains. **Done (31):** A1–A5, B1–B9/C3,
is VERBATIM at the end of this file — its Blocks E–G paragraphs are the specs for what remains. **Done (32):** A1–A5, B1–B9/C3,
C1–C9 (the rewrite, adopted, force-pushed by Drew, gc'd), D1–D5 (README, LICENSE/NOTICE/THIRD_PARTY, badges/objdiff/frogress,
SETUP public-clean, governing docs + `doc_links`), F1 (timeline + story), F2 (retrospective), **F3 (S88: the wiki — 12 files
under `docs/wiki/` + the 13 how-to chapters under `docs/how-to-ai-decomp/`, `tools/wiki_render.py` + `tools/wiki_sync.sh`,
@@ -557,18 +586,21 @@ under `docs/wiki/` + the 13 how-to chapters under `docs/how-to-ai-decomp/`, `too
+ `cite_overrides.tsv`; all 135 map citations tagged `[2.7.2]`/`[2.8.1 pm]`/`[repo]` — 79/55/1 — verified, controls 6/6;
`50c1b69e4d`), **E4 (S88: `tools/xsig/` — `xsig.py` library+CLI, README with the Phase-21 worked example, MIT LICENSE,
`tests/` from a game-free fixture at two link addresses, 8/8; in tools-health + CI; the standalone copy at `.run/P33/xsig-repo/`
with one commit under the noreply identity — Drew creates `Druthulu/xsig` EMPTY and pushes; the commit that carries this
block).** **In progress (Drew, C10):** the GitHub
with one commit under the noreply identity — Drew creates `Druthulu/xsig` EMPTY and pushes; `9c4d32d651`), **E5 (S88: the
upstream PR branch `reloc-masked-scorer` in `.run/P33/permuter-upstream/` + its tracked copy
`tools/permuter/upstream/0001-reloc-masked-scorer.patch` + `docs/permuter-ils.md`; 10/10 tests, black clean, mypy at
upstream's baseline, the real permuter 4 → 0 in 256 iterations where the default scorer read 3,585 — Drew pushes the branch
to his fork and opens the PR + files the issue; the commit that carries this block).** **In progress (Drew, C10):** the GitHub
Support ticket (text: `docs/public-flip-runbook.md` §11 — its filing was never confirmed to S88; ask) and the daily
`tools/public_rewrite/probe_github.sh` until it prints PASS (S88's run: **31 of 33 old hashes still ALIVE = the S87 baseline;
no purge yet**). **Remaining (9):** E5 (permuter PR branch) · E6 (drafter write-up) — on the still-private repo; then, gated on the probe PASS: C10 (the flip — Drew), E1 (decomp.me preset
no purge yet**). **Remaining (8):** E6 (drafter write-up) — on the still-private repo; then, gated on the probe PASS: C10 (the flip — Drew), E1 (decomp.me preset
— Drew), E2 (Archipelago — Drew), D3's outward actions (decomp.dev registration, frogress slug/key — Drew), **the wiki push
(Drew: Wiki → "Create the first page" in the GitHub UI, then `tools/wiki_sync.sh --push`)**; then C11 (aftercare), G1
(`docs/gen3-handoff.md`), G2 (the PhaseEnd v2.0.0 + DIGEST + `v2.0.0` tag; Tier 1; WAIT for gate 2).
### 2. Facts the remaining tasks depend on (measured S88; verify if in doubt, R14)
- **Repository state:** `main` = the rewritten history (4,031 commits) + the S87 tip commits (C7 → F2) + the S88 commits
(`214d0dd15b` the probe fix, `954362c81e` F3 pages + tooling, `0cf971d1f4` the F3 wiring, `50c1b69e4d` E3, the E4 commit = HEAD); `origin/main` == the F2 commit
(`214d0dd15b` the probe fix, `954362c81e` F3 pages + tooling, `0cf971d1f4` the F3 wiring, `50c1b69e4d` E3, `9c4d32d651` E4, the E5 commit = HEAD); `origin/main` == the F2 commit
`5e57e88de2` — **Drew pushed the S87 tip on 2026-09-07 07:23Z; the S88 commits are NOT pushed** (a normal fast-forward push;
R6). The first-ever GitHub runs of both workflows were GREEN on that push (`no-rom` 1 m 35 s, run 34095194524; `progress`
15 s, run 34095194479) — read the Actions tab again after the next push, fix red, never claim green unseen (P9). The repo is
@@ -582,6 +614,10 @@ no purge yet**). **Remaining (9):** E5 (permuter PR branch) · E6 (drafter write
old history). Expected after: one pack ≈ 80 MB, `.git` ≈ 93 MB, `git cat-file -e 296ff5551ba71269972e708031b52e9cf39bc782` fails.
Until then the fixed probe's self-check prints "the WORKING repo's object store holds 31 of 33 sampled OLD commits" — that is
the residue, not a new import (the fixed run left `git count-objects -v` byte-identical before/after).
- **Scratch that carries OUTWARD work for Drew (not regenerable from the repo without redoing it — keep until pushed):**
`.run/P33/xsig-repo/` (E4: one commit; push to `Druthulu/xsig`) and `.run/P33/permuter-upstream/` (E5: branch
`reloc-masked-scorer`, one commit; push to Drew's fork of decomp-permuter — regenerable from the tracked patch via
`git am`); `.run/P33/permuter-e2e/` is the demo dir (regenerable).
- **Scratch to keep until the probe passes, then delete (C11):** `.run/public_rewrite/` (dict.json, mailmap, rom_blob_ids,
old-to-new.tsv, unchanged_commits.txt, old_tag_tip.txt, the rewritten bare clone `repo.git`, the bundle `pre-rewrite.bundle`
556 MB, the trial logs; the probe's `probe_scratch.git` is created and deleted per run); `.run/objdiff/` (the objdiff-cli
@@ -622,13 +658,11 @@ no purge yet**). **Remaining (9):** E5 (permuter PR branch) · E6 (drafter write
Druthulu/BFM-decomp --limit 4`) · `git count-objects -v` (packs: 1 after Drew's gc; 30 = not yet run) · `df -h ~` ·
`.venv/bin/python tools/doc_links.py --strict` (PASS) · ask Drew: pushed? gc run? ticket filed? latest probe result?
(`tools/public_rewrite/probe_github.sh` — ~1 min, gh-authenticated, safe to run from Claude since S88).
2. **E3 and E4 are DONE** (see the log; E4's outward action — Drew creates `Druthulu/xsig` EMPTY on GitHub, then
2. **E3, E4 and E5 are DONE** (see the log). Outward actions pending on Drew: E4 — create `Druthulu/xsig` EMPTY, then
`git -C .run/P33/xsig-repo remote add origin https://github.com/Druthulu/xsig.git && git -C .run/P33/xsig-repo push -u origin
main` — is pending until Drew does it; the `.run/P33/xsig-repo/` copy is regenerable from `tools/xsig/`). **E5** (Max, 1):
the permuter upstream PR branch (`RelocMaskedScorer` behind `--score-mode reloc-masked`; upstream's `Scorer.__init__` gained
`ign_branch_targets, objdump_command`; the MIPS symbol wildcard `"." in field` at `scorer.py:66-67`; fixture tests, mypy,
black, `./run-tests.sh`; PR-2/issue: configurable `symbol_regex`; `docs/permuter-ils.md`); Drew opens the issue/PR. **E6**
(Max, 0.5–1): `docs/matching-drafter-pipeline.md` from `docs/community-matching-model-plan.md`,
main`; E5 — fork `simonlindholm/decomp-permuter`, `git -C .run/P33/permuter-upstream remote add fork <fork-url> && git -C
.run/P33/permuter-upstream push -u fork reloc-masked-scorer`, open the PR (the commit message is the description) and
file the issue from `docs/permuter-ils.md` §3. **E6** (Max, 0.5–1): `docs/matching-drafter-pipeline.md` from `docs/community-matching-model-plan.md`,
`docs/gen2-mips-matching-model.md`, cookbook §12/§500, `docs/wave-playbook.md`; the ROM-derived pair dataset is NOT published.
One commit per task after this file is updated; log; refresh this block.
3. **After the probe PASSES (Drew):** C10 the flip (Settings → Change visibility → Public, only with D/E/F landed) → E1
@@ -645,17 +679,19 @@ no purge yet**). **Remaining (9):** E5 (permuter PR branch) · E6 (drafter write
the milestone evidence, WAIT for gate 2; then `PhaseEnd_Phase33.md` v2.0.0 with the rule candidates (a)–(h), `CURRENT_PHASE.md`
→ `phase-ends/logs/Phase33.md`, DIGEST §0/§2/§3 appended, the annotated `v2.0.0` tag; Drew pushes `main --tags`).
### 4. Files S88 touched (5 commits after the F2 tip)
### 4. Files S88 touched (6 commits after the F2 tip)
Tools (new): `tools/wiki_render.py`, `tools/wiki_sync.sh`, `tools/gccmap_cites.py`, `tools/xsig/` (xsig.py, README, LICENSE,
tests/: fixture.c, make_fixtures.sh, test_xsig.py, fixture_a.txt, fixture_b.txt, fixture_a.s); changed: `tools/public_rewrite/probe_github.sh` (scratch-repo fetch +
tests/: fixture.c, make_fixtures.sh, test_xsig.py, fixture_a.txt, fixture_b.txt, fixture_a.s),
`tools/permuter/upstream/0001-reloc-masked-scorer.patch`; changed: `tools/public_rewrite/probe_github.sh` (scratch-repo fetch +
self-check), `tools/doc_links.py` (DEFAULT_GLOBS + the map README), `Makefile` (`wiki_render --selftest` + `gccmap_cites --check` + the xsig tests in
tools-health), `.github/workflows/no-rom.yml` (the gccmap_cites + xsig steps). Docs (new): `docs/wiki/*.md` (12),
`docs/how-to-ai-decomp/*.md` (13), `docs/gcc-2.7.2-map/README.md` + `cite_overrides.tsv`; changed: the five map files (135
`docs/how-to-ai-decomp/*.md` (13), `docs/gcc-2.7.2-map/README.md` + `cite_overrides.tsv`, `docs/permuter-ils.md`; changed: the five map files (135
cites tagged in place), `docs/SETUP.md` (the probe clause in the public_rewrite row;
2 wiki rows; the P33 F3 section), `docs/public-flip-runbook.md` (§11: the R57 probe paragraph; the wiki push step),
`docs/doc_links_pending.txt` (10 entries mid-task → EMPTY), `phase-ends/CURRENT_PHASE.md` (F3 ticked; the S88 preflight + F3 log
entries; this block). Evidence: `.run/P33/f3_tools_health.log`, `.run/P33/e3_tools_health.log`, `.run/P33/e4_tools_health.log`, `.run/wiki/render/`
(regenerable), `.run/P33/xsig-repo/` (the standalone copy, one commit). The plan file:
(regenerable), `.run/P33/xsig-repo/` (the standalone copy, one commit), `.run/P33/permuter-upstream/` (the upstream clone +
branch + dev venv), `.run/P33/permuter-e2e/` (the demo dir). The plan file:
`~/.claude/plans/max-effort-set-plan-twinkling-moonbeam.md` (copied below).
---
+2 -1
View File
@@ -20,7 +20,8 @@ REPO = pathlib.Path(__file__).resolve().parent.parent
DEFAULT = ["README.md", "THIRD_PARTY.md", "CLAUDE.md", "src/NOTICE.md", "tools/README.md", "docs/SETUP.md",
"docs/verification.md", "docs/public-flip-runbook.md", "docs/decision-log.md", "docs/accelerators.md",
"docs/story.md", "docs/story-timeline.md", "docs/retrospective.md", "phase-ends/README.md", "phase-ends/DIGEST.md",
"docs/gcc-2.7.2-map/README.md", "tools/xsig/README.md"]
"docs/gcc-2.7.2-map/README.md", "tools/xsig/README.md",
"docs/permuter-ils.md"]
# whole directories in the default set (P33 F3): the wiki pages and the how-to chapters — every file, so a new page is
# checked the moment it exists (the glob is expanded at run time; the count is printed with the rest, R41)
DEFAULT_GLOBS = ["docs/wiki/*.md", "docs/how-to-ai-decomp/*.md"]
@@ -0,0 +1,630 @@
From 09079705e56cc6ea535bd7456fc5c1216bc63288 Mon Sep 17 00:00:00 2001
From: Drew T <50529377+Druthulu@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:24:21 -0600
Subject: [PATCH] Add a relocation-masked scoring mode for MIPS (--score-mode
reloc-masked)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The default scorer diffs symbolised objdump rows and, on MIPS, recognises a
relocation slot only when the field contains a "." (scorer.py's
field_matches_any_symbol). For projects whose symbols carry no "." — e.g.
splat's func_80012345 / D_80054C10 — every relocation slot that differs by
symbol name is charged as a register difference the search can never reduce,
so the score floats on a floor above zero and the random walk has no gradient
toward a real match (measured on a 225-instruction function with a byte-exact
answer: base ~225, best ~210, never 0).
RelocMaskedScorer (src/reloc_scorer.py) compares the two objects' instruction
words directly, masking exactly the fields the linker fills in — taken from
the TARGET's relocation records — and requiring the relocation operands to
agree there:
* R_MIPS_26: keep the opcode, mask the 26-bit target, same symbol+addend;
* HI16/LO16/PC16/LITERAL/GPREL16/GOT16/CALL16: keep opcode+rs+rt, mask the
16-bit immediate, same symbol+addend;
* a `j` relocated against .text (a label inside the function): compared
relative to the function start;
* everything else, registers included: the full word.
score = mismatching positions + |length difference|; 0 iff the candidate links
to the same bytes. It subclasses Scorer so Permuter/Candidate need no change,
and is selected with --score-mode reloc-masked or score_mode = "reloc-masked"
in settings.toml (default "mnemonic" = unchanged behaviour). --debug prints
the per-instruction differences. Refused with -J: remote evaluators use the
default scorer. MIPS only; other architectures raise.
Tests (test/test_reloc_scorer.py) run on an embedded objdump -drz listing of a
small gcc 2.7.2 object and need no cross toolchain: link-time fields masked,
symbol/register/opcode/branch/length differences each counted, internal jump
targets compared, and the scorer end to end through objdump_command.
Written for the Brave Fencer Musashi decompilation, where this scorer (as an
out-of-tree drop-in) took the permuter from a non-zero floor to score-0
matches on the register-allocation tail.
---
README.md | 3 +
USAGE.md | 18 ++++
example_settings.toml | 4 +
src/main.py | 51 ++++++++--
src/reloc_scorer.py | 209 ++++++++++++++++++++++++++++++++++++++
test/test_reloc_scorer.py | 209 ++++++++++++++++++++++++++++++++++++++
6 files changed, 487 insertions(+), 7 deletions(-)
create mode 100644 src/reloc_scorer.py
create mode 100644 test/test_reloc_scorer.py
diff --git a/README.md b/README.md
index 6ffed6a..c7160b6 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,9 @@ permuter is currently quite bad at resolving stack differences). For more detail
see scorer.py. It's far from a perfect system, and should probably be tweaked to
look at e.g. the register diff graph.
+For MIPS targets `--score-mode reloc-masked` scores relocation-masked instruction
+words instead (0 exactly when the candidate links to the same bytes) — see USAGE.md.
+
**What sort of non-matchings are the permuter good at?** It's generally best towards
the end, when mostly regalloc changes remain. If there are reorderings or functional
changes, it's often easy to resolve those by hand, and neither the scorer nor the
diff --git a/USAGE.md b/USAGE.md
index 4f3e89d..5f28d69 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -13,3 +13,21 @@ does all this for you. See README.md for more details.
- `<dir>/compile.sh <dir>/base.c -o <dir>/base.o`
- `./permuter.py <dir> --debug`
* `./permuter.py <dir>`
+
+## Scoring modes
+
+By default candidates are scored by a penalty-weighted diff of the objdump'd rows
+(`src/scorer.py`). `--score-mode reloc-masked` (or `score_mode = "reloc-masked"`
+in `settings.toml`) switches MIPS targets to a relocation-masked word comparison
+(`src/reloc_scorer.py`): the fields the linker fills in (`j`/`jal` targets,
+`%hi`/`%lo` and other 16-bit relocations) are masked using the target object's
+relocation records, the relocation symbols must still agree, a `j` to a label
+inside the function is compared relative to the function start, and everything
+else — registers included — is compared as a full word. The score is the number
+of instructions that differ plus the length difference, so it is 0 exactly when
+the candidate links to the same bytes. Use it when the default score floats above
+0 for a function that has a byte-exact answer — typically a project whose symbols
+carry no `.` (splat's `func_80012345`), where the default scorer cannot recognise a
+relocation slot and charges every differing symbol as a register difference.
+`--debug` prints the per-instruction differences. Not available with the network
+(`-J`), whose evaluators use the default scorer.
diff --git a/example_settings.toml b/example_settings.toml
index dd8098a..152873f 100644
--- a/example_settings.toml
+++ b/example_settings.toml
@@ -3,3 +3,7 @@ compiler_type = "ido" # examples: base, ido, mwcc, gcc
[weight_overrides]
perm_temp_for_expr = 100
+
+# How candidates are scored: "mnemonic" (default) or "reloc-masked" (MIPS only; see
+# USAGE.md "Scoring modes"). The --score-mode flag overrides this.
+# score_mode = "reloc-masked"
diff --git a/src/main.py b/src/main.py
index 32965fc..457aedd 100644
--- a/src/main.py
+++ b/src/main.py
@@ -51,6 +51,7 @@ from .preprocess import preprocess
from .printer import Printer
from .profiler import Profiler
from .randomizer import RANDOMIZATION_PASSES
+from .reloc_scorer import RelocMaskedScorer
from .scorer import Scorer
MIN_PRIO = 0.01
@@ -85,6 +86,7 @@ class Options:
debug_mode: bool = False
speed: int = 100
ign_branch_targets: bool = False
+ score_mode: Optional[str] = None
def restricted_float(lo: float, hi: float) -> Callable[[str], float]:
@@ -360,14 +362,36 @@ def run_inner(options: Options, heartbeat: Callable[[], None]) -> List[int]:
objdump_command = json_prop(settings, "objdump_command", str, "") or None
- scorer = Scorer(
- target_o,
- stack_differences=options.stack_differences,
- algorithm=options.algorithm,
- debug_mode=options.debug_mode,
- ign_branch_targets=options.ign_branch_targets,
- objdump_command=objdump_command,
+ score_mode = options.score_mode or json_prop(
+ settings, "score_mode", str, "mnemonic"
)
+ scorer: Scorer
+ if score_mode == "reloc-masked":
+ if options.use_network:
+ print(
+ "--score-mode reloc-masked cannot be combined with the network "
+ "(-J): remote evaluators score with the default scorer.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ scorer = RelocMaskedScorer(
+ target_o,
+ fn_name=fn_name,
+ debug_mode=options.debug_mode,
+ objdump_command=objdump_command,
+ )
+ elif score_mode == "mnemonic":
+ scorer = Scorer(
+ target_o,
+ stack_differences=options.stack_differences,
+ algorithm=options.algorithm,
+ debug_mode=options.debug_mode,
+ ign_branch_targets=options.ign_branch_targets,
+ objdump_command=objdump_command,
+ )
+ else:
+ print(f"Unknown score_mode {score_mode!r}", file=sys.stderr)
+ sys.exit(1)
c_source = preprocess(base_c)
try:
@@ -701,6 +725,18 @@ def main() -> None:
choices=["difflib", "levenshtein"],
help="Diff algorithm to use",
)
+ parser.add_argument(
+ "--score-mode",
+ dest="score_mode",
+ choices=["mnemonic", "reloc-masked"],
+ help="""How candidates are scored (default: the `score_mode` setting, else
+ "mnemonic"). "mnemonic" is the penalty-weighted diff of objdump'd rows.
+ "reloc-masked" (MIPS only) counts the instruction words that still differ
+ once the linker-filled fields are masked, and reaches 0 exactly when the
+ candidate links to the same bytes; use it when the default score floats
+ above 0 for a function that has a byte-exact answer (e.g. symbols without
+ a "." in their name).""",
+ )
parser.add_argument(
"--keep-prob",
dest="keep_prob",
@@ -808,6 +844,7 @@ def main() -> None:
debug_mode=args.debug_mode,
speed=args.speed,
ign_branch_targets=args.ign_branch_targets,
+ score_mode=args.score_mode,
)
run(options)
diff --git a/src/reloc_scorer.py b/src/reloc_scorer.py
new file mode 100644
index 0000000..7ccc14b
--- /dev/null
+++ b/src/reloc_scorer.py
@@ -0,0 +1,209 @@
+"""Relocation-masked scoring for MIPS objects (`--score-mode reloc-masked`).
+
+The default scorer diffs objdump'd mnemonic rows with relocations symbolised and
+counts register, stack, branch, reordering and insertion/deletion penalties. For a
+project whose symbols contain no "." (e.g. splat's `func_80012345` / `D_80054C10`),
+`field_matches_any_symbol` never fires, so every relocation slot that differs by
+symbol name is a REGALLOC penalty the search can never reduce: the score floats on
+a floor above zero and the random walk has no gradient toward a real match. That
+floor was measured on a 225-instruction function that had a byte-exact answer.
+
+This scorer compares the two objects' instruction WORDS directly, masking exactly
+the fields the linker fills in and requiring the relocation operands to agree
+there. The mask is taken from the TARGET's relocation records:
+
+* `R_MIPS_26` (a `j`/`jal` to a symbol): keep the opcode, mask the 26-bit target,
+ and require the same symbol+addend on both sides;
+* a 16-bit-immediate relocation (`R_MIPS_HI16`, `LO16`, `PC16`, `LITERAL`,
+ `GPREL16`, `GOT16`, `CALL16`): keep opcode + rs + rt, mask the low 16 bits, and
+ require the same symbol+addend;
+* a `j` whose relocation is against the section (a label inside the function):
+ the target is compared RELATIVE to the function start, so two candidates that
+ jump to different labels differ even though both words carry a section reloc;
+* everything else: the full 32-bit word — registers, constants, branch offsets.
+
+ score = mismatching positions + |length difference|
+
+It is 0 exactly when the two objects link to identical bytes for that function,
+and every unit is one instruction, so the number is a closeness, not a weighted
+guess. MIPS only; other architectures keep the default scorer.
+"""
+
+import hashlib
+import re
+import shlex
+import subprocess
+from dataclasses import dataclass
+from typing import List, Optional, Sequence, Tuple
+
+from .objdump import find_executable, get_arch
+from .scorer import Scorer
+
+_HDR_RE = re.compile(r"^[0-9a-f]+ <([^>]+)>:")
+_INSN_RE = re.compile(r"^\s*([0-9a-f]+):\s+([0-9a-f]{8})\s+(.*)")
+_RELOC_RE = re.compile(r"R_MIPS_(\w+)\s+(\S+)")
+_IMM16_RELOCS = {"HI16", "LO16", "PC16", "LITERAL", "GPREL16", "GOT16", "CALL16"}
+
+MASK_FULL = 0xFFFFFFFF
+MASK_OPCODE = 0xFC000000 # a relocated j/jal: the 26-bit target is the linker's
+MASK_NO_IMM16 = 0xFFFF0000 # a relocated 16-bit immediate: opcode + rs + rt stay
+
+
+@dataclass
+class Insn:
+ offset: int
+ word: int
+ text: str
+ reloc_kind: Optional[str] = None
+ reloc_op: Optional[str] = None
+ jrel: Optional[int] = (
+ None # an internal `j`: its target relative to the function start
+ )
+
+
+def mask_for(reloc_kind: Optional[str]) -> int:
+ if reloc_kind == "26":
+ return MASK_OPCODE
+ if reloc_kind in _IMM16_RELOCS:
+ return MASK_NO_IMM16
+ return MASK_FULL
+
+
+def parse_listing(lines: Sequence[str], fn_name: Optional[str] = None) -> List[Insn]:
+ """Parse `objdump -drz` output into instructions with their relocation records.
+
+ With `fn_name`, only that function's instructions are returned when the listing
+ has such a label; otherwise (or when the label is absent) the whole `.text` is
+ used, in order — the same scope the default scorer diffs.
+ """
+ insns: List[Insn] = []
+ fn_start: Optional[int] = None
+ in_fn = fn_name is None
+ for line in lines:
+ header = _HDR_RE.match(line)
+ if header:
+ in_fn = fn_name is None or header.group(1) == fn_name
+ continue
+ if not in_fn:
+ continue
+ m = _INSN_RE.match(line)
+ if m:
+ offset = int(m.group(1), 16)
+ if fn_start is None:
+ fn_start = offset
+ insns.append(Insn(offset, int(m.group(2), 16), m.group(3).strip()))
+ continue
+ reloc = _RELOC_RE.search(line)
+ if reloc and insns:
+ kind, op = reloc.group(1), reloc.group(2)
+ insns[-1].reloc_kind = kind
+ insns[-1].reloc_op = op
+ if fn_name is not None and not insns:
+ return parse_listing(lines, None)
+ start = fn_start or 0
+ for insn in insns:
+ if (
+ (insn.word >> 26) == 2
+ and insn.reloc_kind == "26"
+ and insn.reloc_op == ".text"
+ ):
+ insn.jrel = ((insn.word & 0x3FFFFFF) << 2) - start
+ return insns
+
+
+def classify(cand: Insn, target: Insn) -> Optional[str]:
+ """None when the two instructions agree up to relocation, else why they differ."""
+ mask = mask_for(target.reloc_kind)
+ if (cand.word & mask) != (target.word & mask):
+ if (cand.word >> 26) != (target.word >> 26):
+ return "opcode"
+ return "operand"
+ if cand.jrel is not None and target.jrel is not None and cand.jrel != target.jrel:
+ return "jump target"
+ if mask != MASK_FULL and (cand.reloc_op or "") != (target.reloc_op or ""):
+ return "relocation symbol"
+ return None
+
+
+def masked_diff(
+ cand: Sequence[Insn], target: Sequence[Insn]
+) -> Tuple[int, List[Tuple[int, Optional[Insn], Optional[Insn], str]]]:
+ """(score, [(index, cand_insn, target_insn, reason)]) — one unit per position."""
+ diffs: List[Tuple[int, Optional[Insn], Optional[Insn], str]] = []
+ for i in range(max(len(cand), len(target))):
+ if i >= len(cand) or i >= len(target):
+ diffs.append(
+ (
+ i,
+ cand[i] if i < len(cand) else None,
+ target[i] if i < len(target) else None,
+ "length",
+ )
+ )
+ continue
+ reason = classify(cand[i], target[i])
+ if reason is not None:
+ diffs.append((i, cand[i], target[i], reason))
+ return len(diffs), diffs
+
+
+class RelocMaskedScorer(Scorer):
+ """A drop-in for `Scorer` that scores relocation-masked instruction words.
+
+ `Scorer.__init__` is deliberately not called: this scorer neither symbolises nor
+ diffs mnemonic rows, so none of that state exists. The public contract is the
+ same — `score(cand_o) -> (int, hash)`, `PENALTY_INF`, `target_o`, `arch`,
+ `debug_mode`, `objdump_command`.
+ """
+
+ def __init__(
+ self,
+ target_o: str,
+ *,
+ fn_name: Optional[str] = None,
+ debug_mode: bool = False,
+ objdump_command: Optional[str] = None,
+ ):
+ self.target_o = target_o
+ self.arch = get_arch(target_o)
+ if self.arch.name != "mips":
+ raise ValueError(
+ f"reloc-masked scoring supports MIPS objects only, not {self.arch.name}"
+ )
+ self.fn_name = fn_name
+ self.debug_mode = debug_mode
+ self.objdump_command = objdump_command or ""
+ self.stack_differences = False # read by the network client; unused here
+ self.algorithm = "reloc-masked"
+ self.target_insns = self._insns(target_o)
+
+ def _insns(self, o_file: str) -> List[Insn]:
+ if self.objdump_command:
+ command = shlex.split(self.objdump_command)
+ else:
+ command = [find_executable(tuple(self.arch.executable), self.arch.name)]
+ command += ["-drz", "-j", ".text"]
+ output = subprocess.check_output(command + [o_file]).decode("utf-8", "replace")
+ return parse_listing(output.splitlines(), self.fn_name)
+
+ def score(self, cand_o: Optional[str]) -> Tuple[int, str]:
+ if not cand_o:
+ return Scorer.PENALTY_INF, ""
+ cand = self._insns(cand_o)
+ if not cand:
+ return Scorer.PENALTY_INF, ""
+ value, diffs = masked_diff(cand, self.target_insns)
+ if self.debug_mode:
+ for index, c, t, reason in diffs:
+ left = f"{c.word:08x} {c.text}" if c else "--"
+ right = f"{t.word:08x} {t.text}" if t else "--"
+ print(f"#{index:<4} {left:40.40s} | {right:40.40s} {reason}")
+ print(
+ f"reloc-masked score: {value} ({len(cand)} vs {len(self.target_insns)} insns)"
+ )
+ digest = hashlib.sha256(
+ "".join(
+ f"{i.word:08x}:{i.reloc_kind or ''}:{i.reloc_op or ''};" for i in cand
+ ).encode()
+ ).hexdigest()
+ return value, digest
diff --git a/test/test_reloc_scorer.py b/test/test_reloc_scorer.py
new file mode 100644
index 0000000..8bb4e4c
--- /dev/null
+++ b/test/test_reloc_scorer.py
@@ -0,0 +1,209 @@
+import os
+import tempfile
+import unittest
+from typing import Dict
+
+from src.reloc_scorer import (
+ MASK_FULL,
+ MASK_NO_IMM16,
+ MASK_OPCODE,
+ RelocMaskedScorer,
+ mask_for,
+ masked_diff,
+ parse_listing,
+)
+from src.scorer import Scorer
+
+# `objdump -drz -j .text` of a small function compiled by gcc 2.7.2 (MIPS I, -O2 -G0):
+# a %hi/%lo pair (HI16/LO16 against `table`), a jal (R_MIPS_26 against `helper`),
+# two branches (PC-relative, encoded in the word), and a second function.
+LISTING = """
+fixture.o: file format elf32-tradlittlemips
+
+
+Disassembly of section .text:
+
+00000000 <fixture>:
+ 0:\t27bdffd8 \taddiu\tsp,sp,-40
+ 4:\tafb1001c \tsw\ts1,28(sp)
+ 8:\t00808821 \tmove\ts1,a0
+ c:\tafb00018 \tsw\ts0,24(sp)
+ 10:\t00008021 \tmove\ts0,zero
+ 14:\t00002021 \tmove\ta0,zero
+ 18:\tafbf0024 \tsw\tra,36(sp)
+ 1c:\t1a200013 \tblez\ts1,6c <fixture+0x6c>
+ 20:\tafb20020 \tsw\ts2,32(sp)
+ 24:\t3c120000 \tlui\ts2,0x0
+\t\t\t24: R_MIPS_HI16\ttable
+ 28:\t26520000 \taddiu\ts2,s2,0
+\t\t\t28: R_MIPS_LO16\ttable
+ 2c:\t32020007 \tandi\tv0,s0,0x7
+ 30:\t00021080 \tsll\tv0,v0,0x2
+ 34:\t00521021 \taddu\tv0,v0,s2
+ 38:\t8c420000 \tlw\tv0,0(v0)
+ 3c:\t00000000 \tnop
+ 40:\t00822021 \taddu\ta0,a0,v0
+ 44:\t28820065 \tslti\tv0,a0,101
+ 48:\t14400004 \tbnez\tv0,5c <fixture+0x5c>
+ 4c:\t00000000 \tnop
+ 50:\t0c000000 \tjal\t0 <fixture>
+\t\t\t50: R_MIPS_26\thelper
+ 54:\t00000000 \tnop
+ 58:\t00402021 \tmove\ta0,v0
+ 5c:\t26100001 \taddiu\ts0,s0,1
+ 60:\t0211102a \tslt\tv0,s0,s1
+ 64:\t1440fff2 \tbnez\tv0,30 <fixture+0x30>
+ 68:\t32020007 \tandi\tv0,s0,0x7
+ 6c:\t00801021 \tmove\tv0,a0
+ 70:\t8fbf0024 \tlw\tra,36(sp)
+ 74:\t8fb20020 \tlw\ts2,32(sp)
+ 78:\t8fb1001c \tlw\ts1,28(sp)
+ 7c:\t8fb00018 \tlw\ts0,24(sp)
+ 80:\t27bd0028 \taddiu\tsp,sp,40
+ 84:\t03e00008 \tjr\tra
+ 88:\t00000000 \tnop
+
+0000008c <other>:
+ 8c:\t00041040 \tsll\tv0,a0,0x1
+ 90:\t00441021 \taddu\tv0,v0,a0
+ 94:\t00052883 \tsra\ta1,a1,0x2
+ 98:\t03e00008 \tjr\tra
+ 9c:\t00451026 \txor\tv0,v0,a1
+"""
+
+
+def listing(edits: Dict[str, str]) -> str:
+ """LISTING with exact textual replacements applied (each key must occur)."""
+ text = LISTING
+ for old, new in edits.items():
+ assert old in text, old
+ text = text.replace(old, new)
+ return text
+
+
+class TestParse(unittest.TestCase):
+ def test_whole_text_and_one_function(self) -> None:
+ whole = parse_listing(LISTING.splitlines())
+ self.assertEqual(len(whole), 40)
+ only = parse_listing(LISTING.splitlines(), "other")
+ self.assertEqual(
+ [i.word for i in only],
+ [0x00041040, 0x00441021, 0x00052883, 0x03E00008, 0x00451026],
+ )
+ # an absent label falls back to the whole section, like the default scorer's scope
+ self.assertEqual(len(parse_listing(LISTING.splitlines(), "no_such_fn")), 40)
+
+ def test_relocations_are_attached(self) -> None:
+ insns = parse_listing(LISTING.splitlines(), "fixture")
+ by_off = {i.offset: i for i in insns}
+ self.assertEqual(
+ (by_off[0x24].reloc_kind, by_off[0x24].reloc_op), ("HI16", "table")
+ )
+ self.assertEqual(
+ (by_off[0x28].reloc_kind, by_off[0x28].reloc_op), ("LO16", "table")
+ )
+ self.assertEqual(
+ (by_off[0x50].reloc_kind, by_off[0x50].reloc_op), ("26", "helper")
+ )
+ self.assertIsNone(by_off[0x48].reloc_kind) # a branch is encoded, not relocated
+
+ def test_masks(self) -> None:
+ self.assertEqual(mask_for("26"), MASK_OPCODE)
+ self.assertEqual(mask_for("HI16"), MASK_NO_IMM16)
+ self.assertEqual(mask_for("LO16"), MASK_NO_IMM16)
+ self.assertEqual(mask_for(None), MASK_FULL)
+
+
+class TestMaskedDiff(unittest.TestCase):
+ def insns(self, text: str = LISTING) -> list:
+ return parse_listing(text.splitlines(), "fixture")
+
+ def test_identical_is_zero(self) -> None:
+ self.assertEqual(masked_diff(self.insns(), self.insns())[0], 0)
+
+ def test_link_time_fields_are_masked(self) -> None:
+ # the same code with the linker's fields filled differently (a jal target, a %hi/%lo pair)
+ cand = listing(
+ {
+ "0c000000 ": "0c004800 ",
+ "3c120000 ": "3c128002 ",
+ "26520000 ": "26522a40 ",
+ }
+ )
+ self.assertEqual(masked_diff(self.insns(cand), self.insns())[0], 0)
+
+ def test_other_symbol_at_a_relocated_slot_counts(self) -> None:
+ cand = listing({"R_MIPS_26\thelper": "R_MIPS_26\tother_helper"})
+ score, diffs = masked_diff(self.insns(cand), self.insns())
+ self.assertEqual((score, diffs[0][3]), (1, "relocation symbol"))
+ cand = listing(
+ {
+ "R_MIPS_HI16\ttable": "R_MIPS_HI16\ttable2",
+ "R_MIPS_LO16\ttable": "R_MIPS_LO16\ttable2",
+ }
+ )
+ self.assertEqual(masked_diff(self.insns(cand), self.insns())[0], 2)
+
+ def test_register_branch_and_opcode_differences_count(self) -> None:
+ cand = listing({"00521021 ": "00531021 "}) # addu v0,v0,s3 instead of s2
+ score, diffs = masked_diff(self.insns(cand), self.insns())
+ self.assertEqual((score, diffs[0][3]), (1, "operand"))
+ cand = listing({"14400004 ": "14400005 "}) # bnez to a different label
+ self.assertEqual(masked_diff(self.insns(cand), self.insns())[0], 1)
+ cand = listing({"8c420000 ": "94420000 "}) # lw -> lhu: a different opcode
+ score, diffs = masked_diff(self.insns(cand), self.insns())
+ self.assertEqual((score, diffs[0][3]), (1, "opcode"))
+
+ def test_length_difference_counts_per_instruction(self) -> None:
+ cand = LISTING.replace(" 88:\t00000000 \tnop\n", "")
+ score, diffs = masked_diff(self.insns(cand), self.insns())
+ self.assertEqual((score, diffs[-1][3]), (1, "length"))
+
+ def test_internal_jump_targets_are_compared(self) -> None:
+ # a `j` to a label inside the function carries `R_MIPS_26 .text`: its target is compared
+ # relative to the function start, so a jump to a different label is a mismatch
+ target = LISTING.replace(
+ " 4c:\t00000000 \tnop\n",
+ " 4c:\t08000018 \tj\t60 <fixture+0x60>\n\t\t\t4c: R_MIPS_26\t.text\n",
+ )
+ cand = LISTING.replace(
+ " 4c:\t00000000 \tnop\n",
+ " 4c:\t0800001b \tj\t6c <fixture+0x6c>\n\t\t\t4c: R_MIPS_26\t.text\n",
+ )
+ t, c = self.insns(target), self.insns(cand)
+ self.assertEqual(masked_diff(t, t)[0], 0)
+ score, diffs = masked_diff(c, t)
+ self.assertEqual((score, diffs[0][3]), (1, "jump target"))
+
+
+class TestScorer(unittest.TestCase):
+ def test_scorer_end_to_end_with_a_stand_in_objdump(self) -> None:
+ # `objdump_command` may be any command that prints the listing: `cat` turns a listing file
+ # into an "object", so the whole scorer path runs without a cross toolchain
+ # get_arch() reads the ELF ident + e_machine (20 bytes): a little-endian MIPS header in
+ # front of the listing is all the scorer needs from the file itself
+ header = b"\x7fELF\x01\x01\x01" + bytes(9) + b"\x01\x00\x08\x00"
+
+ def fake_object(path: str, text: str) -> str:
+ with open(path, "wb") as f:
+ f.write(header + b"\n" + text.encode())
+ return path
+
+ with tempfile.TemporaryDirectory() as d:
+ target = fake_object(os.path.join(d, "target.o"), LISTING)
+ same = fake_object(
+ os.path.join(d, "same.o"), listing({"0c000000 ": "0c004800 "})
+ )
+ worse = fake_object(
+ os.path.join(d, "worse.o"), listing({"00521021 ": "00531021 "})
+ )
+ scorer = RelocMaskedScorer(target, fn_name="fixture", objdump_command="cat")
+ self.assertIsInstance(scorer, Scorer)
+ self.assertEqual(scorer.score(same)[0], 0)
+ self.assertEqual(scorer.score(worse)[0], 1)
+ self.assertEqual(scorer.score(None)[0], Scorer.PENALTY_INF)
+ self.assertNotEqual(scorer.score(same)[1], scorer.score(worse)[1])
+
+
+if __name__ == "__main__":
+ unittest.main()
--
2.43.0