Skip to content

Recover branching signed /2^k division and wider Thumb jump tables - #24

Merged
macabeus merged 5 commits into
mainfrom
port/m2c-worth-porting
Aug 5, 2026
Merged

Recover branching signed /2^k division and wider Thumb jump tables#24
macabeus merged 5 commits into
mainfrom
port/m2c-worth-porting

Conversation

@macabeus

@macabeus macabeus commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Works the four commits the m2c pin bump (#22) classified as worth porting, following #23 which did the nice-to-haves. Each entry got an isolated commit, a benchmark measurement, and two adversarial review passes — one hunting for bugs and edge cases, one judging fit against docs/level-tower.md and docs/asmlift-101.md.

asmlift 361 → 363 vs m2c 342 (743 rows). Declined 205 → 202.

m2c commit outcome
a7c5c2d label+offset in jtbl loads ported + two asmlift-specific gaps → 3 declines recovered
7e607d6 avoid negative struct/array indexing discarded, reason pinned as tests
8ce8da5 non-word loads in the arg region discarded, nothing to port and nothing to test
49b5d87 more division-by-2^k patterns ported as intent → +2 matches

Jump tables: the port was the smallest of three gaps

The bump doc said asmlift has no jump-table support. It does — it recognised a spelling agbcc rarely emits. m2c's fix was one of three things standing between us and the six benchmark functions that dispatch through a table:

  • Pool offset (m2c's fix). The dispatch pointer is loaded like every other pool word, LABEL[+N]; the recognizer accepted only a bare label whose pool held exactly one word. A literal pool is a pool — which slot the table pointer lands in is an artifact of emission order.
  • Long-jump bounds guard (not in m2c's commit). cmp rX,#M ; bls DISP ; b DEF alongside cmp rX,#M ; bhi DEF. agbcc emits this whenever the default is out of a conditional branch's ±256-byte reach, which for a real switch it usually is: five of the six functions use it, one uses the direct form. So the recognizer covered the rarer spelling.
  • Adjacent labels. agbcc drops a long-jump helper label straight onto an existing one; the empty block was filtered out and branches naming it dangled.

Plus a prerequisite fix(ssa): block args belong to the edge, not the block. A switch_br sending two case values to one body (case 1: case 2:, ordinary C) piled k copies of each arg onto the first edge and left the rest empty. Landed separately because it moves zero rows on its own — but it is not dead code: revert that file on top of the tip and two rows fail the verifier.

declined 205 → 202. Two rows become scored; sa3:sub_802DFC8 becomes noncompile, which is worth stating plainly — it now lifts and emits, and the emission trips the call-arity heuristic over-counting a callee with no prototype. That is loud here because the benchmark supplies project headers; in the playground with no prototypes, C89 implicit declarations would accept the extra argument and compile. The fix belongs in call-arity recovery. The other five functions recover their switch and decline further downstream on gaps unrelated to jump tables (unions, a sym+0x48 pool word, stack frames) — outcomes unchanged, decline reasons now specific and attributable.

Division by 2^k: ported as intent, and it's the win

x / 2^k truncates toward zero but an arithmetic shift rounds toward minus infinity, so a compiler biases a negative dividend by 2^k - 1. asmlift already folded the branchless spelling; this recovers the branch, which is what IDO emits and what GCC emits for larger k.

Deliberately not m2c's form. 49b5d87 adds a third and fourth instruction-window pattern, and its own message notes GCC reorders the final sra and defeats the window. This matches the value/CFG graph instead, which is immune to that — verified: the fold still fires when the sunk shift sits in a later block. A CFG diamond is unstateable in the patterns-as-data layer (its match DAG is over the def-graph inside one block), so it earns a pre-recovery pass, as raise/shortcircuit.ts does.

synthetic:div2:ido7.1 and synthetic:avg2:ido7.1 both flip nonmatch → MATCH — both rows where m2c matched and asmlift did not. synthetic:modpow2 (agbcc and gcc2.7.2kmc) changes text and stays matched, which is round-trip byte evidence on two further compilers. A real GBA function already folds a k=8 diamond (it declines for unrelated reasons), and a second waits behind the bltzl frontend gap.

The pass is ungated on magicdiv's self-verifying-round-trip argument, not on "the identity is arithmetic" — by this project's taxonomy that alone isn't a licence, since the pass does trade one spelling for another.

The two discards

7e607d6 — both halves are structural non-issues. m2c's index arrives as a byte offset it must divide by the element size, so a literal there is genuinely ambiguous; raise/arrays.ts matches only when 1 << k === width, which makes the shifted operand an index by construction. A constant index is then as well-defined as a variable one. And a negative offset stays exact pointer arithmetic (a0[-2]), with no struct synthesized across it. Probed four shapes before writing; the reasons are pinned as tests.

8ce8da5 — nothing to port and nothing to test: asmlift has no outgoing-arg-region inference to blind. Call arity comes from prototypes or argument-register liveness, and stack-passed args decline. A test would assert the absence of a subsystem.

What review changed

Two commits shipped silent miscompiles, both caught and fixed:

  • The label-alias fix treated data labels as aliases, so beq .Lpool silently retargeted past the pool. Every agbcc literal pool is a label on data, so this was the common case.
  • The pow2 pass counted only operands as uses, missing block arguments — a merge value escaping along an edge folded (y >> 2) + y into (x / 4) + (x / 4). A second reviewer hit the same bug independently from real MIPS asm, and also caught that the merge may be the entry block, where retiring a param silently deletes one of the function's own arguments while verify() passes.

Also backed out an over-correction: an n < 2 fail-closed guard broke a legal 1-case dispatch that demonstrably lifted correctly.

Two errata, both mine: the bump doc's "zero inhabitants" for the pow2 lever was correctly scoped to m2c's new windows — the lever had inhabitants all along, and my first erratum mis-stated why. And "declined → noncompile is loud" was too strong, as described above.

Notes

  • Suite green: 949 tests, up from 939. Two new files — thumb-switch.test.ts (there was no Thumb jump-table fixture anywhere) and divpow2.test.ts. Both are refusal batteries: a mis-recovered table is not a wrong expression but a wrong block, and the division pass deletes a block and retires a phi, so in both cases the negative cases are the point.
  • Benchmark artifacts regenerated from a clean tree, so the numbers are checkable in results.json.

🤖 Generated with Claude Code

macabeus and others added 5 commits August 5, 2026 22:03
Braun construction wires a phi by appending one arg to each predecessor's
terminator successor entry. Two halves of that were wrong whenever a predecessor
reaches a block by MORE THAN ONE edge:

  • `appendSuccessorArg` used `successors.find(...)`, so it appended to the FIRST
    matching edge only and left the others short;
  • `preds` lists an entry per EDGE, so the loop ran k times for k edges and piled
    k copies onto that same first edge.

Together they produced an arg list k× too long on one edge and empty on the rest —
`successor of 'switch_br' passes 18 args to a block with 6 params`. The verifier
catches it, so it is a loud decline rather than wrong output, but it makes the
shape unliftable. A `switch_br` reaches it routinely (two case values sharing a
body, `case 1: case 2:`, is ordinary C) and a `cond_br` whose two successors are
the same block reaches it too.

Fixed as one change because the halves have to agree: append to EVERY edge
targeting the block, and iterate DISTINCT predecessor blocks. The same distinction
fixes the join test above it — a block reached by several case values of one
switch has one predecessor supplying one value, not a join, so counting edges
manufactured a phi where none exists.

`preds` genuinely carries two readings and now says so in the module header and at
the parameter: an EDGE list for the args, distinct BLOCKS for the phis. (ir/core.ts
`predecessors` and structure.ts `predecessorBlocks` have the same duality;
structure.ts already dedups ad hoc at two join sites.)

Benchmark: zero rows move on its own — every liftable inhabitant is behind the
Thumb jump-table gaps in the next commit. Landing it separately keeps that
attribution honest. It is not dead code, though: revert this file on top of the
branch tip and two rows fail the verifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Porting m2c's `a7c5c2d` ("support label + offset in jtbl loads") turned out to be
the smallest of three gaps standing between asmlift and the six benchmark
functions that dispatch through a table. asmlift has had Thumb jump-table recovery
for a while; it recognised a spelling agbcc rarely emits.

POOL OFFSET (m2c's fix). The dispatch pointer is loaded like every other pool word
— `LABEL[+N]`, selecting word N/4 — because a literal pool is a POOL: agbcc packs
the table pointer in beside whatever else the function needed, and which slot it
lands in is an artifact of emission order:

	ldr	r1, .L21+0x4        ← .L21 = [gUnk_08078FC8, .L17]; the table is .L17

The recognizer accepted only a bare label whose pool held exactly ONE word. It now
reuses the shared POOL_LABEL grammar the const and gaddr resolvers already parse
operands with, so the three cannot disagree about what `.L21+0x4` addresses.

LONG-JUMP BOUNDS (not in m2c's commit; found while porting). The guard has two
spellings and the recognizer knew the near one:

	direct     cmp rX,#M ; bhi DEF                → fall through to the dispatch
	long jump  cmp rX,#M ; bls DISP ; b DEF       → branch TO it, long-branch the default

agbcc emits the second whenever the default is out of a conditional branch's reach
(Thumb-1 `B<cond>` carries a signed 8-bit halfword offset — ±256 bytes, about 128
instructions), which for a real switch it usually is: five of the six functions use
it, one uses the direct form. The dispatch may now be a branch target when named
exactly once, by the `bls` in its own bounds block, and the trailing lone `b DEF`
block supplies the default and is elided with it — that block is checked to be
unnamed by any branch, because leaving it in would make it a parameterless
predecessor of the default block, where wiring a phi fabricates an entry parameter.

ADJACENT LABELS. agbcc drops a long-jump helper label straight onto an existing one
(`.LCB80:` then `.L7:` on one instruction). Decode started a block at each, the
empty one was filtered out, and a branch naming it then resolved to nothing. Those
branches are now pointed at the block the label actually names — except that a
label naming DATA is emphatically not an alias, and that guard is the whole point:
every agbcc literal pool is a label on data, so aliasing blindly would silently
retarget `beq .Lpool` at whatever code followed the pool. Marker-free, plausible,
wrong. A data label neither aliases nor is aliased through.

Benchmark: declined 205 → 202. kleod:TransformSingleEntityToScreen and
kleod:CheckWorldCompletion move declined → scored; sa3:sub_802DFC8 moves declined →
NONCOMPILE, which is worth stating plainly. It now lifts and emits, and the
emission fails on `too many arguments to function 'VramMalloc'` — the call-arity
heuristic over-counting a callee with no prototype. That is loud HERE because the
benchmark supplies the project headers; in the CLI or playground with no
prototypes, C89 implicit declarations would accept the extra argument and compile.
So it is a genuine step from "asmlift knows it cannot" toward silence, and the fix
belongs in call-arity recovery.

The other five functions recover their switch and then decline further downstream
on gaps that have nothing to do with jump tables — union/overlapping struct fields,
a `sym+0x48` pool word, local stack frames. Their outcomes do not change; their
decline reasons become specific and attributable, which is the payoff this project
optimizes for.

Adds packages/core/test/thumb-switch.test.ts — there was no Thumb jump-table
fixture anywhere. It pins both bounds spellings, the pool addressing, the
shared-case-body arity invariant the previous commit enables, alias equivalence
(asserted against the un-aliased control, not a spelling), and six near-miss
dispatches that must decline. A mis-recovered table is not a wrong expression but a
wrong BLOCK — the output looks ordinary and runs the wrong code — so the refusals
are the point of the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`x / 2^k` truncates toward zero but an arithmetic shift rounds toward minus
infinity, so a compiler biases a negative dividend by `2^k - 1` first. asmlift
already folded the branchless spelling (the SDIV_POW2_2 idiom pattern). This
recovers the other one — a BRANCH, which is what IDO emits and what GCC emits for
larger k:

	bgez	a0, .L2
	addiu	at, a0, 1
   .L2:	sra	v0, at, 1

A CFG diamond is not something the patterns-as-data layer can state — its match
DAG is over the def-graph inside one block — so this earns a pre-recovery pass, for
the same reason raise/shortcircuit.ts is one. That is also why it is not a port of
m2c's `49b5d87` in form, only in intent: that commit adds a third and fourth
INSTRUCTION-WINDOW pattern, and its own message notes GCC reorders the final `sra`
and defeats the window. Matching the value graph is immune to that — verified: the
fold still fires when the sunk shift sits in a later block.

Both merge spellings are recovered: the shift SUNK after the join, and SPLIT into
both arms — the latter is what a filled delay slot produces.

The identity is arithmetic, but by this project's taxonomy that alone is not a
licence to run ungated: the compiler-pinned patterns are gated precisely because
they trade a spelling for another and are byte-safe only where measured, and this
pass does trade a spelling. What carries it is raise/magicdiv.ts's argument,
unchanged — the round-trip is SELF-VERIFYING. asmlift emits a plain `x / 2^k`, the
target compiler regenerates its own lowering, and a wrong divisor recompiles to
different bytes and shows up as a nonmatch, never a false match. Measured positive
on ido7.1, agbcc and gcc2.7.2kmc; mwcc_242_81 and gcc2.7.2 have no inhabitant, so
they are unmeasured rather than clean.

Benchmark: **synthetic:div2:ido7.1 and synthetic:avg2:ido7.1 both flip nonmatch →
MATCH; asmlift 361 → 363.** Both were rows where m2c matched and asmlift did not.
synthetic:modpow2 (agbcc and gcc2.7.2kmc) changes text and STAYS matched —
`a0 - (a0 / 16 << 4)` — which is the byte evidence that re-emitting `/ 2^k`
reproduces the sequence on two further compilers. A real GBA function is an
inhabitant already (kleod:TransformSingleEntityToScreen folds a k=8 diamond; it
declines for unrelated reasons) and a second real one waits behind the `bltzl`
branch-likely frontend gap (snowboardkids2:func_8005DF10_5EB10, ~10 instances).

The pass deletes a block and retires a phi, so its safety story is its refusals,
and divpow2.test.ts pins one per guard. Two of them are silent-miscompile classes
found by adversarial review of the first draft: a merge phi that also escapes as a
BLOCK ARGUMENT is a second consumer (args live in `successors[].args`, not
`operands` — counting only operands folded `(y >> 2) + y` into `(x / 4) + (x / 4)`),
and neither end of the diamond may be the ENTRY block (an entry that is also a loop
header shows two predecessors while really being a three-way join; retiring a param
there deletes one of the function's own arguments, and verify passes).

Erratum for the bump review, which parked this lever as having no inhabitants: that
scan asked whether `49b5d87`'s two new windows had inhabitants, and correctly
answered no — neither flipped row matches those windows; both are shapes m2c has
recognised since 2022. The LEVER had inhabitants all along. The review's own
recommendation was to match the value DAG rather than instruction windows, which is
what this is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remaining two "worth porting" entries are both DISCARDS, verified rather than
assumed, and the reasons are structural — so this pins the reasons rather than
porting the guards.

`7e607d6` (avoid negative struct/array indexing) has two halves. The literal-index
half: m2c's index arrives as a BYTE offset it must divide by the element size, so a
literal there is genuinely ambiguous — `p + 0x10` could be `p[4]` or a struct field
and guessing wrong changes the address. raise/arrays.ts never guesses; it matches
only when the shift amount and the access width AGREE (`1 << k === width`), which
is what makes the shifted operand an index by construction, and is the relation
docs/level-tower.md cites as what earned the pass. A constant index is then exactly
as well-defined as a variable one. The negative half: a negative offset stays exact
pointer arithmetic — `lw v0,-8(a0)` emits `a0[-2]`, and two accesses at -8 and +4
through one base do not synthesize a struct spanning them.

Probed before writing: the negative literal offset, the folded `addiu a0,a0,-8`
form, a negative residual on a strided access, and a constant index — all four emit
exact, correct C today.

`8ce8da5` (non-word loads when finding the subroutine-arg region) has nothing to
port and nothing to pin: asmlift has NO outgoing-arg-region inference to blind.
Call arity comes from the project's prototypes, falling back to argument-register
liveness; stack-passed arguments beyond the register args are explicitly not
modelled and decline. A test there would assert the absence of a subsystem.

Test-only: no src change, so the benchmark cannot move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branching pow2-division recovery flips synthetic:div2:ido7.1 and
synthetic:avg2:ido7.1 to MATCH, both rows where m2c matched and asmlift did not.
The Thumb jump-table work moves declined 205 → 202: two rows become scored, one
becomes noncompile (the call-arity gap noted in that commit). Seven further rows
change text without moving — five are jump-table declines becoming specific and
attributable, two are modpow2 keeping its byte-exact match through a respelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macabeus macabeus changed the title Port m2c's four "worth porting" commits (+2 matches, 3 declines recovered) Recover branching signed /2^k division and wider Thumb jump tables (361 -> 363 matches) Aug 5, 2026
@macabeus macabeus changed the title Recover branching signed /2^k division and wider Thumb jump tables (361 -> 363 matches) Recover branching signed /2^k division and wider Thumb jump tables Aug 5, 2026
@macabeus
macabeus merged commit 4a85b70 into main Aug 5, 2026
2 checks passed
@macabeus
macabeus deleted the port/m2c-worth-porting branch August 5, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant