@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
@@ -24,6 +24,7 @@ import {
24
24
  ORDERBASE_GATES,
25
25
  UNFOLDED_GATES,
26
26
  } from './l3/basecse';
27
+ import { jumpToCaseEnds } from './l3/caseendgoto';
27
28
  import type { Gate } from './l3/gates';
28
29
  import type { HoistPlacement } from './l3/hoist';
29
30
  import { initFirstGuards } from './l3/initfirst';
@@ -55,7 +56,11 @@ import type { VariationName } from './variation-tokens';
55
56
  * that setting's own lifted fn (a map-lifted shared lift spells const bases as gaddr, which would
56
57
  * blind the /raw-globals siblings — the /addr-home lesson). `strip` opts the variation into the
57
58
  * dropped-sibling closure: a candidate that applies it is skipped when its default sibling (the
58
- * same setting without it) failed the boundary contracts. Which of these entries `structure()`'s
59
+ * same setting without it) failed the boundary contracts. `offWitness` is the exact gate where
60
+ * only the structuring itself knows whether the variation can change the tree: the OFF sibling
61
+ * (the same setting without it, structured earlier in the same pass) runs with that hook, and a
62
+ * setting whose OFF sibling never fired it is skipped, since it would build that sibling's tree
63
+ * again. Which of these entries `structure()`'s
59
64
  * `assertDefaultAccepts` guard does NOT reset — the ones that cannot unlock a function the default
60
65
  * declines, and so carry a written argument in place of the guard — is stated ONCE, beside that
61
66
  * guard in structure.ts, and derived from the guard's own reset list in
@@ -74,11 +79,15 @@ export interface StructureVariation {
74
79
  | 'signedCmp'
75
80
  | 'freshMerge'
76
81
  | 'copyDefPos'
77
- | 'siteSense';
82
+ | 'siteSense'
83
+ | 'tailCopies'
84
+ | 'forContinue'
85
+ | 'caseGoto';
78
86
  name: VariationName;
79
87
  options: (on: boolean) => Parameters<typeof structureChecked>[1];
80
88
  sharedGate?: (sharedLift: Fn, defs: Map<Value, Op>) => boolean;
81
89
  perLiftGate?: (fn: Fn) => boolean;
90
+ offWitness?: 'onTailFallerKept' | 'onForOffered' | 'onCaseCopied';
82
91
  strip: boolean;
83
92
  }
84
93
  export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
@@ -376,6 +385,80 @@ export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
376
385
  ),
377
386
  strip: false,
378
387
  },
388
+ // `/tail-copies` — WHICH ARM KEEPS A CROSS-JUMPED TAIL (structure.ts `copyEveryTailEntrant`,
389
+ // switch-recover.ts `copySharedFallTargets`). A compiler that cross-jumps keeps one copy of the
390
+ // code two or more jump-table arms end in, so those arms all enter one sibling arm's body. The
391
+ // default writes the body back into every entrant but the one the arm-order policy puts directly
392
+ // above the sibling, which keeps its fall-through; this writes it into every entrant. Which arm
393
+ // the source wrote the tail under is not in the asm, and one compiler answers both ways at one
394
+ // set of flags: on KMC `-O2` and gcc 2.7.2 `-O1`, `synthetic:jt_xjcase` (case 1 written above
395
+ // case 2 and falling into it) recompiles only from the default, while the same arms written
396
+ // out of case order (`case 3`, `case 2`, `case 1`) are 24 objdump lines off from the default
397
+ // and 4 from this. So the differ referees it per row, like `/copy-defpos`.
398
+ //
399
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): the two settings read
400
+ // different exits only where an entrant is left falling into a cross-jumped sibling
401
+ // (`copySharedFallTargets`' `onFallerKept`), so where the OFF sibling never left one the ON setting
402
+ // is not structured at all. No question of the raised fn answers that ahead of the pass: a block
403
+ // two arms reach is no sibling where it is the switch's merge (nearly every switch's post-switch
404
+ // code), and two entrants can reach their sibling through one shared cross-jumped block, so one
405
+ // in-edge. In the strip closure, and reset by `assertDefaultAccepts`: where the target language
406
+ // has no fall-through (Pascal) the default can still decline on the one fall-through it keeps,
407
+ // and copying every entrant would structure what the default refused.
408
+ {
409
+ flag: 'tailCopies',
410
+ name: 'tail-copies',
411
+ options: (on) => ({ copyEveryTailEntrant: on }),
412
+ offWitness: 'onTailFallerKept',
413
+ strip: true,
414
+ },
415
+ // `/for-continue` — A COUNTED LOOP AS `for` WHERE THE DEFAULT LIFTS IT AS `while (1)` (structure.ts
416
+ // `spellCountedLoopsFor`, `forPlanAt`). A loop whose latch steps one counter and tests it, entered
417
+ // through the same test, is one asm under both spellings, and the `for` is the one a jump-table arm
418
+ // inside it can `continue` and leave by `break;` after the switch. structure() reaches that `for`
419
+ // on its own only where every `while (1)` structuring declined (`ForRanking`); here it is ranked
420
+ // where the default lifts. Measured on agbcc, objdump instruction lines off the target, both lifts
421
+ // 0/20000 against the source in a host differential: a `for` whose five jump-table arms each
422
+ // `continue` or call and leave lifts as `while (1)` with the code after the loop copied into every
423
+ // arm at 54, and as the `for` at 27; two nested-loop probes go 81 → 68 and 55 → 44.
424
+ //
425
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): only a `while (1)` loop that passes
426
+ // every one of `forPlanAt`'s checks can change, and the OFF sibling reports meeting one
427
+ // (`onForOffered`). In the strip closure, and reset by `assertDefaultAccepts`: a first run that
428
+ // spells every such loop `for` can structure a function the default (with its own reruns) refuses.
429
+ {
430
+ flag: 'forContinue',
431
+ name: 'for-continue',
432
+ options: (on) => ({ spellCountedLoopsFor: on }),
433
+ offWitness: 'onForOffered',
434
+ strip: true,
435
+ },
436
+ // `/case-goto` — TWO ARMS ENTERING ONE CASE'S BODY AS A `goto` TO ITS LABEL WHERE THE DEFAULT
437
+ // COPIES IT (structure.ts `jumpToSharedCases`, switch-recover.ts `jumpSharedFallTargets`). Where
438
+ // two or more jump-table arms enter one case arm's body, the default writes that body back into the
439
+ // entrants but one (`copySharedFallTargets`) and reads the goto only where every goto-free
440
+ // structuring of the function declines, in a switch none of them spelled goto-free
441
+ // (`JumpRanking`); this reads the goto first. The asm does not
442
+ // say which: a compiler that cross-jumps merges a source copy into the goto's bytes when the whole
443
+ // copy matches, and on agbcc it stops short where CSE has the arm re-use an address the copy
444
+ // re-forms, so the copy compiles to more code than the goto (`synthetic:sw_gotocase2`, case 1's copy
445
+ // re-reading `gE[i]`). The differ referees it per row, like `/tail-copies`, and a tie goes to the
446
+ // candidate without the goto (rank.ts `gotoCount`). It does not take `/tail-copies`' faller choice:
447
+ // the entrant directly above the case always falls, since a jump from it would land on the label
448
+ // right below it, a path its fall-through already spells — so `/tail-copies/case-goto` is no
449
+ // candidate of its own.
450
+ //
451
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): the two settings read different
452
+ // exits only where the copies write a case arm's body into an entrant (`onCaseCopied`). In the
453
+ // strip closure, and reset by `assertDefaultAccepts`: read first, the jumps can leave a chain the
454
+ // copies after them structure where the default (copies, then jumps) declined.
455
+ {
456
+ flag: 'caseGoto',
457
+ name: 'case-goto',
458
+ options: (on) => ({ jumpToSharedCases: on }),
459
+ offWitness: 'onCaseCopied',
460
+ strip: true,
461
+ },
379
462
  ];
