@asmlift/core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +39 -11
  4. package/src/backend/pascal.ts +2 -2
  5. package/src/codegen-flags.ts +640 -0
  6. package/src/contracts.ts +60 -11
  7. package/src/frontend/disasm.ts +141 -11
  8. package/src/frontend/high-half.ts +149 -0
  9. package/src/frontend/mips.ts +458 -209
  10. package/src/frontend/ppc.ts +332 -67
  11. package/src/frontend/reloc-symbol.ts +109 -0
  12. package/src/frontend/splat.ts +56 -18
  13. package/src/frontend/ssa.ts +127 -30
  14. package/src/frontend/stackargs.ts +420 -0
  15. package/src/frontend/thumb.ts +209 -232
  16. package/src/ir/alias.ts +24 -0
  17. package/src/ir/core.ts +70 -3
  18. package/src/ir/opcodes.ts +52 -7
  19. package/src/ir/parse.ts +7 -1
  20. package/src/ir/simplify.ts +1 -1
  21. package/src/l3/address.ts +2 -2
  22. package/src/l3/advance.ts +373 -0
  23. package/src/l3/argbase.ts +6 -6
  24. package/src/l3/argcopy.ts +269 -0
  25. package/src/l3/ast.ts +110 -22
  26. package/src/l3/basecse.ts +50 -30
  27. package/src/l3/coalesce.ts +118 -61
  28. package/src/l3/gates.ts +75 -1
  29. package/src/l3/hoist.ts +1 -1
  30. package/src/l3/homesplit.ts +13 -13
  31. package/src/l3/initfirst.ts +3 -3
  32. package/src/l3/inlinebase.ts +16 -16
  33. package/src/l3/mentions.ts +68 -5
  34. package/src/l3/mulfirst.ts +3 -3
  35. package/src/l3/nearbase.ts +4 -4
  36. package/src/l3/offmember.ts +5 -5
  37. package/src/l3/parkfirst.ts +6 -6
  38. package/src/l3/pollguard.ts +3 -3
  39. package/src/l3/ptrfield.ts +4 -4
  40. package/src/l3/regspell.ts +8 -8
  41. package/src/l3/reindex.ts +22 -17
  42. package/src/l3/scopebase.ts +32 -29
  43. package/src/l3/sinkinit.ts +7 -7
  44. package/src/l3/slotorder.ts +3 -3
  45. package/src/l3/storage.ts +1 -1
  46. package/src/l3/tailmerge.ts +2 -2
  47. package/src/l3/tailret.ts +70 -0
  48. package/src/l3/typing.ts +3 -3
  49. package/src/l3/unmerge.ts +483 -59
  50. package/src/l3/unreduce.ts +15 -14
  51. package/src/l3/volatileptr.ts +11 -11
  52. package/src/l3/volatileval.ts +11 -11
  53. package/src/l3/volstore.ts +16 -16
  54. package/src/l3/zerosub.ts +6 -6
  55. package/src/mangle.ts +49 -0
  56. package/src/pattern/engine.ts +132 -17
  57. package/src/pipeline.ts +39 -16
  58. package/src/proto.ts +2 -2
  59. package/src/raise/const.ts +203 -3
  60. package/src/raise/divpow2.ts +2 -2
  61. package/src/raise/extscale.ts +345 -0
  62. package/src/raise/globalshape.ts +32 -12
  63. package/src/raise/gvn.ts +2 -2
  64. package/src/raise/magicdiv.ts +2 -2
  65. package/src/raise/memberarrays.ts +4 -4
  66. package/src/raise/narrowlocal.ts +18 -2
  67. package/src/raise/paramwidth.ts +133 -3
  68. package/src/raise/pre-recovery.ts +100 -25
  69. package/src/raise/retsink.ts +389 -19
  70. package/src/raise/shortcircuit.ts +595 -34
  71. package/src/raise/structs.ts +4 -4
  72. package/src/raise/tailsink.ts +141 -0
  73. package/src/rank-declare.ts +21 -13
  74. package/src/{rank-axes.ts → rank-variations.ts} +319 -189
  75. package/src/rank.ts +1176 -805
  76. package/src/structure/analysis.ts +87 -90
  77. package/src/structure/bitfields.ts +130 -30
  78. package/src/structure/globalaccess.ts +30 -4
  79. package/src/structure/namecoalesce.ts +32 -13
  80. package/src/structure/retspell.ts +95 -0
  81. package/src/structure/structure.ts +1425 -201
  82. package/src/structure/switch-recover.ts +101 -8
  83. package/src/symbols.ts +127 -6
  84. package/src/target.ts +374 -44
  85. package/src/trace.ts +28 -19
  86. package/src/variation-definitions.ts +1590 -0
  87. package/src/variation-gates.ts +92 -0
  88. package/src/variation-tokens.ts +356 -0
@@ -1,6 +1,6 @@
1
1
  // asmlift — return-sinking (F-CFG-class structural pass; successor-aware, ISA-neutral).
2
2
  //
3
- // A short-circuit `if (a && b) return X; return Y;` (and the `||` / value-returning variants) compiles to
3
+ // A short-circuit `if (a && b) return X; return Y;` (and the `||` / value-returning forms) compiles to
4
4
  // a diamond whose arms converge on a single RETURN block: `br ^merge(X)` / `br ^merge(Y)` into
