@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
@@ -67,7 +67,7 @@ export interface RewritePattern {
67
67
  // `applies` is DATA, consumed generically by patternApplies — NOT an `arch ==` branch.
68
68
  // `isa` pins the ISA; `compilers` pins which COMPILERS emit this idiom (the same shift-sequence
69
69
  // for `/2` is produced by agbcc AND gcc, so a compiler LIST, not a single arch, is the honest
70
- // predicate); `capabilities` is a hardware predicate. An absent axis means "don't constrain on it".
70
+ // predicate); `capabilities` is a hardware predicate. An absent field means "don't constrain on it".
71
71
  applies: { isa?: string; compilers?: string[]; capabilities?: Partial<{ hwDivide: boolean; hwFloat: boolean }> };
72
72
  match: MatchNode; // rooted at the op result to replace
73
73
  // NOTE: a RELATIONAL guard (a `where` clause constraining the bound immediates, e.g. "two shift
@@ -91,16 +91,30 @@ export interface RewritePattern {
91
91
  * is one inhabitant. The direction was measured on ONE compiler, so `validatePattern` pins the
92
92
  * declaring pattern's compiler gate to that set; widening it fails loud. */
93
93
  unsequencedRightFirst?: [string, string];
94
+ /** The compilers measured to RECOMPUTE this idiom instead of CSEing it back, when an interior op
95
+ * of the match survives the fold because something outside still reads it. On one of these the
96
+ * fold refuses that shape (`sharesInterior`, which carries the disassemblies); everywhere else
97
+ * it is byte-neutral and refusing it costs matches, so the list is opt-in and per-compiler. A
98
+ * compiler absent from it was either measured neutral or not measured — `validatePattern`
99
+ * refuses a name that is not in `applies.compilers`, where the fold cannot fire anyway. */
100
+ recomputesSharedInterior?: string[];
94
101
  }
95
102
 
96
- /** Does this pattern apply to `target`? Every DECLARED axis must match: the ISA (so an idiom can be
103
+ /** What the pattern layer reads off a target. STRUCTURAL on purpose: the pattern set is
104
+ * serializable data and does not import `target.ts`, so a `TargetDescription` satisfies this by
105
+ * shape. It is the layer's ONE channel — `patternApplies` and `applyPattern` both take it, so a
106
+ * caller that raises through the patterns cannot hand one of them a target and the other nothing. */
107
+ export interface PatternTarget {
108
+ id: string;
109
+ compiler: string;
110
+ capabilities: { hwDivide: boolean; hwFloat: boolean };
111
+ }
112
+
113
+ /** Does this pattern apply to `target`? Every DECLARED field must match: the ISA (so an idiom can be
97
114
  * pinned to one frontend), the compiler set (so an idiom fires only for the compilers that emit
98
115
  * it — the reason MIPS+IDO and MIPS+GCC are distinguishable despite one frontend), and every
99
- * declared capability. An omitted axis is unconstrained. */
100
- export function patternApplies(
101
- p: RewritePattern,
102
- target: { id: string; compiler: string; capabilities: { hwDivide: boolean; hwFloat: boolean } },
103
- ): boolean {
116
+ * declared capability. An omitted field is unconstrained. */
117
+ export function patternApplies(p: RewritePattern, target: PatternTarget): boolean {
104
118
  if (p.applies.isa && p.applies.isa !== target.id) {
105
119
  return false;
106
120
  }
@@ -172,7 +186,7 @@ export const SDIV_POW2_2: RewritePattern = {
172
186
  // out as its own definition. Folding the triple back to one `smod`/`umod` gives recovery and the
173
187
  // structurer the operator the source wrote, and re-emitting `%` reproduces the triple byte-exact.
174
188
  //
175
- // This is NOT the `capabilities.hwDivide` axis: MIPS also divides in hardware and needs no fold at
189
+ // This is NOT the `capabilities.hwDivide` field: MIPS also divides in hardware and needs no fold at
176
190
  // all, because `div` leaves the remainder in `hi` and the frontend reads it straight out. The
177
191
  // narrower fact is a hardware divide that yields the QUOTIENT ONLY; `isa: 'ppc'` STANDS IN for it
178
192
  // until a second ISA earns the capability, and `compilers` carries the measured half, that mwcc's
@@ -271,9 +285,27 @@ export const MUL_CONST_PATTERNS: RewritePattern[] = [MUL_SHIFT_ADD, MUL_SHIFT_SU
271
285
  // lsr/asr #24`). The naive lift prints `x << 24 >> 24` — but C's `>>` over the s32-typed value is
272
286
  // ARITHMETIC, so the UNSIGNED case recompiles with `asr` where the target has `lsr`: a miscompile
273
287
  // (tou8/zextb/tou16 nonmatch). Folding to a cast op both fixes that and reads correctly; recompiling
274
- // `(u8)x` reproduces `lsl;lsr`. Gated to agbcc: on IDO/GCC the zero-extend is `andi`/`and` (not a
275
- // shift pair) and `(u8)x` lowers to `andi` there — so this shift-pair shape is agbcc's alone, and the
276
- // fold must not touch the other compilers (where it would change `srl`↔`andi`). `k = 32 - w`.
288
+ // `(u8)x` reproduces `lsl;lsr`. `k = 32 - w`.
289
+ //
290
+ // THE TWO SIGNEDNESSES ARE GATED SEPARATELY, because MIPS spells them differently and the pair of
291
+ // gates is the measurement. Compiled at each row's own flags — IDO 7.1 `-mips2 -O2 -32 -non_shared
292
+ // -G 0`, KMC gcc `-mips3 -O2`, gcc 2.7.2 `-mips3 -O1`:
293
+ //
294
+ // (s8)x sll v0,a0,0x18 ; sra v0,v0,0x18 (s16)x sll 0x10 ; sra 0x10 all three
295
+ // (u8)x andi v0,a0,0xff all three
296
+ //
297
+ // So the SIGN-extend is a shift pair on MIPS exactly as on agbcc, and folding it is byte-neutral
298
+ // there: the fold changes the printed spelling from `x << 24 >> 24` to `(s8)x` and recompiles to
299
+ // the same two instructions. The ZERO-extend is not — `andi` is not a shift pair, and folding an
300
+ // IDO `srl` to a `zext` would re-spell it as `andi`, a miscompile. So `zextPat` is pinned to agbcc
301
+ // alone, and the row that pin protects is `synthetic:zextb:ido7.1` — whose target IS that `andi`.
302
+ //
303
+ // mwcc/PowerPC is in NEITHER list: it has `extsb`/`extsh`, which the frontend lifts straight to
304
+ // `sext`, so the shift-pair shape is not one it emits and no measurement licenses the fold there.
305
+ /** The compilers measured to lower a SIGNED narrowing cast to a shift pair — see the pair of
306
+ * disassemblies above. A compiler outside this list keeps the raw shifts. */
307
+ export const SEXT_SHIFT_PAIR_COMPILERS = ['agbcc', 'ido', 'gcc'];
308
+
277
309
  const zextPat = (w: number, k: number): RewritePattern => ({
278
310
  id: `zext${w}`,
279
311
  applies: { compilers: ['agbcc'] },
@@ -282,7 +314,10 @@ const zextPat = (w: number, k: number): RewritePattern => ({
282
314
  });
283
315
  const sextPat = (w: number, k: number): RewritePattern => ({
284
316
  id: `sext${w}`,
285
- applies: { compilers: ['agbcc'] },
317
+ applies: { compilers: SEXT_SHIFT_PAIR_COMPILERS },
318
+ // IDO alone emits the `sll` twice when the pair's own `sll` has a second reader; agbcc and both
319
+ // MIPS GCCs CSE it back, byte-identically. `sharesInterior` carries the three-spelling table.
320
+ recomputesSharedInterior: ['ido'],
286
321
  match: { op: 'shr_s', attrEquals: { imm: k }, args: [{ op: 'shl', attrEquals: { imm: k }, args: [{ bind: 'X' }] }] },
287
322
  replaceWith: { op: 'sext', args: ['X'], attrs: { width: w } },
288
323
  });
@@ -296,7 +331,8 @@ const sextPat = (w: number, k: number): RewritePattern => ({
296
331
  * and never reaches the bitfield member recognizer (structure.ts, which matches the raw
297
332
  * `shr(shl(load))` shape only). Honest output, not a miscompile — the field just keeps the cast
298
333
  * spelling at those widths. Teaching the recognizer a zext/sext arm is the coverage extension if
299
- * a row ever needs it. */
334
+ * a row ever needs it. The SIGNED half of that shadow reaches MIPS too, where the symbol maps are:
335
+ * measured over the whole corpus, no row's emitted C moves for it (`pnpm bench sweep`). */
300
336
  export const CAST_PATTERNS: RewritePattern[] = [zextPat(8, 24), zextPat(16, 16), sextPat(8, 24), sextPat(16, 16)];
301
337
 
302
338
  // ── boolean-negation idiom ───────────────────────────────────────────────────────────────────
@@ -408,6 +444,9 @@ const COMMUTATIVE = new Set(['add', 'mul', 'and', 'or', 'xor', 'icmp_eq', 'icmp_
408
444
  interface Binds {
409
445
  values: Map<string, Value>;
410
446
  imms: Map<string, number>;
447
+ /** Every value matched by an `op` node of the pattern, root included — the ops this rewrite is
448
+ * about to REPLACE. `sharesInterior` below turns it into the fold's own legality condition. */
449
+ interior: Set<Value>;
411
450
  }
412
451
 
413
452
  function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): boolean {
@@ -430,6 +469,7 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
430
469
  if (!d || d.opcode !== node.op) {
431
470
  return false;
432
471
  }
472
+ b.interior.add(v);
433
473
  if (node.attrEquals) {
434
474
  for (const [k, val] of Object.entries(node.attrEquals)) {
435
475
  if (d.attrs[k] !== val) {
@@ -459,7 +499,7 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
459
499
  [0, 1],
460
500
  [1, 0],
461
501
  ] as const) {
462
- const trial: Binds = { values: new Map(b.values), imms: new Map(b.imms) };
502
+ const trial: Binds = { values: new Map(b.values), imms: new Map(b.imms), interior: new Set(b.interior) };
463
503
  if (tryMatch(node.args[0], d.operands[i], defs, trial) && tryMatch(node.args[1], d.operands[j], defs, trial)) {
464
504
  for (const [k, val] of trial.values) {
465
505
  b.values.set(k, val);
@@ -467,6 +507,9 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
467
507
  for (const [k, val] of trial.imms) {
468
508
  b.imms.set(k, val);
469
509
  }
510
+ for (const val of trial.interior) {
511
+ b.interior.add(val);
512
+ }
470
513
  return true;
471
514
  }
472
515
  }
@@ -523,6 +566,17 @@ function validatePattern(pat: RewritePattern): void {
523
566
  );
524
567
  }
525
568
  }
569
+ const recompiles = pat.recomputesSharedInterior;
570
+ if (recompiles?.length) {
571
+ const on = pat.applies.compilers;
572
+ const inert = on === undefined ? [] : recompiles.filter((c) => !on.includes(c));
573
+ if (inert.length) {
574
+ throw new Error(
575
+ `pattern '${pat.id}' names [${inert.join(', ')}] in 'recomputesSharedInterior' but does not apply to ` +
576
+ `${on === undefined ? 'them' : `[${on.join(', ')}]`} — the refusal could never fire, so the declaration is inert`,
577
+ );
578
+ }
579
+ }
526
580
  validated.add(pat);
527
581
  }
528
582
 
@@ -621,8 +675,66 @@ function reordersUnsequenced(
621
675
  return false;
622
676
  }
623
677
 
624
- /** Apply one pattern greedily to a fixed point. Returns the number of rewrites. */
625
- export function applyPattern(fn: Fn, pat: RewritePattern): number {
678
+ /** Would this fold RECOMPUTE the idiom rather than re-spell it leave an INTERIOR op standing
679
+ * because something outside the match still reads it, while the replacement computes the same
680
+ * thing again?
681
+ *
682
+ * Every pattern here is licensed by a compiled pair showing that the replacement's C recompiles to
683
+ * the SAME instructions. That pair is measured on the shape where the fold's interior ops DIE with
684
+ * it — `dce` below removes exactly the ops nothing else reads. An interior op with a surviving
685
+ * reader does not die, and whether the recompiling compiler then emits the work twice or CSEs it
686
+ * back is a COMPILER fact, so it was compiled rather than reasoned. One function, three spellings,
687
+ * each toolchain at its own canonical flags:
688
+ *
689
+ * int r1(int x){ int y = x << 24; return (y >> 24) + y; }
690
+ *
691
+ * ido7.1 raw `(a0 << 24 >> 24) + (a0 << 24)` 4 words == the object of the C above
692
+ * folded `(s8)a0 + (a0 << 24)` 5 words an extra `sll`
693
+ * gcc2.7.2kmc raw 4 words / folded 4 words, BYTE-IDENTICAL
694
+ * gcc2.7.2 raw 4 words / folded 4 words, BYTE-IDENTICAL
695
+ * agbcc raw and folded identical (`lsl;asr;add`) — one `lsl` either way
696
+ *
697
+ * So only IDO recomputes, and `recomputesSharedInterior` carries that list. On the other three the
698
+ * fold stays byte-neutral in this shape and refusing it would COST: agbcc's `s16 i; i++` is one
699
+ * `lsl #16` read by both the write-back's `lsr` and the comparison's `asr`, and a blanket refusal
700
+ * measured `synthetic:{membnarrow,sibwalk}:agbcc` out of MATCH and `kleod:sub_0800A5B8:agbcc` from
701
+ * 173 to 179.
702
+ *
703
+ * The ROOT is exempt and must be: `replaceAllUsesWith` brings its readers along, which is what
704
+ * makes a fold a re-spelling at all. It is the interior — the operands the match walked THROUGH —
705
+ * that this asks about. CONSTANTS REACH IT ASYMMETRICALLY, and both answers are the wanted ones:
706
+ * a `constImm` node binds a literal and returns before the record, so a shared constant the
707
+ * replacement re-spells as a literal never refuses a fold — nothing keeps a register alive for a
708
+ * literal. An `{ op: 'const' }` node is walked like any other op and IS interior, so one the
709
+ * replacement drops while another op still reads it counts as a survivor and refuses, which is the
710
+ * conservative direction for a materialization the compiler may or may not rematerialize.
711
+ *
712
+ * Refusing leaves the raw ops standing, which is the spelling that was byte-exact before any fold
713
+ * existed — a worse-READING answer, never a worse-scoring one. */
714
+ function sharesInterior(fn: Fn, root: Op, interior: Set<Value>, defs: Map<Value, Op>): boolean {
715
+ const matched = new Set([...interior].map((v) => defs.get(v)));
716
+ for (const b of fn.blocks) {
717
+ for (const o of b.ops) {
718
+ if (matched.has(o)) {
719
+ continue; // a read from INSIDE the idiom is one this rewrite is replacing
720
+ }
721
+ for (const v of [...o.operands, ...o.successors.flatMap((x) => x.args)]) {
722
+ if (v !== root.results[0] && interior.has(v)) {
723
+ return true;
724
+ }
725
+ }
726
+ }
727
+ }
728
+ return false;
729
+ }
730
+
731
+ /** Apply one pattern greedily to a fixed point. Returns the number of rewrites.
732
+ *
733
+ * `target` is the same one `patternApplies` filtered with, REQUIRED because exactly one refusal
734
+ * reads it (`recomputesSharedInterior`, whose answer is per-compiler). Both towers that fold —
735
+ * `pipeline.ts` and `trace.ts` — must hand it over or the traced tower shows a fold `decompile()`
736
+ * refuses; required, a caller that omits it is a type error rather than a wrong answer. */
737
+ export function applyPattern(fn: Fn, pat: RewritePattern, target: PatternTarget): number {
626
738
  validatePattern(pat);
627
739
  let count = 0,
628
740
  changed = true;
@@ -635,10 +747,13 @@ export function applyPattern(fn: Fn, pat: RewritePattern): number {
635
747
  if (op.results.length !== 1) {
636
748
  continue;
637
749
  }
638
- const binds: Binds = { values: new Map(), imms: new Map() };
750
+ const binds: Binds = { values: new Map(), imms: new Map(), interior: new Set() };
639
751
  if (!tryMatch(pat.match, op.results[0], defs, binds)) {
640
752
  continue;
641
753
  }
754
+ if (pat.recomputesSharedInterior?.includes(target.compiler) && sharesInterior(fn, op, binds.interior, defs)) {
755
+ continue;
756
+ }
642
757
  if (pat.unsequencedRightFirst && reordersUnsequenced(fn, op, pat.unsequencedRightFirst, binds, defs, pat.id)) {
643
758
  continue;
644
759
  }
package/src/pipeline.ts CHANGED
@@ -21,6 +21,7 @@ import { LanguageBackend, SFn, gapReasonFor, walkExprs } from './l3/ast';
21
21
  import { BASECSE_GATES, hoistBaseLocals } from './l3/basecse';
22
22
  import { eliminateDeadStores } from './l3/dce';
23
23
  import { mergeCommonTails } from './l3/tailmerge';
24
+ import { dropUnspelledReturns } from './l3/tailret';
24
25
  import { DEFAULT_IDIOM_PATTERNS, RewritePattern, applyPattern, dce, patternApplies } from './pattern/engine';
25
26
  import { type FnProto, type Prototypes, prototypesFromSymbols } from './proto';
26
27
  import { RaiseUnsupportedError } from './raise/errors';
@@ -212,7 +213,7 @@ export function applyIdiomPatterns(fn: Fn, target: TargetDescription, patterns?:
212
213
  const active = (patterns ?? DEFAULT_IDIOM_PATTERNS).filter((p) => patternApplies(p, target));
213
214
  let hits = 0;
214
215
  for (const p of active) {
215
- hits += applyPattern(fn, p);
216
+ hits += applyPattern(fn, p, target);
216
217
  }
217
218
  if (active.length) {
218
219
  dce(fn);
@@ -261,7 +262,7 @@ export function raiseRecovered(
261
262
  self?: FnProto,
262
263
  pre: PreRecoveryOptions = {},
263
264
  ): void {
264
- runPreRecovery(
265
+ const lifted = runPreRecovery(
265
266
  fn,
266
267
  target,
267
268
  (pass, result) => {
@@ -276,7 +277,15 @@ export function raiseRecovered(
276
277
  verify(fn);
277
278
  assertTypesRecovered(fn);
278
279
  hooks.afterRecover?.();
279
- if (sinkReturns(fn)) {
280
+ // `lifted.mergeShapes` is the CFG as it ENTERED pre-recovery, and retsink's `pre-diamond` needs
281
+ // exactly that: `raise/shortcircuit.ts` manufactures two-armed diamonds out of condition trees the
282
+ // ROM never merged, and a diamond this pass reads at its own turn may be one of those.
283
+ if (
284
+ sinkReturns(fn, {
285
+ hoistsSingleSetArm: target.compilerBehaviors.hoistsSingleSetArm,
286
+ mergeShapes: lifted.mergeShapes,
287
+ })
288
+ ) {
280
289
  verify(fn);
281
290
  hooks.afterRetsink?.();
282
291
  }
@@ -333,16 +342,37 @@ function attributeOpaques<T>(fn: Fn, body: () => T): T {
333
342
  if (!names.size || /unmodelled instruction/.test(e.message)) {
334
343
  throw e;
335
344
  }
336
- // Through `gapReasonFor`, so the classifier sees its canonical text — a hand-written variant
345
+ // Through `gapReasonFor`, so the classifier sees its canonical text — a hand-written spelling
337
346
  // misses the mnemonic-anchored classes and every attributed decline lands in the generic bucket.
338
347
  const list = [...names].sort().map(gapReasonFor).join(', ');
339
348
  throw new StructureError(`${e.message} — and the function carries ${list}, which is the more likely cause`);
340
349
  }
341
350
  }
342
351
 
352
+ /** The committed readability/quality rewrites, in the order they are committed in: merge a
353
+ * statement common to every arm of an if, drop the void returns the assembly did not spell, drop
354
+ * dead stores (whose empty-then peephole flips the arm the merge empties), then hoist each leaf
355
+ * base the DEFAULT gate table admits into a typed local pointer.
356
+ *
357
+ * THE ORDER IS OBSERVABLE, not a preference: drop the returns before the merge and the same arms
358
+ * become peelable, emptying both of them. `l3/tailret.ts` states the mechanism, and
359
+ * `test/tailret.test.ts` pins the difference and which side of it this function is on.
360
+ *
361
+ * `hoistBaseLocals`' two arguments are SPELLED, defaults or not: this is the one call to that pass
362
+ * that is committed rather than offered, so it is the one whose gate table and whose placement can
363
+ * cost a MATCH instead of a candidate (docs/level-tower.md). A committed policy that reads as
364
+ * "whatever the default is" is the policy nobody reviews. */
365
+ export function readabilityRewrites(raw: SFn): SFn {
366
+ return hoistBaseLocals(eliminateDeadStores(dropUnspelledReturns(mergeCommonTails(raw))), BASECSE_GATES, 'head');
367
+ }
368
+
343
369
  /** Stage 4 — structure + its boundary contracts, always as a pair. */
344
- export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]): SFn {
345
- const raw = attributeOpaques(fn, () => structure(fn, opts));
370
+ export function structureChecked(
371
+ fn: Fn,
372
+ opts: Parameters<typeof structure>[1],
373
+ hooks?: Parameters<typeof structure>[2],
374
+ ): SFn {
375
+ const raw = attributeOpaques(fn, () => structure(fn, opts, hooks));
346
376
  // The boundary contracts run on the pre-DCE tree: the readability pass must never be able to
347
377
  // hide a structuring defect by dropping the dead statement that carries it. assertResolved
348
378
  // catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
@@ -353,16 +383,9 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
353
383
  assertDerefsTyped(raw);
354
384
  assertLocalsWritten(raw);
355
385
  assertEffectsPreserved(fn, raw);
356
- // Then the readability/quality rewrites: merge a statement common to every arm of an if,
357
- // drop dead stores (whose empty-then peephole flips the arm the merge empties), then hoist each
358
- // leaf base the DEFAULT gate table admits into a typed local pointer. The hoist moves the deref
359
- // cast from each `index` node onto the local's initializer, so re-validate deref typing on the
360
- // rewritten tree.
361
- // Both arguments SPELLED, defaults or not: this is the one call to this pass that is committed
362
- // rather than offered, so it is the one whose gate table and whose placement can cost a MATCH
363
- // instead of a candidate (docs/level-tower.md). A committed policy that reads as "whatever the
364
- // default is" is the policy nobody reviews.
365
- const sfn = hoistBaseLocals(eliminateDeadStores(mergeCommonTails(raw)), BASECSE_GATES, 'head');
386
+ // The hoist moves the deref cast from each `index` node onto the local's initializer, so
387
+ // re-validate deref typing on the rewritten tree.
388
+ const sfn = readabilityRewrites(raw);
366
389
  assertDerefsTyped(sfn);
367
390
  // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
368
391
  // merges arms or drops statements must not be able to lose or duplicate a call.
package/src/proto.ts CHANGED
@@ -16,7 +16,7 @@ import type { SymbolMap, SymbolTypeFacts } from './symbols';
16
16
  * the extension is an inference off an encoding two different C sources produce. Where the asm
17
17
  * carries no extension, this list is NOT consulted: pinning there would type every parameter of
18
18
  * every row from the declaration, and a declared `u32` kills rank.ts's signed arm before the
19
- * differ ever sees it. That half is an axis question and is not answered here. */
19
+ * differ ever sees it. That half is a variation question and is not answered here. */
20
20
  export type ParamType = string;
21
21
 
22
22
  /** What the headers know about one function. All fields optional: a partial table (only
@@ -143,7 +143,7 @@ export function validatePrototypes(value: unknown): string[] {
143
143
  * determine one. A pointer is `void *` — address-identical to any object pointer, and asmlift
144
144
  * makes every stride explicit — so nothing is guessed about what it points at. A richer spelling
145
145
  * would also be INERT: `declaredWidth` answers 32 for every `*`, and a CALLEE's parameter types
146
- * are read for the list's length alone (test/param-pointee-axis.test.ts). */
146
+ * are read for the list's length alone (test/param-pointee-variation.test.ts). */
147
147
  function typeSpelling(t: SymbolTypeFacts): ParamType | null {
148
148
  if (t.pointer) {
149
149
  return 'void *';
@@ -3,17 +3,33 @@
3
3
  // A RISC target builds a 32-bit literal in two halves: a high-half load (MIPS `lui`, PPC `lis`) then a
4
4
  // low-half `ori`/`addiu`. The frontends lift that pair faithfully as `or(const(hi<<16), const(lo))` /
5
5
  // `add(const(hi<<16), const(lo))` — a live binary op over two `const` ops — because neither frontend can
6
- // see across the two instructions. This pass folds any such const/const `or`/`add` into a single `const`,
6
+ // see across the two instructions. This pass folds such a const/const `or`/`add` into a single `const`,
7
7
  // which is the form that (a) type-recovers as one 32-bit literal and (b) recompiles to the exact
8
8
  // `lui;ori` / `lis;ori` pair. Without it a magic-division reciprocal or an address literal is never a
9
9
  // single value the later passes can reason about.
10
10
  //
11
+ // THE CLIENTELE IS "A VALUE THE TARGET MATERIALISES IN TWO INSTRUCTIONS", and it is wider than the
12
+ // RISC pair above in both directions, and both halves are measured:
13
+ // - It is NOT ARM-free. On Thumb a literal is a pool word plus an immediate `add`, which is exactly a
14
+ // two-instruction materialisation and lifts as the same const/const pair. Ablating the whole pass
15
+ // costs three agbcc BYTE-MATCHES — `synthetic:dmafield` MATCH -> diff:29, `synthetic:fieldbase`
16
+ // MATCH -> diff:22, `synthetic:bgfixed` MATCH -> diff:2 (measured, whole pass off). Do not gate
17
+ // this pass on a RISC target.
18
+ // - The pair is NOT confined to ONE block. `synthetic:mergepool:gcc2.7.2kmc` lifts a genuine
19
+ // `lui;ori` as `or(const 65536, const 9029)` whose two halves are defined in DIFFERENT blocks
20
+ // (instrumented: `b0=0 b1=1`), because gcc hoisted the high half above the branch. A guard
21
+ // tightened to a literal same-block test loses folds the corpus depends on.
22
+ // What the refusal below actually excludes is narrower than either: an operand a terminator hands to a
23
+ // successor's block-parameter — a REGISTER the compiler held live across a branch — where the pair is
24
+ // ALSO not recognisable as a hi/lo pair and the result is not an address. See its site for the cost.
25
+ //
11
26
  // This cannot be a data-`RewritePattern`: the fold's result is COMPUTED from the two operands' values,
12
27
  // which the pattern engine's numeric-exact `attrEquals` cannot express. So it lives here as an always-on
13
28
  // recognizer, run before type recovery. Value-preserving and local; a single left-to-right pass suffices
14
29
  // (SSA guarantees each const is defined before the op that consumes it, and a folded result feeds
15
30
  // forward for any chained materialisation).
16
- import { Fn, Op, defOpMap, mkOp } from '../ir/core';
31
+ import { Fn, Op, Value, defOpMap, mkOp } from '../ir/core';
32
+ import { MEM_BASE_OPS } from '../ir/opcodes';
17
33
 
18
34
  // The binary opcodes whose const/const form is a constant. `>> 0` normalises to a signed 32-bit result
19
35
  // (hardware wraparound): `|` already yields int32, `+` may exceed it and is truncated to match `addu`/`add`.
@@ -22,11 +38,123 @@ const FOLD: Record<string, (a: number, b: number) => number> = {
22
38
  add: (a, b) => (a + b) >> 0,
23
39
  };
24
40
 
41
+ /** Is `v` a RISC HIGH HALF — what one `lui`/`lis`/`addis` puts in a register? `hi << 16`, for a
42
+ * NON-ZERO `hi`. Zero is excluded deliberately, and that exclusion is what makes this test
43
+ * trustworthy as a positive: `lui rD, 0` is a no-op no compiler emits, so a `const 0` is never a
44
+ * half being materialised — it is an initialised register, exactly the shape the refusal below
45
+ * exists to protect.
46
+ *
47
+ * IT COVERS THE RISC HALF OF THE CLIENTELE ONLY, because the 16/16 split is an ISA fact: censused
48
+ * over the corpus's const/const fold sites, all 19 RISC ones (gcc2.7.2kmc / mwcc_242_81 / ido7.1)
49
+ * are recognisable to it and 0 of the 20 agbcc ones are — an ARM pool word is an arbitrary 32-bit
50
+ * value (`0x03001C00`, low half `0x1C00`) and can never pass. The ARM half of the clientele is
51
+ * protected by `memBases` and by simply not being edge-carried; four agbcc sites on three rows
52
+ * (`dmascope` ×2, `dmascope2`, `dmafield`'s `add(…,112)`) sit behind neither carve-out. If a row
53
+ * ever needs the buy-back on ARM, the half-width belongs on the target description —
54
+ * `PreRecoveryPass` already threads `target`, and `softdiv` gates on `capabilities.hwDivide` —
55
+ * rather than as a second constant here. */
56
+ const isHighHalf = (v: number): boolean => (v & 0xffff) === 0 && v !== 0;
57
+
58
+ /** Is `v` a LOW HALF — what one `ori`/`addi`/`addiu` can supply? `ori` takes an UNSIGNED 16-bit
59
+ * immediate and `addi`/`addiu` a SIGNED one, so the admissible range is the union: mwcc completes an
60
+ * `addis` with a NEGATIVE `addi` whenever bit 15 of the low half is set (`0x12350000 + -25924` is
61
+ * `0x12345ABC`). Deliberately NOT split per-opcode (`or` unsigned, `add` signed): that narrowing is
62
+ * true of the ISA, reaches 0 rows of the corpus, and no fixture reddens when it is removed, so it
63
+ * would be an unpinned clause. The half-width belongs on the target description if a row needs it. */
64
+ const isLowHalf = (v: number): boolean => v >= -0x8000 && v <= 0xffff;
65
+
66
+ /** The constant a foldable const/const pair denotes, or `null` when `opcode` is not one this pass
67
+ * folds. Exported because `structure.ts` repairs the refusal's residue at RENDER time and must
68
+ * print exactly what an unrefused fold here would have produced — the opcode set and the int32
69
+ * normalisation are one decision, so they live in one place. */
70
+ export function foldConstPair(opcode: string, a: number, b: number): number | null {
71
+ const f = FOLD[opcode];
72
+ return f ? f(a, b) : null;
73
+ }
74
+
75
+ /** Does this opcode's const/const form denote a constant? The membership half of `foldConstPair`,
76
+ * for callers that must classify an op before they have its operands' values. */
77
+ export const isConstFoldOpcode = (opcode: string): boolean => opcode in FOLD;
78
+
79
+ /** THE EVIDENCE THE FOLD WOULD OTHERWISE DESTROY, stamped on the literal it produces.
80
+ *
81
+ * Three Thumb shapes put two accesses a constant distance apart, and the assembly tells them
82
+ * apart: two pool words (two independent `const` ops), one pool word plus memory-operand
83
+ * displacements (`l3/ast.ts`'s `operandOff`), and one pool word plus an `add` to the register
84
+ * that already held the first address (`ldr r3,=X; strh [r3]; adds r3,#2; strh [r3]`). The third
85
+ * lifts as `add(const X, const 2)` — indistinguishable, once folded, from a literal `X + 2` the
86
+ * compiler materialised in two instructions, which is this pass's whole clientele. So the fold
87
+ * still happens and the distinction is recorded, exactly as `structure.ts` records `operandOff`
88
+ * before its own fold (see the `operandOff` note in l3/ast.ts).
89
+ *
90
+ * WHAT IT ASSERTS is narrow and is a fact about REGISTERS, not about C: at this instruction the
91
+ * machine held the base address in a register, used it as an address, and advanced it by `step`
92
+ * bytes to reach another address it also used. THE READER decides what to spell — `l3/advance.ts`
93
+ * offers a pointer local advanced in place, which is the only C spelling that reproduces the
94
+ * `add` on a compiler that folds a constant subscript into the memory operand, and which is
95
+ * INERT (same bytes as the subscript) wherever the pointee is not `volatile`.
96
+ *
97
+ * THE THREE REFUSALS, each of which makes the stamp mean something else:
98
+ * • `or`, not `add` — a hi/lo `or` is a literal being assembled, never a pointer being moved.
99
+ * • the ADDEND is itself a memory base. Then both halves are addresses and neither is the step;
100
+ * the shape is not an advance and the stamp would name an arbitrary one of them.
101
+ * • either the base or the result is never used AS AN ADDRESS. A register that only feeds
102
+ * arithmetic is a value, and `X + 2` over two values is a literal by every reading.
103
+ * A zero step is dropped too: it names no advance, and `l3/ast.ts`'s readers all test
104
+ * `!== undefined` rather than truthiness, so a stamped 0 would read as a real one. */
105
+ function advanceEvidence(op: Op, a: number, c: number, memBases: ReadonlySet<Value>): { advancedBy?: number } {
106
+ if (op.opcode !== 'add' || !memBases.has(op.results[0])) {
107
+ return {};
108
+ }
109
+ const baseIdx = memBases.has(op.operands[0]) ? 0 : memBases.has(op.operands[1]) ? 1 : -1;
110
+ if (baseIdx < 0 || memBases.has(op.operands[1 - baseIdx])) {
111
+ return {};
112
+ }
113
+ const step = baseIdx === 0 ? c : a;
114
+ return step === 0 ? {} : { advancedBy: step };
115
+ }
116
+
25
117
  /** Fold each const/const `or`/`add` into one `const`, in place. Returns whether anything changed. The
26
118
  * now-dead source consts are left for DCE (they may still have other uses; liveness is not our concern). */
27
119
  export function recognizeConsts(fn: Fn): boolean {
28
120
  let changed = false;
29
121
  const defs = defOpMap(fn);
122
+ // The two facts the CLIENTELE REFUSAL below reads, both collected in one walk.
123
+ // `edgeSlots` — for every value a terminator hands to a successor, WHICH block-parameters it
124
+ // feeds. Being in the map at all is "a register the compiler held live across a branch": in
125
+ // functional-form SSA the machine had this value in a register at the branch and the join
126
+ // reads it back. WHICH parameter is the second question, and the buy-back below needs it.
127
+ // `memBases` — every value used as a memory base, i.e. the values that ARE addresses.
128
+ const edgeSlots = new Map<Value, Set<Value>>();
129
+ const memBases = new Set<Value>();
130
+ for (const b of fn.blocks) {
131
+ for (const op of b.ops) {
132
+ for (const sc of op.successors) {
133
+ sc.args.forEach((v, i) => {
134
+ const param = sc.block.params[i];
135
+ if (param === undefined) {
136
+ return;
137
+ }
138
+ const slots = edgeSlots.get(v);
139
+ if (slots) {
140
+ slots.add(param);
141
+ } else {
142
+ edgeSlots.set(v, new Set([param]));
143
+ }
144
+ });
145
+ }
146
+ if (MEM_BASE_OPS.has(op.opcode) && op.operands.length > 0) {
147
+ memBases.add(op.operands[0]);
148
+ }
149
+ }
150
+ }
151
+ /** Do these two values reach the SAME block-parameter — i.e. are they two arms' feeds of one
152
+ * merge? See the refusal for why that is what tells an accumulator from a shared high half. */
153
+ const meetAtSameParam = (x: Value, y: Value): boolean => {
154
+ const sx = edgeSlots.get(x);
155
+ const sy = edgeSlots.get(y);
156
+ return !!sx && !!sy && [...sx].some((k) => sy.has(k));
157
+ };
30
158
  const constOf = (op: Op | undefined): number | null =>
31
159
  op && op.opcode === 'const' ? (op.attrs.value as number) : null;
32
160
  for (const b of fn.blocks) {
@@ -41,8 +169,80 @@ export function recognizeConsts(fn: Fn): boolean {
41
169
  if (a === null || c === null) {
42
170
  continue;
43
171
  }
172
+ // ── THE REFUSAL: this shape is not a literal being materialised ───────────────────────────
173
+ // An operand a terminator ALSO hands to a successor's block-parameter is a register the compiler
174
+ // held live across the branch, whose value on this path happens to be a constant. agbcc's
175
+ // `s = 0; ... if (c) s += 1;` lifts as `add(%s = const 0, const 1)` in the taken arm, where
176
+ // `%s` is also the value bb0 hands the join. Folding it to `const 1` deletes the accumulator's
177
+ // last reference, so every later level sees an arm that materialises a literal and spells it
178
+ // as one (`v = 1;` with an `else v = 0;`) instead of the `s += 1` the target records — and the
179
+ // enumeration gate for the shipped `/merge-home` variation, which is what would have spelled the
180
+ // hoisted init, reads FALSE because the merge feed it looks for is gone.
181
+ //
182
+ // The mapping is a FUNCTION, not an open question, so this is a default and not a variation: a register
183
+ // carried across a branch is not a literal being materialised, whichever compiler produced it.
184
+ //
185
+ // IT IS A PROXY, and the two carve-outs are where it is bought back. Edge-carrying is evidence
186
+ // of a register, not proof, and the same `add(const 0, const K)` still folds wherever nothing
187
+ // carries the zero (6 sites on 5 marioparty3/snowboardkids2 rows) — defensible, since with no
188
+ // merge there is no home to hoist and the incident cannot occur, but the rule is narrower than
189
+ // "never fold a const/const pair over a branch".
190
+ // - `hiLoPair` — the pass's OWN clientele beats the proxy. mwcc materialises `0x12345678` as
191
+ // `lis; addi` and shares the `lis` across a branch whenever the high half is live at the
192
+ // join, so the genuine pair IS edge-carried and the proxy refuses it: measured, a
193
+ // `base = 0x12340000; if (c) q[0] = base|0x5678; else q[1] = base|0x9ABC; *p = base;` row
194
+ // emitted `*a2 = 305397760 + 22136;` on mwcc_242_81 where every other toolchain emitted the
195
+ // folded literal. The literal is then no longer ONE value for `recognizeMagicDivision`, type
196
+ // recovery or the symbol map — the same never-enumerated failure this refusal exists to fix,
197
+ // one level down.
198
+ //
199
+ // `feedsSameMerge` IS A CONDITION ON THAT BUY-BACK: read as a bare "is this a hi/lo pair"
200
+ // it re-opens the very incident this refusal exists for. An accumulator's `const 0` init
201
+ // passes `isLowHalf`, so `s = 0; if (c) s += 0x10000;` — one 16.16 fixed-point step — is a
202
+ // hi/lo pair by the letter of the test, and its MIRROR `s = 0x10000; if (c) s += 1;` is
203
+ // one whichever operand is read as the half. Folded, a two-arm `s += 0x10000` row scores
204
+ // **diff:1** on mwcc_242_81 with `hasMergeFeedHome` FALSE; refused, **MATCH** with the
205
+ // gate TRUE. What separates the two shapes is WHERE THE VALUES GO rather than their bit
206
+ // patterns: an accumulator's init and its updated copy are two arms' feeds of ONE block
207
+ // parameter — exactly the merge `/merge-home` exists to home — whereas a shared high half
208
+ // reaches the join while the COMPLETED literal is stored or returned, never merged with
209
+ // the half it was built from. Measured FREE: with it in, 770 synthetic + 252 real rows are
210
+ // byte-identical in post-recovery IR, in emitted source and in gap list to the branch
211
+ // without it.
212
+ // - `memBases` — an address literal, `0x03001C00 + 1206` reached through one arm's base
213
+ // register. It decides 0 folds over the corpus's 806 lifted rows and is here as a
214
+ // statement of scope; `hiLoPair` is the clause that carries real traffic. Deliberately NOT
215
+ // transitive and NOT extended to call arguments: an address literal escaping as a call
216
+ // argument is a shape the refusal HELPS (a probe row scored diff:7 -> MATCH with it
217
+ // firing), so widening this test would give that back.
218
+ //
219
+ // NOT CONVERTIBLE TO A `Gate` TABLE, and the reason is the type rather than the minutes.
220
+ // `firstRejection` reads a table as a DISJUNCTION of independent refusals; the test below is
221
+ // a CONJUNCTION with two buy-backs, and "refuse unless exempted" decomposes only by making
222
+ // every term re-carry the whole conjunction. The census would then report one opaque id and
223
+ // stay silent about WHICH exemption fired — the only question the paragraphs above ask. Said
224
+ // here because a selector that ranks passes by refusal count or by instrument minutes points
225
+ // at this file (`grep -n "raise/const.ts" docs/level-tower.md`), and both readings are wrong
226
+ // about it for different reasons.
227
+ //
228
+ // A REFUSAL IS ALSO A SCHEDULING DECISION, and that coupling is invisible at this site:
229
+ // `pre-recovery.ts` registers this pass `dce: true` and runs `dce(fn)` only when the pass
230
+ // returns TRUTHY, so a function whose ONLY const/const pair is refused gets no DCE here at
231
+ // all — and `addrnum` above is `dce: false`, so this is the first pass that can schedule one.
232
+ // Measured inert: forcing the cancelled `dce(fn)` removes 0 ops on every invocation of every
233
+ // reachable row (`sinkacc`, 3 invocations). Worth knowing before this refusal is widened.
234
+ const edgeCarried = edgeSlots.has(op.operands[0]) || edgeSlots.has(op.operands[1]);
235
+ const feedsSameMerge =
236
+ meetAtSameParam(op.results[0], op.operands[0]) || meetAtSameParam(op.results[0], op.operands[1]);
237
+ const hiLoPair = ((isHighHalf(a) && isLowHalf(c)) || (isHighHalf(c) && isLowHalf(a))) && !feedsSameMerge;
238
+ if (edgeCarried && !hiLoPair && !memBases.has(op.results[0])) {
239
+ continue;
240
+ }
44
241
  // Reuse the SAME result Value → every existing use already points at it (no RAUW needed).
45
- const folded = mkOp('const', { results: [op.results[0]], attrs: { value: fold(a, c) } });
242
+ const folded = mkOp('const', {
243
+ results: [op.results[0]],
244
+ attrs: { value: fold(a, c), ...advanceEvidence(op, a, c, memBases) },
245
+ });
46
246
  b.ops.splice(i, 1, folded);
47
247
  defs.set(op.results[0], folded); // keep the def map current so a chained fold sees this const
48
248
  changed = true;
@@ -31,9 +31,9 @@
31
31
  // SELF-VERIFYING. asmlift emits a plain `x / 2^k` and the target compiler regenerates ITS own
32
32
  // lowering; a wrong divisor recompiles to different bytes and shows up as a nonmatch, never as a
33
33
  // false match. The residual exposure is a lost match, not a miscompile — on a compiler that lowers
34
- // `/2^k` branchlessly, a diamond of this shape came from hand-written biasing, and respelling it
34
+ // `/2^k` branchlessly, a diamond of this shape came from hand-written biasing, and rewriting it
35
35
  // costs a match that used to land. Measured positive on ido7.1 (two flips), agbcc and gcc2.7.2kmc
36
- // (modpow2 stays byte-exact through the respelling); mwcc_242_81 and gcc2.7.2 have no inhabitant, so
36
+ // (modpow2 stays byte-exact through the rewrite); mwcc_242_81 and gcc2.7.2 have no inhabitant, so
37
37
  // they are unmeasured rather than clean.
38
38
  //
39
39
  // It is deliberately IDENTITY-OR-DECLINE about the shape (the bias constant must be exactly