380
463
 
381
464
  /** The statement-order/shape members. */
@@ -467,8 +550,18 @@ export const STACKED_VARIATIONS = [...STACKED_SHAPES, ...STACKED_WIDTHS];
467
550
  * `synthetic:ladidx1`, `synthetic:ladidx2`, `synthetic:revlad5s`. `bench regression` is an
468
551
  * OUTCOME gate over all tiers, so every one of them turns it red — but `benchmark.yml` is
469
552
  * `workflow_dispatch`, manual, with no cron, so nothing runs that gate on a PR. The scores and the
470
- * method are in `apps/benchmark/dataset/synthetic.ts`'s `/unmerge` block. */
471
- export const PRE_RESPELL_VARIATIONS: typeof STACKED_VARIATIONS = [{ name: 'unmerge', apply: unmergeJoins }];
553
+ * method are in `apps/benchmark/dataset/synthetic.ts`'s `/unmerge` block.
554
+ *
555
+ * `/case-end-goto` (l3/caseendgoto.ts) is the second entry: a search loop's hit flag read where the
556
+ * code after its loop ends the switch arm, rewritten as the case-end goto the source wrote. Here
557
+ * rather than among the stacked members because the rest of the respell set must derive from the
558
+ * goto tree (an `initfirst` or `sinkinit` over a tree that no longer declares the flag), and here
559
+ * rather than as a structure variation for the cost l3/caseendgoto.ts's header gives. Each entry applies to
560
+ * the structured tree on its own, so `/unmerge` and `/case-end-goto` never compose. */
561
+ export const PRE_RESPELL_VARIATIONS: typeof STACKED_VARIATIONS = [
562
+ { name: 'unmerge', apply: unmergeJoins },
563
+ { name: 'case-end-goto', apply: jumpToCaseEnds },
564
+ ];
472
565
 
473
566
  /** Every subset of `xs`, the empty one first, each in `xs`' order. */
474
567
  const subsets = <X>(xs: readonly X[]): X[][] =>
package/src/rank.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  // names, and which of those names a declaration must refuse to claim.
21
21
  import { cBackend } from './backend/c';
