@asmlift/core 0.5.0 → 0.6.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 (86) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -167
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +194 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +54 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +337 -2
  16. package/src/ir/opcodes.ts +140 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/argbase.ts +2 -1
  24. package/src/l3/ast.ts +464 -57
  25. package/src/l3/basecse.ts +664 -76
  26. package/src/l3/coalesce.ts +429 -43
  27. package/src/l3/dce.ts +31 -9
  28. package/src/l3/gates.ts +21 -0
  29. package/src/l3/hoist.ts +293 -14
  30. package/src/l3/homesplit.ts +285 -0
  31. package/src/l3/initfirst.ts +301 -0
  32. package/src/l3/inlinebase.ts +193 -0
  33. package/src/l3/mentions.ts +113 -0
  34. package/src/l3/mulfirst.ts +42 -0
  35. package/src/l3/nearbase.ts +152 -0
  36. package/src/l3/offmember.ts +371 -0
  37. package/src/l3/parkfirst.ts +96 -0
  38. package/src/l3/pollguard.ts +154 -0
  39. package/src/l3/ptrfield.ts +227 -0
  40. package/src/l3/regspell.ts +110 -85
  41. package/src/l3/reindex.ts +715 -78
  42. package/src/l3/scopebase.ts +644 -218
  43. package/src/l3/sinkinit.ts +40 -0
  44. package/src/l3/slotorder.ts +123 -0
  45. package/src/l3/storage.ts +48 -0
  46. package/src/l3/symbol-refs.ts +41 -8
  47. package/src/l3/tailmerge.ts +15 -0
  48. package/src/l3/typing.ts +198 -9
  49. package/src/l3/unmerge.ts +263 -0
  50. package/src/l3/unreduce.ts +971 -0
  51. package/src/l3/volatileptr.ts +207 -0
  52. package/src/l3/volatileval.ts +130 -0
  53. package/src/l3/volstore.ts +229 -0
  54. package/src/l3/zerosub.ts +62 -0
  55. package/src/pattern/engine.ts +236 -13
  56. package/src/pipeline.ts +157 -56
  57. package/src/proto.ts +112 -14
  58. package/src/raise/arrays.ts +6 -1
  59. package/src/raise/divpow2.ts +2 -2
  60. package/src/raise/globalshape.ts +1038 -0
  61. package/src/raise/gvn.ts +33 -18
  62. package/src/raise/latch.ts +126 -0
  63. package/src/raise/memberarrays.ts +594 -0
  64. package/src/raise/narrow.ts +124 -0
  65. package/src/raise/narrowlocal.ts +556 -0
  66. package/src/raise/paramwidth.ts +179 -0
  67. package/src/raise/pre-recovery.ts +97 -14
  68. package/src/raise/recover.ts +56 -23
  69. package/src/raise/retsink.ts +210 -10
  70. package/src/raise/shortcircuit.ts +474 -74
  71. package/src/raise/struct-arrays.ts +19 -2
  72. package/src/raise/structs.ts +33 -3
  73. package/src/rank-axes.ts +630 -0
  74. package/src/rank-declare.ts +256 -0
  75. package/src/rank.ts +1723 -272
  76. package/src/structure/analysis.ts +1392 -141
  77. package/src/structure/bitfields.ts +332 -0
  78. package/src/structure/globalaccess.ts +274 -0
  79. package/src/structure/hazards.ts +411 -20
  80. package/src/structure/loops.ts +2 -49
  81. package/src/structure/namecoalesce.ts +435 -0
  82. package/src/structure/structure.ts +2678 -526
  83. package/src/structure/switch-recover.ts +616 -144
  84. package/src/symbols.ts +62 -1
  85. package/src/target.ts +367 -24
  86. package/src/trace.ts +111 -32
@@ -5,13 +5,22 @@
5
5
  //
6
6
  // Crucially, rewrites go through replaceAllUsesWith + DCE — never in-place opcode
7
7
  // mutation of a live value.
8
- import { Fn, Op, Value, defOpMap, mkOp, mkValue, replaceAllUsesWith } from '../ir/core';
9
- import { NEGATED_ICMP, type Opcode, isDceSafe } from '../ir/opcodes';
8
+ import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, replaceAllUsesWith } from '../ir/core';
9
+ import { EFFECTFUL_OPS, NEGATED_ICMP, ORDER_SENSITIVE_OPS, type Opcode, isDceSafe } from '../ir/opcodes';
10
10
  import type { IrType } from '../ir/types';
11
11
  import { T } from '../ir/types';
12
12
 
13
13
  export type MatchNode =
14
- | { op: string; attrEquals?: Record<string, number>; bindImm?: Record<string, string>; args: MatchNode[] }
14
+ | {
15
+ op: string;
16
+ attrEquals?: Record<string, number>;
17
+ bindImm?: Record<string, string>;
18
+ /** Match this node's two operands in the WRITTEN order only, even when the opcode is in
19
+ * `COMMUTATIVE` — for an idiom where the machine's operand order is evidence about the
20
+ * source rather than an accident of the encoding (see HWMOD_PATTERNS for the one case). */
21
+ ordered?: true;
22
+ args: MatchNode[];
23
+ }
15
24
  | { bind: string } // bind this operand's VALUE to a name
16
25
  | { same: string } // this operand must equal a previously-bound value
17
26
  | { constImm: string }; // this operand must be a `const`; bind its numeric VALUE to an imm name
