@asmlift/core 0.3.0 → 0.5.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 (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
@@ -0,0 +1,227 @@
1
+ // asmlift — signed division by a power of two, in its BRANCHING form.
2
+ //
3
+ // `x / 2^k` must round toward zero, but an arithmetic right shift rounds toward minus infinity, so a
4
+ // compiler biases the dividend by `2^k - 1` when it is negative. Two lowerings exist, and asmlift
5
+ // already folded one of them: the branchless `(x + (x >>u 31)) >> 1` that agbcc and GCC emit for
6
+ // `/2` is the SDIV_POW2_2 idiom pattern (pattern/engine.ts). The other is a BRANCH — what IDO emits,
7
+ // and what GCC emits for larger k:
8
+ //
9
+ // bgez a0, .L2 ; skip the bias when x >= 0
10
+ // addiu at, a0, 1 ; bias = 2^k - 1
11
+ // .L2: sra v0, at, 1 ; >> k
12
+ //
13
+ // That is a CFG diamond, not an instruction window, so the patterns-as-data idiom layer cannot state
14
+ // it — its match DAG is over the def-graph inside a block. It earns a pre-recovery pass for the same
15
+ // reason raise/shortcircuit.ts is one: the thing being recognised is a shape in the CONTROL FLOW.
16
+ // (m2c reached the same conclusion from the other side — its `49b5d87` adds a third and fourth
17
+ // instruction-window pattern for the GCC spellings, and the commit notes GCC reorders the final
18
+ // `sra` and defeats the window. Matching the value graph instead is immune to that.)
19
+ //
20
+ // Both spellings of the merge appear in the corpus and both are recognised here:
21
+ //
22
+ // SUNK the shift is after the merge — phi(x, x + 2^k-1) feeding `shr_s(phi, k)`
23
+ // SPLIT the shift is duplicated into both arms — phi(shr_s(x, k), shr_s(x + 2^k-1, k)),
24
+ // which is what a delay-slot fill produces (the `sra` runs on both paths)
25
+ //
26
+ // The identity is arithmetic — for signed x, `(x < 0 ? x + 2^k-1 : x) >> k` is exactly `x / 2^k`
27
+ // under C's truncating division — but by this project's taxonomy that is NOT on its own a licence
28
+ // to run ungated: the compiler-pinned idiom patterns are gated precisely because they trade one
29
+ // spelling for another and are byte-safe only where measured, and this pass does trade a spelling.
30
+ // What carries it is raise/magicdiv.ts's argument, which applies here unchanged: the round-trip is
31
+ // SELF-VERIFYING. asmlift emits a plain `x / 2^k` and the target compiler regenerates ITS own
32
+ // lowering; a wrong divisor recompiles to different bytes and shows up as a nonmatch, never as a
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
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
37
+ // they are unmeasured rather than clean.
38
+ //
39
+ // It is deliberately IDENTITY-OR-DECLINE about the shape (the bias constant must be exactly
40
+ // `2^k - 1` for the SAME k the shift uses, the guard must test the SAME value the arms divide, and
41
+ // the biased arm must be the negative one — the consistency check m2c's `49b5d87` also adds),
42
+ // because every one of those is a way for a superficially similar diamond to mean something else.
43
+ import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
44
+ import { HOIST_UNSAFE_OPS } from '../ir/opcodes';
45
+ import { T } from '../ir/types';
46
+
47
+ /** `shr_s v {imm=k}` → k, else null. */
48
+ function shiftAmount(defs: Map<Value, Op>, v: Value): { k: number; src: Value; op: Op } | null {
49
+ const d = defs.get(v);
50
+ if (!d || d.opcode !== 'shr_s' || d.operands.length !== 1 || typeof d.attrs.imm !== 'number') {
51
+ return null;
52
+ }
53
+ return { k: d.attrs.imm, src: d.operands[0], op: d };
54
+ }
55
+
56
+ /** `add v, const(2^k - 1)` → the addend's base, else null. The add is commutative. */
57
+ function biasedBy(defs: Map<Value, Op>, v: Value, k: number): Value | null {
58
+ const d = defs.get(v);
59
+ if (!d || d.opcode !== 'add' || d.operands.length !== 2) {
60
+ return null;
61
+ }
62
+ for (const [a, b] of [
63
+ [0, 1],
64
+ [1, 0],
65
+ ] as const) {
66
+ const c = defs.get(d.operands[b]);
67
+ if (c && c.opcode === 'const' && c.attrs.value === 2 ** k - 1) {
68
+ return d.operands[a];
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Fold the branching signed-division-by-2^k diamond into `sdiv x {imm=2^k}`. Returns true if the IR
76
+ * changed. Leaves the now-dead bias/shift ops behind for the driver's DCE.
77
+ */
78
+ export function recognizeDivPow2(fn: Fn): boolean {
79
+ let changed = false;
80
+ let progress = true;
81
+ while (progress) {
82
+ progress = false;
83
+ const defs = defOpMap(fn);
84
+ const preds = predecessors(fn);
85
+ const term = (b: Block) => b.ops[b.ops.length - 1];
86
+
87
+ outer: for (const m of fn.blocks) {
88
+ // The merge carries exactly the quotient (or the value about to be shifted into it), and is
89
+ // reached only by the two arms of this diamond — a third predecessor means the phi is a join
90
+ // of something larger and retiring it would drop that edge's value.
91
+ //
92
+ // NEITHER end of the diamond may be the ENTRY block. `predecessors()` walks successor edges
93
+ // only, so an entry that is also a loop header shows two predecessors while really being a
94
+ // three-way join — the implicit entry edge is invisible, and the head does not dominate it.
95
+ // The bias-arm case blows up loudly in the verifier; the MERGE case is silent, because its
96
+ // params are the FUNCTION'S OWN PARAMETERS and `m.params = []` below would quietly delete one,
97
+ // handing back a signature with an argument missing. raise/shortcircuit.ts documents the same
98
+ // trap for its feeder; only half of that guard was copied here at first.
99
+ if (m.params.length !== 1 || (preds.get(m) ?? []).length !== 2 || m === fn.blocks[0]) {
100
+ continue;
101
+ }
102
+ const p = m.params[0];
103
+ for (const bias of preds.get(m)!) {
104
+ // The BIAS arm: sole predecessor is the head, and it does nothing but bias (and possibly
105
+ // shift). The whole block is DELETED, not hoisted, so anything else in it would be silently
106
+ // dropped — a store, a call or an opaque there would simply stop happening (an opaque
107
+ // whether or not its result is read: liveness says nothing about what the instruction did).
108
+ const bt = term(bias);
109
+ if (bt.opcode !== 'br' || bt.successors[0]?.block !== m || bias === fn.blocks[0]) {
110
+ continue;
111
+ }
112
+ const bp = preds.get(bias) ?? [];
113
+ if (bp.length !== 1 || bias.ops.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
114
+ continue;
115
+ }
116
+ const h = bp[0];
117
+ const ht = term(h);
118
+ if (ht.opcode !== 'cond_br') {
119
+ continue;
120
+ }
121
+ const [taken, fall] = ht.successors;
122
+ const direct = taken.block === m ? taken : fall.block === m ? fall : null;
123
+ if (!direct || (taken.block !== bias && fall.block !== bias)) {
124
+ continue; // the head's two successors must be exactly {merge, bias arm}
125
+ }
126
+ const biasedIsTaken = taken.block === bias;
127
+
128
+ // Read the two incoming values and split into the SUNK and SPLIT spellings. `k` and the
129
+ // dividend come from whichever arm shape matches; both arms must agree on both.
130
+ const vDirect = direct.args[0];
131
+ const vBias = bt.successors[0].args[0];
132
+ if (vDirect === undefined || vBias === undefined) {
133
+ continue;
134
+ }
135
+ // SPLIT is TRIED, not assumed, and a failed attempt falls through to SUNK. The direct arm
136
+ // being a `shr_s` does not make this the split form — the dividend may simply BE a shifted
137
+ // value (`(a >> 2) / 4`), and treating that as split-with-a-broken-bias-arm used to abandon
138
+ // a perfectly good sunk diamond.
139
+ let split: { k: number; x: Value } | null = null;
140
+ const splitDirect = shiftAmount(defs, vDirect);
141
+ if (splitDirect) {
142
+ const splitBias = shiftAmount(defs, vBias);
143
+ if (
144
+ splitBias &&
145
+ splitBias.k === splitDirect.k &&
146
+ biasedBy(defs, splitBias.src, splitDirect.k) === splitDirect.src
147
+ ) {
148
+ split = { k: splitDirect.k, x: splitDirect.src };
149
+ }
150
+ }
151
+ let k: number, x: Value, sunkShift: Op | null;
152
+ if (split) {
153
+ // SPLIT: both arms shift. The direct arm shifts the dividend, the bias arm the biased one.
154
+ k = split.k;
155
+ x = split.x;
156
+ sunkShift = null;
157
+ } else {
158
+ // SUNK: the merge value is the (maybe biased) dividend, shifted once after the join. The
159
+ // phi must feed exactly that shift and NOTHING else — the fold stops computing the
160
+ // unshifted biased value, so any second consumer would silently read the quotient in its
161
+ // place. A use is an operand OR a successor argument (block args are uses too, and live in
162
+ // `successors[].args`, which is why `replaceAllUsesWith` rewrites both) — counting only
163
+ // operands let a phi that was also passed along an edge through this guard, and the arm's
164
+ // value then came out as the quotient: `(y >> 2) + y` folded to `(x / 4) + (x / 4)`.
165
+ const uses = fn.blocks
166
+ .flatMap((b) => b.ops)
167
+ .filter((op) => op.operands.includes(p) || op.successors.some((su) => su.args.includes(p)));
168
+ if (uses.length !== 1 || !uses[0].operands.includes(p)) {
169
+ continue;
170
+ }
171
+ const sunk = shiftAmount(defs, uses[0].results[0]);
172
+ // The shift must be IN the merge block: the cleanup below removes it from there, so a
173
+ // shift found elsewhere would be searched for and not cleaned, leaving the two out of step.
174
+ if (!sunk || sunk.src !== p || !m.ops.includes(sunk.op)) {
175
+ continue;
176
+ }
177
+ k = sunk.k;
178
+ x = vDirect;
179
+ if (biasedBy(defs, vBias, k) !== x) {
180
+ continue;
181
+ }
182
+ sunkShift = sunk.op;
183
+ }
184
+ if (k < 1 || k > 30) {
185
+ continue; // 2^k must be a representable positive divisor
186
+ }
187
+
188
+ // The guard must test THE DIVIDEND against zero, and route the biased value to the NEGATIVE
189
+ // side. `x >= 0` takes the direct arm; `x < 0` takes the bias arm. Anything else — a compare
190
+ // against another value, a different operand, the arms the other way round — is a diamond
191
+ // that merely looks like this one.
192
+ const cond = defs.get(ht.operands[0]);
193
+ if (!cond || cond.operands.length !== 2 || cond.operands[0] !== x) {
194
+ continue;
195
+ }
196
+ const zero = defs.get(cond.operands[1]);
197
+ if (!zero || zero.opcode !== 'const' || zero.attrs.value !== 0) {
198
+ continue;
199
+ }
200
+ const takenIsNegative = cond.opcode === 'icmp_slt';
201
+ if (!takenIsNegative && cond.opcode !== 'icmp_sge') {
202
+ continue;
203
+ }
204
+ if (takenIsNegative !== biasedIsTaken) {
205
+ continue; // the bias is on the wrong side — this is not a round-toward-zero correction
206
+ }
207
+
208
+ // Rewrite: the head computes the quotient outright and falls straight to the merge; the bias
209
+ // arm and the phi go away. The dead bias/shift ops are left for the driver's DCE.
210
+ const q = mkValue(T.s());
211
+ h.ops.splice(h.ops.length - 1, 0, mkOp('sdiv', { operands: [x], results: [q], attrs: { imm: 2 ** k } }));
212
+ h.ops[h.ops.length - 1] = mkOp('br', { successors: [{ block: m, args: [] }] });
213
+ if (sunkShift) {
214
+ replaceAllUsesWith(fn, sunkShift.results[0], q);
215
+ m.ops = m.ops.filter((op) => op !== sunkShift);
216
+ }
217
+ replaceAllUsesWith(fn, p, q);
218
+ m.params = [];
219
+ fn.blocks = fn.blocks.filter((b) => b !== bias);
220
+ changed = true;
221
+ progress = true;
222
+ break outer; // defs/preds are stale after the mutation
223
+ }
224
+ }
225
+ }
226
+ return changed;
227
+ }
@@ -0,0 +1,151 @@
1
+ // asmlift — value numbering for OPERAND-FREE PURE definitions (today: `gaddr`).
2
+ //
3
+ // A compiler materializes a global's address wherever it needs one. Two arms of an `if` that both
4
+ // touch `gTable` each get their own pool load, so the frontend lifts two DISTINCT SSA values that
5
+ // denote the same address, and a merge of those arms gets a block param over them:
6
+ //
7
+ // ^bb6: %29 = gaddr {sym="gBgTilemapBufs"} br ^bb9(%29)
8
+ // ^bb8: %43 = gaddr {sym="gBgTilemapBufs"} br ^bb9(%43)
9
+ // ^bb9(%45: u16*): … %45[594] …
10
+ //
11
+ // Nothing downstream can see that `%29` and `%43` are equal, so `%45` is a real phi: the structurer
12
+ // destroys it into a local and every later access reads it. The source it came from had no such
13
+ // variable — it just named the global at each use, and let the compiler decide where to put the
14
+ // address. In emitted C, that is the whole difference:
15
+ //
16
+ // before: v5 = (u16 *)&gBgTilemapBufs; … v5[594] = v5[659];
17
+ // after: gBgTilemapBufs[0][594] = gBgTilemapBufs[0][659];
18
+ //
19
+ // RUNS FIRST in PRE_RECOVERY_PASSES: collapsing addresses removes block params every later
20
+ // recognizer would otherwise have to reason around, and it can only shrink the value graph.
21
+ //
22
+ // SOUNDNESS. `gaddr` takes no operands and reads no memory: its result is a function of its `attrs`
23
+ // alone, so two with equal attrs are equal in every execution, on every path, always. Replacing all
24
+ // of them with ONE definition is exact.
25
+ //
26
+ // THE ADMISSION RULE IS `gaddr`, NOT "operand-free and pure" — and the difference is the whole
27
+ // safety argument, so do not relax it to the general-sounding version. `const` is ALSO operand-free
28
+ // and pure, and numbering consts function-wide would be actively harmful: structure/analysis.ts
29
+ // materializes a multi-use `const` that is live across a call into a named local, and its own
30
+ // comment records that this exact widening ("the small-constant regression") already cost matches
31
+ // once. The gate is a MATCHING policy, not a property of the opcode — which is why it is not a flag
32
+ // on the opcode table, where `const` would satisfy it.
33
+ //
34
+ // PLACEMENT. One fresh definition per class is created in the ENTRY block, which dominates every
35
+ // REACHABLE block — so no reachable use can precede it (unreachable blocks are excluded from the
36
+ // scan for exactly that reason; see `reachable` below) (the originals are deleted rather than moved — a fresh Value
37
+ // keeps the rewrite uniform, including for a duplicate that was already in the entry block). That is safe here precisely because the op is free: `gaddr` lowers to
38
+ // nothing on its own — the structurer inlines a pure non-`const` value at each use site (see
39
+ // analysis.ts, whose materialize-into-a-local rule covers `const`, `call` and the memory reads, NOT
40
+ // address ops), so the address is re-spelled at each access exactly as the original source did.
41
+ // Hoisting therefore does not create the long live range that hoisting a LOADED value would.
42
+ // That is a promise ANOTHER module keeps, so `test/addr-placement.test.ts` holds it to it: let
43
+ // analysis.ts materialize an address op and the entry hoist becomes a function-top local — the one
44
+ // this pass exists to delete, reintroduced one level up.
45
+ //
46
+ // SCOPE, deliberately narrow: `code: true` symbols (a promoted function pointer, spelled `(u32)Name`
47
+ // rather than `&Name`) are numbered separately from data ones, because the attr is part of what the
48
+ // value renders as.
49
+ //
50
+ // THE WIN IS CONTINGENT ON THE SYMBOL MAP, which is worth knowing before relying on it. With a map
51
+ // supplying an array's rank the accesses render as `gSym[0][i]`, a `var` base that
52
+ // `l3/basecse.ts`'s `isHoistableBase` cannot see, so nothing re-creates the local this pass
53
+ // deleted. WITHOUT one the same accesses spell as `addr`, basecse sees the reuse, and it hoists a
54
+ // function-top `p0 = (u16 *)&gBgTilemapBufs` — the same local, one level up. Both arms are pinned
55
+ // in `test/addr-placement.test.ts`; before that they rested on a run nobody could repeat.
56
+ //
57
+ // FOUR modules now answer "is this address a local?" with independent policies — here: never;
58
+ // basecse: at the function top, when reused 2+ times; l3/scopebase.ts: at the innermost scope
59
+ // holding the uses; l3/argbase.ts: immediately before a call whose arguments share it. Reconciling
60
+ // them is recorded debt, and the same test pins the two places they actively disagree, because a
61
+ // consolidation has to PICK rather than discover them: a `for`'s init (basecse reads it at loop
62
+ // cadence and refuses, scopebase at the enclosing one and hoists) and a global name shadowed by a
63
+ // local (scopebase must refuse — it re-spells the base as `&g` — while argbase may fire, because it
64
+ // keeps the base expression verbatim).
65
+ import { Block, Fn, Op, Value, mkOp, replaceAllUsesWith } from '../ir/core';
66
+
67
+ /** Ops whose result depends on `attrs` alone — no operands, no memory, no control flow. */
68
+ const NUMBERABLE = new Set(['gaddr', 'laddr']); // laddr: same argument — operand-free, pure, attr-keyed
69
+
70
+ /** The value-number key: the opcode plus every attribute, in a stable order. */
71
+ function keyOf(op: Op): string {
72
+ const attrs = Object.keys(op.attrs)
73
+ .sort()
74
+ .map((k) => `${k}=${String(op.attrs[k])}`)
75
+ .join(',');
76
+ return `${op.opcode}(${attrs})`;
77
+ }
78
+
79
+ /**
80
+ * Collapse operand-free pure definitions that share a key down to one apiece, defined in the entry
81
+ * block. Returns the number of definitions removed (0 when nothing changed).
82
+ */
83
+ export function numberPureValues(fn: Fn): number {
84
+ // REACHABLE blocks only. The entry dominates everything REACHABLE — it does not dominate an
85
+ // unreachable block, and verify()'s dominator fixpoint models that faithfully (a block with no
86
+ // predecessors converges to dom = {itself}). Numbering a group with a member in such a block
87
+ // deletes its local definition and leaves the use reading one the entry holds, which fails
88
+ // `def does not dominate use` — turning a fully-decompiled function into an ASMLIFT_ERROR stub.
89
+ // Loud, not silent, but a real loss: the thumb frontend deliberately KEEPS unreachable blocks
90
+ // ("Other unreachable blocks are LEFT ALONE"), so this shape is supported, not malformed.
91
+ const reachable = new Set<Block>();
92
+ const walk = (b: Block): void => {
93
+ if (reachable.has(b)) {
94
+ return;
95
+ }
96
+ reachable.add(b);
97
+ for (const op of b.ops) {
98
+ for (const s of op.successors) {
99
+ walk(s.block);
100
+ }
101
+ }
102
+ };
103
+ if (fn.blocks[0]) {
104
+ walk(fn.blocks[0]);
105
+ }
106
+ const groups = new Map<string, { op: Op; block: number }[]>();
107
+ fn.blocks.forEach((b, bi) => {
108
+ if (!reachable.has(b)) {
109
+ return;
110
+ }
111
+ for (const op of b.ops) {
112
+ if (NUMBERABLE.has(op.opcode) && op.results.length === 1) {
113
+ const k = keyOf(op);
114
+ groups.set(k, [...(groups.get(k) ?? []), { op, block: bi }]);
115
+ }
116
+ }
117
+ });
118
+
119
+ const entry = fn.blocks[0];
120
+ let removed = 0;
121
+ const hoisted: Op[] = [];
122
+ for (const dups of groups.values()) {
123
+ if (dups.length < 2) {
124
+ continue; // nothing to number — a single definition already dominates its own uses
125
+ }
126
+ // One fresh definition for the class. A fresh Value (rather than reusing the first duplicate's)
127
+ // keeps the rewrite uniform: every original result is replaced, including the one in the entry
128
+ // block, so no path is left reading a definition this pass has moved.
129
+ const survivor = dups[0].op;
130
+ const value: Value = { type: survivor.results[0].type };
131
+ hoisted.push(
132
+ mkOp(survivor.opcode as Parameters<typeof mkOp>[0], { results: [value], attrs: { ...survivor.attrs } }),
133
+ );
134
+ for (const d of dups) {
135
+ replaceAllUsesWith(fn, d.op.results[0], value);
136
+ removed++;
137
+ }
138
+ }
139
+ if (hoisted.length === 0) {
140
+ return 0;
141
+ }
142
+ // Drop every numbered definition, then seed the survivors at the entry block's head. Done as a
143
+ // filter over each block's op list rather than by index, because the replacement above may have
144
+ // rewritten successor args and left the op lists otherwise untouched.
145
+ const dead = new Set<Op>([...groups.values()].filter((d) => d.length >= 2).flatMap((d) => d.map((x) => x.op)));
146
+ for (const b of fn.blocks) {
147
+ b.ops = b.ops.filter((op) => !dead.has(op));
148
+ }
149
+ entry.ops.unshift(...hoisted);
150
+ return removed;
151
+ }
@@ -11,12 +11,15 @@
11
11
  // pass that changed the IR is INTRINSIC to the pass (it declares whether it leaves dead ops) and lives
12
12
  // in the driver.
13
13
  import { Fn } from '../ir/core';
14
+ import { simplifyTrivialPhis } from '../ir/simplify';
14
15
  import { dce } from '../pattern/engine';
15
16
  import type { TargetDescription } from '../target';
16
17
  import { recognizeArrays } from './arrays';
17
18
  import { recognizeConsts } from './const';
19
+ import { recognizeDivPow2 } from './divpow2';
20
+ import { numberPureValues } from './gvn';
18
21
  import { recognizeMagicDivision } from './magicdiv';
19
- import { recognizeShortCircuit } from './shortcircuit';
22
+ import { recognizeBranchShortCircuit, recognizeShortCircuit } from './shortcircuit';
20
23
  import { recognizeSoftDiv } from './softdiv';
21
24
  import { recognizeStructArrays } from './struct-arrays';
22
25
  import { recognizeStructs } from './structs';
@@ -33,11 +36,35 @@ export interface PreRecoveryPass {
33
36
  }
34
37
 
35
38
  /** THE ordered pre-recovery pass list — the single source of truth shared by pipeline / rank / report.
36
- * const-materialize → magic-division → soft-division → array-legalizestruct-array
37
- * struct-pointer → short-circuit. See each recognizer's file for the rationale. */
39
+ * address-numbering → const-materialize → magic-division → pow2-division → soft-divisionarray-legalize
40
+ * struct-array → struct-pointer → short-circuit → branch-short-circuit. See each recognizer's file
41
+ * for the rationale. */
38
42
  export const PRE_RECOVERY_PASSES: PreRecoveryPass[] = [
43
+ // FIRST: collapsing duplicate address definitions removes block params every later recognizer
44
+ // would otherwise have to reason around, and it can only shrink the value graph.
45
+ {
46
+ id: 'addrnum',
47
+ // Numbering alone is not enough and not safe to ship alone: collapsing the duplicates leaves a
48
+ // block param whose edges now all carry one value, and the structurer still destroys THAT into
49
+ // a local (it only reuses a name a carrier already has, and an inlined `gaddr` has none).
50
+ // Numbering alone costs kleod:UpdateHUDCounterDisplay its match, so the pair is the atomic
51
+ // unit, expressed as a body rather than a sum of two unrelated counts. It is NOT monotone,
52
+ // which is worth knowing before tuning either half: dropping the cleanup IMPROVES
53
+ // kleod:ConfigureEntityBehavior and kleod:CountCollectedGems, neither of them near matching.
54
+ run: (fn) => {
55
+ const n = numberPureValues(fn);
56
+ return n + simplifyTrivialPhis(fn);
57
+ },
58
+ dce: false,
59
+ },
39
60
  { id: 'const', run: recognizeConsts, dce: true },
40
61
  { id: 'magicdiv', run: recognizeMagicDivision, dce: true },
62
+ // Position is NOT load-bearing, unlike its neighbours above and below, and saying so is the point:
63
+ // no other pass can see this shape. magicdiv matches a `mulh` DAG; both short-circuit folds require
64
+ // a boolean const arm or a `cond_br`-terminated second block, and this diamond has an `add` arm
65
+ // ending in `br`. Verified by running it before and after each of them — same result. It sits
66
+ // beside magicdiv so that a reader looking for division recovery finds both together.
67
+ { id: 'divpow2', run: recognizeDivPow2, dce: true },
41
68
  { id: 'softdiv', run: (fn) => recognizeSoftDiv(fn), dce: false, gate: (t) => !t.capabilities.hwDivide },
42
69
  { id: 'arrays', run: recognizeArrays, dce: true },
43
70
  // struct-arrays AFTER arrays (scalar stride==width shapes are claimed first — see the
@@ -46,6 +73,15 @@ export const PRE_RECOVERY_PASSES: PreRecoveryPass[] = [
46
73
  { id: 'struct-arrays', run: recognizeStructArrays, dce: true },
47
74
  { id: 'structs', run: recognizeStructs, dce: false },
48
75
  { id: 'shortcircuit', run: recognizeShortCircuit, dce: true },
76
+ // The control-flow sibling, and this order IS load-bearing — value form FIRST.
77
+ //
78
+ // Their input SHAPES are disjoint (the value form's second block ends in `br` carrying a phi
79
+ // argument, this one's ends in `cond_br`), so neither can eat the other's literal pattern. But
80
+ // this pass REWRITES its head's condition into a `logic_or`/`logic_and`, and the value form
81
+ // refuses any head whose condition is not a negatable icmp — so running this one first can
82
+ // permanently disqualify a value fold that was available. The reverse cannot happen: the value
83
+ // form replaces its head's `cond_br` with a `br`, which this pass never matches.
84
+ { id: 'branch-shortcircuit', run: recognizeBranchShortCircuit, dce: true },
49
85
  ];
50
86
 
51
87
  /** Run the pre-recovery passes in order. For each pass whose gate passes and that CHANGES the IR, run
@@ -40,8 +40,11 @@ export function recoverTypes(fn: Fn): void {
40
40
  op.operands.forEach((o) => setInt(o, false));
41
41
  op.results.forEach((r) => setInt(r, false));
42
42
  }
43
- // A rotate is a bitwise permutation: its value operand and result are unsigned (the C
44
- // idiom's `>>` must be the LOGICAL shift or the spelling stops round-tripping). The
43
+ // A rotate is a bitwise permutation, so its value operand and result are unsigned. This is
44
+ // now a TYPE fact only: the C idiom's logical shift is stated on the node (structure.ts
45
+ // spells the idiom with `>>>`, and the C backend supplies whatever cast that needs), so the
46
+ // spelling no longer depends on this seeding to round-trip. Kept because it is true, and
47
+ // because it keeps the rotated value from rendering as a signed operand elsewhere. The
45
48
  // rotate AMOUNT (operand 1, register form) keeps its own signedness.
46
49
  if (op.opcode === 'rotr' || op.opcode === 'rotl') {
47
50
  setInt(op.operands[0], false);
@@ -199,14 +202,28 @@ function propagatePointers(fn: Fn): void {
199
202
  }
200
203
  }
201
204
 
202
- /** The recovered return type = the type of the value returned by the first `ret`. */
205
+ /** The recovered return type: `void` when EVERY `ret` is operand-less, otherwise the type of the
206
+ * first value any `ret` carries. */
203
207
  export function returnType(fn: Fn): IrType {
208
+ // VOID needs EVERY exit to agree. A function can have several `ret`s, and a frontend decides
209
+ // per block whether the return register holds anything — so an operand-less FIRST `ret` beside a
210
+ // valued second one is a real shape (MIPS/PPC compute it per block). Answering `void` off the
211
+ // first would declare void over a body the structurer still emits `return expr;` in, which is
212
+ // ill-formed C and a signature that contradicts its own body.
213
+ const rets = fn.blocks.map((b) => b.ops[b.ops.length - 1]).filter((t) => t?.opcode === 'ret');
214
+ if (rets.length > 0 && rets.every((t) => t!.operands.length === 0)) {
215
+ // NO operand is not "unknown type", it is NO VALUE. Every frontend already says it the same
216
+ // way: MIPS/PPC emit an operand-less `ret` when the return register has no reaching
217
+ // definition, and Thumb when the epilogue branches THROUGH that register (`bx r0`, where r0
218
+ // is the return address and so cannot also be a value). Defaulting to `s32` produced a
219
+ // non-void signature over a body with no `return` value — C's implicit-int function that
220
+ // falls off its end — contradicting the project's own prototype and keeping otherwise-dead
221
+ // computation alive to feed a return that never happens.
222
+ return T.void();
223
+ }
204
224
  for (const b of fn.blocks) {
205
225
  const term = b.ops[b.ops.length - 1];
206
- if (term?.opcode === 'ret') {
207
- if (term.operands.length === 0) {
208
- return T.s(32);
209
- }
226
+ if (term?.opcode === 'ret' && term.operands.length > 0) {
210
227
  const v = term.operands[0];
211
228
  return v.type.kind === 'unknown' ? T.s(32) : v.type;
212
229
  }
@@ -21,15 +21,20 @@
21
21
  //
22
22
  // This does NOT recover the boolean-VALUE form `return a && b` — that is shortcircuit.ts's job
23
23
  // (the `logic_and`/`logic_or` connective plus agbcc's `(-b|b)>>31` = `b!=0` normalisation).
24
- import { Block, Fn, mkOp, predecessors } from '../ir/core';
24
+ import { Block, Fn, defOpMap, mkOp, predecessors } from '../ir/core';
25
+
26
+ /** The fused short-circuit connectives (raise/shortcircuit.ts). A `cond_br` on one of these is the
27
+ * post-fusion record of the ≥2 conditions that used to reach a shared arm. */
28
+ const CONNECTIVES = new Set(['logic_and', 'logic_or']);
25
29
 
26
30
  /** Tail-duplicate a return-only merge block into its unconditional-branch predecessors, but ONLY in the
27
- * short-circuit shape (some branch-pred is shared). Returns whether anything changed. A "return-only"
28
- * block is exactly one `ret` whose operands are all its own block-params, so each predecessor already
29
- * carries the returned value as a successor arg. */
31
+ * short-circuit shape (some branch-pred is shared, or the arms are selected by a fused connective).
32
+ * Returns whether anything changed. A "return-only" block is exactly one `ret` whose operands are all
33
+ * its own block-params, so each predecessor already carries the returned value as a successor arg. */
30
34
  export function sinkReturns(fn: Fn): boolean {
31
35
  let changed = false;
32
36
  const preds = predecessors(fn);
37
+ const defs = defOpMap(fn);
33
38
  const isBrTo = (p: Block, m: Block) => {
34
39
  const t = p.ops[p.ops.length - 1];
35
40
  return t.opcode === 'br' && t.successors.length === 1 && t.successors[0].block === m;
@@ -52,9 +57,34 @@ export function sinkReturns(fn: Fn): boolean {
52
57
  if (brPreds.length === 0) {
53
58
  continue;
54
59
  }
55
- // SHORT-CIRCUIT GATE: at least one branch-pred must be a shared block (≥2 preds of its own). A simple
56
- // single-condition select has only single-pred arms and is left as a merge variable (which matches).
57
- if (!brPreds.some((p) => (preds.get(p)?.length ?? 0) >= 2)) {
60
+ // SHORT-CIRCUIT GATE, in two shapes the chain must be visible in the CFG or in the value domain.
61
+ //
62
+ // (a) UNFUSED: at least one branch-pred is a shared block (≥2 preds of its own) the common
63
+ // early-exit reached from every condition of the chain.
64
+ // (b) FUSED: `branch-shortcircuit` (raise/shortcircuit.ts) rewrites the head's condition into a
65
+ // `logic_and`/`logic_or` and collapses the second condition block into it. That leaves both
66
+ // arms single-pred, so (a) cannot see the chain any more — but the CONNECTIVE is now the
67
+ // record of the ≥2 conditions the shared arm used to be. That pass runs in pre-recovery,
68
+ // i.e. BEFORE this one, so on `ifand`/`and3` shape (b) is the only one that ever fires.
69
+ //
70
+ // The connective ALONE is not enough, and the extra requirement is BOTH arms being real
71
+ // blocks (≥2 `br` preds). `return a || b` (synthetic:lor:agbcc) also ends up as a
72
+ // `cond_br` on a `logic_or`, but it is a value-merge: one edge runs from the head STRAIGHT
73
+ // into the merge, so the merge has a single `br` pred. Sinking it replaces the merge
74
+ // variable that byte-matches: dropping the `brPreds.length >= 2` half of this gate costs
75
+ // that row its match. A two-armed diamond is what
76
+ // distinguishes `if (a && b) return X; return Y;` from every value-merge.
77
+ //
78
+ // A simple single-condition select is excluded by both arms of the gate for the same reason:
79
+ // `clamp0`/`sel` reach their merge on the `cond_br` edge itself, so they too have exactly one
80
+ // `br` pred, and their condition is a bare icmp rather than a connective.
81
+ const selectedByConnective = (p: Block) =>
82
+ (preds.get(p) ?? []).some((q) => {
83
+ const t = q.ops[q.ops.length - 1];
84
+ return t.opcode === 'cond_br' && CONNECTIVES.has(defs.get(t.operands[0])?.opcode ?? '');
85
+ });
86
+ const fusedDiamond = brPreds.length >= 2 && brPreds.some(selectedByConnective);
87
+ if (!brPreds.some((p) => (preds.get(p)?.length ?? 0) >= 2) && !fusedDiamond) {
58
88
  continue;
59
89
  }
60
90
  for (const p of brPreds) {