22
22
  import {
23
+ assertCaseGotosNarrow,
23
24
  assertDerefsTyped,
25
+ assertEffectsPreserved,
24
26
  assertFrameUnionsViewed,
25
27
  assertLocalStorageStaysInteger,
26
28
  assertLocalsWritten,
@@ -30,6 +32,7 @@ import {
30
32
  assertResolved,
31
33
  } from './contracts';
32
34
  import type { AsmData } from './frontend/asmdata';
35
+ import type { CalleeBodies } from './frontend/callee-body';
33
36
  import { hasSetupArgsNarrowing, narrowToSetupArgs, setupArgsDiscardsPassedResult } from './frontend/ssa';
34
37
  import { Fn, Value, defOpMap } from './ir/core';
35
38
  import { T } from './ir/types';
@@ -62,7 +65,15 @@ import { volatileValueLocals } from './l3/volatileval';
62
65
  import { volatileDeviceStores } from './l3/volstore';
63
66
  import { zeroSubNegates } from './l3/zerosub';
64
67
  import { RewritePattern } from './pattern/engine';
65
- import { applyIdiomPatterns, liftStamped, raiseRecovered, stampBehaviorsFor, structureChecked } from './pipeline';
68
+ import {
69
+ applyIdiomPatterns,
70
+ liftStamped,
71
+ offsetNameBehaviorsFor,
72
+ raiseRecovered,
73
+ sourceLineCount,
74
+ structureChecked,
75
+ structureReturnTailReading,
76
+ } from './pipeline';
66
77
  import { type FnProto, type Prototypes, declaresVoidReturn, prototypesFromSymbols } from './proto';
67
78
  import { type ContextGlobals, splitOwnContext } from './proto-context';
68
79
  import { inferGlobalArrays, orderLicensedGlobals, sameDerivedShape } from './raise/globalshape';
@@ -96,7 +107,13 @@ import {
96
107
  sameBases,
97
108
  } from './rank-variations';
98
109
  import { type Stillborn, stillbornVerdict } from './stillborn';
99
- import { DefaultAcceptsMemo, DefaultAcceptsMemoMisuse, hasDivergentSharedRet } from './structure/structure';
110
+ import {
111
+ DefaultAcceptsMemo,
112
+ DefaultAcceptsMemoMisuse,
113
+ FOR_BOUND_GATES,
114
+ StructureError,
115
+ hasDivergentSharedRet,
116
+ } from './structure/structure';
100
117
  import {
101
118
  type SymbolInfo,
102
119
  type SymbolMap,
@@ -170,6 +187,8 @@ export interface EnumerateOptions {
170
187
  contextGlobals?: ContextGlobals;
171
188
  /** same contract as DecompileOptions.ownContextProto */
172
189
  ownContextProto?: FnProto;
190
+ /** same contract as DecompileOptions.calleeBodies */
191
+ calleeBodies?: CalleeBodies;
173
192
  /** Called when a respell variation THROWS or fails a boundary contract, so the failure is visible
174
193
  * instead of the candidate silently not existing. Enumeration continues either way — the default
175
194
  * candidate is unaffected — but a variation that never fires because it always throws is a defect,
@@ -492,6 +511,27 @@ interface TreeSource {
492
511
  signedUnsignedCompare?: true;
493
512
  }
494
513
 
514
+ /** Did `bound-plus-one-counter-wraps` CAUSE this structuring's decline? The rule fires from every
515
+ * attempt `forPlanAt` makes, committed or not, and on loops the thrown refusal never reaches
516
+ * (`synthetic:sw_gotocase_inner:ido7.1` fires it and declines on its switches' cases), so its firing
517
+ * alone says nothing about the decline. The question is asked the one way that answers it: the
518
+ * same structuring with that rule off. Where that one structures, the refusal was the rule's, and
519
+ * the other pin, whose counter the rule admits, can take the loop; where it still throws, the
520
+ * decline is the function's and the row aborts as any other default decline does. The tree the
521
+ * ablated run builds is discarded — it is the `for` the rule refuses as wrong under this pin.
522
+ * Not holding every pinnable decline instead: that would let the signed pin past any structure
523
+ * refusal that reads a declared type, not only this one, and re-lift every declined row. */
524
+ function declinesOnCounterSign(fn: Fn, opts: Parameters<typeof structureChecked>[1]): boolean {
525
+ try {
526
+ structureChecked(fn, opts, {
527
+ forBoundGates: FOR_BOUND_GATES.filter((g) => g.id !== 'bound-plus-one-counter-wraps'),
528
+ });
529
+ return true;
530
+ } catch {
531
+ return false;
532
+ }
533
+ }
534
+
495
535
  /** Emit the DISTINCT type/branch-sense candidates for `name` — PURE, no scoring.
496
536
  * It differs from `decompile()` in exactly two arguments to the shared spine — the signedness
497
537
  * pin, injected between pre-recovery and recoverTypes via the `beforeRecover` hook, and the
@@ -515,6 +555,17 @@ export function enumerateCandidates(
515
555
  /** The last refusal from a backend asked to spell a tree — what the empty-enumeration check
516
556
  * below reports, so "this backend can spell nothing here" names its reason. */
517
557
  let lastEmitError: unknown = null;
558
+ /** THE UNSIGNED PIN'S DECLINE, HELD FOR THE SIGNED ONE. The default candidate's structuring aborts
559
+ * the row (`abortsRow`) on the premise that a structure refusal does not depend on the pin. One
560
+ * does: `forPlanAt`'s `bound-plus-one-counter-wraps` reads the counter's declared type, so IDO's
561
+ * counted loop (`synthetic:sw_gotocase2:ido7.1`) takes its `for` under the `s32` pin and not the
562
+ * `u32` one, and without the `for` the switch's loop exit cannot be spelled at all. The decline is
563
+ * held only where that rule CAUSED it (`declinesOnCounterSign`), and then the rest of the unsigned
564
+ * pin is skipped: its other settings would run behind a declined default, which `abortsRow` exists
565
+ * to forbid. The signed pin runs as it would have; a row that then produces no candidate declines
566
+ * with the held error. Nothing the signed pin builds is new: it is the same pass, under its own
567
+ * default setting and its own gates. */
568
+ let heldPinDecline: unknown = undefined;
518
569
  // Same merge as `decompile`: the project's DWARF signatures fill in what the caller did not
519
570
  // state, so both the annotate pass and the ranked candidates reason about one prototype table.
520
571
  const ownContext = splitOwnContext(opts.prototypes, name, opts.ownContextProto);
@@ -526,6 +577,7 @@ export function enumerateCandidates(
526
577
  // every candidate carries it, the whole row).
527
578
  spellSwitchFallthrough: backend.spellsSwitchFallthrough,
528
579
  spellSwitchBreak: backend.spellsSwitchBreak,
580
+ spellEarlyReturns: backend.spellsEarlyReturn,
529
581
  ...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
530
582
  ...(opts.contextGlobals ? { contextGlobals: opts.contextGlobals } : {}),
531
583
  };
@@ -663,7 +715,7 @@ export function enumerateCandidates(
663
715
  opts.asmData,
664
716
  opts.symbols,
665
717
  opts.contextGlobals,
666
- undefined,
718
+ { calleeBodies: opts.calleeBodies },
667
719
  ownContext.ownContextProto,
668
720
  );
669
721
  // The ARRAY SHAPES the input assembly evidences (raise/globalshape.ts), for the DECLARATION
@@ -736,7 +788,7 @@ export function enumerateCandidates(
736
788
  opts.asmData,
737
789
  symbols,
738
790
  opts.contextGlobals,
739
- undefined,
791
+ { calleeBodies: opts.calleeBodies },
740
792
  ownContext.ownContextProto,
741
793
  );
742
794
  applyIdiomPatterns(p, target, opts.patterns);
@@ -1164,6 +1216,7 @@ export function enumerateCandidates(
1164
1216
  // `assertEffectsPreserved` is the fourth and is NOT here: it needs the L1 `fn`, and
1165
1217
  // `respellTree`'s parameter list is the invariant the tree-dedup skip rests on (see its header).
1166
1218
  // Widening it for a contract is a defensible change and an argued one — not a silent import.
1219
+ // The PRE-RESPELL loop below has `fn` in scope and no such invariant, so it runs it there.
1167
1220
  // A respell variation returns its tree, or `{ sfn, needsProof }` when it cannot establish its own
1168
1221
  // semantics from inside the pass (Candidate.matchOnly carries the argument).
1169
1222
  const respell = (variations: readonly Variation[], make: () => RespellResult, alreadyShaped = false): void => {
@@ -1184,6 +1237,7 @@ export function enumerateCandidates(
1184
1237
  assertNoOrphanedLocals(sfn, alt);
1185
1238
  assertLocalStorageStaysInteger(sfn, alt);
1186
1239
  assertFrameUnionsViewed(alt);
1240
+ assertCaseGotosNarrow(alt);
1187
1241
  sources.push({
1188
1242
  variations,
1189
1243
  source: backend.emit(alt),
@@ -1236,6 +1290,7 @@ export function enumerateCandidates(
1236
1290
  assertNoOrphanedLocals(alt, shaped.out);
1237
1291
  assertLocalStorageStaysInteger(alt, shaped.out);
1238
1292
  assertFrameUnionsViewed(shaped.out);
1293
+ assertCaseGotosNarrow(shaped.out);
1239
1294
  assertPlacementSurvives(alt, shaped.out, minted);
1240
1295
  sources.push({
1241
1296
  variations: [...variations, ...shaped.variations],
@@ -1982,7 +2037,7 @@ export function enumerateCandidates(
1982
2037
  // Read off the pin's OWN call, per symbol-map setting — the `/raw-globals` setting lifts without
1983
2038
  // the map and answers for itself, so no lift is governed by a fact measured on a different one.
1984
2039
  let pinnable = false;
1985
- for (const cand of SIGNEDNESS) {
2040
+ pins: for (const cand of SIGNEDNESS) {
1986
2041
  if (cand.signed && !pinnable) {
1987
2042
  break;
1988
2043
  }
@@ -1999,6 +2054,7 @@ export function enumerateCandidates(
1999
2054
  onTailSlot: () => {
2000
2055
  tailSlot = true;
2001
2056
  },
2057
+ calleeBodies: opts.calleeBodies,
2002
2058
  },
2003
2059
  ownContext.ownContextProto,
2004
2060
  );
@@ -2143,7 +2199,7 @@ export function enumerateCandidates(
2143
2199
  opts.asmData,
2144
2200
  symbolSetting.symbols,
2145
2201
  opts.contextGlobals,
2146
- { keepTailSlots: liftSetting.keepTailSlots },
2202
+ { keepTailSlots: liftSetting.keepTailSlots, calleeBodies: opts.calleeBodies },
2147
2203
  ownContext.ownContextProto,
2148
2204
  );
2149
2205
  if (liftSetting.narrow && !narrowToSetupArgs(fn)) {
@@ -2181,9 +2237,10 @@ export function enumerateCandidates(
2181
2237
  // …and the NAMES the map holds for the addresses this lift built by arithmetic off a
2182
2238
  // named one (raise/offsetnames.ts). Per lift setting, off that setting's own map: the
2183
2239
  // `/raw-globals` sibling has no map and so no name to reach. AFTER the two readings, and
2184
- // the staleness that leaves them is priced in pipeline.ts's copy of this note.
2240
+ // the staleness that leaves them is priced in pipeline.ts's copy of this note, which also
2241
+ // states the one-spelling-per-cell decline the pass throws from here.
2185
2242
  if (symbolSetting.symbols) {
2186
- OFFSET_NAME_PASS.run(fn, symbolSetting.symbols, stampBehaviorsFor(target, opts.contextGlobals));
2243
+ OFFSET_NAME_PASS.run(fn, symbolSetting.symbols, offsetNameBehaviorsFor(target, opts.contextGlobals));
2187
2244
  }
2188
2245
  for (const [n, si] of [...inferredSymbols]) {
2189
2246
  if (baseOpts.symbols?.has(n) === true || !sameDerivedShape(declSymbols.get(n), si)) {
@@ -2229,6 +2286,33 @@ export function enumerateCandidates(
2229
2286
  reportThrow(settingVariations, e);
2230
2287
  continue;
2231
2288
  }
2289
+ // THE OTHER RETURN-TAIL READING, `/ret-copy` or `/ret-join`, in its own `reading` pass
2290
+ // between the follow and the sink. The default pass structures each setting both ways where
2291
+ // the join past early returns took a join somewhere (`onJoinPastReturns`), and publishes one
2292
+ // (pipeline.ts `structureReturnTailReading`). On a target that declares the join its default
2293
+ // (`joinPastReturnsByDefault`: mwcc 2.4.2 and 2.3.3) it structures the join first
2294
+ // and publishes the copies (`copyReturnTails`) only where they spell the function in fewer
2295
+ // lines, the layout shows a cross-jumped return tail, or an early return keeps a
2296
+ // branch-proven constant unfolded. On every other target it structures the copies first and publishes them, and the
2297
+ // join only where the copies refuse. This pass
2298
+ // enumerates the tree it did not publish, so neither reading is structured twice:
2299
+ // - `/ret-copy`, the copies, where the join was published. A chain of `if`s each holding
2300
+ // an early `return;` has no CFG join (`synthetic:retchain`), and where the early returns
2301
+ // cross-jump into one return tail the follow lands on that tail
2302
+ // (`synthetic:retchain_xj`); without the join both copy the code below every such `if`
2303
+ // into its arms, and a switch with a `case` that returns early likewise. The copies are
2304
+ // kept as a candidate because some rows' bytes are them (`synthetic:sw_fall:ido7.1`);
2305
+ // - `/ret-join`, the join, where the copies were published: the join copies a return tail
2306
+ // into each early return in its turn, and where that is the longer spelling it is kept
2307
+ // as a candidate beside the copies.
2308
+ // A setting whose structuring never took the join has one reading, and pays no candidate.
2309
+ // With both in the fan, the bound moves a ranked score nowhere: `compareScored` orders a
2310
+ // score tie by goto, cast and line counts before enumeration order.
2311
+ //
2312
+ // The shared-tail passes below structure with the copies too: their follow and sink were
2313
+ // ranked against the copies, their winners hold those trees, and crossing them with the join
2314
+ // is a reading nothing has measured.
2315
+ //
2232
2316
  // THE SHARED-TAIL VARIATIONS: the same raised fn, structured again with `followEarlyReturns`,
2233
2317
  // in two passes after the default one.
2234
2318
  // - `/shared-ret` is the follow ALONE, on the fn as raised. Some divergent `if` shares a
@@ -2271,12 +2355,24 @@ export function enumerateCandidates(
2271
2355
  // `droppedDefault` is the DEFAULT pass's drops. Each shared-tail pass reads it and keeps its
2272
2356
  // own drops in a copy, so one pass's structuring failure never removes the other's candidate.
2273
2357
  const droppedDefault = new Set<string>();
2358
+ // Per DEFAULT-pass setting whose structuring was a choice between the join past early
2359
+ // returns and the return-tail copies (pipeline.ts `structureReturnTailReading`), the reading
2360
+ // it did not publish: the only trees the `reading` pass enumerates, as `/ret-copy` where the
2361
+ // join was published and `/ret-join` where the copies were.
2362
+ const unpublishedDefault = new Map<string, { readonly reading: 'copies' | 'join'; readonly sfn: SFn }>();
2363
+ // DEFAULT-pass settings whose copies were dropped (they declined or failed a boundary
2364
+ // contract) or barred: the strip closure over them, as `dropped` is over whole settings, so
2365
+ // `X/merge-names` never publishes or offers the copies where `X`'s copies failed.
2366
+ const copiesDropped = new Set<string>();
2274
2367
  // What `assertDefaultAccepts` learned about this fn, shared by every setting structured from it.
2275
2368
  let acceptsMemo = new DefaultAcceptsMemo(fn);
2276
- for (const pass of ['default', 'follow', 'sink'] as const) {
2369
+ for (const pass of ['default', 'follow', 'reading', 'sink'] as const) {
2277
2370
  if (pass === 'follow' && !hasDivergentSharedRet(fn)) {
2278
2371
  continue;
2279
2372
  }
2373
+ if (pass === 'reading' && unpublishedDefault.size === 0) {
2374
+ continue;
2375
+ }
2280
2376
  if (pass === 'sink') {
2281
2377
  let sunk: boolean;
2282
2378
  try {
@@ -2310,6 +2406,8 @@ export function enumerateCandidates(
2310
2406
  const settingsForLift = usableStructureSettings.filter((s) =>
2311
2407
  offForThisLift.every((variation) => !s[variation.flag]),
2312
2408
  );
2409
+ // Per setting key structured in THIS pass, the `offWitness` hooks its structuring fired.
2410
+ const witnessed = new Map<string, Set<NonNullable<StructureVariation['offWitness']>>>();
2313
2411
  // `/merge-names` combinations whose un-merged sibling was DROPPED. `structure()` already
2314
2412
  // refuses to let the variation unlock a function the default declines, but it can only see its own
2315
2413
  // refusals — a boundary contract fails out here, in `structureChecked`. Without this a
@@ -2326,14 +2424,50 @@ export function enumerateCandidates(
2326
2424
  if (alternative && droppedDefault.has(key)) {
2327
2425
  continue;
2328
2426
  }
2329
- if (
2427
+ // THE OTHER RETURN-TAIL READING: the tree the default pass structured and did not publish,
2428
+ // enumerated as it is. It is the same setting's, so the witness and the strip closure
2429
+ // below already answered for it there.
2430
+ const otherReading = pass === 'reading' ? unpublishedDefault.get(key) : undefined;
2431
+ if (pass === 'reading' && otherReading === undefined) {
2432
+ continue;
2433
+ }
2434
+ const settingLiftVariations: readonly Variation[] =
2435
+ otherReading === undefined
2436
+ ? liftVariations
2437
+ : [...liftSetting.variations, otherReading.reading === 'copies' ? 'ret-copy' : 'ret-join'];
2438
+ // THE OFF-SIBLING WITNESS (`StructureVariation.offWitness`): a setting whose variation's
2439
+ // OFF sibling structured without firing the witness would rebuild that sibling's tree, so
2440
+ // it is skipped and reported as the tree dedup it is. A sibling not structured in this
2441
+ // pass (dropped, or skipped above) witnessed nothing, and the setting is structured.
2442
+ //
2443
+ // A setting skipped HERE builds its sibling's structuring, so it fires the hooks the
2444
+ // sibling fired, and it is recorded with the sibling's set. Without that the gate does
2445
+ // not compose: a setting carrying two witnessed variations (`tail-copies/for-continue`)
2446
+ // finds each sibling unrecorded and is structured however inert both are. A setting
2447
+ // skipped here in the default pass has no reading of its own in the `reading` pass:
2448
+ // the sibling's fired set is the union of both its readings' (`structureReturnTailReading`),
2449
+ // so both of this setting's readings are the sibling's trees, which the sibling enumerates.
2450
+ const sameAs = STRUCTURE_VARIATIONS.flatMap((variation) => {
2451
+ if (otherReading !== undefined || variation.offWitness === undefined || !s[variation.flag]) {
2452
+ return [];
2453
+ }
2454
+ const sibling = s.variations.filter((v) => v !== variation.name).join('/');
2455
+ const seen = witnessed.get(sibling);
2456
+ return seen !== undefined && !seen.has(variation.offWitness) ? [seen] : [];
2457
+ });
2458
+ if (sameAs.length > 0) {
2459
+ witnessed.set(key, sameAs[0]);
2460
+ opts.onTreeDeduped?.();
2461
+ continue;
2462
+ }
2463
+ const stripped = (set: ReadonlySet<string>): boolean =>
2330
2464
  STRUCTURE_VARIATIONS.some(
2331
2465
  (variation) =>
2332
2466
  variation.strip &&
2333
2467
  s[variation.flag] &&
2334
- dropped.has(s.variations.filter((v) => v !== variation.name).join('/')),
2335
- )
2336
- ) {
2468
+ set.has(s.variations.filter((v) => v !== variation.name).join('/')),
2469
+ );
2470
+ if (otherReading === undefined && stripped(dropped)) {
2337
2471
  // A SKIPPED setting is recorded exactly like a dropped one, or the closure would not be
2338
2472
  // transitive: with plain X dropped and X/inplace skipped-but-unrecorded,
2339
2473
  // X/inplace/merge-names would find neither stripped key and run — shipping a
@@ -2345,36 +2479,97 @@ export function enumerateCandidates(
2345
2479
  // which it re-derives from its map on every call, so both branch senses structure the same
2346
2480
  // recovered function without re-lifting.
2347
2481
  let sfn: SFn;
2482
+ const fired = new Set<NonNullable<StructureVariation['offWitness']>>();
2483
+ const witnessHooks = Object.fromEntries(
2484
+ STRUCTURE_VARIATIONS.flatMap((variation) =>
2485
+ variation.offWitness !== undefined && !s[variation.flag]
2486
+ ? [[variation.offWitness, () => fired.add(variation.offWitness!)]]
2487
+ : [],
2488
+ ),
2489
+ );
2490
+ let counterSignRefused = false;
2491
+ const structOpts = {
2492
+ ...symbolSettingOpts,
2493
+ ...(inferredSymbols.size ? { inferredSymbols } : {}),
2494
+ ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
2495
+ preserveDivergentBranchSense: s.sense,
2496
+ negateJoinedBranchSense: s.join ? !defSense : defSense,
2497
+ ...(s.flipSites ? { branchSenseFlipSites: s.flipSites } : {}),
2498
+ anchorConstCopies: s.anchor,
2499
+ anchorLoopEntryConsts: s.entry,
2500
+ spellBitfieldMembers: s.bitfields,
2501
+ spellPtrMemberElements: s.ptrElems,
2502
+ spellDeclaredSubscripts: s.declRank,
2503
+ ...STRUCTURE_VARIATIONS.reduce(
2504
+ (acc, variation) => ({ ...acc, ...variation.options(s[variation.flag]) }),
2505
+ {},
2506
+ ),
2507
+ ...(alternative ? { followEarlyReturns: true, copyReturnTails: true } : {}),
2508
+ };
2509
+ const hooks = {
2510
+ ...witnessHooks,
2511
+ onCounterSignRefused: () => {
2512
+ counterSignRefused = true;
2513
+ },
2514
+ };
2348
2515
  try {
2349
- sfn = structureChecked(
2350
- fn,
2351
- {
2352
- ...symbolSettingOpts,
2353
- ...(inferredSymbols.size ? { inferredSymbols } : {}),
2354
- ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
2355
- preserveDivergentBranchSense: s.sense,
2356
- negateJoinedBranchSense: s.join ? !defSense : defSense,
2357
- ...(s.flipSites ? { branchSenseFlipSites: s.flipSites } : {}),
2358
- anchorConstCopies: s.anchor,
2359
- anchorLoopEntryConsts: s.entry,
2360
- spellBitfieldMembers: s.bitfields,
2361
- spellPtrMemberElements: s.ptrElems,
2362
- spellDeclaredSubscripts: s.declRank,
2363
- ...STRUCTURE_VARIATIONS.reduce(
2364
- (acc, variation) => ({ ...acc, ...variation.options(s[variation.flag]) }),
2365
- {},
2366
- ),
2367
- ...(alternative ? { followEarlyReturns: true } : {}),
2368
- },
2369
- undefined,
2370
- acceptsMemo,
2371
- );
2516
+ if (otherReading !== undefined) {
2517
+ sfn = otherReading.sfn;
2518
+ } else if (pass === 'default') {
2519
+ // THE RETURN-TAIL READING, bounded by size and read off the layout (pipeline.ts
2520
+ // `structureReturnTailReading`): on a target that declares the join past early
2521
+ // returns its default, the join, or the copies where they spell this setting's
2522
+ // function shorter or the asm layout or an unfolded return marks them; on every other
2523
+ // target the copies, structured first, and the join only where they refuse, with
2524
+ // their refusal reported under `ret-copy` (the reading the fan then cannot hold). A
2525
+ // setting fails only where both refuse, and that failure is handled below as any
2526
+ // structuring failure. The losing tree is kept for the `reading` pass, so neither
2527
+ // reading is structured twice.
2528
+ const copiesBarred = stripped(copiesDropped);
2529
+ const read = structureReturnTailReading(
2530
+ fn,
2531
+ { ...structOpts, joinPastReturns: copiesBarred },
2532
+ (t) => backend.emit(t),
2533
+ hooks,
2534
+ acceptsMemo,
2535
+ );
2536
+ sfn = read.sfn;
2537
+ if (read.unpublished === undefined) {
2538
+ if (copiesBarred) {
2539
+ copiesDropped.add(key);
2540
+ }
2541
+ } else if ('error' in read.unpublished) {
2542
+ copiesDropped.add(key);
2543
+ reportThrow(
2544
+ [...liftVariations, 'ret-copy', ...s.variations, ...symbolSetting.variations],
2545
+ read.unpublished.error,
2546
+ );
2547
+ } else {
2548
+ unpublishedDefault.set(key, read.unpublished);
2549
+ }
2550
+ witnessed.set(key, fired);
2551
+ } else {
2552
+ sfn = structureChecked(fn, structOpts, hooks, acceptsMemo);
2553
+ witnessed.set(key, fired);
2554
+ }
2372
2555
  } catch (e) {
2373
2556
  if (e instanceof DefaultAcceptsMemoMisuse) {
2374
2557
  throw e; // a memo wired to the wrong fn is a defect here, never a dropped variation
2375
2558
  }
2376
2559
  if (abortsRow([...liftVariations, ...symbolSetting.variations], s)) {
2377
- throw e; // the default lift's default setting keeps its behavior: a failure aborts the row
2560
+ if (!(
2561
+ counterSignRefused &&
2562
+ !cand.signed &&
2563
+ pinnable &&
2564
+ e instanceof StructureError &&
2565
+ declinesOnCounterSign(fn, structOpts)
2566
+ )) {
2567
+ throw e; // the default lift's default setting keeps its behavior: a failure aborts the row
2568
+ }
2569
+ // THE UNSIGNED PIN'S DECLINE, HELD FOR THE SIGNED ONE — and the rest of the unsigned
2570
+ // pin is not built: every other setting of it would run behind a declined default.
2571
+ heldPinDecline ??= e;
2572
+ continue pins;
2378
2573
  }
2379
2574
  // Recorded for EVERY dropped setting: a candidate with more variations on looks its siblings
2380
2575
  // up by stripping one variation at a time, and the stripped key can itself carry the other.
@@ -2425,7 +2620,13 @@ export function enumerateCandidates(
2425
2620
  // The PRE-RESPELL variations (PRE_RESPELL_VARIATIONS, the last composition the POLICY note
2426
2621
  // names): rewrite the TREE, then run the whole respell set over the result, so every
2427
2622
  // respell variation derives from the rewrite instead of composing onto it. The gate is the pass's
2428
- // own decline; the contracts are `respell`'s three, for `respell`'s reasons.
2623
+ // own decline; the contracts are `respell`'s, for `respell`'s reasons, plus the effect count
2624
+ // `respell` cannot run (its note above): a pre-respell rewrite moves control, not just
2625
+ // spelling — `/unmerge` copies a join into its arms, `/case-end-goto` jumps over the code
2626
+ // between a loop and its arm's end — and the per-path count is what refuses a rewrite that
2627
+ // runs a call twice on one path or drops one. It is also the only production reader of
2628
+ // that count's case-end-goto paths (contracts.ts `PathCounts.end`): structuring never
2629
+ // writes the goto, so without it those paths are read by unit tests alone.
2429
2630
  for (const pf of PRE_RESPELL_VARIATIONS) {
2430
2631
  try {
2431
2632
  const made = pf.apply(sfn);
@@ -2450,6 +2651,7 @@ export function enumerateCandidates(
2450
2651
  assertNoOrphanedLocals(sfn, made);
2451
2652
  assertLocalStorageStaysInteger(sfn, made);
2452
2653
  assertFrameUnionsViewed(made);
2654
+ assertEffectsPreserved(fn, made);
2453
2655
  // A backend refusal on this REWRITTEN tree is not a refusal of the row's own
2454
2656
  // tree, so it never becomes the row's stated cause: `TreeSources.emit` is dropped
2455
2657
  // here and only the call over the row's own tree above records one.
@@ -2525,7 +2727,7 @@ export function enumerateCandidates(
2525
2727
  }
2526
2728
  const variations: readonly Variation[] = [
2527
2729
  cand.variation,
2528
- ...liftVariations,
2730
+ ...settingLiftVariations,
2529
2731
  ...s.variations,
2530
2732
  ...sp.variations,
2531
2733
  ...symbolSetting.variations,
@@ -2559,6 +2761,9 @@ export function enumerateCandidates(
2559
2761
  // Every tree the fan produced was refused by the backend. Each refusal on its own is a dropped
2560
2762
  // candidate; all of them together is the row, and it stays LOUD — the alternative is a caller
2561
2763
  // ranking an empty list and reporting no match for a function nothing ever tried to spell.
2764
+ if (out.length === 0 && heldPinDecline !== undefined) {
2765
+ throw heldPinDecline;
2766
+ }
2562
2767
  if (out.length === 0) {
2563
2768
  throw new NoSpellableCandidateError(
2564
2769
  `no spellable candidate for '${name}': ${firstLine(lastEmitError ?? 'no candidate produced')}`,
@@ -2748,18 +2953,28 @@ export function compareScored<S extends { score: number }>(
2748
2953
  a.preference - b.preference ||
2749
2954
  (b.deviceVolatile ?? 0) - (a.deviceVolatile ?? 0) ||
2750
2955
  (a.farStoreCast ? 1 : 0) - (b.farStoreCast ? 1 : 0) ||
2956
+ gotoCount(a.source) - gotoCount(b.source) ||
2751
2957
  castCount(a.source) - castCount(b.source) ||
2752
2958
  lineCount(a.source) - lineCount(b.source) ||
2753
2959
  a.order - b.order
2754
2960
  );
2755
2961
  }
2756
2962
 
2757
- /** Non-blank lines in a candidate's rendered source — the compactness tie-break above, counted
2758
- * exactly as `quality.ts` counts `lines`. Deterministic, and total on any string. */
2759
- function lineCount(source: string): number {
2760
- return source.split('\n').filter((l) => l.trim().length > 0).length;
2963
+ /** `goto` statements in a candidate's rendered source — the readability tie-break above, asked
2964
+ * before the casts. Each goto asmlift writes (l3/ast.ts: a jump to a case label of the switch it
2965
+ * sits in, or to the `break;` that ends its arm) is a ranked spelling against a goto-free one —
2966
+ * copies (`/case-goto`) or a flag (`/case-end-goto`) — and where the two score alike the goto-free
2967
+ * one is published: a goto is the narrow exception the differ has to earn, never a tie's winner.
2968
+ * Deterministic, and total on any string. */
2969
+ export function gotoCount(source: string): number {
2970
+ return source.match(/\bgoto [A-Za-z_]\w*;/g)?.length ?? 0;
2761
2971
  }
2762
2972
 
2973
+ /** Non-blank lines in a candidate's rendered source — the compactness tie-break above, counted
2974
+ * exactly as `quality.ts` counts `lines`, and the one count the default's return-tail reading is
2975
+ * bounded by (pipeline.ts `sourceLineCount`). Deterministic, and total on any string. */
2976
+ const lineCount = sourceLineCount;
2977
+
2763
2978
  /** Scalar casts in a candidate's rendered source — the readability tie-break above.
2764
2979
  *
2765
2980
  * A WITHIN-PREFERENCE tie-break over two spellings of ONE function, and deliberately NARROWER than