@@ -67,6 +76,21 @@ export interface RewritePattern {
67
76
  // COMPUTED-attr half of the envelope (ImmExpr, above) IS built — earned by the
68
77
  // multiply-by-constant idioms.
69
78
  replaceWith: { op: string; args: ReplaceArg[]; attrs?: Record<string, number | ImmExpr>; resultType?: IrType };
79
+ /** Two REPLACEMENT operand names, in the order the replacement RENDERS them, whose defs C leaves
80
+ * UNSEQUENCED against each other. A fold that collapses a multi-op idiom into one op drops its
81
+ * operands from several uses to one, which lets the structurer inline both at that one use — and
82
+ * then the RECOMPILING COMPILER, not asmlift, picks which of the two runs first. Naming this pair
83
+ * makes the driver refuse a fold that would CHANGE the machine's order. What counts as "the
84
+ * operand" and as "the order" is subtler than it reads, and `reordersUnsequenced` below owns both.
85
+ *
86
+ * The `RightFirst` half is a compiler fact about the operator the replacement spells, verified by
87
+ * compiling in both directions rather than assumed: mwcc lowers `f() % g()` as `bl g; bl f` and
88
+ * `g() % f()` as `bl f; bl g`. It holds per OPERATOR, not per target — the same compiler runs
89
+ * `-`'s LEFT operand first (`f() - g()` is `bl f; bl g`) — which is why it lives on the pattern
90
+ * rather than on TargetDescription, and stays a field name rather than an order enum while there
91
+ * is one inhabitant. The direction was measured on ONE compiler, so `validatePattern` pins the
92
+ * declaring pattern's compiler gate to that set; widening it fails loud. */
93
+ unsequencedRightFirst?: [string, string];
70
94
  }
71
95
 
72
96
  /** Does this pattern apply to `target`? Every DECLARED axis must match: the ISA (so an idiom can be
@@ -142,6 +166,52 @@ export const SDIV_POW2_2: RewritePattern = {
142
166
  replaceWith: { op: 'sdiv', args: ['X'], attrs: { imm: 2 }, resultType: T.s() },
143
167
  };
144
168
 
169
+ // ── the synthesized remainder (PPC) ─────────────────────────────────────────────────────
170
+ // PowerPC divides in hardware but has no remainder instruction, so `a % b` is lowered as
171
+ // `divw rQ,a,b; mullw rP,rQ,b; subf rD,rP,a` — the operator is GONE from the machine code, spelled
172
+ // out as its own definition. Folding the triple back to one `smod`/`umod` gives recovery and the
173
+ // structurer the operator the source wrote, and re-emitting `%` reproduces the triple byte-exact.
174
+ //
175
+ // This is NOT the `capabilities.hwDivide` axis: MIPS also divides in hardware and needs no fold at
176
+ // all, because `div` leaves the remainder in `hi` and the frontend reads it straight out. The
177
+ // narrower fact is a hardware divide that yields the QUOTIENT ONLY; `isa: 'ppc'` STANDS IN for it
178
+ // until a second ISA earns the capability, and `compilers` carries the measured half, that mwcc's
179
+ // lowering is exactly this triple in exactly this order. Neither clause is independently
180
+ // falsifiable today — PPC_MWCC is the only target either one selects.
181
+ //
182
+ // `ordered: true` on the multiply says quotient-first is the PRECONDITION for re-emitting `%` — NOT
183
+ // that the order identifies what the source wrote, which is false: `int q = a / b; return a - q *
184
+ // b;` is a decomposition that compiles quotient-first too. So over-firing is byte-neutral (verified
185
+ // by compiling both), and the flag buys the refusal direction: matching the swapped `mullw rP,b,rQ`
186
+ // of `a - a / b * b` would respell an already byte-exact decomposition into a miss.
187
+ //
188
+ // A CONSTANT divisor is out of reach and deliberately left so — see the recognizer table on
189
+ // `smod` in ir/opcodes.ts, which owns why. It costs nothing: `mulli` has no register operand
190
+ // order to lose, and both spellings assemble to the same bytes.
191
+ function hwModPattern(div: 'sdiv' | 'udiv', mod: 'smod' | 'umod'): RewritePattern {
192
+ return {
193
+ id: `hwmod-${mod}`,
194
+ applies: { isa: 'ppc', compilers: ['mwcc'] },
195
+ match: {
196
+ op: 'sub',
197
+ args: [
198
+ { bind: 'A' },
199
+ {
200
+ op: 'mul',
201
+ ordered: true,
202
+ args: [{ op: div, args: [{ same: 'A' }, { bind: 'B' }] }, { same: 'B' }],
203
+ },
204
+ ],
205
+ },
206
+ replaceWith: { op: mod, args: ['A', 'B'] },
207
+ unsequencedRightFirst: ['A', 'B'],
208
+ };
209
+ }
210
+
211
+ /** `a - a / b * b` → `a % b`, signed and unsigned. The `same` bindings are load-bearing: a
212
+ * different dividend or divisor is a subtraction, not a remainder. */
213
+ export const HWMOD_PATTERNS: RewritePattern[] = [hwModPattern('sdiv', 'smod'), hwModPattern('udiv', 'umod')];
214
+
145
215
  // ── multiply-by-constant idioms (DIVMUL) ────────────────────────────────────────────────
146
216
  // A compiler strength-reduces `x * C` for a small constant C into shifts + one add/sub, because a
147
217
  // shift-add chain is cheaper than a general multiply. The reduction is COMPILER-driven and shared