5
5
  // `^merge(v): ret v`. The structurer lowers that merge as a shared VARIABLE — `v0 = X … v0 = Y … return v0`
6
6
  // — which is byte-exact-CORRECT but recompiles DIFFERENTLY from the source: agbcc/gcc, given the natural
@@ -11,13 +11,188 @@
11
11
  // merge. The structurer then emits early returns in each arm (it already duplicates a shared arm block),
12
12
  // which recompiles to the compiler's shared-return form. Purely structural: no new IR/AST vocabulary.
13
13
  //
14
- // GATE — only the SHORT-CIRCUIT shape, never a simple value-select. A single-condition select
15
- // (`c ? x : y`, and the branchless-compare idioms `clamp0`/`le0`/…) also converges two arms on a return
16
- // merge, but there the compiler emits the MERGE-VARIABLE form, which is what byte-matches — sinking it
17
- // would REGRESS those. The distinguishing signal is structural: a short-circuit chain converges on a
18
- // SHARED arm — the common early-exit reached from ≥2 CONDITIONS — whereas a simple diamond's arms are
19
- // each reached from one. So sink only when some branch-predecessor of the merge is ARRIVED at from
20
- // two places; every simple select stays a merge var.
14
+ // GATE — the SHORT-CIRCUIT shape, plus the one single-condition shape a merge variable cannot spell.
15
+ // A single-condition select (`c ? x : y`, and the branchless-compare idioms `clamp0`/`le0`/…) also
16
+ // converges two arms on a return merge, and for most of them the compiler emits the MERGE-VARIABLE
17
+ // form, which is what byte-matches — sinking those would REGRESS them. The distinguishing signal is
18
+ // structural: a short-circuit chain converges on a SHARED arm — the common early-exit reached from
19
+ // ≥2 CONDITIONS — whereas a simple diamond's arms are each reached from one. So sink when some
20
+ // branch-predecessor of the merge is ARRIVED at from two places.
21
+ //
22
+ // A ONE-SET-ARM DIAMOND IS THE EXCEPTION (`SELECT_GATES`), and it is a compiler fact rather than a
23
+ // preference — THE SAME compiler fact `raise/narrowlocal.ts` already owns, read in the same
24
+ // backwards direction. gcc 2.x's `jump_optimize` (`gcc/jump.c:443-445`, guard at `:471-502`)
25
+ // collapses `if (c) v = a; else v = b;` into `v = b; if (c) v = a;` when both arms are ONE
26
+ // speculatable SET, so agbcc never emits that diamond back: one arm is HOISTED above the compare
27
+ // and the other becomes a conditional skip — `movs r0,#5; cmp r1,#0; bne .L; movs r0,#3; .L: bx lr`,
28
+ // four blocks collapsed to two, with no unconditional branch to the merge at all. For the {0,1} pair
29
+ // it goes further and folds branchlessly (`negs r0,r0; lsrs r0,r0,#31`), erasing the comparison too.
30
+ // So where the TARGET holds that diamond, a merge variable is the spelling of some other function,
31
+ // and sinking is the only candidate that can match (measured on `kleod:IsSelectButtonPressed:agbcc`,
32
+ // retired 2026-09-13).
33
+ //
34
+ // ONE MODEL, NOT TWO. The predicate is `narrowlocal.ts`'s exported `armIsOneSet` — no op that
35
+ // `REEVAL_UNSAFE_OPS` calls unsafe, and EXACTLY one result-producing op — read here by
36
+ // `arms-are-one-set`. It is cited there line by line to `jump.c:474/:480/:482/:483` and
37
+ // `rtlanal.c:1770-1784`, with the three things an op count alone gets wrong, and a reach census over
38
+ // 13733 blocks. It is SHARED rather than re-derived here because it is one optimizer guard and a
39
+ // second model of it would drift; where the shared predicate costs this pass something narrowlocal
40
+ // does not pay is named below.
41
+ //
42
+ // THE EVIDENCE IS COMPILED AND COMMITTED, not described — seven functions written each way, agbcc
43
+ // -O2 -mthumb (`test/corpus/agbcc-select-{merge,early}.s`, regenerated by
44
+ // `scripts/regen-select-spelling-probes.ts`, asserted by `select-spelling.test.ts`). Read as a
45
+ // truth-table for `armIsOneSet` in THIS direction:
46
+ //
47
+ // selbtn v = 1 / v = 0 one SET merge LOSES the diamond, early keeps it ADMIT
48
+ // selk53 v = 5 / v = 3 one SET merge LOSES the diamond, early keeps it ADMIT
49
+ // selpool two 32-bit pool consts one SET merge LOSES the diamond, early keeps it ADMIT
50
+ // selcomp v = a + b / v = a - b one SET merge LOSES the diamond, early keeps it ADMIT
51
+ // selbody *p = 1; v = 5 / … bodied BOTH keep it, opposite ARM ORDER refuse
52
+ // selcomp3 v = a + b + 7 / … 3 ops BOTH keep it, opposite ARM ORDER refuse
53
+ // selload v = *p / v = *q a READ BOTH keep it, opposite ARM ORDER refuse
54
+ //
55
+ // The refusals are the load-bearing half. Where the arms are not one SET the hoist does not
56
+ // happen, so BOTH spellings emit a diamond and they differ only
57
+ // in ARM ORDER — sinking there is not wrong, it is the wrong SENSE, and the unranked primary
58
+ // (`packages/cli/test/matching`, the CLI one-shot, apps/web's preset) has no `/flip-branch` to
59
+ // recover it. Five shapes were measured losing a byte-exact match to the missing clause — a bodied
60
+ // arm, one bodied arm, a bodied `x == 3` head, bodied pool constants, and a two-way `switch` with a
61
+ // `default` — and they are pinned in `packages/cli/test/matching/fixtures.ts` (`selbody`, `sw2`).
62
+ //
63
+ // WHERE THE SHARED PREDICATE IS CONSERVATIVE, and why that is accepted. `armIsOneSet` COUNTS
64
+ // constants, because the lifted IR does not say which immediate a target can fold. `selk3`
65
+ // (`v = a + 3` / `v = a - 3`) is two ops in the IR and one `adds` on this target, so `jump.c`'s own
66
+ // guard counts one SET where this predicate counts two and the arm is hoisted while the clause
67
+ // refuses it — an over-refusal that costs a CANDIDATE here, where narrowlocal's same over-refusal
68
+ // costs nothing (its fallback is the spelling it emits anyway). That divergence is ARGUED from the
69
+ // optimizer, not compiled: `selk3` is deliberately absent from the committed pair below, which is
70
+ // the truth-table for the predicate rather than for the compiler. Accepted rather than forked: no
71
+ // corpus row inhabits the difference, and a cost model neither pass has is a worse thing to own than
72
+ // a shared conservative predicate. Two more shapes it refuses on the same counting grounds, both
73
+ // unreached on the corpus: an arm left EMPTY by an earlier asmlift pass hoisting its constant into
74
+ // the head (`:480` runs `single_set` on the arm's own insn, and an arm whose only insn is its jump
75
+ // has none), and an arm carrying TWO constants into a multi-operand `ret`.
76
+ //
77
+ // THAT IS A FACT ABOUT ONE COMPILER, so it has a per-compiler home rather than an `arch ==` branch,
78
+ // and it is the field narrowlocal already threads: `compilerBehaviors.hoistsSingleSetArm`
79
+ // (target.ts), threaded in as `RetSinkOptions` from `decompile`'s own target, read by the
80
+ // `compiler-hoists-single-set-arm` clause. ONE field, because it is one guard: a second boolean for
81
+ // it would let a round that measures mwcc's `jump_optimize` set one reader's and leave the other's
82
+ // false. Set on agbcc
83
+ // and nowhere else — this pass is ISA-neutral and runs for IDO, gcc2.7.2kmc and mwcc too, and
84
+ // nothing has compiled the pair on any of them. The short-circuit admissions above take no such
85
+ // clause: their argument is about a SHARED ARM in the CFG, not about a compiler's hoist.
86
+ //
87
+ // THE COMPILER CLAUSE IS LAST IN THE TABLE, not first. A census of a gate table counts the clause
88
+ // that FIRST refuses each site, and `target.ts` states that this admission reaches no non-agbcc row.
89
+ // Placed first, this COMPILER fact collects 28 of the 74 sites — 26 of them shapes that are not
90
+ // diamonds at all — and a census then reads the opposite of that sentence. Last, it decides 0 sites
91
+ // on the entire corpus, which is what the sentence claims. Costs nothing either way (measured: 0 of
92
+ // 1039 rows move on the reorder).
93
+ //
94
+ // WHAT EACH CLAUSE ACTUALLY DECIDES, measured by ablating it and re-lifting all 1039 corpus rows
95
+ // offline (`targetAsm` out of the artifact, source-hash diff), ON THE TABLE AS IT STANDS — a number
96
+ // here is only true of the clause ORDER it was measured under, so `retsink.test.ts` pins that order
97
+ // and the next reorder fails a test:
98
+ //
99
+ // two-arms-one-head 61 sites / 0 rows arms-are-one-set 8 sites / 6 rows
100
+ // pre-diamond 2 sites / 0 rows a-value-is-returned 2 sites / 0 rows
101
+ // compiler-hoists-single-set-arm 0 sites / 0 rows ADMIT 1 site
102
+ // no-arrival-but-the-arms 0 sites / 0 rows
103
+ //
104
+ // The table is reached at 74 sites over the 1039 rows and admits ONE, and the SIX rows
105
+ // `arms-are-one-set` decides alone are `pokeemerald:GiveBerryPowder:agbcc`,
106
+ // `pokeemerald:MathUtil_Div16:agbcc`, `pokeemerald:MathUtil_Div16Shift:agbcc`,
107
+ // `sa3:sub_8001FD4:agbcc`, `synthetic:armkeep:agbcc`, `synthetic:bgfixed:agbcc` — five of them MATCH
108
+ // today. Three clauses decide 0 rows, and they are not the same kind of zero:
109
+ //
110
+ // • `compiler-hoists-single-set-arm` decides 0 SITES as well, which is the point of putting it
111
+ // last, and is what `target.ts` claims for it. Moved back to first it collects 28 sites and
112
+ // still moves 0 rows.
113
+ // • `no-arrival-but-the-arms` and `pre-diamond` decide the SAME two sites
114
+ // (`pokeemerald:GetGenderFromSpeciesAndPersonality:agbcc`,
115
+ // `pokeemerald:TrySetCantSelectMoveBattleScript:agbcc`) and the order between them decides which
116
+ // one the census bills. Both are merges with a third in-edge: `mergeArms` counts ALL
117
+ // predecessors where `two-arms-one-head` counts only the `br` ones, so the pre-recovery map
118
+ // already called them no diamond. NO MANUFACTURED DIAMOND REACHES THIS TABLE TODAY — the reason
119
+ // is still `fusedDiamond` being tested first in the same disjunction, and `pre-diamond` is the
120
+ // clause that stops that being an accident. Argued, not corpus-paid, and marked as such.
121
+ // • `a-value-is-returned` first-refuses two agbcc sites (below) and is subsumed by
122
+ // `arms-are-one-set` a step later.
123
+ //
124
+ // THE THREADING IS NOT FRAGILE, which was the open question when `pre-diamond` was proposed: of the
125
+ // 74 sites, ZERO find their merge block ABSENT from the pre-recovery map. Block identity survives
126
+ // all twelve pre-recovery passes, their eight `dce` runs and `recoverTypes`, so every refusal here
127
+ // is a real `diamond: false` and never a lost key.
128
+ //
129
+ // The named single-condition controls — `maxi`/`mini`/`absdiff`/`clamp0`/`bittest` — are NOT
130
+ // protected by any of these clauses: agbcc
131
+ // emits those as a branch-over-one-instruction (or branchlessly), so their targets hold no two-armed
132
+ // diamond and `two-arms-one-head` has already refused them. A control that stays MATCH because it
133
+ // never reaches the table is not evidence the table is right — the shapes that DO reach it are the
134
+ // bodied ones above, and they are what `packages/cli/test/matching` pins.
135
+ //
136
+ // REACH: ONE corpus row. Re-lifting all 1039 rows against `origin/main` changes the emitted source
137
+ // of `kleod:IsSelectButtonPressed:agbcc` and of nothing else. That is one inhabitant because the
138
+ // corpus has one, not because the rule is shaped to it: the three synthetic rows minted for this
139
+ // admission (`selconst`, `selhead` — a body in the HEAD, arms still one SET each — and `selloop`, a
140
+ // loop ahead of the diamond; see `apps/benchmark/dataset/synthetic.ts`) are MATCH on agbcc as well,
141
+ // the last of them composing with another variation. FAILURE DIRECTION: a wrong admission costs
142
+ // a SPELLING and never an answer — the transform is a tail duplication, every arm keeps the value it
143
+ // carried — which is why every clause here is `sound: false`.
144
+ //
145
+ // SINKING IS NECESSARY, NOT SUFFICIENT: `/flip-branch` was necessary on every inhabitant measured —
146
+ // the 4 shapes the admission was built on (`if (x & 0x40) return 1; return 0;` and its inverse,
147
+ // `if (x > 3) return 5; return 3;`, `if (x == 0) return 1; return 0;`), and the three synthetic rows
148
+ // above (the loop one's winner's variations are `signed/flip-branch/indexed`). Unranked, all four of the first
149
+ // score 3 and none matches; the target row's own winner's variations moved `unsigned` → `unsigned/flip-branch`. The mechanism is structural rather than a
150
+ // property of the sample: a sunk diamond has NO JOIN left, so the shipped joined-if default (the
151
+ // layout reading) does not cover it, and on every diamond measured here agbcc puts the source's
152
+ // taken arm in the FAR block, which makes the layout reading systematically inverted. "Both arms are
153
+ // one SET" is a per-SITE fact this pass holds at the moment it fires, so emitting the target's
154
+ // sense HERE would move the unranked path too — the CLI one-shot, apps/web's preset, and
155
+ // `packages/cli/test/matching`, none of which the ranked fan rescues. That is a separate round with
156
+ // its own regression surface (`regression.test.ts`'s fixtures, the byte-pinned playground preset)
157
+ // and is booked rather than bundled.
158
+ //
159
+ // WHY A GATE AND NOT A VARIATION, since `l3/unmerge.ts` is this tree's other "duplicate a join back
160
+ // into the arms" pass and IS one. `unmerge.ts` is a variation on the stated grounds that the mapping
161
+ // from its tree back to a source is not a function and not uniformly many-to-one — which way it
162
+ // goes is a property of the SHAPE, and no gate there can read that off the tree. This admission
163
+ // asserts the opposite for its own shape, and the assertion is what `arms-are-one-set` IS: on the
164
+ // question that clause reads, the mapping is a function, the differ never has to referee it, and the fan
165
+ // does not grow (4 candidates before, 4 after). A variation where a default belongs doubles every
166
+ // enumeration to referee a question with one answer.
167
+ //
168
+ // WHAT MOVES THE THING THIS DEFAULT PLACES. `docs/level-tower.md`: a compiler behavior read
169
+ // backwards "owes an explicit refusal for every pass that moves the thing it is placing".
170
+ // The thing placed is a TWO-ARMED DIAMOND, and asmlift manufactures one. `raise/shortcircuit.ts`'s
171
+ // `branch-shortcircuit` builds diamonds out of condition trees the ROM never merged — measured in
172
+ // `narrowlocal.ts`'s `mergeShapes` header, where 20 sa3 blocks gain `diamond` between lift and
173
+ // pre-recovery's end and that pass accounts for all 10 that gain `hoistable`. `sinkReturns` runs
174
+ // LATER STILL (`pipeline.ts`: after all twelve pre-recovery passes, eight of them followed by `dce`,
175
+ // and after `recoverTypes`), so it sees every one of them.
176
+ //
177
+ // THE REFUSAL IS `pre-diamond`, and it is the same answer narrowlocal reached: the shape is read
178
+ // from `PreRecoveryFacts.mergeShapes`, computed ONCE on the CFG as it ENTERS pre-recovery and
179
+ // threaded down. A merge block absent from that map — created by a later pass — reads as no diamond
180
+ // and is refused. A manufactured diamond therefore cannot be mistaken for one the ROM held, which is
181
+ // the whole content of the backwards read.
182
+ //
183
+ // IT IS AN ARGUED GUARD AND NOT A CORPUS-PAID ONE, and the honest version of the claim is this: no
184
+ // manufactured diamond reaches this table today, because `fusedDiamond` is tested before
185
+ // `constantSelect()` in the same disjunction, so a `logic_and`-fused manufacture takes the
186
+ // short-circuit path instead. That is a coincidence of evaluation order on a corpus with exactly ONE
187
+ // admit site — nothing here could have caught it changing — and `pre-diamond` is what makes the
188
+ // refusal stated rather than accidental. A pre-recovery fusion CAN destroy the CFG signal these
189
+ // clauses read without any hosted gate noticing, so the divergence is worth pinning even unreached:
190
+ // the fixture (`retsink.test.ts`) builds it by hand — a live diamond the pre-recovery map does not
191
+ // have — because the corpus supplies none.
192
+ // What the clause does NOT claim to cover is the second half of the tower's sentence — an IR
193
+ // boundary the FRONTEND invents. `mergeShapes`' own header names that residue (the frontend cuts
194
+ // blocks at labels, `applyIdiomPatterns` folds shift pairs, both before pre-recovery runs, and both
195
+ // only ever make an arm SHORTER), and it is inherited here unchanged rather than re-argued.
21
196
  //
