Repository navigation
v0.5.0 β the Thumb stack frame, and gates that cost something
@asmlift/core@0.5.0 and @asmlift/cli@0.5.0 are live on npm.
npm i @asmlift/cli # pulls @asmlift/core ^0.5.0Where the board stands
asmlift 363 matches vs m2c 342, over 743 rows β identical to 0.4.0. This release wins no new rows, and that is the honest headline. What moved is the column beside it: nine functions asmlift used to refuse now produce output, and two rows that scored one byte off a match now decline loudly, because that byte was a carry being silently dropped.
| match | nonmatch | declined | noncompile | |
|---|---|---|---|---|
| asmlift 0.4.0 | 363 | 173 | 202 | 5 |
| asmlift 0.5.0 | 363 | 179 | 195 | 6 |
| m2c | 342 | 202 | 42 | 157 |
17 of 743 rows moved: 8 out of declined into scored C, 6 nonmatch scores improved (one from 340 to 217), 2 from nonmatch:1 to an honest declined, and 1 from declined to noncompile, where the compiler now names the gap instead of asmlift refusing before reaching it. Explore it in the benchmark report.
The shape of the release follows from that table. Four rounds built capability against the largest decline family, and five more were spent on the thing the 0.4.0 notes ended on β the gates that hosted CI cannot run.
The Thumb stack frame
stack pointer used as data was the single biggest refusal in the Thumb frontend, and it was one message covering four unrelated situations. Taken apart across four PRs:
- A frame adjustment is bookkeeping in both spellings (#28). agbcc writes
add sp, sp, #N; disassemblers writeadd sp, #N. asmlift was transparent to the second and declined on the first β an inconsistency between two spellings of one instruction, not a decision about frames. The counts are now recorded next to the predicate, because the first version of that claim had the producers backwards and a reviewer caught it with a grep. - Incoming stack arguments (#29). AAPCS puts arguments 5+ on the stack, so a callee reads them at
[sp, #N]at or above its own frame. Those are parameters, and they are separable from locals, which live below the frame top. Modelled by reading a virtual@sarg<k>key that the existing live-in path already turns into a parameter β no new opcode, pass or representation. - Local stack slots (#30). A spill whose address is never taken is transparent to dataflow:
str rX,[sp,#k]β¦ldr rY,[sp,#k]moves a value. Now SSA variables keyed by offset, so a slot read-modify-written across a loop becomes one variable with a phi. Braun's phi construction does the hard part. - Five stacked capabilities (#31), which cleared lift, raise and recovery on the target function and left it declining at structure on a back-edge β a different tier, named precisely. Across 2,686 sa3 and Klonoa functions: 34 decline β lift, 0 lift β decline, 0 output changes.
The guard that remains is narrower and lives in one place. #28 ended by moving it from three decode arms onto the write itself, after a reviewer ran the lifter instead of reading it and found five arms that wrote sp and dropped it in silence β lsl sp, r4, #2, ldr sp, [r0, #4], and ldmia r0!, {sp} lifting as return a0 + 4. An enumeration of decode arms can only cover the arms someone thought of.
Jump tables, by instruction rather than by spelling
The recogniser matched its idiom by operand text, so it declined the shape whenever a disassembler wrote it equivalently but differently: lsls rejected, #0x02 rejected, adds rejected. In Thumb-1 the trailing s is a dialect marker, not a modifier β and it is the dominant dialect of the format asmlift advertises, since every pret-style split wraps its bodies in .syntax unified, where the non-suffixed spelling is a syntax error. Counted under one such tree: lsls 5,795 against lsl 0 (#34).
Separately, arms that share a body or fall through are now recovered (#32). SwitchCase.values and fallsThrough had always been renderable, and the L3 passes were already written around them β nothing had ever produced either. Both jump-table rows in the benchmark now score, and m2c declines both.
Correctness
A jump table recovered wrong is not a wrong expression, it is a wrong block β the C looks entirely ordinary and runs the wrong code. Fixing the spelling turned up three ways that could happen, all closed in #34.
An unmodelled instruction may not vanish because its destination is dead (#35). opaque.ts implements this project's first rule β an unmodelled instruction degrades to a loud failure, never a silent drop β and that contract held only while the fabricated destination stayed live. A dead one was reaped by DCE, and even when kept was skipped by structure's sideEffects, which had no opaque arm. What it cost, in this repo's own benchmark:
// long long lladd(long long a, long long b) { return a + b; }
s32 lladd(u32 a0, u32 a1, u32 a2, u32 a3) { return a0 + a2; } // the adc carry, goneThose are the two rows that went from nonmatch:1 to declined. In a 12,420-function corpus sweep the same hole emptied whole functions β void sqrtf(s32 a0) { return; }, thirteen float instructions.
The value-home fix (#33) landed as safety infrastructure first: one shared memory-disjointness query, where "could this write change what that read sees" had been answered at three different strengths with the weakest governing the most common case. The behaviour change ships as a differ-refereed axis (/reread-globals) β five rows better, none worse, no match flips.
Gates that cost something
benchmark.yml installed every toolchain the byte-level suites need and then never ran them (#36). Both missing gates were the difference between a claim and a guarantee: pnpm test:matching β 279 byte-exact tests β was invoked by no workflow at all, and a lost match was not a failure, so a match β nonmatch flip could be committed as a routine refresh. Both are now wired, and seven mechanisms were re-measured by ablation to establish which are actually guarded by a row.
coalesce.ts justified its loop-body exclusion as sound-critical and then admitted no such harness was committed. Now it is (#37) β a differential fuzzer that interprets the original and every candidate the pass emits, with control flow from a pre-drawn schedule so both trees take the same path by construction. Gates as shipped: 176 candidates, 0 changed reads. Loop flag suppressed: 376 candidates, 55 changed reads.
Then gates became data (#38). A gate is now {id, why, sound, guardedBy, rejects}, so sound: true costs something: ablation is a value, guardedBy is matched against real test titles (which caught a stale one immediately), and an earlier ~45-line ablation-by-input hack was deleted. The same PR found that the "three uncoordinated address-placement policies" are four, and that the unguarded edge β gvn hoisting to the entry block β is safe only because another module keys a rule on opcode === 'const'. Widen it and the function-top local gvn exists to delete comes back, with the entire offline suite passing.
That is the lesson worth carrying out of this release: a test can pass for the wrong reason, so every refusal test now needs a positive control.
Also
docs/refreshed for what these rounds changed (#39)- 10 new test files and roughly 2,200 lines of tests against 2,600 lines of core source β the suite now stands at 868 offline core/cli tests (1,127 across the whole workspace) plus the 279 byte-exact matching tests that hosted CI still cannot run and that
benchmark.ymlfinally does
Full changelog: v0.4.0...v0.5.0