@@ -307,15 +377,16 @@ export const NOT_CMP_PATTERNS: RewritePattern[] = [
307
377
 
308
378
  // The DEFAULT idiom bundle `decompile()` applies when the caller passes no `patterns`. It is
309
379
  // EVERY idiom asmlift owns; the list self-selects per target through patternApplies — agbcc/gcc get
310
- // sdiv-pow2, agbcc/ido/gcc get the mul-const folds, mwcc gets cntlzw-eq0 + rotl-mirror, and agbcc
311
- // gets the casts. MOST patterns are `{compilers}`-gated because they trade one spelling for another
312
- // and are only byte-safe where measured; the boolean-negation folds are deliberately UNGATED (see
313
- // their comment — the shape is its own gate), so "gated per compiler" is the common case, not the
314
- // invariant. Ordered like the sub-bundles: the
315
- // division idiom, then the multiplies (base folds before the composite tail). Passing an explicit
316
- // `patterns` (including `[]`) overrides this — `[]` runs the naive lift with no idiom folding.
380
+ // sdiv-pow2, agbcc/ido/gcc get the mul-const folds, mwcc gets cntlzw-eq0 + rotl-mirror + the PPC
381
+ // remainder fold, and agbcc gets the casts. MOST patterns are `{compilers}`-gated because they
382
+ // trade one spelling for another and are only byte-safe where measured; the boolean-negation folds
383
+ // are deliberately UNGATED (see their comment — the shape is its own gate), so "gated per compiler"
384
+ // is the common case, not the invariant. Ordered like the sub-bundles: the division idioms, then
385
+ // the multiplies (base folds before the composite tail). Passing an explicit `patterns` (including
386
+ // `[]`) overrides this — `[]` runs the naive lift with no idiom folding.
317
387
  export const DEFAULT_IDIOM_PATTERNS: RewritePattern[] = [
318
388
  SDIV_POW2_2,
389
+ ...HWMOD_PATTERNS,
319
390
  CNTLZW_EQ0,
320
391
  // AFTER cntlzw-eq0, which is what turns mwcc's `clz(x) >> 5` into the `icmp_eq` this fold then
321
392
  // negates — `!(x == 0)` composes only in that order (each pattern runs to fixpoint in turn).
@@ -331,6 +402,7 @@ export const DEFAULT_IDIOM_PATTERNS: RewritePattern[] = [
331
402
  // same test, and which one a frontend builds is an accident of how the branch was decoded — the
332
403
  // zero-test folds must match either. The ORDERED comparisons are deliberately absent: swapping the
333
404
  // operands of `a < b` is `b > a`, a different opcode, which this mechanism cannot express.
405
+ // Membership here is about the OPCODE; a single pattern node opts back out with `ordered: true`.
334
406
  const COMMUTATIVE = new Set(['add', 'mul', 'and', 'or', 'xor', 'icmp_eq', 'icmp_ne']);
335
407
 
336
408
  interface Binds {
@@ -379,9 +451,10 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
379
451
  if (d.operands.length !== node.args.length) {
380
452
  return false;
381
453
  }
382
- // A commutative binary op matches its two args in EITHER order. Each order is tried on a cloned
383
- // bind map so a partial (then-failed) match can't leak bindings; the first full match commits.
384
- if (COMMUTATIVE.has(d.opcode) && node.args.length === 2) {
454
+ // A commutative binary op matches its two args in EITHER order, unless the pattern declared this
455
+ // node `ordered`. Each order is tried on a cloned bind map so a partial (then-failed) match can't
456
+ // leak bindings; the first full match commits.
457
+ if (COMMUTATIVE.has(d.opcode) && node.args.length === 2 && !node.ordered) {
385
458
  for (const [i, j] of [
386
459
  [0, 1],
387
460
  [1, 0],
@@ -402,8 +475,155 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
402
475
  return node.args.every((a, i) => tryMatch(a, d.operands[i], defs, b));
403
476
  }
404
477
 
478
+ /** Walk a pattern's match DAG once and reject a declaration that could not have an effect, or one
479
+ * whose compiler gate outruns the evidence behind it. Same diagnosability-first rule as the
480
+ * unbound-`replaceWith` throw below: `ordered` is consulted ONLY inside `tryMatch`'s
481
+ * commutative-swap branch, so on a non-commutative or non-binary node it would be silently inert,
482
+ * and a pattern author (or a generator emitting patterns as data) would get no error and no effect.
483
+ *
484
+ * Everything checked here is a property of the pattern OBJECT, so the answer is memoized against it
485
+ * rather than recomputed per lift — patterns may still be built at runtime, which is why this is a
486
+ * WeakSet and not module-scope validation of the DEFAULT list. Only a PASSING run is recorded: a
487
+ * pattern that threw must throw again on the next lift, or the first caller (annotate mode, which
488
+ * swallows the throw into a stub) would silently license the malformed pattern for the whole run. */
489
+ const UNSEQUENCED_RIGHT_FIRST_MEASURED_ON = ['mwcc'];
490
+ const validated = new WeakSet<RewritePattern>();
491
+
492
+ function validatePattern(pat: RewritePattern): void {
493
+ if (validated.has(pat)) {
494
+ return;
495
+ }
496
+ const walk = (n: MatchNode): void => {
497
+ if (!('op' in n)) {
498
+ return;
499
+ }
500
+ if (n.ordered && !(COMMUTATIVE.has(n.op) && n.args.length === 2)) {
501
+ throw new Error(
502
+ `pattern '${pat.id}' declares 'ordered' on a '${n.op}' node with ${n.args.length} operand(s), ` +
503
+ `where the written order is already the only reading — the flag would be inert`,
504
+ );
505
+ }
506
+ n.args.forEach(walk);
507
+ };
508
+ walk(pat.match);
509
+ if (pat.unsequencedRightFirst) {
510
+ for (const name of pat.unsequencedRightFirst) {
511
+ if (!pat.replaceWith.args.includes(name)) {
512
+ throw new Error(
513
+ `pattern '${pat.id}' names '${name}' in 'unsequencedRightFirst', which is not a replaceWith operand`,
514
+ );
515
+ }
516
+ }
517
+ const on = pat.applies.compilers ?? [];
518
+ const measured = UNSEQUENCED_RIGHT_FIRST_MEASURED_ON;
519
+ if (on.length !== measured.length || !measured.every((c) => on.includes(c))) {
520
+ throw new Error(
521
+ `pattern '${pat.id}' declares 'unsequencedRightFirst' but applies to compilers [${on.join(', ')}]; ` +
522
+ `the operand direction is only measured for [${measured.join(', ')}] — measure the new one and widen the set`,
523
+ );
524
+ }
525
+ }
526
+ validated.add(pat);
527
+ }
528
+
529
+ /** Would this fold DE-SEQUENCE the two named operands — leave the recompiling compiler, rather than
530
+ * the machine code, deciding which of two observable effects runs first?
531
+ *
532
+ * The fold collapses a several-op idiom into one, which drops each named operand from two uses to
533
+ * one; the structurer then inlines a single-use def at its one use, TRANSITIVELY THROUGH PURE
534
+ * SINGLE-USE OPS. So what lands at an operand position is that operand's whole inlinable CONE, not
535
+ * just its def, and the question has to be asked over the cones: one pure `+ 1` between an effect
536
+ * and the fold's operand is the difference between `f() % g()` and `(f() + 1) % g()`, and the
537
+ * second one reorders exactly as the first does. Both cones become operands of ONE expression,
538
+ * where C leaves their order unspecified. asmlift's inline-at-use model (structure/analysis.ts)
539
+ * exempts exactly this case — "a sibling effect inlined into the SAME statement is not a reorder,
540
+ * the recompiling compiler orders unsequenced operands of one expression exactly as it originally
541
+ * chose to". That premise holds only when the expression asmlift re-spells is the one the source
542
+ * wrote. A fold INVENTS an expression, so it must check.
543
+ *
544
+ * A cone's members are weighed by ORDER_SENSITIVE_OPS, not EFFECTFUL_OPS: a memory read answers
545
+ * whichever stores ran before it, so hoisting a `load` over a `call` — one asmlift may itself be
546
+ * passing the loaded pointer to — changes the answer as surely as swapping two calls. Two READS
547
+ * are the exception and commute, the same fact the structurer states as "a load never bars a
548
+ * load", so a hazard needs an EFFECT on at least one side; `*p % *q` is admitted.
549
+ *
550
+ * Refuses only what it must — the alternative spelling (the idiom written out) names the operands
551
+ * and states the order, so a refusal is a loud, correct, slightly-worse-scoring answer. */
552
+ function reordersUnsequenced(
553
+ fn: Fn,
554
+ root: Op,
555
+ names: [string, string],
556
+ binds: Binds,
557
+ defs: Map<Value, Op>,
558
+ pid: string,
559
+ ): boolean {
560
+ // Both cones are read within the ROOT's block, on two different grounds. A cross-block EFFECT the
561
+ // structurer materializes unconditionally — its execution would otherwise become path-dependent —
562
+ // so the C names it and pins the order there. A cross-block READ it does not; what pins that one
563
+ // is the structurer's own def→render write scan, where a call counts as a write, so a read cannot
564
+ // reach this expression across the very call it would be racing.
565
+ const blk: Block | undefined = fn.blocks.find((b) => b.ops.includes(root));
566
+ if (!blk) {
567
+ return false;
568
+ }
569
+ const at = new Map<Op, number>(blk.ops.map((o, i) => [o, i]));
570
+ const uses = new Map<Value, number>();
571
+ for (const b of fn.blocks) {
572
+ for (const o of b.ops) {
573
+ for (const v of [...o.operands, ...o.successors.flatMap((x) => x.args)]) {
574
+ uses.set(v, (uses.get(v) ?? 0) + 1);
575
+ }
576
+ }
577
+ }
578
+ /** What the structurer may pull in at one operand position: the operand's own def — which this
579
+ * fold is about to drop to a single use — then each operand def that is ALREADY single-use, a
580
+ * multi-use value being named and so staying a statement of its own. */
581
+ const coneOf = (name: string): Set<Op> => {
582
+ const v = binds.values.get(name);
583
+ if (!v) {
584
+ throw new Error(`pattern '${pid}' names unbound value '${name}' in 'unsequencedRightFirst'`);
585
+ }
586
+ const cone = new Set<Op>();
587
+ const stack: (Op | undefined)[] = [defs.get(v)];
588
+ while (stack.length) {
589
+ const op = stack.pop();
590
+ if (!op || cone.has(op) || !at.has(op)) {
591
+ continue;
592
+ }
593
+ cone.add(op);
594
+ for (const o of op.operands) {
595
+ if (uses.get(o) === 1) {
596
+ stack.push(defs.get(o));
597
+ }
598
+ }
599
+ }
600
+ return cone;
601
+ };
602
+ const [lc, rc] = names.map(coneOf);
603
+ const sensitive = (c: Set<Op>) => [...c].filter((o) => ORDER_SENSITIVE_OPS.has(o.opcode));
604
+ for (const l of sensitive(lc)) {
605
+ for (const r of sensitive(rc)) {
606
+ // One op standing in BOTH cones is one evaluation and cannot be sequenced against itself; two
607
+ // READS commute; and a right-cone op the machine ALREADY runs first loses nothing.
608
+ if (l === r || at.get(l)! >= at.get(r)! || !(EFFECTFUL_OPS.has(l.opcode) || EFFECTFUL_OPS.has(r.opcode))) {
609
+ continue;
610
+ }
611
+ // The left one runs first, so an inlined `A op B` would swap them — UNLESS a sibling effect
612
+ // stands between, which the inline-at-use model refuses to cross, forcing a named temp at the
613
+ // def's own position. A cone member is no sibling: it is inlined into this very expression.
614
+ if (
615
+ !blk.ops.slice(at.get(l)! + 1, at.get(r)!).some((o) => EFFECTFUL_OPS.has(o.opcode) && !lc.has(o) && !rc.has(o))
616
+ ) {
617
+ return true;
618
+ }
619
+ }
620
+ }
621
+ return false;
622
+ }
623
+
405
624
  /** Apply one pattern greedily to a fixed point. Returns the number of rewrites. */
406
625
  export function applyPattern(fn: Fn, pat: RewritePattern): number {
626
+ validatePattern(pat);
407
627
  let count = 0,
408
628
  changed = true;
409
629
  while (changed) {
@@ -419,6 +639,9 @@ export function applyPattern(fn: Fn, pat: RewritePattern): number {
419
639
  if (!tryMatch(pat.match, op.results[0], defs, binds)) {
420
640
  continue;
421
641
  }
642
+ if (pat.unsequencedRightFirst && reordersUnsequenced(fn, op, pat.unsequencedRightFirst, binds, defs, pat.id)) {
643
+ continue;
644
+ }
422
645
  // Materialize any synthesized-constant replacement operands as their own `const` ops,
423
646
  // spliced in before the rewrite; bound-value operands resolve from the value binds.
424
647
  const rw = pat.replaceWith;
package/src/pipeline.ts CHANGED
@@ -5,28 +5,32 @@ import {
5
5
  ContractError,
6
6
  assertDerefsTyped,
7
7
  assertEffectsPreserved,
8
+ assertLocalsWritten,
8
9
  assertResolved,
9
10
  assertTypesRecovered,
10
11
  } from './contracts';
11
12
  import type { AsmData } from './frontend/asmdata';
12
13
  import { FrontendUnsupportedError } from './frontend/errors';
13
14
  import { frontendFor } from './frontend/registry';
14
- import { type Block, type Fn, successorsOf } from './ir/core';
15
+ import { type Fn, reachableBlocks } from './ir/core';
15
16
  import { print } from './ir/print';
17
+ import { firstTrivialPhi } from './ir/simplify';
16
18
  import { T } from './ir/types';
17
19
  import { VerifyError, verify } from './ir/verify';
18
- import { Expr, LanguageBackend, SFn, Stmt, exprChildren, gapReasonFor, stmtChildren, stmtExprs } from './l3/ast';
19
- import { hoistReusedGlobalBases } from './l3/basecse';
20
+ import { LanguageBackend, SFn, gapReasonFor, walkExprs } from './l3/ast';
21
+ import { BASECSE_GATES, hoistBaseLocals } from './l3/basecse';
20
22
  import { eliminateDeadStores } from './l3/dce';
21
23
  import { mergeCommonTails } from './l3/tailmerge';
22
24
  import { DEFAULT_IDIOM_PATTERNS, RewritePattern, applyPattern, dce, patternApplies } from './pattern/engine';
23
- import { type Prototypes, prototypesFromSymbols } from './proto';
25
+ import { type FnProto, type Prototypes, prototypesFromSymbols } from './proto';
24
26
  import { RaiseUnsupportedError } from './raise/errors';
25
- import { type PreRecoveryPass, runPreRecovery } from './raise/pre-recovery';
27
+ import { assumedShapes, inferGlobalArrays, orderLicensedGlobals } from './raise/globalshape';
28
+ import { foldEmptyLatches } from './raise/latch';
29
+ import { type PreRecoveryOptions, type PreRecoveryPass, runPreRecovery } from './raise/pre-recovery';
26
30
  import { recoverTypes } from './raise/recover';
27
31
  import { sinkReturns } from './raise/retsink';
28
32
  import { StructureError, structure } from './structure/structure';
29
- import { type SymbolMap, symbolsByName } from './symbols';
33
+ import { type SymbolInfo, type SymbolMap, symbolsByName } from './symbols';
30
34
  import { type TargetDescription, structureOptionsFor } from './target';
31
35
 
32
36
  /** How a gap (a construct asmlift cannot faithfully model) degrades:
@@ -74,10 +78,36 @@ export interface DecompileResult {
74
78
  sfn: SFn;
75
79
  ir: { raw: string; folded: string; recovered: string }; // IR dumps: post-lift, post-idiom, post-recovery
76
80
  patternHits: number;
77
- /** structured gap list — ALWAYS present; empty ⇔ the emission is gap-free (compiles + candidate
78
- * for scoring). Non-empty the source contains ASMLIFT_ERROR markers / a stub and will NOT
79
- * compile until the user defines that symbol (the loud-in-artifact contract). */
81
+ /** structured gap list — ALWAYS present. Non-empty ⇔ the source contains ASMLIFT_ERROR markers /
82
+ * a stub and will NOT compile until the user acts (the loud-in-artifact contract).
83
+ *
84
+ * EMPTY IS THE ABSENCE OF A GAP, NOT A PROMISE THAT THE C COMPILES. A candidate can also fail
85
+ * on a symbol the caller never declared, which is a CONTEXT question the benchmark answers by
86
+ * escalating to the vendored preprocessed context. Most such names survive K&R implicit
87
+ * declaration; a function ADDRESS does not, and klonoa's `UpdateStageSelectScreen` reports 0
88
+ * gaps while emitting `((s32 *)50345232)[1] = &HandlePauseMenuInput;`, which agbcc rejects
89
+ * where the plain call above it passes with a warning. Declaring an address-taken unknown
90
+ * callee would close it — an emitter change, moving source bytes on every row that has one. */
80
91
  diagnostics: Diagnostic[];
92
+ /** THE SHAPES THIS SOURCE'S SPELLING ASSUMES, which no symbol map supplied (raise/globalshape.ts).
93
+ *
94
+ * Everything else a backend emits is byte-correct under ANY declaration of the names it spells
95
+ * — that is exactly why `((T *)&gSym)[i]` is the fallback (structure/globalaccess.ts). A bare
96
+ * `gSym[i]` is not: it means what the DECLARATION of `gSym` says it means, and where that
97
+ * declaration was derived from the assembly rather than read from the project's map, the
98
+ * emitted source is right about the target's bytes only beside the declaration derived with it.
99
+ * The element SIGNEDNESS is the sharp case, and it is an assumption rather than a reading:
100
+ * compiled through the benchmark's own agbcc command, `(u16)gS[i]` over `extern const s16 gS[]`
101
+ * and `gS[i]` over `extern const u16 gS[]` are the SAME OBJECT, so the assembly cannot say which
102
+ * the source wrote — asmlift picks the one its own declaration block states.
103
+ *
104
+ * So this travels with the source on every path that can emit it: the scoring layer renders it
105
+ * (declare.ts, and main.ts's `[declared]` block), and a caller that shows the source alone must
106
+ * show these too, or it is publishing a spelling whose meaning it has not stated. Empty on every
107
+ * run that assumed nothing — which includes every derived shape the structurer did not spell
108
+ * bare, and every name the caller's own map described (raise/globalshape.ts `assumedShapes`
109
+ * computes that narrowing and names the corpus row behind each half). */
110
+ assumedSymbols: SymbolInfo[];
81
111
  }
82
112
 
83
113
  export function decompile(
@@ -115,39 +145,66 @@ function runTower(
115
145
  // (1) lift: ISA frontend (resolved by target) → L1 with block-argument SSA
116
146
  const fn = frontendFor(target).lift(name, asm, target, prototypes, opts.asmData, opts.symbols);
117
147
  verify(fn);
118
- const raw = print(fn);
148
+ // Every dump carries the write-order record (ir/print.ts `PrintOptions`): it decides the
149
+ // edge-copy order and the raising folds mutate it, so two dumps compare whole program states.
150
+ const raw = print(fn, { writeOrder: true });
151
+ // (1.5) the ARRAY SHAPES this function's own assembly evidences, for globals the project map
152
+ // does not describe. Read HERE, off the lifted fn, because the fact it needs — whether the base
153
+ // was materialized before the index was scaled — is destroyed by the raising tower below
154
+ // (raise/globalshape.ts's module note). Empty unless the target opts in.
155
+ const inferredSymbols = inferGlobalArrays(fn, target);
156
+ // …and the ORDER half of the same reading, which reaches names the shape derivation refuses (a
157
+ // struct element among them). Read off the same lifted fn, for the same reason.
158
+ const orderLicensed = orderLicensedGlobals(fn, target);
119
159
 
120
160
  // (2) idiom fold: apply serializable patterns on the IR (the AI-improvement surface),
121
161
  // gated generically by the Target's capabilities (not an `arch ==` branch).
122
162
  const patternHits = applyIdiomPatterns(fn, target, opts.patterns);
123
- const folded = print(fn);
163
+ const folded = print(fn, { writeOrder: true });
124
164
 
125
165
  // (2.35–3.5) pre-recovery recognizers → type recovery → return-sinking, the ONE shared spine
126
166
  // (`raiseRecovered`) that trace.ts and the cli's rank.ts/report.ts also run.
127
- raiseRecovered(fn, target);
128
- const recovered = print(fn);
167
+ raiseRecovered(fn, target, {}, prototypes[name]);
168
+ const recovered = print(fn, { writeOrder: true });
129
169
 
130
170
  // (4) structure: IR → neutral AST; boundary contract: no unresolved value leaked (strict), or
131
171
  // every unresolved value spelled as a loud ASMLIFT_ERROR marker (annotate).
172
+ const mapSymbols = opts.symbols ? symbolsByName(opts.symbols) : undefined;
132
173
  const sfn = structureChecked(fn, {
133
174
  ...structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false),
175
+ // What the EMITTED LANGUAGE can say is a structuring input wherever two recoveries of one
176
+ // shape are behaviourally identical and only one of them is printable (switch fall-through
177
+ // vs plain if-nesting): recovery must not mint a tree this backend would refuse.
178
+ spellSwitchFallthrough: backend.spellsSwitchFallthrough,
134
179
  onGap,
135
- ...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
180
+ ...(mapSymbols ? { symbols: mapSymbols } : {}),
181
+ ...(inferredSymbols.size ? { inferredSymbols } : {}),
182
+ ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
136
183
  });
137
184
 
138
185
  // (5) lower + print: neutral AST → target language
139
186
  const source = backend.emit(sfn);
140
187
 
141
- return { source, sfn, ir: { raw, folded, recovered }, patternHits, diagnostics: collectMarkers(sfn) };
188
+ return {
189
+ source,
190
+ sfn,
191
+ ir: { raw, folded, recovered },
192
+ patternHits,
193
+ diagnostics: collectMarkers(sfn),
194
+ // WHAT THE SOURCE RESTS ON, not what the derivation found: a shape the structurer did not
195
+ // spell bare, and a name the caller's own map described, are both obligations this reader
196
+ // does not have (raise/globalshape.ts `assumedShapes`).
197
+ assumedSymbols: assumedShapes(inferredSymbols, sfn, mapSymbols),
198
+ };
142
199
  }
143
200
 
144
201
  // ── the shared raising tower ────────────────────────────────────────────────────────────────
145
- // decompile(), decompileTraced (trace.ts), and the cli's decompileRanked (rank.ts) /
146
- // decompileWithReport + its score probe (report.ts) all raise a lifted fn through the SAME
147
- // stage sequence. The optional hooks are the only per-caller
148
- // differences: rank pins its signedness candidate `beforeRecover`; the report pushes trace
149
- // entries after each stage. Every hook fires AFTER the stage's verify, so a hook can never
150
- // observe unverified IR.
202
+ // decompile(), decompileTraced (trace.ts), rank.ts's decompileRanked and the cli's
203
+ // decompileWithReport + its score probe (report.ts) all raise a lifted fn through the SAME stage
204
+ // sequence. Two things vary per caller and nothing else does: the optional HOOKS — rank pins its
205
+ // signedness candidate at `beforeRecover`, decompileTraced pushes a trace entry after each stage —
206
+ // and the `pre` options bag, which reaches the pre-recovery passes themselves. Every hook fires
207
+ // AFTER the stage's verify, so a hook can never observe unverified IR.
151
208
 
152
209
  /** Stage 2 — idiom fold: filter the pattern set by target capabilities, apply, dce + verify.
153
210
  * Returns total hits. `patterns` defaults to DEFAULT_IDIOM_PATTERNS exactly like decompile(). */
@@ -173,17 +230,47 @@ export interface RaiseHooks {
173
230
  afterRecover?: () => void;
174
231
  /** after return-sinking, only when it changed the fn (fires after its verify) */
175
232
  afterRetsink?: () => void;
233
+ /** after empty-latch folding, only when it removed a block (fires after its verify) */
234
+ afterLatchFold?: () => void;
176
235
  }
177
236
 
178
237
  /** Stages 2.35–3.5 — pre-recovery recognizers (the shared ordered list in raise/pre-recovery.ts)
179
238
  * → type recovery (boundary contract: no `unknown` survives) → return-sinking (tail-duplicate a
180
- * return-only merge so short-circuits emit early returns). `verify` after every pass that
181
- * changed the IR. */
182
- export function raiseRecovered(fn: Fn, target: TargetDescription, hooks: RaiseHooks = {}): void {
183
- runPreRecovery(fn, target, (pass, result) => {
184
- verify(fn);
185
- hooks.afterPass?.(pass, result);
186
- });
239
+ * return-only merge so short-circuits emit early returns) empty-latch folding (splice out a
240
+ * back-edge block SSA construction emptied). `verify` after every pass that changed the IR.
241
+ *
242
+ * The two CFG passes look ordered and are not: running latch folding FIRST instead changes
243
+ * nothing across a 3337-function agbcc corpus. Worth saying, because folding an empty block ahead
244
+ * of return-sinking does take away `br` predecessors it needs — the dominance gate is what makes
245
+ * that unreachable, since the blocks retsink wants are never back-edge sources. It goes last
246
+ * because that is where the CFG stops moving.
247
+ *
248
+ * The tail is three separate parameters rather than an options bag on purpose: this is the
249
+ * published package root export, so every caller outside this repo is pinned to the positions.
250
+ *
251
+ * @param hooks per-stage observers; see {@link RaiseHooks}. Each fires after that stage's verify.
252
+ * @param self this function's own prototype, where the caller has one — what the pre-recovery
253
+ * passes read to type the parameters they are narrowing.
254
+ * @param pre per-caller PRE-RECOVERY options. One shipped user: rank.ts's `/connective`
255
+ * candidate, which passes `{ shortCircuit: { foldTreeOwned: true } }` to take the
256
+ * fold the comparison-tree refusal owns (raise/shortcircuit.ts). */
257
+ export function raiseRecovered(
258
+ fn: Fn,
259
+ target: TargetDescription,
260
+ hooks: RaiseHooks = {},
261
+ self?: FnProto,
262
+ pre: PreRecoveryOptions = {},
263
+ ): void {
264
+ runPreRecovery(
265
+ fn,
266
+ target,
267
+ (pass, result) => {
268
+ verify(fn);
269
+ hooks.afterPass?.(pass, result);
270
+ },
271
+ self,
272
+ pre,
273
+ );
187
274
  hooks.beforeRecover?.();
188
275
  recoverTypes(fn);
189
276
  verify(fn);
@@ -193,6 +280,26 @@ export function raiseRecovered(fn: Fn, target: TargetDescription, hooks: RaiseHo
193
280
  verify(fn);
194
281
  hooks.afterRetsink?.();
195
282
  }
283
+ if (foldEmptyLatches(fn)) {
284
+ verify(fn);
285
+ hooks.afterLatchFold?.();
286
+ }
287
+ // THE BOUNDARY POSTCONDITION. Above this line passes move the CFG; below it nothing does, and the
288
+ // structurer reads a block parameter as a JOIN — a name it must give a local of its own. A param
289
+ // whose every in-edge carries one value is not a join, and leaving one standing is how a
290
+ // CFG-motion pass does its damage three stages away rather than where it happened: retsink's own
291
+ // stranded merge was destroyed into `v0 = 0; return v0;` and read by Regime-A switch recovery as a
292
+ // SECOND `default` candidate, declining every fall-through tree. This names it at retsink.
293
+ // Not an `ir/verify.ts` rule: a trivial phi is well-formed IR, and both SSA construction and the
294
+ // `addrnum` pass mint one and clear it inside their own scope (see `firstTrivialPhi`).
295
+ const stranded = firstTrivialPhi(fn);
296
+ if (stranded) {
297
+ throw new Error(
298
+ `internal: raising left a trivial phi — block #${fn.blocks.indexOf(stranded.block)} takes ` +
299
+ `'${stranded.param}', whose every in-edge carries one value. A pass that retires an in-edge ` +
300
+ `must run simplifyTrivialPhis after it.`,
301
+ );
302
+ }
196
303
  }
197
304
 
198
305
  /** Run `body`; if it declines, name the unmodelled instructions the function carries.
@@ -214,15 +321,7 @@ function attributeOpaques<T>(fn: Fn, body: () => T): T {
214
321
  if (!(e instanceof StructureError) || !fn.blocks[0]) {
215
322
  throw e;
216
323
  }
217
- const seen = new Set<Block>([fn.blocks[0]]);
218
- for (const stack = [fn.blocks[0]]; stack.length;) {
219
- for (const s of successorsOf(stack.pop()!)) {
220
- if (!seen.has(s)) {
221
- seen.add(s);
222
- stack.push(s);
223
- }
224
- }
225
- }
324
+ const seen = reachableBlocks(fn);
226
325
  const names = new Set<string>();
227
326
  for (const b of seen) {
228
327
  for (const op of b.ops) {
@@ -244,20 +343,26 @@ function attributeOpaques<T>(fn: Fn, body: () => T): T {
244
343
  /** Stage 4 — structure + its boundary contracts, always as a pair. */
245
344
  export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]): SFn {
246
345
  const raw = attributeOpaques(fn, () => structure(fn, opts));
247
- // BOTH boundary contracts run on the pre-DCE tree: the readability pass must never be able to
346
+ // The boundary contracts run on the pre-DCE tree: the readability pass must never be able to
248
347
  // hide a structuring defect by dropping the dead statement that carries it. assertResolved
249
348
  // catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
250
- // under a rejected operator) — even one sitting in dead code structure emitted. DCE then only
251
- // removes statements/flips branches over an already-validated tree.
349
+ // under a rejected operator) — even one sitting in dead code structure emitted;
350
+ // assertLocalsWritten catches a materialized def whose assignment no position emitted. DCE then
351
+ // only removes statements/flips branches over an already-validated tree.
252
352
  assertResolved(raw);
253
353
  assertDerefsTyped(raw);
354
+ assertLocalsWritten(raw);
254
355
  assertEffectsPreserved(fn, raw);
255
356
  // Then the readability/quality rewrites: merge a statement common to every arm of an if,
256
- // drop dead stores (whose empty-then peephole flips the arm the merge empties), then hoist a
257
- // reused aggregate-global
258
- // base into a typed local pointer. The hoist moves the deref cast from each `index` node onto the
259
- // local's initializer, so re-validate deref typing on the rewritten tree.
260
- const sfn = hoistReusedGlobalBases(eliminateDeadStores(mergeCommonTails(raw)));
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');
261
366
  assertDerefsTyped(sfn);
262
367
  // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
263
368
  // merges arms or drops statements must not be able to lose or duplicate a call.
@@ -317,26 +422,22 @@ export function stubResult(name: string, asm: string, backend: LanguageBackend,
317
422
  ir: { raw: '', folded: '', recovered: '' },
318
423
  patternHits: 0,
319
424
  diagnostics: [{ stage, reason: msg }],
425
+ // A stub spells no global, so it assumes nothing about one.
426
+ assumedSymbols: [],
320
427
  };
321
428
  }
322
429
 
323
430
  /** Every ASMLIFT_ERROR marker in the emitted AST, as a structured diagnostic (one per marker,
324
431
  * document order). The harness/self-improve loop reads THIS; the source text is for humans. */
325
432
  function collectMarkers(sfn: SFn): Diagnostic[] {
326
- // On the shared exprChildren/stmtExprs/stmtChildren traversal. Order is exprs-then-children
327
- // per statement — deterministic and near-document-order (a `for`'s cond is visited before its
328
- // init; see the note on stmtChildren).
433
+ // On the shared `walkExprs` traversal (l3/ast.ts). Order is exprs-then-nested-statements per
434
+ // statement — deterministic and near-document-order (a `for`'s cond is visited before its init;
435
+ // see the note on stmtChildren).
329
436
  const out: Diagnostic[] = [];
330
- const we = (e: Expr): void => {
437
+ for (const e of walkExprs(sfn.body)) {
331
438
  if (e.k === 'marker') {
332
439
  out.push({ stage: 'structure', reason: e.reason });
333
440
  }
334
- exprChildren(e).forEach(we);
335
- };
336
- const ws = (s: Stmt): void => {
337
- stmtExprs(s).forEach(we);
338
- stmtChildren(s).forEach(ws);
339
- };
340
- sfn.body.forEach(ws);
441
+ }
341
442
  return out;
342
443
  }