@asmlift/core 0.10.0 → 0.11.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 (68) hide show
  1. package/package.json +1 -1
  2. package/src/backend/c.ts +1 -0
  3. package/src/backend/cfamily.ts +33 -12
  4. package/src/backend/cpp.ts +1 -0
  5. package/src/backend/lines.ts +10 -0
  6. package/src/backend/pascal.ts +17 -5
  7. package/src/cdecl/parse.ts +21 -0
  8. package/src/contracts.ts +73 -20
  9. package/src/frontend/arm-body.ts +155 -0
  10. package/src/frontend/asmdata.ts +2 -1
  11. package/src/frontend/call-plan.ts +197 -45
  12. package/src/frontend/callee-body.ts +370 -0
  13. package/src/frontend/device-pins.ts +18 -8
  14. package/src/frontend/frame-objects.ts +504 -61
  15. package/src/frontend/frontend.ts +20 -0
  16. package/src/frontend/jump-table.ts +35 -20
  17. package/src/frontend/mips.ts +226 -8
  18. package/src/frontend/ppc.ts +26 -2
  19. package/src/frontend/register-struct-call.ts +1 -86
  20. package/src/frontend/ssa.ts +263 -1
  21. package/src/frontend/stackargs.ts +95 -17
  22. package/src/frontend/thumb-entry.ts +340 -0
  23. package/src/frontend/thumb.ts +410 -61
  24. package/src/ir/alias.ts +29 -8
  25. package/src/ir/bits.ts +1081 -66
  26. package/src/ir/core.ts +154 -4
  27. package/src/ir/entry.ts +3 -5
  28. package/src/ir/opcodes.ts +28 -0
  29. package/src/ir/parse.ts +1 -0
  30. package/src/ir/simplify.ts +61 -10
  31. package/src/ir/verify.ts +16 -0
  32. package/src/l3/argbase.ts +2 -0
  33. package/src/l3/ast.ts +294 -2
  34. package/src/l3/basecse.ts +35 -10
  35. package/src/l3/caseendgoto.ts +223 -0
  36. package/src/l3/coalesce.ts +42 -14
  37. package/src/l3/constinit.ts +34 -6
  38. package/src/l3/dce.ts +52 -8
  39. package/src/l3/ptrcell.ts +2 -0
  40. package/src/l3/regspell.ts +2 -0
  41. package/src/l3/reindex.ts +13 -4
  42. package/src/l3/scopebase.ts +2 -0
  43. package/src/l3/tailmerge.ts +2 -0
  44. package/src/pipeline.ts +507 -23
  45. package/src/proto-context.ts +135 -1
  46. package/src/raise/const.ts +25 -0
  47. package/src/raise/declared-volatile.ts +1 -1
  48. package/src/raise/divpow2.ts +192 -0
  49. package/src/raise/offsetnames.ts +412 -10
  50. package/src/raise/pre-recovery.ts +8 -5
  51. package/src/raise/pre-structure.ts +24 -5
  52. package/src/raise/reroll.ts +4 -24
  53. package/src/raise/shortcircuit.ts +192 -23
  54. package/src/raise/unpre.ts +594 -0
  55. package/src/rank-variations.ts +97 -4
  56. package/src/rank.ts +258 -43
  57. package/src/structure/analysis.ts +56 -10
  58. package/src/structure/cross-jumped-tail.ts +216 -0
  59. package/src/structure/globalaccess.ts +46 -1
  60. package/src/structure/structure.ts +4221 -368
  61. package/src/structure/switch-recover.ts +345 -26
  62. package/src/structure/unfolded-branch-constant.ts +90 -0
  63. package/src/symbols.ts +52 -15
  64. package/src/target.ts +269 -8
  65. package/src/trace.ts +28 -14
  66. package/src/variation-definitions.ts +207 -6
  67. package/src/variation-gates.ts +2 -0
  68. package/src/variation-tokens.ts +63 -2
@@ -7,6 +7,7 @@
7
7
  import { constWord } from '../ir/bits';
8
8
  import { Block, Fn, Op, Value, forwardingTarget, isBodyless, successorsOf } from '../ir/core';
9
9
  import { orderSensitive } from '../ir/discipline';
10
+ import { SWAPPED_ICMP } from '../ir/opcodes';
10
11
  import { Expr, Stmt, SwitchCase } from '../l3/ast';
11
12
 
12
13
  /** Which side of a relational test a compiler's dispatch lands a pinned case body on. */
@@ -83,6 +84,10 @@ export interface SwitchRecoverDeps {
83
84
  * l3/ast.ts non-neutrality note). With `breaks`, some paths out reach the
84
85
  * switch's merge instead: `case 1: if (c) break; case 2: …`, the arm's own
85
86
  * `break;` on those paths.
87
+ * - `goto` a `fallthrough` exit whose sibling another arm already falls into: the same
88
+ * paths, ending in `goto` to that sibling's case label (l3/ast.ts) instead of
89
+ * running on into it. Only `jumpSharedFallTargets` below makes one, and only
90
+ * Regime B asks it.
86
91
  * - `unstructurable` anything else: two different siblings, or a sibling beside a block the
87
92
  * switch does not dominate. C needs a `goto` for those. Regime A declines to
88
93
  * if-recovery on this verdict; Regime B, having no fallback, fails LOUD.
