@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
package/src/macros.ts CHANGED
@@ -22,32 +22,206 @@ export interface AddressMacro {
22
22
  size: number;
23
23
  /** the cast type's signedness */
24
24
  signed: boolean;
25
+ /** the cast type was volatile-qualified (`vu16`) — the MMIO idiom. Load-bearing: a
26
+ * non-volatile spelling lets the compiler fold or reorder repeated accesses. */
27
+ volatile?: true;
25
28
  }
26
29
 
27
30
  /** The scalar type spellings a cast may use, and what each one means. Deliberately a CLOSED table:
28
31
  * an unrecognized spelling (a project typedef, an enum, a struct) is refused rather than guessed,
29
32
  * and every `volatile` alias is absent so it can never be silently dropped — the qualifier changes
30
33
  * whether repeated reads may be folded, which is both a byte and a semantic difference. */
31
- const SCALAR_TYPES: Record<string, { size: number; signed: boolean }> = {
34
+ const SCALAR_TYPES: Record<string, { size: number; signed: boolean; volatile?: true }> = {
32
35
  u8: { size: 1, signed: false },
33
36
  s8: { size: 1, signed: true },
34
37
  u16: { size: 2, signed: false },
35
38
  s16: { size: 2, signed: true },
36
39
  u32: { size: 4, signed: false },
37
40
  s32: { size: 4, signed: true },
41
+ // The `volatile` aliases. They were excluded so the qualifier could never be silently dropped;
42
+ // it is now CARRIED instead (`volatile: true`, reproduced by every spelling this feeds), which
43
+ // is the same guarantee without the cost — refusing them lost every MMIO register name a GBA
44
+ // project has, since those are exactly the cells one declares volatile.
45
+ vu8: { size: 1, signed: false, volatile: true },
46
+ vs8: { size: 1, signed: true, volatile: true },
47
+ vu16: { size: 2, signed: false, volatile: true },
48
+ vs16: { size: 2, signed: true, volatile: true },
49
+ vu32: { size: 4, signed: false, volatile: true },
50
+ vs32: { size: 4, signed: true, volatile: true },
38
51
  };
39
52
 
40
- /** `#define NAME (*(TYPE *)0xADDR)` the ONE shape recognized. Anything else (a two-level
41
- * indirection, an offset expression, a function-like macro, a bare integer constant) does not
42
- * match and is therefore refused by construction. */
43
- const ADDRESS_CAST = /^\s*#define\s+([A-Za-z_]\w*)\s+(\(\s*\*\s*\(\s*(\w+)\s*\*\s*\)\s*(0[xX][0-9A-Fa-f]+)\s*\))\s*$/;
53
+ /** A pointer cast inside an address expression (`(void *)0x4000000`). The VALUE is the integer it
54
+ * wraps: these headers spell a register base as a `void *` and add a byte offset to it, which is
55
+ * GCC's byte-arithmetic extension, so the cast contributes nothing to the address.
56
+ *
57
+ * BYTE-SIZED POINTEES ONLY, and that restriction is load-bearing rather than tidy. C pointer
58
+ * arithmetic SCALES by the pointee: `(vu16 *)0x4000000 + 5` is 0x400000A, not 0x4000005. Stripping
59
+ * a wider cast would fold the wrong address AND then republish it in a synthesized body that
60
+ * agrees with itself — so the candidate still byte-matches the numeric pool word it was looked up
61
+ * by, while naming a different register. A wrong name that survives the differ is the one failure
62
+ * this module cannot let through.
63
+ *
64
+ * A wider pointee is REFUSED EXPLICITLY below, not left to fall out of the token grammar further
65
+ * down — the enforcing line belongs next to the rule it enforces. The cost is named rather than
66
+ * hidden: a wider cast with NO arithmetic after it would fold correctly and is refused anyway,
67
+ * because the hazard is cast-THEN-add and this cannot tell which it is looking at. */
68
+ const PTR_CAST_ANY = /\(\s*(\w+)\s*\*\s*\)/g;
69
+ const BYTE_POINTEE = new Set(['void', 'u8', 's8', 'vu8', 'vs8']);
70
+
71
+ /** `src` with byte-sized pointer casts removed, or null if any cast SCALES. */
72
+ function stripPointerCasts(src: string): string | null {
73
+ let scaling = false;
74
+ const out = src.replace(PTR_CAST_ANY, (_m, pointee: string) => {
75
+ if (!BYTE_POINTEE.has(pointee)) {
76
+ scaling = true;
77
+ }
78
+ return ' ';
79
+ });
80
+ return scaling ? null : out;
81
+ }
82
+
83
+ /** An object-like `#define NAME body`, for the expansion table the address evaluator resolves
84
+ * identifiers against. Function-like macros (`NAME(x)`) are deliberately excluded: an address
85
+ * expression that calls one is refused, not expanded. */
86
+ const OBJECT_DEFINE = /^\s*#define\s+([A-Za-z_]\w*)\s+(\S.*?)\s*$/;
87
+
88
+ /**
89
+ * Evaluate a macro's ADDRESS operand to a number, or null when it is not a constant expression
90
+ * this module can be sure of.
91
+ *
92
+ * Real decomp headers rarely write the address as a literal. The Klonoa headers spell every
93
+ * register as `(*(vu16 *)REG_ADDR_BLDALPHA)` over `REG_ADDR_BLDALPHA = (REG_BASE +
94
+ * REG_OFFSET_BLDALPHA)`, `REG_BASE = (void *)0x4000000`, `REG_OFFSET_BLDALPHA = 0x52` — so a
95
+ * literal-only recognizer sees none of the 466 `REG_*` names, and reads every MMIO cell as a
96
+ * decimal address instead.
97
+ *
98
+ * The accepted language is deliberately tiny — integer literals, `+`, `-`, parentheses, pointer
99
+ * casts (see {@link PTR_CAST}), and identifiers that resolve to another object-like define. Any
100
+ * other token, an unknown identifier, a function-like macro, a cycle, or a negative result refuses
101
+ * the whole expression. Folding is done on the EXPANDED integer text, so an operand only ever
102
+ * evaluates to a number every step of which this module recognized.
103
+ */
104
+ function evalAddressExpr(
105
+ src: string,
106
+ defines: ReadonlyMap<string, string>,
107
+ seen: ReadonlySet<string>,
108
+ memo: Map<string, number | null> = new Map(),
109
+ ): number | null {
110
+ if (seen.size > 12) {
111
+ return null; // pathological nesting — refuse rather than walk further
112
+ }
113
+ const stripped = stripPointerCasts(src);
114
+ if (stripped === null) {
115
+ return null; // a scaling pointer cast — see PTR_CAST_ANY
116
+ }
117
+ const tokens = stripped.match(/[A-Za-z_]\w*|0[xX][0-9A-Fa-f]+|\d+|[()+-]/g);
118
+ // every character must belong to a token — anything else (`*`, `<<`, a comma) is out of language
119
+ if (!tokens || tokens.join('') !== stripped.replace(/\s+/g, '')) {
120
+ return null;
121
+ }
122
+ const expanded: string[] = [];
123
+ for (const tok of tokens) {
124
+ if (/^[A-Za-z_]/.test(tok)) {
125
+ const body = defines.get(tok);
126
+ if (body === undefined || seen.has(tok)) {
127
+ return null; // undefined name, or a cycle
128
+ }
129
+ // Memoized per NAME. The depth cap bounds nesting but not BRANCHING — a define mentioning k
130
+ // others re-evaluates the whole subtree k times, so a deep, wide table costs exponentially.
131
+ //
132
+ // A name's result CAN depend on the path that reached it: both refusals below are
133
+ // path-sensitive (already in `seen`; depth cap hit), so a cached `null` may be pessimistic
134
+ // for a shorter path. Safe in ONE direction only — path-dependence can make this refuse
135
+ // more, never fold a wrong address, which is the direction this module may be wrong in.
136
+ //
137
+ // The memo being PER TOP-LEVEL MACRO (the default parameter, fresh at each entry) is
138
+ // load-bearing rather than incidental: hoisting it across macros to "go faster" would let
139
+ // one deep macro poison a name for every macro after it, silently dropping recognized cells.
140
+ let inner: number | null;
141
+ if (memo.has(tok)) {
142
+ inner = memo.get(tok)!;
143
+ } else {
144
+ inner = evalAddressExpr(body, defines, new Set([...seen, tok]), memo);
145
+ memo.set(tok, inner);
146
+ }
147
+ if (inner === null) {
148
+ return null;
149
+ }
150
+ expanded.push(`(${inner})`);
151
+ } else if (/^0[xX]/.test(tok)) {
152
+ expanded.push(String(Number.parseInt(tok, 16)));
153
+ } else {
154
+ expanded.push(tok);
155
+ }
156
+ }
157
+ const folded = foldIntegerExpr(expanded.join(' '));
158
+ return folded !== null && Number.isSafeInteger(folded) && folded >= 0 ? folded : null;
159
+ }
160
+
161
+ /** Fold a fully-expanded `+`/`-`/parenthesis integer expression. Written out rather than handed to
162
+ * an evaluator so nothing outside that grammar can ever be executed. */
163
+ function foldIntegerExpr(text: string): number | null {
164
+ const toks = text.match(/\d+|[()+-]/g);
165
+ if (!toks || toks.join('') !== text.replace(/\s+/g, '')) {
166
+ return null;
167
+ }
168
+ let at = 0;
169
+ const expr = (): number | null => {
170
+ let acc = term();
171
+ if (acc === null) {
172
+ return null;
173
+ }
174
+ while (toks[at] === '+' || toks[at] === '-') {
175
+ const op = toks[at++];
176
+ const rhs = term();
177
+ if (rhs === null) {
178
+ return null;
179
+ }
180
+ acc = op === '+' ? acc + rhs : acc - rhs;
181
+ }
182
+ return acc;
183
+ };
184
+ const term = (): number | null => {
185
+ if (toks[at] === '(') {
186
+ at++;
187
+ const inner = expr();
188
+ if (inner === null || toks[at] !== ')') {
189
+ return null;
190
+ }
191
+ at++;
192
+ return inner;
193
+ }
194
+ if (toks[at] === '-') {
195
+ at++;
196
+ const v = term();
197
+ return v === null ? null : -v;
198
+ }
199
+ const tok = toks[at];
200
+ if (tok === undefined || !/^\d+$/.test(tok)) {
201
+ return null;
202
+ }
203
+ at++;
204
+ return Number(tok);
205
+ };
206
+ const value = expr();
207
+ return value !== null && at === toks.length ? value : null;
208
+ }
209
+
210
+ /** `#define NAME (*(TYPE *)ADDR)` — the ONE shape recognized, where ADDR is any constant
211
+ * expression {@link evalAddressExpr} can be sure of (a literal, or names that resolve to one).
212
+ * Anything else — a two-level indirection `(*(T **)…)`, a function-like macro, a bare integer
213
+ * constant — does not match and is therefore refused by construction. */
214
+ const ADDRESS_CAST = /^\s*#define\s+([A-Za-z_]\w*)\s+(\(\s*\*\s*\(\s*(\w+)\s*\*\s*\)\s*(.+?)\s*\))\s*$/;
215
+
216
+ /** A bare hex literal — the operand form whose macro body is already self-contained. */
217
+ const HEX_LITERAL = /^0[xX][0-9A-Fa-f]+$/;
44
218
 
45
219
  /**
46
220
  * Recognize the address-cast macros in `cpp -dD` output, keyed by the address each names.
47
221
  *
48
222
  * REFUSALS, all of them because the alternative is a plausible-but-wrong spelling:
49
- * - a cast type outside {@link SCALAR_TYPES} — including every `volatile` alias (`vu16`), whose
50
- * qualifier must not be silently dropped;
223
+ * - a cast type outside {@link SCALAR_TYPES} — a project typedef, an enum, a struct;
224
+ * - an address expression {@link evalAddressExpr} cannot fold to a definite number;
51
225
  * - two macros naming the SAME address (`REG_VCOUNT`/`REG_VCOUNT_L`/`REG_VCOUNT_H` at 0x04000006
52
226
  * differ in width, and picking wrong turns an `ldrh` into an `ldrb`) — both are dropped;
53
227
  * - one name defined at two addresses, which no correct spelling can disambiguate.
@@ -59,6 +233,21 @@ export function addressCastMacros(cppOutput: string): Map<number, AddressMacro>
59
233
  /** The same recognizer over already-split `#define NAME body` lines — what a DWARF
60
234
  * `.debug_macinfo` reader produces once each definition is re-spelled as a directive. */
61
235
  export function addressCastMacrosFrom(defineLines: readonly string[]): Map<number, AddressMacro> {
236
+ // Pass 1: every object-like define, so an address expression can resolve the names it mentions.
237
+ // A macro's address is frequently spelled in terms of others (`REG_BASE + REG_OFFSET_X`), and
238
+ // those helpers are not themselves address casts — they exist only to be expanded.
239
+ //
240
+ // LAST DEFINITION WINS, and `#undef` is not modelled: the record is a flat list with no scope, so
241
+ // a name redefined differently across translation units resolves to whichever came last. Sound
242
+ // for a project whose headers agree (the Klonoa ELF redefines no name with a differing body);
243
+ // a project where they disagree would need per-CU scoping, which the record does not carry.
244
+ const defines = new Map<string, string>();
245
+ for (const line of defineLines) {
246
+ const d = OBJECT_DEFINE.exec(line);
247
+ if (d) {
248
+ defines.set(d[1], d[2]);
249
+ }
250
+ }
62
251
  const byAddress = new Map<number, AddressMacro>();
63
252
  const collided = new Set<number>();
64
253
  const seenNames = new Map<string, number>();
@@ -67,15 +256,28 @@ export function addressCastMacrosFrom(defineLines: readonly string[]): Map<numbe
67
256
  if (!m) {
68
257
  continue;
69
258
  }
70
- const [, name, body, typeName, addrText] = m;
259
+ const [, name, rawBody, typeName, addrText] = m;
71
260
  const type = SCALAR_TYPES[typeName];
72
261
  if (!type) {
73
- continue; // unknown or volatile-qualified spelling — refuse
262
+ continue; // a spelling outside the closed table — refuse
74
263
  }
75
- const address = Number.parseInt(addrText, 16);
76
- if (!Number.isFinite(address)) {
77
- continue;
264
+ const address = evalAddressExpr(addrText, defines, new Set([name]));
265
+ if (address === null) {
266
+ continue; // an address expression this module cannot be sure of — refuse
78
267
  }
268
+ // The body must be SELF-CONTAINED and COMPILABLE, because it is republished verbatim as the
269
+ // definition a reproduction compiles against (macroDefinesUsedBy) — a body naming
270
+ // `REG_ADDR_VCOUNT` would need that macro, and its two helpers, carried along with it. An
271
+ // unqualified literal address keeps the project's own spelling; anything else is re-spelled at
272
+ // the address it evaluated to, which is the same cell and the same type.
273
+ // A VOLATILE body is re-spelled even when its address is already a literal: the alias it uses
274
+ // (`vu8`) is a PROJECT typedef, and the prelude a candidate compiles against declares only
275
+ // u8/u16/u32 + s8/s16/s32. Keeping such a body verbatim republishes a `#define` that does not
276
+ // compile — latent, because it only bites in the self-declared world.
277
+ const body =
278
+ HEX_LITERAL.test(addrText) && !type.volatile
279
+ ? rawBody
280
+ : `(*(${type.volatile ? 'volatile ' : ''}${type.signed ? 's' : 'u'}${type.size * 8} *)0x${address.toString(16).toUpperCase()})`;
79
281
  const priorAddr = seenNames.get(name);
80
282
  if (priorAddr !== undefined && priorAddr !== address) {
81
283
  collided.add(priorAddr);
@@ -88,7 +290,14 @@ export function addressCastMacrosFrom(defineLines: readonly string[]): Map<numbe
88
290
  collided.add(address);
89
291
  continue;
90
292
  }
91
- byAddress.set(address, { name, address, body, size: type.size, signed: type.signed });
293
+ byAddress.set(address, {
294
+ name,
295
+ address,
296
+ body,
297
+ size: type.size,
298
+ signed: type.signed,
299
+ ...(type.volatile ? { volatile: true as const } : {}),
300
+ });
92
301
  }
93
302
  for (const addr of collided) {
94
303
  byAddress.delete(addr);
@@ -6,7 +6,7 @@
6
6
  // Crucially, rewrites go through replaceAllUsesWith + DCE — never in-place opcode
7
7
  // mutation of a live value.
8
8
  import { Fn, Op, Value, defOpMap, mkOp, mkValue, replaceAllUsesWith } from '../ir/core';
9
- import { type Opcode, isDceSafe } from '../ir/opcodes';
9
+ import { NEGATED_ICMP, type Opcode, isDceSafe } from '../ir/opcodes';
10
10
  import type { IrType } from '../ir/types';
11
11
  import { T } from '../ir/types';
12
12
 
@@ -219,18 +219,107 @@ const sextPat = (w: number, k: number): RewritePattern => ({
219
219
 
220
220
  /** Byte/half zero- and sign-extension casts. Byte = shift by 24, half = shift by 16. The
221
221
  * zero-extend forms fix a miscompile; the sign-extend forms already byte-matched as raw shifts and
222
- * fold here for readability + `(s8)`/`(s16)` parity, staying byte-exact (`(s8)x` → `lsl;asr`). */
222
+ * fold here for readability + `(s8)`/`(s16)` parity, staying byte-exact (`(s8)x` → `lsl;asr`).
223
+ *
224
+ * SHADOWING NOTE: these run at the idiom stage, BEFORE structuring — so a symbol-map BITFIELD of
225
+ * width exactly 8 or 16 whose bits start at bit 0 of its load is folded to `(u8)x`/`(u16)x` here
226
+ * and never reaches the bitfield member recognizer (structure.ts, which matches the raw
227
+ * `shr(shl(load))` shape only). Honest output, not a miscompile — the field just keeps the cast
228
+ * spelling at those widths. Teaching the recognizer a zext/sext arm is the coverage extension if
229
+ * a row ever needs it. */
223
230
  export const CAST_PATTERNS: RewritePattern[] = [zextPat(8, 24), zextPat(16, 16), sextPat(8, 24), sextPat(16, 16)];
224
231
 
232
+ // ── boolean-negation idiom ───────────────────────────────────────────────────────────────────
233
+ // `cmp ^ 1` IS `!cmp`: an `icmp_*` result is 0 or 1 by construction (ir/opcodes.ts), so xoring the
234
+ // low bit flips exactly the boolean. A compiler with no set-on-greater-equal spells a MATERIALISED
235
+ // `a >= b` as its opposite plus that flip — MIPS `slt v0,a0,a1; xori v0,v0,1`, the shape IDO and
236
+ // both GCCs emit and the only one asmlift has measured (m2c `40cbae3` reports ARM `eor #1` too;
237
+ // no agbcc row in the corpus carries it, agbcc materializing the same boolean via branches).
238
+ // The naive lift prints that as the double-negative `a < b ^ 1`, and hides the comparison from
239
+ // every consumer that reasons about booleans: the short-circuit recognizer's `&&`/`||` fold matches
240
+ // an `icmp` feeder, not an `xor` of one. (It does not by itself unblock that fold — measured on
241
+ // this idiom's one benchmark inhabitant, the diamond still declines because raise/shortcircuit.ts
242
+ // additionally wants a 0/1 CONST arm and both arms here are comparisons. It removes one of the two
243
+ // blockers, and the spelling win stands on its own.)
244
+ //
245
+ // UNGATED, unlike the compiler-pinned folds above. Two independent reasons, and the second is the
246
+ // load-bearing one:
247
+ // • it is a semantic IDENTITY on asmlift's own IR, not a spelling trade — `xor(icmp, 1)` cannot
248
+ // mean anything but the negated compare on any target;
249
+ // • THE SHAPE IS ITS OWN GATE. The pattern can only fire where the compiler itself emitted the
250
+ // flip, and wherever it did, "the negated comparison" is precisely what it was spelling. A
251
+ // compiler with a set-on-greater-equal never produces the shape and so can never be harmed.
252
+ // Byte evidence is narrower than the reasoning: synthetic:inrange stays MATCH under gcc2.7.2kmc
253
+ // with the folded spelling, which proves the round-trip there; elsewhere it is unmeasured.
254
+ //
255
+ // The negation comes from THE shared table (ir/opcodes.ts NEGATED_ICMP), so this fold, the MIPS
256
+ // `slt …; beqz` branch fold and the short-circuit diamond negation cannot disagree about what the
257
+ // opposite of a compare is. One pattern per comparison — a data-driven fold needs a fixed
258
+ // replacement opcode, so the table is unrolled into ten patterns rather than expressed as a
259
+ // (nonexistent) computed-opcode replacement.
260
+ const notCmpPat = (cmp: string): RewritePattern => ({
261
+ id: `not-${cmp}`,
262
+ applies: {},
263
+ match: {
264
+ op: 'xor',
265
+ args: [
266
+ { op: cmp, args: [{ bind: 'A' }, { bind: 'B' }] },
267
+ { op: 'const', attrEquals: { value: 1 }, args: [] },
268
+ ],
269
+ },
270
+ // Pinned u32, matching CNTLZW_EQ0 — the other pattern in this file that produces a comparison.
271
+ // It is what raise/recover.ts stamps on every icmp result unconditionally anyway, so inheriting
272
+ // the `xor`'s type would reach the same place; saying it here keeps the two icmp-producing
273
+ // patterns on one discipline instead of leaving a reader to infer which is canonical.
274
+ replaceWith: { op: NEGATED_ICMP[cmp], args: ['A', 'B'], resultType: T.u(32) },
275
+ });
276
+
277
+ // The BRANCH-form siblings. Testing a boolean against zero is the same negation by another
278
+ // spelling, and it is what a compare-and-branch ISA actually emits: MIPS `slt v0,…; xori v0,v0,1;
279
+ // beqz v0,L` lifts to `icmp_eq(icmp_sge(…), 0)` once the `xori` has folded, because the branch is a
280
+ // genuine test of the materialised boolean (the frontend's own `slt …; beqz` fusion cannot fire —
281
+ // its pending compare was invalidated by the `xori` that redefined the register). Without these the
282
+ // `^ 1` fold just trades one double negative for another: `a0 >= a1 == 0`.
283
+ // icmp_eq(cmp, 0) → !cmp `(a >= b) == 0` is `a < b`
284
+ // icmp_ne(cmp, 0) → cmp `(a >= b) != 0` is `a >= b`
285
+ // Same soundness argument as the `^ 1` fold (an icmp result is 0/1, so `== 0` is exactly negation)
286
+ // and the same shape-is-its-own-gate reason to leave them ungated.
287
+ const cmpZeroPat = (cmp: string, test: 'icmp_eq' | 'icmp_ne'): RewritePattern => ({
288
+ id: `${test === 'icmp_eq' ? 'not' : 'is'}-zerotest-${cmp}`,
289
+ applies: {},
290
+ match: {
291
+ op: test,
292
+ args: [
293
+ { op: cmp, args: [{ bind: 'A' }, { bind: 'B' }] },
294
+ { op: 'const', attrEquals: { value: 0 }, args: [] },
295
+ ],
296
+ },
297
+ replaceWith: { op: test === 'icmp_eq' ? NEGATED_ICMP[cmp] : cmp, args: ['A', 'B'], resultType: T.u(32) },
298
+ });
299
+
300
+ /** `cmp ^ 1` and the zero-test forms `cmp == 0` / `cmp != 0` → the (negated) comparison. Every
301
+ * entry is one comparison of `NEGATED_ICMP`; the bundle is what the boolean-reasoning consumers
302
+ * downstream (short-circuit recovery, the structurer's condition spelling) actually match on. */
303
+ export const NOT_CMP_PATTERNS: RewritePattern[] = [
304
+ ...Object.keys(NEGATED_ICMP).map(notCmpPat),
305
+ ...Object.keys(NEGATED_ICMP).flatMap((cmp) => [cmpZeroPat(cmp, 'icmp_eq'), cmpZeroPat(cmp, 'icmp_ne')]),
306
+ ];
307
+
225
308
  // The DEFAULT idiom bundle `decompile()` applies when the caller passes no `patterns`. It is
226
- // EVERY idiom asmlift owns; each is `{compilers}`-gated (patternApplies), so this one global list
227
- // self-selects per target — agbcc/gcc get sdiv-pow2, agbcc/ido/gcc get the mul-const folds, and a
228
- // target whose compiler matches none (mwcc) applies nothing. Ordered like the sub-bundles: the
309
+ // 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
229
315
  // division idiom, then the multiplies (base folds before the composite tail). Passing an explicit
230
316
  // `patterns` (including `[]`) overrides this — `[]` runs the naive lift with no idiom folding.
231
317
  export const DEFAULT_IDIOM_PATTERNS: RewritePattern[] = [
232
318
  SDIV_POW2_2,
233
319
  CNTLZW_EQ0,
320
+ // AFTER cntlzw-eq0, which is what turns mwcc's `clz(x) >> 5` into the `icmp_eq` this fold then
321
+ // negates — `!(x == 0)` composes only in that order (each pattern runs to fixpoint in turn).
322
+ ...NOT_CMP_PATTERNS,
234
323
  ROTL_MIRROR,
235
324
  ...MUL_CONST_PATTERNS,
236
325
  ...CAST_PATTERNS,
@@ -238,7 +327,11 @@ export const DEFAULT_IDIOM_PATTERNS: RewritePattern[] = [
238
327
 
239
328
  // Ops whose operands a compiler may emit in either order — so an idiom's match must try both
240
329
  // (agbcc emits `add(X, shr_u(X,31))`; KMC GCC emits `add(shr_u(X,31), X)` for the SAME `x/2`).
241
- const COMMUTATIVE = new Set(['add', 'mul', 'and', 'or', 'xor']);
330
+ // `icmp_eq`/`icmp_ne` are here for the same reason, not as arithmetic: `x == 0` and `0 == x` are the
331
+ // same test, and which one a frontend builds is an accident of how the branch was decoded — the
332
+ // zero-test folds must match either. The ORDERED comparisons are deliberately absent: swapping the
333
+ // operands of `a < b` is `b > a`, a different opcode, which this mechanism cannot express.
334
+ const COMMUTATIVE = new Set(['add', 'mul', 'and', 'or', 'xor', 'icmp_eq', 'icmp_ne']);
242
335
 
243
336
  interface Binds {
244
337
  values: Map<string, Value>;
package/src/pipeline.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  // asmlift — the library entry point. `decompile(name, asm, target)` runs the raising tower and
2
2
  // returns structured results: the source, the per-level IR dumps, and diagnostics.
3
3
  import { cBackend } from './backend/c';
4
- import { ContractError, assertDerefsTyped, assertResolved, assertTypesRecovered } from './contracts';
4
+ import {
5
+ ContractError,
6
+ assertDerefsTyped,
7
+ assertEffectsPreserved,
8
+ assertResolved,
9
+ assertTypesRecovered,
10
+ } from './contracts';
5
11
  import type { AsmData } from './frontend/asmdata';
6
12
  import { FrontendUnsupportedError } from './frontend/errors';
7
13
  import { frontendFor } from './frontend/registry';
8
- import type { Fn } from './ir/core';
14
+ import { type Block, type Fn, successorsOf } from './ir/core';
9
15
  import { print } from './ir/print';
10
16
  import { T } from './ir/types';
11
17
  import { VerifyError, verify } from './ir/verify';
12
- import { Expr, LanguageBackend, SFn, Stmt, exprChildren, stmtChildren, stmtExprs } from './l3/ast';
18
+ import { Expr, LanguageBackend, SFn, Stmt, exprChildren, gapReasonFor, stmtChildren, stmtExprs } from './l3/ast';
13
19
  import { hoistReusedGlobalBases } from './l3/basecse';
14
20
  import { eliminateDeadStores } from './l3/dce';
21
+ import { mergeCommonTails } from './l3/tailmerge';
15
22
  import { DEFAULT_IDIOM_PATTERNS, RewritePattern, applyPattern, dce, patternApplies } from './pattern/engine';
16
23
  import { type Prototypes, prototypesFromSymbols } from './proto';
17
24
  import { RaiseUnsupportedError } from './raise/errors';
@@ -188,9 +195,55 @@ export function raiseRecovered(fn: Fn, target: TargetDescription, hooks: RaiseHo
188
195
  }
189
196
  }
190
197
 
198
+ /** Run `body`; if it declines, name the unmodelled instructions the function carries.
199
+ *
200
+ * An `opaque` degrades its own value AND makes its block impure, so a shape recognizer refuses:
201
+ * `headerPure` rejects a header holding one, and the loop declines with "unrecovered back-edge …".
202
+ * True and useless — the shape is fine, an instruction is missing — and the benchmark classifies
203
+ * declines by that text, so the round is filed as a loop-capability gap and the improvement loop
204
+ * builds the wrong thing.
205
+ *
206
+ * Only ADDS attribution: never converts a decline into a success, never fires without an
207
+ * unmodelled instruction, reachable blocks only (one in dead code did not cause the refusal). */
208
+ function attributeOpaques<T>(fn: Fn, body: () => T): T {
209
+ try {
210
+ return body();
211
+ } catch (e) {
212
+ // Attribution is a nicety, so it must not be able to throw: a crash here would replace a
213
+ // DESIGNED loud failure with an incidental one, which contract-invariant.test.ts rejects by name.
214
+ if (!(e instanceof StructureError) || !fn.blocks[0]) {
215
+ throw e;
216
+ }
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
+ }
226
+ const names = new Set<string>();
227
+ for (const b of seen) {
228
+ for (const op of b.ops) {
229
+ if (op.opcode === 'opaque') {
230
+ names.add(typeof op.attrs.mnemonic === 'string' ? op.attrs.mnemonic : '?');
231
+ }
232
+ }
233
+ }
234
+ if (!names.size || /unmodelled instruction/.test(e.message)) {
235
+ throw e;
236
+ }
237
+ // Through `gapReasonFor`, so the classifier sees its canonical text — a hand-written variant
238
+ // misses the mnemonic-anchored classes and every attributed decline lands in the generic bucket.
239
+ const list = [...names].sort().map(gapReasonFor).join(', ');
240
+ throw new StructureError(`${e.message} — and the function carries ${list}, which is the more likely cause`);
241
+ }
242
+ }
243
+
191
244
  /** Stage 4 — structure + its boundary contracts, always as a pair. */
192
245
  export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]): SFn {
193
- const raw = structure(fn, opts);
246
+ const raw = attributeOpaques(fn, () => structure(fn, opts));
194
247
  // BOTH boundary contracts run on the pre-DCE tree: the readability pass must never be able to
195
248
  // hide a structuring defect by dropping the dead statement that carries it. assertResolved
196
249
  // catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
@@ -198,11 +251,17 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
198
251
  // removes statements/flips branches over an already-validated tree.
199
252
  assertResolved(raw);
200
253
  assertDerefsTyped(raw);
201
- // Then the readability/quality rewrites: drop dead stores, then hoist a reused aggregate-global
254
+ assertEffectsPreserved(fn, raw);
255
+ // 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
202
258
  // base into a typed local pointer. The hoist moves the deref cast from each `index` node onto the
203
259
  // local's initializer, so re-validate deref typing on the rewritten tree.
204
- const sfn = hoistReusedGlobalBases(eliminateDeadStores(raw));
260
+ const sfn = hoistReusedGlobalBases(eliminateDeadStores(mergeCommonTails(raw)));
205
261
  assertDerefsTyped(sfn);
262
+ // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
263
+ // merges arms or drops statements must not be able to lose or duplicate a call.
264
+ assertEffectsPreserved(fn, sfn);
206
265
  return sfn;
207
266
  }
208
267