22
197
  // THE QUANTITY IS ARRIVALS, NOT PREDECESSORS. A FALL-THROUGH switch arm is the difference:
23
198
  // `case 2: r++; case 1: r++;` gives case 1's body two predecessors — the dispatch's `beq`, and
@@ -64,10 +239,11 @@
64
239
  //
65
240
  // This does NOT recover the boolean-VALUE form `return a && b` — that is shortcircuit.ts's job
66
241
  // (the `logic_and`/`logic_or` connective plus agbcc's `(-b|b)>>31` = `b!=0` normalisation).
67
- import { Block, Fn, Op, Value, defOpMap, isBodyless, mkOp, predecessors, terminator } from '../ir/core';
242
+ import { Block, Fn, Op, Value, defOpMap, fallThroughOf, isBodyless, mkOp, predecessors, terminator } from '../ir/core';
68
243
  import { NEGATED_ICMP } from '../ir/opcodes';
69
244
  import { simplifyTrivialPhis } from '../ir/simplify';
70
245
  import { type Gate, firstRejection } from '../l3/gates';
246
+ import { type MergeShape, armIsOneSet } from './narrowlocal';
71
247
 
72
248
  /** The fused short-circuit connectives (raise/shortcircuit.ts). A `cond_br` on one of these is the
73
249
  * post-fusion record of the ≥2 conditions that used to reach a shared arm. */
@@ -114,11 +290,130 @@ export const FALL_IN_GATES: readonly Gate<FallInCandidate>[] = [
114
290
  id: 'one-dispatch-owning-the-merge',
115
291
  why: 'both arms of ONE dispatch on one scrutinee, and that dispatch owns the return merge',
116
292
  sound: false,
117
- guardedBy: 'ablating the dispatch gate reads an `if` join, and a guarded switch, as fall-ins',
293
+ guardedBy: 'retsink.test.ts: ablating the dispatch gate reads an `if` join, and a guarded switch, as fall-ins',
118
294
  rejects: (c) => c.dispatches.length === 0,
119
295
  },
120
296
  ];
121
297
 
298
+ /** A return merge offered to the ONE-SET-ARM admission of the header. Like `FallInCandidate` the
299
+ * table is a value, so each clause can be dropped and the pass re-run on real input. */
300
+ export interface SelectCandidate {
301
+ /** the unconditional-branch predecessors of the merge */
302
+ readonly brPreds: readonly Block[];
303
+ /** every predecessor of the merge, `brPreds` included */
304
+ readonly preds: readonly Block[];
305
+ /** the block both arms are reached from, when exactly one block reaches both by a `cond_br`
306
+ * whose two successors ARE the arms — null when the shape is anything else */
307
+ readonly head: Block | null;
308
+ /** whether the merge was ALREADY a two-armed diamond on the CFG as it entered pre-recovery
309
+ * (`PreRecoveryFacts.mergeShapes`) — false for a block a later pass created or reshaped, and
310
+ * false whenever the caller threaded no map */
311
+ readonly preDiamond: boolean;
312
+ /** the ops defining the values the arms carry in, one per arm per returned operand; `undefined`
313
+ * where the value has no defining op (a block parameter, or a live-in) */
314
+ readonly carried: readonly (Op | undefined)[];
315
+ /** whether EVERY arm is one speculatable SET — `raise/narrowlocal.ts`'s exported `armIsOneSet`,
316
+ * this tree's one model of `gcc/jump.c:471-502`'s guard */
317
+ readonly armsOneSet: boolean;
318
+ /** whether THIS target's compiler is one the hoist was measured on
319
+ * (`compilerBehaviors.hoistsSingleSetArm`) */
320
+ readonly targetHoists: boolean;
321
+ }
322
+
323
+ export const SELECT_GATES: readonly Gate<SelectCandidate>[] = [
324
+ {
325
+ // The diamond itself: two distinct arms, each reached only from one head, and that head's
326
+ // `cond_br` choosing between exactly the two of them. A FALL-THROUGH switch has neither — its
327
+ // arms run on into one another and its tests reach the shared return directly — so this
328
+ // admission never overlaps the fall-in machinery above. What it DOES overlap is a two-way
329
+ // `switch` with a `default`: that is a diamond, it
330
+ // reaches this table, and it is judged on the shape it has rather than the keyword that spelled
331
+ // it (`sw2`, packages/cli/test/matching — a bodied one, so `arms-are-one-set` refuses it).
332
+ id: 'two-arms-one-head',
333
+ why: 'both arms chosen by ONE `cond_br` and reached from nowhere else — the diamond itself',
334
+ sound: false,
335
+ rejects: (c) => c.head === null,
336
+ },
337
+ {
338
+ // THE TOWER'S OBLIGATION FOR A BACKWARDS DEFAULT, discharged (header). The clause above reads
339
+ // the CFG as it stands at this pass's turn; this one asks whether the ROM had the same shape,
340
+ // by reading `mergeShapes` off the CFG as it ENTERED pre-recovery. `raise/shortcircuit.ts`
341
+ // MANUFACTURES two-armed diamonds out of condition trees the ROM never merged, and it runs
342
+ // before this pass; a manufactured one is not evidence about how agbcc spelled anything.
343
+ id: 'pre-diamond',
344
+ why: "the diamond must be the ROM's, not one a pre-recovery pass manufactured out of a condition tree",
345
+ sound: false,
346
+ guardedBy: 'retsink.test.ts: a diamond absent from the pre-recovery map is refused',
347
+ rejects: (c) => !c.preDiamond,
348
+ },
349
+ {
350
+ // `carried` is read off the two arms, so an arrival that is not an arm carries a value nothing
351
+ // here has judged. A guard branching onto the same `return` hands the merge whatever it was
352
+ // holding — and the hoist argument is about ALL of a merge variable's assignments, not two of
353
+ // the three.
354
+ id: 'no-arrival-but-the-arms',
355
+ why: 'a third in-edge carries a value the arm test never saw',
356
+ sound: false,
357
+ // Dropping it also changes the lift of `pokeemerald:GetGenderFromSpeciesAndPersonality:agbcc`.
358
+ guardedBy: 'retsink.test.ts: every one-set-arm clause refuses a shape the two-armed evidence does not cover',
359
+ rejects: (c) => c.preds.length !== c.brPreds.length,
360
+ },
361
+ {
362
+ // The claim is about a merge VARIABLE, and a `ret` with no operands has none: there is no value
363
+ // for agbcc to hoist above the compare, so nothing says it would not re-emit this shape.
364
+ //
365
+ // SUBSUMED ON THIS CORPUS, and the row ids are the point. Instrumenting `firstRejection` over
366
+ // all 1039 rows shows the clause first-refusing two agbcc sites — `kleod:Decompress` (kl-eod-decomp's source, before 2026-09-13) and
367
+ // `kleod:ReadKeyInput` — so the ARM Thumb frontend really does hand this table operand-less
368
+ // `ret` merges. Ablating it still moves 0 rows,
369
+ // because `arms-are-one-set` refuses both a step later. It is kept for the same reason
370
+ // `no-arrival-but-the-arms` is: the two make independent claims, and this one is the only thing
371
+ // between an operand-less `ret` and admission on a shape whose arms ARE one SET each.
372
+ id: 'a-value-is-returned',
373
+ why: 'a `ret` with no operands carries no merge variable, so the hoist the admission rests on cannot apply',
374
+ sound: false,
375
+ rejects: (c) => c.carried.length === 0,
376
+ },
377
+ {
378
+ // THE HOIST'S OWN GUARD, borrowed rather than re-derived: `raise/narrowlocal.ts`'s
379
+ // `armIsOneSet`, cited there line by line to `gcc/jump.c:474/:480/:482/:483`. An arm that is not
380
+ // one speculatable SET is not hoisted, so the merge-variable spelling keeps its diamond too and
381
+ // sinking trades a byte-exact match for the same shape in the other ARM ORDER — which only the
382
+ // ranked `/flip-branch` can pay back, and the unranked primary cannot. Paid for by five measured
383
+ // byte-exact matches (header).
384
+ //
385
+ // The three places this shared predicate is narrower than the optimizer it models — a
386
+ // foldable-immediate arm, an emptied arm, a two-constant arm — are named in the header; no
387
+ // corpus row inhabits any of them, and all three are in the refusing direction.
388
+ id: 'arms-are-one-set',
389
+ why: 'an arm that is not ONE speculatable SET is never hoisted, so the merge variable keeps the diamond too',
390
+ sound: false,
391
+ // The clause the compiled evidence is committed FOR: `selbody`/`selcomp3`/`selload` keep their
392
+ // diamond in both spellings while `selbtn`/`selk53`/`selpool`/`selcomp` lose it in the merge one
393
+ // (select-spelling.test.ts), and five shapes lose a byte-exact match without it
394
+ // (packages/cli/test/matching). On the corpus, dropping it changes `pokeemerald:GiveBerryPowder`.
395
+ guardedBy: 'select-spelling.test.ts: an arm that is NOT one SET CAN be spelled with a merge variable',
396
+ rejects: (c) => !c.armsOneSet,
397
+ },
398
+ {
399
+ // THE COMPILER THE ARGUMENT IS ABOUT. Every measurement behind this table is agbcc -O2
400
+ // -mthumb; nothing has compiled the pair on IDO, KMC GCC or mwcc. The rest of this file is
401
+ // ISA-neutral and runs for all four, so without this clause an agbcc cost model would decide a
402
+ // PowerPC function with nothing in the code saying so. It claims nothing instead
403
+ // (`target.ts hoistsSingleSetArm`, absent ⇒ false), which is free: re-lifting all 1039 corpus
404
+ // rows shows the admission reaches no non-agbcc row either way.
405
+ //
406
+ // LAST in the table on purpose. A census attributes a site to the clause that FIRST refuses it,
407
+ // so first position would charge this COMPILER fact with 28 refusals, 26 of them shapes that are
408
+ // not diamonds at all. Last, it decides 0 sites on the whole corpus, which is exactly what
409
+ // `target.ts` claims for it.
410
+ id: 'compiler-hoists-single-set-arm',
411
+ why: 'the hoist this admission reads backwards was measured on agbcc and declared nowhere else',
412
+ sound: false,
413
+ rejects: (c) => !c.targetHoists,
414
+ },
415
+ ];
416
+
122
417
  /** The two questions the fall-in clauses ask of the function's comparison-tree dispatches. */
123
418
  interface DispatchModel {
124
419
  /** Is this block part of the dispatch on `s` — either one of its tests, or an arm of one? */
@@ -194,11 +489,35 @@ function dispatchModel(fn: Fn, defs: Map<Value, Op>): DispatchModel {
194
489
  };
195
490
  }
196
491
 
197
- /** Tail-duplicate a return-only merge block into its unconditional-branch predecessors, but ONLY in the
198
- * short-circuit shape (some branch-pred is shared, or the arms are selected by a fused connective).
199
- * Returns whether anything changed. A "return-only" block is exactly one `ret` whose operands are all
200
- * its own block-params, so each predecessor already carries the returned value as a successor arg. */
201
- export function sinkReturns(fn: Fn, gates: readonly Gate<FallInCandidate>[] = FALL_IN_GATES): boolean {
492
+ /** What the one-set-arm admission needs from OUTSIDE the IR it is handed.
493
+ *
494
+ * `hoistsSingleSetArm` is the COMPILER fact, threaded from `decompile`'s own target the SAME
495
+ * `compilerBehaviors` field `raise/narrowlocal.ts` reads, because it is the same `gcc/jump.c` guard.
496
+ * Absent the one-set-arm admission never fires; the short-circuit admissions are
497
+ * compiler-independent and unaffected.
498
+ *
499
+ * `mergeShapes` is the CFG AS IT ENTERED PRE-RECOVERY (`PreRecoveryFacts`), and it is threaded for
500
+ * the reason narrowlocal threads it: `raise/shortcircuit.ts` manufactures two-armed diamonds before
501
+ * this pass runs, and a manufactured one says nothing about how agbcc spelled the function. Absent
502
+ * ⇒ `pre-diamond` refuses every site, so a caller that does not thread it gets no one-set-arm
503
+ * admission at all — the refusing direction, and what `sinkReturns`' hand-built unit callers get
504
+ * unless they opt in. */
505
+ export interface RetSinkOptions {
506
+ readonly hoistsSingleSetArm?: boolean;
507
+ readonly mergeShapes?: ReadonlyMap<Block, MergeShape>;
508
+ }
509
+
510
+ /** Tail-duplicate a return-only merge block into its unconditional-branch predecessors, in the three
511
+ * shapes the header argues for: a short-circuit chain visible in the CFG, one fused into a
512
+ * connective, and a two-armed diamond whose arms are ONE speculatable SET each. Returns whether
513
+ * anything changed. A "return-only" block is exactly one `ret` whose operands are all its own
514
+ * block-params, so each predecessor already carries the returned value as a successor arg. */
515
+ export function sinkReturns(
516
+ fn: Fn,
517
+ opts: RetSinkOptions = {},
518
+ gates: readonly Gate<FallInCandidate>[] = FALL_IN_GATES,
519
+ selectGates: readonly Gate<SelectCandidate>[] = SELECT_GATES,
520
+ ): boolean {
202
521
  let changed = false;
203
522
  const preds = predecessors(fn);
204
523
  const defs = defOpMap(fn);
@@ -216,6 +535,8 @@ export function sinkReturns(fn: Fn, gates: readonly Gate<FallInCandidate>[] = FA
216
535
  return t?.opcode === 'br' && t.successors.length === 1 && t.successors[0].block === m;
217
536
  };
218
537
  for (const m of [...fn.blocks]) {
538
+ // RETURN-ONLY merges. A merged `store…; ret` is `raise/tailsink.ts`'s, and only as rank.ts's
539
+ // `/shared-tail` twin: its IR comes from both spellings.
219
540
  if (m.ops.length !== 1) {
220
541
  continue;
221
542
  }
@@ -275,13 +596,62 @@ export function sinkReturns(fn: Fn, gates: readonly Gate<FallInCandidate>[] = FA
275
596
  const fellInto = (q: Block, target: Block) =>
276
597
  firstRejection(gates, { q, target, dispatches: siblingArms(q, target).filter(ownedBy) }) === null;
277
598
  const arrivals = (p: Block) => (preds.get(p) ?? []).filter((q) => !fellInto(q, p)).length;
278
- if (!brPreds.some((p) => arrivals(p) >= 2) && !fusedDiamond) {
599
+ // (c) ONE-SET-ARM DIAMOND the header's compiler fact. `head` is the diamond read backwards:
600
+ // each arm's only predecessor is the same block, and that block's `cond_br` chooses between the
601
+ // two of them. `carried` is what each arm hands the merge, one entry per arm per returned
602
+ // operand, so a pair whose values come in by different edges is judged together.
603
+ //
604
+ // It is deliberately NOT `narrowlocal.ts`'s `mergeArms`, even though the two agree conjunct for
605
+ // conjunct once `no-arrival-but-the-arms` has run. That one answers "was this a diamond in the
606
+ // ROM" and is read off the PRE-RECOVERY CFG; `pre-diamond` carries that answer here. This one
607
+ // answers "is it a diamond NOW", at the moment a tail duplication is about to rewrite these
608
+ // terminators, and only the live CFG can say so.
609
+ const armsMeetAt = (): Block | null => {
610
+ const [x, y] = brPreds;
611
+ if (brPreds.length !== 2 || x === y) {
612
+ return null;
613
+ }
614
+ const [px, py] = [preds.get(x) ?? [], preds.get(y) ?? []];
615
+ if (px.length !== 1 || py.length !== 1 || px[0] !== py[0]) {
616
+ return null;
617
+ }
618
+ const t = terminator(px[0]);
619
+ const succs = t?.opcode === 'cond_br' ? t.successors.map((e) => e.block) : [];
620
+ return succs.length === 2 && succs.includes(x) && succs.includes(y) ? px[0] : null;
621
+ };
622
+ /** What an arm carries in: one entry per returned operand, resolved through the function-wide
623
+ * `defs` because the value may be defined in the head rather than in the arm. */
624
+ const carriedBy = (p: Block) => {
625
+ const args = p.ops[p.ops.length - 1].successors[0].args;
626
+ return ret.operands.map((o) => defs.get(args[m.params.indexOf(o)]));
627
+ };
628
+ // LAZY, and not for speed: `gate-census.ts` counts a table's refusals as sites it DECIDED, and
629
+ // the short-circuit admissions above settle most sites before this table would have a say.
630
+ // Evaluating it there would report refusals at sites whose answer was never in question.
631
+ const constantSelect = () => {
632
+ return (
633
+ firstRejection(selectGates, {
634
+ brPreds,
635
+ preds: ps,
636
+ head: armsMeetAt(),
637
+ preDiamond: opts.mergeShapes?.get(m)?.diamond === true,
638
+ carried: brPreds.flatMap(carriedBy),
639
+ armsOneSet: brPreds.every(armIsOneSet),
640
+ targetHoists: opts.hoistsSingleSetArm === true,
641
+ }) === null
642
+ );
643
+ };
644
+ if (!brPreds.some((p) => arrivals(p) >= 2) && !fusedDiamond && !constantSelect()) {
279
645
  continue;
280
646
  }
281
647
  for (const p of brPreds) {
282
- const args = p.ops[p.ops.length - 1].successors[0].args;
283
- const sunk = ret.operands.map((o) => args[m.params.indexOf(o)]);
284
- p.ops[p.ops.length - 1] = mkOp('ret', { operands: sunk });
648
+ const t = p.ops[p.ops.length - 1];
649
+ const sunk = ret.operands.map((o) => t.successors[0].args[m.params.indexOf(o)]);
650
+ // The `br` being replaced carries whether the machine BRANCHED to this epilogue or fell into
651
+ // it, which is what `structure/retspell.ts` reads. Sinking puts the return ON that edge, so
652
+ // the edge's fact travels with it; ask the new return block's in-edges instead and they answer
653
+ // how control reached the statements above the return, not how it reached the epilogue.
654
+ p.ops[p.ops.length - 1] = mkOp('ret', { operands: sunk, attrs: fallThroughOf(t) });
285
655
  changed = true;
286
656
  }
287
657
  // If no predecessor still branches to m (all were unconditional), it is unreachable — drop it.