@@ -92,18 +97,38 @@ export interface SwitchRecoverDeps {
92
97
  * path to the merge in the middle of the arm as `break;` (`switchBreakAt`), in a closed arm as in a
93
98
  * falling one: `case 1: if (a) { if (b) break; g(1); } h(2); break;`. A closed arm that leaves
94
99
  * sideways, into a block the switch does not dominate, has none: its paths through such a block
95
- * are not the arm's, and it structures to the CFG's joins. */
100
+ * are not the arm's, and it structures to the CFG's joins. It names those blocks instead
101
+ * (`escapedTo`, the first block outside the region on each such path), which decide whether its
102
+ * body can still be copied into the arms that fall into it (`copySharedFallTargets`). An arm that
103
+ * took such a copy carries the SIBLING's `escapedTo`, not its own first outside block: the blocks
104
+ * where the copy steps sideways, which is all the copies read of it. */
96
105
  export type ArmExit =
97
- | { kind: 'break'; region?: ReadonlySet<Block> }
106
+ | { kind: 'break'; region?: ReadonlySet<Block>; escapedTo?: ReadonlySet<Block> }
98
107
  | { kind: 'fallthrough'; to: Block; breaks: boolean; region: ReadonlySet<Block> }
108
+ | { kind: 'goto'; to: Block; breaks: boolean; region: ReadonlySet<Block> }
99
109
  | { kind: 'unstructurable'; why: string };
100
110
 
101
111
  export interface SwitchRecovery {
102
- recognizeSwitch: (b: Block, stop: Block | null) => Stmt[] | null;
112
+ /** `mergeAt`, given the tree's case and default blocks, may name the switch's end ahead of the
113
+ * CFG's (structure.ts's join past early returns); `undefined` keeps `ipdom ?? stop`. */
114
+ recognizeSwitch: (
115
+ b: Block,
116
+ stop: Block | null,
117
+ mergeAt?: (arms: readonly Block[]) => Block | undefined,
118
+ ) => Stmt[] | null;
103
119
  /** shared with the Regime-B (`switch_br`) path in structure.ts. Both regimes recover the
104
120
  * fall-through this returns; on an `unstructurable` verdict Regime A declines to if-recovery and
105
121
  * Regime B, which has no fallback, fails loud. */
106
- analyzeArmExit: (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>, stopAtLoop?: boolean) => ArmExit;
122
+ analyzeArmExit: (
123
+ entry: Block,
124
+ b: Block,
125
+ merge: Block | null,
126
+ siblings: Set<Block>,
127
+ stopAtLoop?: boolean,
128
+ jumps?: ReadonlySet<Block>,
129
+ ) => ArmExit;
130
+ /** may the copies write out a sibling that leaves sideways into `s`? (`copySharedFallTargets`) */
131
+ forwardToMerge: (s: Block, b: Block, merge: Block | null, siblings: ReadonlySet<Block>) => boolean;
107
132
  /** a block's position in the ASSEMBLY — the arm-order evidence, shared with Regime B so the two
108
133
  * regimes read it from one definition (and one statement of what it rests on). */
109
134
  layoutIndex: (blk: Block) => number;
@@ -180,18 +205,6 @@ const INT_MIN = -0x80000000;
180
205
  const INT_MAX = 0x7fffffff;
181
206
  const ALL: Ranges = [[INT_MIN, INT_MAX]];
182
207
 
183
- /** The same opcode with its operands swapped: `k < x` is `x > k`. */
184
- const MIRRORED: Record<string, string> = {
185
- icmp_slt: 'icmp_sgt',
186
- icmp_sgt: 'icmp_slt',
187
- icmp_sle: 'icmp_sge',
188
- icmp_sge: 'icmp_sle',
189
- icmp_ult: 'icmp_ugt',
190
- icmp_ugt: 'icmp_ult',
191
- icmp_ule: 'icmp_uge',
192
- icmp_uge: 'icmp_ule',
193
- };
194
-
195
208
  /** `[lo, hi]` of the UNSIGNED order as signed intervals: one, or two where it straddles 2^31. */
196
209
  function fromUnsigned(lo: number, hi: number): Ranges {
197
210
  if (lo > hi) {
@@ -236,7 +249,7 @@ function intersect(a: Ranges, b: Ranges): Ranges {
236
249
 
237
250
  /** The register values that take the test's BRANCH. */
238
251
  function takenBy(ti: TestInfo): Ranges {
239
- const op = ti.xOnLeft ? ti.opcode : (MIRRORED[ti.opcode] ?? ti.opcode);
252
+ const op = ti.xOnLeft ? ti.opcode : (SWAPPED_ICMP[ti.opcode] ?? ti.opcode);
240
253
  if (op === 'icmp_eq' || op === 'icmp_ne') {
241
254
  const k = ti.k | 0;
242
255
  return op === 'icmp_eq' ? [[k, k]] : complement([[k, k]]);
@@ -276,6 +289,204 @@ function singleton(r: Ranges): number | null {
276
289
  return r.length === 1 && r[0][0] === r[0][1] ? r[0][0] : null;
277
290
  }
278
291
 
292
+ /** TWO OR MORE ARMS ENTERING ONE SIBLING'S BODY, put back as copies of it. A compiler that
293
+ * cross-jumps (agbcc's and gcc 2.7.2's `jump.c` cross-jumping, KMC's with it) finds two arms
294
+ * ending in the same code as a sibling arm and keeps one copy, so `case 1: g(v); gOut = v;
295
+ * break; case 2: gOut = v; break; case 3: h(v); gOut = v; break;` comes back with case 1 and
296
+ * case 3 both entering case 2's `gOut = v`. C drops into an arm from above along one edge only,
297
+ * so read as written that is two fall-throughs into one arm and `chainArms` refuses it — but the
298
+ * source had one fall-through at most.
299
+ *
300
+ * WHICH ENTRANT FALLS IS NOT IN THE ASM, so it is a ranked choice (`/tail-copies`,
301
+ * rank-variations.ts), never a fixed default. The cross-jump keeps ONE copy of the tail and
302
+ * says nothing about which arm the source wrote it under, and one compiler at one set of flags
303
+ * answers both ways: KMC `-O2` and gcc 2.7.2 `-O1` lay arm bodies out in SOURCE order, and a
304
+ * source whose fall-through arm is written directly above its target in case order recompiles
305
+ * only with that arm falling, while one whose arms are written out of case order (`case 3`,
306
+ * `case 2`, `case 1`) recompiles nearer with every entrant holding its own copy. Neither the
307
+ * layout nor the case order decides it: the copy the cross-jump kept is not the source's
308
+ * fall-through, so the arm that physically runs into the tail can be the wrong faller too.
309
+ *
310
+ * - `faller` (the default): at most one entrant keeps its fall-through, the one the caller's
311
+ * arm-order policy (`order`, before the chain moves anything) puts DIRECTLY ABOVE the sibling —
312
+ * on `switchArmsFollowLayout` targets (agbcc) its layout neighbour, elsewhere the arm before it in
313
+ * case order. Every other entrant copies; no entrant above it ⇒ every entrant copies.
314
+ * - not `faller`: every entrant copies, whichever arm sits above the sibling.
315
+ *
316
+ * Either way each copying entrant gets the sibling's region written out again under its own arm:
317
+ * its exit becomes a closed `break` over the two regions.
318
+ *
319
+ * A `default:` SIBLING takes the LAST arm of `order` as the arm above it: that is where `chainArms`
320
+ * puts a default that an arm falls into, at the end. It is not always where the label is printed —
321
+ * a target that prints `default:` at its layout position (`defaultLayoutPos`) can put it between two
322
+ * cases, and then the arm the source wrote above it is not `order`'s last, and its entrants all copy
323
+ * (a measured agbcc shape of that kind still recompiles to its target).
324
+ *
325
+ * A `default:` SIBLING MAY BE CODE THE SOURCE WROTE AFTER THE SWITCH. Where the other arms
326
+ * `return` from a void function they reach the same epilogue the default block does, so
327
+ * post-dominance ends the switch there, and arms that `break` to the code after the switch read as
328
+ * falling into the default block. The copies then write that code into each of them: the same
329
+ * program, not the source's spelling (which would end the switch at the default block and spell
330
+ * the other arms `return`, a reading nothing makes yet).
331
+ *
332
+ * ONLY OVER A CLOSED SIBLING. The copy is the sibling's region walked again inside the entrant's
333
+ * arm, so it is spelled only where that region ends the switch: the sibling's own exit a `break`
334
+ * WITH a region — it reaches no sibling of its own and leaves through no block the switch does
335
+ * not dominate. A sibling that falls on stays two-into-one for `chainArms` to refuse. PURE: the
336
+ * closed exit is exactly what `analyzeArmExit` reads for the entrant once the sibling is no
337
+ * longer a sibling — its walk reaches the sibling's blocks, and they reach the merge and nothing
338
+ * else — so it is assembled here rather than walked again.
339
+ *
340
+ * OR A SIBLING THAT LEAVES SIDEWAYS ONLY FORWARD. A closed sibling with no region stepped into
341
+ * blocks the switch does not dominate (`escapedTo`). Its body is still the switch's end on every
342
+ * path where each of those blocks runs only on to the merge or a `ret` — never back into the
343
+ * dispatch, a sibling (the entrants among them) or round a cycle; a path out through the
344
+ * enclosing loop's exit is one more path to a `ret`. `sidewaysEnds` answers that per block
345
+ * (`forwardToMerge`, the caller's to hand in; absent, no such sibling is copied). Nothing in it is
346
+ * particular to one compiler: it is asked of every jump table whose shared tail lost its region
347
+ * because a block on the tail's way to the merge is also reached from outside the switch. Where it
348
+ * shows:
349
+ * - agbcc's gcse PRE puts the next switch's index in the dispatch block, which then no longer
350
+ * dominates the default the bounds test reaches too, and the default's tail runs on through
351
+ * blocks other arms cross-jumped into (`synthetic:jt_xjdefpre`, kleod EntityCreate);
352
+ * - with no PRE at all (IDO 7.1 has no gcse), the tail runs on into a second jump table after the
353
+ * switch that the other branch of an `if` around it reaches too, and from there to the switch's
354
+ * end: in a `for` whose tail `continue`s (switch-arms.test.ts, the ido7.1 `jl_cont` fixture),
355
+ * or where a `return` in the tail makes the epilogue the merge, and the copies then write that
356
+ * whole second switch into each entrant.
357
+ * Each copying entrant then has no region either: like every arm that leaves sideways, it
358
+ * structures to the CFG's joins, which walks the sibling's blocks once inside it and stops at the
359
+ * merge.
360
+ *
361
+ * `onFallerKept` fires each time `faller` leaves an entrant its fall-through — exactly where the
362
+ * two settings of `faller` return different exits. Where it never fires over a whole structuring,
363
+ * the other setting builds the same tree, which is what rank.ts reads to skip `/tail-copies`.
364
+ * `onCopied` fires with the sibling each time an entrant takes a copy of a CASE arm (not the
365
+ * `default:` arm's), which is where `jumpSharedFallTargets` can read a `goto` instead: rank.ts's
366
+ * witness for `/case-goto`, and the siblings that variation hands it. */
367
+ export function copySharedFallTargets(
368
+ order: readonly Block[],
369
+ dflt: Block | null,
370
+ exitOf: ReadonlyMap<Block, ArmExit>,
371
+ faller: boolean,
372
+ onFallerKept?: () => void,
373
+ sidewaysEnds?: (s: Block) => boolean,
374
+ onCopied?: (to: Block) => void,
375
+ ): Map<Block, ArmExit> {
376
+ const out = new Map(exitOf);
377
+ for (const { to, movers } of sharedFallTargets(order, dflt, exitOf, faller)) {
378
+ const own = exitOf.get(to);
379
+ if (own?.kind !== 'break') {
380
+ continue;
381
+ }
382
+ const { region, escapedTo } = own;
383
+ if (
384
+ region === undefined &&
385
+ (escapedTo === undefined || sidewaysEnds === undefined || ![...escapedTo].every(sidewaysEnds))
386
+ ) {
387
+ continue;
388
+ }
389
+ for (const [e, x] of movers(onFallerKept)) {
390
+ if (to !== dflt) {
391
+ onCopied?.(to);
392
+ }
393
+ out.set(
394
+ e,
395
+ region === undefined
396
+ ? { kind: 'break', escapedTo: escapedTo! }
397
+ : { kind: 'break', region: new Set([...x.region, ...region]) },
398
+ );
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+
404
+ /** TWO OR MORE ARMS ENTERING ONE SIBLING'S BODY, put back as one fall-through and `goto`s to the
405
+ * sibling's case label — where the copies above do not take them (they are read first, and this
406
+ * is read over what they leave). It is the source's own spelling of the shape: `case 1: if (c)
407
+ * continue; goto L; case 2: if (d) continue; goto L; case 3: case 4: L: …` (kleod sub_0800D188's
408
+ * `goto block_1643;`), which every compiler that does not cross-jump a copy into it compiles with
409
+ * the sibling's code once. Unlike the copies it needs nothing of the sibling: the sibling's own exit
410
+ * is read for itself and may fall on, break, or leave sideways, since the jump does not write it
411
+ * out again.
412
+ *
413
+ * WHO FALLS is read from the one place the copies ask it too (`sharedFallTargets`), but with no
414
+ * choice in it: the entrant the caller's arm-order policy puts directly above the sibling always
415
+ * keeps its fall-through, and every other entrant jumps. A `goto` from that arm would land on the
416
+ * label directly below it, a path its plain fall-through already spells, so it is a jump goto-free
417
+ * C does not need (asmlift-101 §2.7). `/tail-copies`' setting of `faller` asks which entrant the
418
+ * SOURCE wrote the body under, a question a jump does not have: it writes no body.
419
+ *
420
+ * ONLY INTO A CASE ARM: the sibling is in `order`, never `dflt`, since only a case arm carries a
421
+ * label (l3/ast.ts `SwitchCase.label`) — and, given `only`, only into those siblings (`/case-goto`
422
+ * hands it the case arms the copies wrote back).
423
+ *
424
+ * WHAT MAKES THE JUMP THE SAME PROGRAM is the exit it replaces. A `goto` exit is a `fallthrough`
425
+ * exit with its last edge spelled as a jump: the arm is structured to stop at the sibling's entry
426
+ * exactly as a falling arm is (structure.ts `structureArm`), so every path that leaves the arm's
427
+ * body at its end is a path into the sibling, with the edge's own copies already run, and the
428
+ * `goto` is the arm's last top-level statement. The jump lands right after the sibling's case
429
+ * labels, which is where a fall-through lands; the emission refuses a sibling that opens with
430
+ * switch-edge copies, as it does for a fall-through: entering it by its own case value runs those,
431
+ * and the jump, landing above them, would run them again. No loop stands between the jump and the
432
+ * label: both are the switch's, in the loop the switch is in. */
433
+ export function jumpSharedFallTargets(
434
+ order: readonly Block[],
435
+ dflt: Block | null,
436
+ exitOf: ReadonlyMap<Block, ArmExit>,
437
+ only?: ReadonlySet<Block>,
438
+ ): Map<Block, ArmExit> {
439
+ const out = new Map(exitOf);
440
+ for (const { to, movers } of sharedFallTargets(order, dflt, exitOf, true)) {
441
+ if (to === dflt || !order.includes(to) || (only !== undefined && !only.has(to))) {
442
+ continue;
443
+ }
444
+ for (const [e, x] of movers()) {
445
+ out.set(e, { kind: 'goto', to, breaks: x.breaks, region: x.region });
446
+ }
447
+ }
448
+ return out;
449
+ }
450
+
451
+ type Entrant = [Block, Extract<ArmExit, { kind: 'fallthrough' }>];
452
+
453
+ /** WHO FALLS, the one answer both rewrites above read: each arm two or more arms fall into (`to`,
454
+ * in the order its first entrant comes in `order` and then `dflt`), and `movers` — its entrants
455
+ * other than the one that keeps its fall-through, with their exits, which each rewrite spells its
456
+ * own way (a copy, a jump). The keeper is the arm the caller's arm-order policy puts directly
457
+ * above `to`, where `faller` is set (`dflt` sits below the last of `order`); `movers` fires
458
+ * `onFallerKept` when that arm is an entrant, which is where the two settings of `faller` differ.
459
+ * It fires on the call, so a rewrite that refuses `to` before asking reports nothing. */
460
+ function sharedFallTargets(
461
+ order: readonly Block[],
462
+ dflt: Block | null,
463
+ exitOf: ReadonlyMap<Block, ArmExit>,
464
+ faller: boolean,
465
+ ): { to: Block; movers: (onFallerKept?: () => void) => Entrant[] }[] {
466
+ const entrants = new Map<Block, Entrant[]>();
467
+ for (const e of [...order, ...(dflt ? [dflt] : [])]) {
468
+ const x = exitOf.get(e);
469
+ if (x?.kind === 'fallthrough') {
470
+ entrants.set(x.to, [...(entrants.get(x.to) ?? []), [e, x]]);
471
+ }
472
+ }
473
+ return [...entrants]
474
+ .filter(([, from]) => from.length >= 2)
475
+ .map(([to, from]) => {
476
+ const at = to === dflt ? order.length : order.indexOf(to);
477
+ const above = faller && at > 0 ? order[at - 1] : undefined;
478
+ return {
479
+ to,
480
+ movers: (onFallerKept?: () => void) => {
481
+ if (above !== undefined && from.some(([e]) => e === above)) {
482
+ onFallerKept?.();
483
+ }
484
+ return from.filter(([e]) => e !== above);
485
+ },
486
+ };
487
+ });
488
+ }
489
+
279
490
  /** Re-thread `order` so every FALLING arm sits directly above the arm it falls into. Each
280
491
  * fall-through chain is emitted contiguously and takes the position of its HEAD in `order`,
281
492
  * which is the caller's own arm-order policy — so with no fall-through every chain is a
@@ -357,8 +568,12 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
357
568
  // the list for every conditional-return branch, wherever in the stream that branch sits. So
358
569
  // its real blocks keep address order among themselves, and each synthetic one — a bare
359
570
  // return, never a test — sorts after all of them;
360
- // - raising only ever REMOVES blocks from the list (raise/{divpow2,latch,retsink,shortcircuit}
361
- // .ts all `filter`), never inserts or reorders, so the frontend's order is what survives.
571
+ // - raising mostly REMOVES blocks from the list (raise/{divpow2,latch,retsink,shortcircuit}.ts
572
+ // all `filter`), but not only: raise/reroll.ts moves a rerolled loop's new head ahead of the
573
+ // old one and inserts a block of its own before it. So this order is the frontend's only
574
+ // where no reroll fired. A reader that needs the asm's own order wherever a pass rewrote the
575
+ // list reads the frontend's record instead (ir/core.ts `AsmLayout`, which the return-tail
576
+ // chooser reads); this one has not been moved onto it.
362
577
  // TWO READERS, both target-gated, and they need DIFFERENT strengths of the compiler half:
363
578
  // - `switchArmsFollowLayout` (arm ORDER, the sort below) needs the full claim — no block moved
364
579
  // at all — because it PLACES the arms from the layout;
@@ -619,6 +834,74 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
619
834
  return true;
620
835
  };
621
836
 
837
+ // A SIDEWAYS PATH THAT ONLY RETURNS. An arm can step into a block the switch does not dominate
838
+ // and still go nowhere but the end of the function: every path from `s` runs forward to a `ret`,
839
+ // never back into the switch, its merge or a sibling, and never round a cycle. Such an arm spells
840
+ // the escaping path in place as its `return;` (`case 0: if (c) { k(a); return; } gOut = 1;`
841
+ // falling into `case 1:`), and a falling arm's join already reads that path as a jump
842
+ // (`regionJoins`, "a `return k` through a shared epilogue"). It is a property of the ARM, not of
843
+ // any loop, so it is asked of every jump-table switch (Regime B; `analyzeArmExit` says why not of
844
+ // Regime A's comparison trees). Where it shows:
845
+ // - in a loop body, where an arm `return`s into the one epilogue past the loop (agbcc and IDO
846
+ // when the code after the loop returns early too, mwcc always) and the switch ends at the
847
+ // body's own join (structure.ts, THE LOOP CLAMP);
848
+ // - outside any loop, where an arm `goto`s a returning tail it shares with code the switch does
849
+ // not dominate: a guard above the switch cross-jumped into the same `k(7); return;`, a shared
850
+ // `out:` label, the other branch of an `if` around the switch, or a switch with no merge.
851
+ // The cycle test is a walk of the paths themselves: a block met again while it is still on the
852
+ // path being walked is a cycle, reducible or not (an IDO `goto mid;` into a `while` body makes an
853
+ // irreducible one, which a dominance test does not see). An edge to a block that dominates its
854
+ // source is refused too, since that block may lie above `s` where the walk never put it.
855
+ //
856
+ // WITH `mergeEnds` it is the copies' question (`forwardToMerge`): a path may also END at the
857
+ // merge, and nothing more is admitted. A path out through the enclosing loop's exit is one more
858
+ // forward path to a `ret`, wherever it falls on the path: the copy writes the code past the loop
859
+ // and its `return;` inside the case arm. A step round the loop to its header needs no test of its
860
+ // own: the header dominates every block of the loop, so the dominance test refuses it from inside,
861
+ // and the cycle test from a path that entered at the header.
862
+ //
863
+ // There the SIBLING and CYCLE refusals are conservative, not needed for soundness: forced off, a
864
+ // tail that steps into a sibling (agbcc, the `jt_xjdefpre` shape with `if (v == 9) return;` in the
865
+ // tail, so the epilogue is the merge and the empty `case 4:` is a sibling) or round a cycle lifts
866
+ // correct, that sibling's body or the loop written out in each entrant. They spare that
867
+ // duplication; dropping one needs a differential over the shapes it then admits.
868
+ const onlyReturns = (
869
+ s: Block,
870
+ b: Block,
871
+ merge: Block | null,
872
+ siblings: ReadonlySet<Block>,
873
+ mergeEnds = false,
874
+ ): boolean => {
875
+ const done = new Set<Block>();
876
+ const onPath = new Set<Block>([s]);
877
+ const stack: { x: Block; next: number }[] = [{ x: s, next: 0 }];
878
+ while (stack.length) {
879
+ const top = stack[stack.length - 1];
880
+ const succ = successorsOf(top.x);
881
+ if (top.next === 0 && succ.length === 0 && top.x.ops[top.x.ops.length - 1]?.opcode !== 'ret') {
882
+ return false;
883
+ }
884
+ if (top.next === succ.length) {
885
+ stack.pop();
886
+ onPath.delete(top.x);
887
+ done.add(top.x);
888
+ continue;
889
+ }
890
+ const y = succ[top.next++];
891
+ if (mergeEnds && y === merge && merge !== null) {
892
+ continue; // a path that reaches the switch's end
893
+ }
894
+ if (y === b || y === merge || siblings.has(y) || onPath.has(y) || dom.get(top.x)!.has(y)) {
895
+ return false; // back into the switch, or a cycle
896
+ }
897
+ if (!done.has(y)) {
898
+ onPath.add(y);
899
+ stack.push({ x: y, next: 0 });
900
+ }
901
+ }
902
+ return true;
903
+ };
904
+
622
905
  // Where does one arm's region LEAVE? Walk it from `entry`, never stepping THROUGH the merge or a
623
906
  // sibling arm's entry, and classify what it steps INTO. `siblings` is every OTHER arm entry the
624
907
  // caller can emit a `case`/`default` label for — the merge is deliberately not among them, so a
@@ -649,19 +932,27 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
649
932
  // under this arm. That is duplication, not a wrong dispatch — the same duplication the structurer
650
933
  // already does for any tail two arms share, and how the case bodies agbcc tail-merged are put
651
934
  // back. Costly for matching, correct to run.
935
+ //
936
+ // `jumps` are blocks an edge into which is a jump that ends its path where it sits, whatever the
937
+ // switch: the latch of a loop spelled `for`, which the structurer writes as `continue;`. Such an
938
+ // edge is neither a fall into a sibling nor an escape, and an arm that IS one is a bare jump.
652
939
  const analyzeArmExit = (
653
940
  entry: Block,
654
941
  b: Block,
655
942
  merge: Block | null,
656
943
  siblings: Set<Block>,
657
944
  stopAtLoop = false,
945
+ jumps: ReadonlySet<Block> = new Set(),
658
946
  ): ArmExit => {
659
947
  if (entry === merge) {
660
948
  return { kind: 'break' }; // an empty arm (a table slot pointing straight at the switch's end)
661
949
  }
950
+ if (jumps.has(entry)) {
951
+ return { kind: 'break', region: new Set([entry]) };
952
+ }
662
953
  const into = new Set<Block>(); // sibling entries this arm flows into
954
+ const escapedTo = new Set<Block>(); // the blocks outside the switch's region it steps into
663
955
  let toMerge = false,
664
- escapes = false,
665
956
  leavesLoop = false;
666
957
  const seen = new Set<Block>([entry]);
667
958
  const q = [entry];
@@ -671,11 +962,13 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
671
962
  for (const s of successorsOf(cur)) {
672
963
  if (s === merge) {
673
964
  toMerge = true;
965
+ } else if (jumps.has(s)) {
966
+ continue;
674
967
  } else if (s !== entry && siblings.has(s)) {
675
968
  into.add(s);
676
969
  } else if (s !== entry && !seen.has(s)) {
677
970
  if (!dom.get(s)!.has(b)) {
678
- escapes = true;
971
+ escapedTo.add(s);
679
972
  continue;
680
973
  }
681
974
  seen.add(s);
@@ -684,7 +977,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
684
977
  }
685
978
  }
686
979
  if (into.size === 0) {
687
- return escapes ? { kind: 'break' } : { kind: 'break', region: seen };
980
+ return escapedTo.size > 0 ? { kind: 'break', escapedTo } : { kind: 'break', region: seen };
688
981
  }
689
982
  // A path to the merge beside the fall-through is the arm's own `break;` — switch-scoped, so it
690
983
  // spells only where nothing breakable sits between that edge and the arm, which the structurer
@@ -695,7 +988,17 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
695
988
  why: `a case body falls into sibling case ${[...into].map((x) => `#${layoutIndex(x)}`).join(', ')}, reaches the end of the switch, and leaves the loop the switch sits in`,
696
989
  };
697
990
  }
698
- if (into.size === 1 && !escapes) {
991
+ // A sideways path that only RETURNS does not stop the fall-through (`onlyReturns`): it is a
992
+ // jump that ends where it sits, as a `ret` inside the arm is. Regime B only. Regime A
993
+ // (`stopAtLoop`) commits to its `switch` on gates that read an arm's `breaks` alone, and has
994
+ // if-recovery to fall back on, so a falling arm beside an escape stays `unstructurable` there:
995
+ // admitted, it commits Regime A to switches whose emission then refuses the whole function (an
996
+ // escape to a `goto out` past a loop exit, or out of a nested switch) where if-recovery spells
997
+ // them.
998
+ if (
999
+ into.size === 1 &&
1000
+ (escapedTo.size === 0 || (!stopAtLoop && [...escapedTo].every((s) => onlyReturns(s, b, merge, siblings))))
1001
+ ) {
699
1002
  return { kind: 'fallthrough', to: [...into][0], breaks: toMerge, region: seen };
700
1003
  }
701
1004
  // Name what is actually missing. These are two different facts: the first is a shape C's
@@ -713,7 +1016,11 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
713
1016
  };
714
1017
  };
715
1018
 
716
- const recognizeSwitch = (b: Block, stop: Block | null): Stmt[] | null => {
1019
+ const recognizeSwitch = (
1020
+ b: Block,
1021
+ stop: Block | null,
1022
+ mergeAt?: (arms: readonly Block[]) => Block | undefined,
1023
+ ): Stmt[] | null => {
717
1024
  const root = testInfo(b);
718
1025
  if (!root) {
719
1026
  return null;
@@ -937,8 +1244,8 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
937
1244
  // a sibling beside a block the switch does not dominate — and Regime A declines to if-recovery,
938
1245
  // which spells every one of those edges. (Regime B reads the same verdicts and fails LOUD on
939
1246
  // either.)
940
- const merge = ipdom.get(b) ?? stop;
941
1247
  const targets = new Set<Block>([...caseBlocks, ...(defaultBlk ? [defaultBlk] : [])]);
1248
+ const merge = mergeAt?.([...targets]) ?? ipdom.get(b) ?? stop;
942
1249
  const siblings = new Set([...targets].filter((t) => t !== merge));
943
1250
  const exitOf = new Map<Block, ArmExit>();
944
1251
  for (const t of siblings) {
@@ -1150,6 +1457,13 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
1150
1457
  // The `default:` arm is a chain member too — an arm may run on into it, and C prints it last.
1151
1458
  const dfltArm = defaultBlk !== null && defaultBlk !== merge ? defaultBlk : null;
1152
1459
  const preChain = [...armsByBlock.keys()];
1460
+ // NO COPIES HERE. Arms a cross-jump sent into one sibling's body do not linearize, and Regime
1461
+ // B reads them back as copies (structure.ts, THE COPIES) because its only alternative is a loud
1462
+ // failure. Regime A's alternative is if-recovery, which LIFTS: a copy reading here would replace
1463
+ // one lift with another, the choice between two legal spellings of one CFG that only a ranked
1464
+ // variation may make (docs/level-tower.md). On comparison trees agbcc and KMC cross-jumped,
1465
+ // the switch spelling measured nearer the target on some and further on others, and no corpus
1466
+ // row reaches this point, so the refusal stands and if-recovery answers.
1153
1467
  const entries = chainArms(preChain, dfltArm, exitOf);
1154
1468
  if (entries === null) {
1155
1469
  return null;
@@ -1279,7 +1593,12 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
1279
1593
  }
1280
1594
  return out;
1281
1595
  };
1282
- return { recognizeSwitch, analyzeArmExit, layoutIndex, defaultLayoutPos, chainArms };
1596
+ /** `copySharedFallTargets`' `sidewaysEnds`: does every path from `s`, a block outside the
1597
+ * switch's region, run only forward to `merge` or a `ret` (`onlyReturns` with the merge as an
1598
+ * end)? */
1599
+ const forwardToMerge = (s: Block, b: Block, merge: Block | null, siblings: ReadonlySet<Block>): boolean =>
1600
+ merge !== null && onlyReturns(s, b, merge, siblings, true);
1601
+ return { recognizeSwitch, analyzeArmExit, forwardToMerge, layoutIndex, defaultLayoutPos, chainArms };
1283
1602
  }
1284
1603
 
1285
1604
  /** Internal surface for this module's own tests, and for nothing else: PRE3 reads each test
@@ -0,0 +1,90 @@
1
+ // AN EARLY RETURN THAT THE JOIN PAST EARLY RETURNS CANNOT SPELL: a return of the value its branch
2
+ // tested equal to a constant, where the object still returns the value and not the constant.
3
+ //
4
+ // On a compiler whose cse folds a branch-proven constant only into the branch's FALL-THROUGH arm
5
+ // (target.ts `foldsBranchConstantOnTakenEdge` false: gcc 2.x at -O1), `if (x == 0) return x;`
6
+ // compiles to `return 0`, and so does the same test spelled `if (x != 0) ; else return x;`. The join
7
+ // writes the early return one of those two ways. A return of `x` the object left unfolded was
8
+ // therefore the jump-target arm of its branch in the source: the `else` of `if (x != 0) { ...tail }
9
+ // else { return x; }`, which is the copies' spelling. So this mark publishes the copies.
10
+ //
11
+ // THE CONSTANT IS NOT REMATERIALISED ON THE EDGE. Read off the lifted IR: the return block's value
12
+ // is the very SSA value the comparison reads, not a constant. A delay slot that set the return
13
+ // register to the constant (`beqz v0,L; move v0,zero`) lifts as a `const` returned on that edge, and
14
+ // is no mark.
15
+ //
16
+ // MEASURED on the probe pair `corpus/probe-rettail-pg-{copies,join}.c` (unfolded-branch-constant.test.ts)
17
+ // and on Mario Party 3's `func_8010BD28_472F98_mgmode`, which it is the shape of: gcc2.7.2 -O1, the
18
+ // copies `nop` in the `beqz`'s slot and the join `move v0,zero`. There the join loses the copies'
19
+ // match (0 -> 1 line off, really compiled, 2026-11-06).
20
+ //
21
+ // AN `if` SITE ONLY. The premise is an `if`'s two arms, one of them the fall-through. A switch's case
22
+ // arms, read as a switch (`tree`) or a jump table (`switch`), are each a jump target, a case label,
23
+ // so no return in one folds the switch value at -O1 whatever the source wrote, and an unfolded one
24
+ // says nothing. Compiled at gcc2.7.2 -O1: `switch (x) { case 0: return x; case 5: p[1] = 2; break; }`
25
+ // followed by its tail, written with the join, keeps `move v0,a0` in the case-0 jump's slot, and
26
+ // the join and the copies both compile byte-exact. A switch the structurer reads as `if`s (three
27
+ // cases of that function) is still read, the residue of the rule: no reading of it is exact.
28
+ //
29
+ // A MARK IS EVIDENCE, NOT PROOF, as structure/cross-jumped-tail.ts says of its own: it moves which
30
+ // verified reading is the default, never what is lifted. Read only where the target says its cse
31
+ // leaves the taken edge unfolded; elsewhere (`true`, or absent) an unfolded return says nothing.
32
+ import type { Block, Fn, Op, Value } from '../ir/core';
33
+ import type { JoinPastReturnsSite } from './structure';
34
+
35
+ const succsOf = (b: Block): Block[] => [...new Set(b.ops[b.ops.length - 1]?.successors.map((s) => s.block) ?? [])];
36
+
37
+ /** Does an early return of the `if` site `site` give back, unfolded, the value its own branch tested
38
+ * equal to a constant? The return block holds only its `ret`, has one predecessor, and that
39
+ * predecessor's `cond_br` takes it on the equal side of an `icmp_eq`/`icmp_ne` of that value against
40
+ * a `const`. */
41
+ export function unfoldedBranchConstant(fn: Fn, site: JoinPastReturnsSite): boolean {
42
+ if (site.kind !== 'if') {
43
+ return false;
44
+ }
45
+ const def = new Map<Value, Op>();
46
+ const preds = new Map<Block, Block[]>(fn.blocks.map((b) => [b, []]));
47
+ for (const b of fn.blocks) {
48
+ for (const op of b.ops) {
49
+ for (const r of op.results) {
50
+ def.set(r, op);
51
+ }
52
+ }
53
+ for (const s of succsOf(b)) {
54
+ preds.get(s)?.push(b);
55
+ }
56
+ }
57
+ const seen = new Set<Block>();
58
+ const stack = [site.branch];
59
+ while (stack.length) {
60
+ const x = stack.pop()!;
61
+ if (x === site.join || seen.has(x)) {
62
+ continue;
63
+ }
64
+ seen.add(x);
65
+ stack.push(...succsOf(x));
66
+ }
67
+ for (const r of seen) {
68
+ const ret = r.ops.length === 1 ? r.ops[0] : undefined;
69
+ const value = ret?.opcode === 'ret' && ret.operands.length === 1 ? ret.operands[0] : undefined;
70
+ const from = preds.get(r) ?? [];
71
+ if (value === undefined || from.length !== 1 || fn.asmLayout?.get(r) === undefined) {
72
+ continue;
73
+ }
74
+ const br = from[0].ops[from[0].ops.length - 1];
75
+ const cmp = br?.opcode === 'cond_br' ? def.get(br.operands[0]) : undefined;
76
+ if (cmp === undefined || br.successors.length !== 2 || br.successors[0].block === br.successors[1].block) {
77
+ continue;
78
+ }
79
+ const equalSide = cmp.opcode === 'icmp_eq' ? 0 : cmp.opcode === 'icmp_ne' ? 1 : -1;
80
+ if (equalSide < 0 || br.successors[equalSide].block !== r || cmp.operands.length !== 2) {
81
+ continue;
82
+ }
83
+ const [a, b] = cmp.operands;
84
+ const isConst = (v: Value) => def.get(v)?.opcode === 'const';
85
+ if ((a === value && isConst(b)) || (b === value && isConst(a))) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }