@asmlift/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/package.json +14 -0
  4. package/src/backend/c.ts +20 -0
  5. package/src/backend/cfamily.ts +352 -0
  6. package/src/backend/cpp.ts +145 -0
  7. package/src/backend/pascal.ts +279 -0
  8. package/src/contracts.ts +131 -0
  9. package/src/detect.ts +12 -0
  10. package/src/frontend/asmdata.ts +170 -0
  11. package/src/frontend/disasm.ts +102 -0
  12. package/src/frontend/emit.ts +57 -0
  13. package/src/frontend/errors.ts +14 -0
  14. package/src/frontend/format.ts +47 -0
  15. package/src/frontend/frontend.ts +22 -0
  16. package/src/frontend/mips.ts +875 -0
  17. package/src/frontend/opaque.ts +82 -0
  18. package/src/frontend/ppc.ts +990 -0
  19. package/src/frontend/registry.ts +34 -0
  20. package/src/frontend/ssa.ts +214 -0
  21. package/src/frontend/thumb.ts +1419 -0
  22. package/src/ir/core.ts +104 -0
  23. package/src/ir/opcodes.ts +143 -0
  24. package/src/ir/parse.ts +221 -0
  25. package/src/ir/print.ts +77 -0
  26. package/src/ir/types.ts +106 -0
  27. package/src/ir/verify.ts +221 -0
  28. package/src/l3/ast.ts +301 -0
  29. package/src/l3/basecse.ts +218 -0
  30. package/src/l3/dce.ts +256 -0
  31. package/src/l3/regspell.ts +331 -0
  32. package/src/l3/reindex.ts +447 -0
  33. package/src/l3/typing.ts +145 -0
  34. package/src/mangle.ts +135 -0
  35. package/src/pattern/engine.ts +392 -0
  36. package/src/pipeline.ts +272 -0
  37. package/src/proto.ts +42 -0
  38. package/src/raise/arrays.ts +84 -0
  39. package/src/raise/const.ts +52 -0
  40. package/src/raise/errors.ts +10 -0
  41. package/src/raise/magicdiv.ts +386 -0
  42. package/src/raise/pre-recovery.ts +71 -0
  43. package/src/raise/recover.ts +215 -0
  44. package/src/raise/retsink.ts +72 -0
  45. package/src/raise/shortcircuit.ts +207 -0
  46. package/src/raise/softdiv.ts +62 -0
  47. package/src/raise/struct-arrays.ts +257 -0
  48. package/src/raise/structs.ts +223 -0
  49. package/src/rank.ts +208 -0
  50. package/src/structure/analysis.ts +410 -0
  51. package/src/structure/hazards.ts +142 -0
  52. package/src/structure/loops.ts +169 -0
  53. package/src/structure/structure.ts +1726 -0
  54. package/src/structure/switch-recover.ts +410 -0
  55. package/src/target.ts +140 -0
  56. package/src/trace.ts +233 -0
@@ -0,0 +1,72 @@
1
+ // asmlift — return-sinking (F-CFG-class structural pass; successor-aware, ISA-neutral).
2
+ //
3
+ // A short-circuit `if (a && b) return X; return Y;` (and the `||` / value-returning variants) compiles to
4
+ // a diamond whose arms converge on a single RETURN block: `br ^merge(X)` / `br ^merge(Y)` into
5
+ // `^merge(v): ret v`. The structurer lowers that merge as a shared VARIABLE — `v0 = X … v0 = Y … return v0`
6
+ // — which is byte-exact-CORRECT but recompiles DIFFERENTLY from the source: agbcc/gcc, given the natural
7
+ // `if (a==0) return Y; if (b==0) return Y; return X;` (early returns), re-share the return block and match;
8
+ // given the merge-variable spelling they materialise the merge differently and MISS (verified on `ifand`).
9
+ // The fix is a classic transform: TAIL-DUPLICATE a return-only merge block into each predecessor that
10
+ // reaches it by an unconditional branch — replace `br ^merge(v)` with `ret v` and drop the now-unreachable
11
+ // merge. The structurer then emits early returns in each arm (it already duplicates a shared arm block),
12
+ // which recompiles to the compiler's shared-return form. Purely structural: no new IR/AST vocabulary.
13
+ //
14
+ // GATE — only the SHORT-CIRCUIT shape, never a simple value-select. A single-condition select
15
+ // (`c ? x : y`, and the branchless-compare idioms `clamp0`/`le0`/…) also converges two arms on a return
16
+ // merge, but there the compiler emits the MERGE-VARIABLE form, which is what byte-matches — sinking it
17
+ // would REGRESS those. The distinguishing signal is structural: a short-circuit chain converges on a
18
+ // SHARED arm (the common early-exit reached from ≥2 conditions, so it has ≥2 predecessors), whereas a
19
+ // simple diamond's arms each have exactly one predecessor. So sink only when some branch-predecessor of
20
+ // the merge is itself shared (≥2 preds); every simple select stays a merge var.
21
+ //
22
+ // This does NOT recover the boolean-VALUE form `return a && b` — that is shortcircuit.ts's job
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';
25
+
26
+ /** 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. */
30
+ export function sinkReturns(fn: Fn): boolean {
31
+ let changed = false;
32
+ const preds = predecessors(fn);
33
+ const isBrTo = (p: Block, m: Block) => {
34
+ const t = p.ops[p.ops.length - 1];
35
+ return t.opcode === 'br' && t.successors.length === 1 && t.successors[0].block === m;
36
+ };
37
+ for (const m of [...fn.blocks]) {
38
+ if (m.ops.length !== 1) {
39
+ continue;
40
+ }
41
+ const ret = m.ops[0];
42
+ if (ret.opcode !== 'ret') {
43
+ continue;
44
+ }
45
+ // Every returned value must be a param of this block (so it comes in on the edge). A `ret` of a
46
+ // value computed elsewhere, or of a non-param, can't be reconstructed from the predecessor's args.
47
+ if (!ret.operands.every((o) => m.params.includes(o))) {
48
+ continue;
49
+ }
50
+ const ps = preds.get(m) ?? [];
51
+ const brPreds = ps.filter((p) => isBrTo(p, m));
52
+ if (brPreds.length === 0) {
53
+ continue;
54
+ }
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)) {
58
+ continue;
59
+ }
60
+ for (const p of brPreds) {
61
+ const args = p.ops[p.ops.length - 1].successors[0].args;
62
+ const sunk = ret.operands.map((o) => args[m.params.indexOf(o)]);
63
+ p.ops[p.ops.length - 1] = mkOp('ret', { operands: sunk });
64
+ changed = true;
65
+ }
66
+ // If no predecessor still branches to m (all were unconditional), it is unreachable — drop it.
67
+ if (brPreds.length === ps.length && fn.blocks[0] !== m) {
68
+ fn.blocks = fn.blocks.filter((b) => b !== m);
69
+ }
70
+ }
71
+ return changed;
72
+ }
@@ -0,0 +1,207 @@
1
+ // asmlift — boolean-value short-circuit recovery (F-CFG; successor-aware, agbcc-class).
2
+ //
3
+ // `return a && b` compiles (agbcc) to a value-producing diamond: `if (a==0) result=0; else result=(b!=0)`,
4
+ // where the merge block returns the phi. The structurer lowers that as `if (a==0){v0=0}else{v0=(-b|b)>>31}
5
+ // return v0` — which misses, because (1) the merge is a variable, not the `&&` expression, and (2) the
6
+ // second operand is agbcc's branchless `(-b|b)>>31` bool-normalisation, not a clean `b != 0`. This module
7
+ // recovers the `logic_and`/`logic_or` value so the backend prints `a != 0 && b != 0`, which recompiles to
8
+ // the exact diamond. Two passes:
9
+ //
10
+ // 1. recognizeBoolNormalize — fold `(-x | x) >> 31` (logical) into `x != 0` (icmp_ne). This is agbcc's
11
+ // branchless "is-nonzero", and it is what the short-circuit second operand looks like.
12
+ // 2. recognizeShortCircuit — collapse a SIMPLE boolean diamond into one connective. The head H ends
13
+ // `cond_br(cond)[…]`; one edge goes straight to the merge M carrying a boolean CONSTANT 0/1; the other
14
+ // goes to a single-predecessor block B that computes a boolean Vb and `br M(Vb)`. Then the phi is
15
+ // `cond ? const : Vb` (or the mirror), which is a `&&`/`||` of `cond` (or its negation) and `Vb`:
16
+ // head-edge = taken, const 0 → !cond && Vb head-edge = fall, const 0 → cond && Vb
17
+ // head-edge = taken, const 1 → cond || Vb head-edge = fall, const 1 → !cond || Vb
18
+ // B's (pure) ops are hoisted into H, the phi is replaced by the connective, and H `br M`.
19
+ //
20
+ // The feeder Vb may be a boolean OP (→ `logic_and`/`logic_or`) or itself a CONSTANT 0/1 (then the diamond
21
+ // is just `cond ? 0 : 1` = the condition or its negation, no connective). Because the fold is applied
22
+ // ITERATIVELY (a merge with >2 predecessors collapses one diamond at a time, each reducing the pred
23
+ // count), a `&&`-CHAIN like `a > 0 && b > 0 && …` — a shared const-0 exit reached from every condition —
24
+ // folds bottom-up: the innermost diamond becomes a bare condition, which the next diamond consumes as its
25
+ // Vb, and so on. SCOPE: the shared-arm must be reachable as a single-predecessor `br` feeder; the `||`
26
+ // form where the const-1 "true" block has TWO predecessors (`return a || b`) is not folded.
27
+ // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
28
+ // negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
29
+ import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
30
+ import type { Opcode } from '../ir/opcodes';
31
+ import { EFFECTFUL_OPS } from '../ir/opcodes';
32
+ import { T } from '../ir/types';
33
+
34
+ const NEGATE_ICMP: Record<string, Opcode> = {
35
+ icmp_eq: 'icmp_ne',
36
+ icmp_ne: 'icmp_eq',
37
+ icmp_slt: 'icmp_sge',
38
+ icmp_sge: 'icmp_slt',
39
+ icmp_sgt: 'icmp_sle',
40
+ icmp_sle: 'icmp_sgt',
41
+ icmp_ult: 'icmp_uge',
42
+ icmp_uge: 'icmp_ult',
43
+ icmp_ugt: 'icmp_ule',
44
+ icmp_ule: 'icmp_ugt',
45
+ };
46
+ const BOOL_OPS = new Set([...Object.keys(NEGATE_ICMP), 'logic_and', 'logic_or']);
47
+ // Ops with an observable side effect — unsafe to HOIST out of a short-circuit's conditional arm
48
+ // (they would run unconditionally). Derived from the ONE effect table in ir/opcodes.ts.
49
+ const SIDE_EFFECT = EFFECTFUL_OPS;
50
+
51
+ /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
52
+ // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
53
+ // internal call preserves.
54
+ function recognizeBoolNormalize(fn: Fn): boolean {
55
+ let changed = false;
56
+ const defs = defOpMap(fn);
57
+ for (const b of fn.blocks) {
58
+ for (let i = 0; i < b.ops.length; i++) {
59
+ const op = b.ops[i];
60
+ if (op.opcode !== 'shr_u' || op.attrs.imm !== 31 || op.operands.length !== 1) {
61
+ continue;
62
+ }
63
+ const orOp = defs.get(op.operands[0]);
64
+ if (!orOp || orOp.opcode !== 'or') {
65
+ continue;
66
+ }
67
+ const [p, q] = orOp.operands; // one operand of the `or` must be `neg` of the other
68
+ const negP = defs.get(p),
69
+ negQ = defs.get(q);
70
+ const x =
71
+ negP?.opcode === 'neg' && negP.operands[0] === q
72
+ ? q
73
+ : negQ?.opcode === 'neg' && negQ.operands[0] === p
74
+ ? p
75
+ : null;
76
+ if (!x) {
77
+ continue;
78
+ }
79
+ const zero = mkValue(T.unk(32));
80
+ const c0 = mkOp('const', { results: [zero], attrs: { value: 0 } });
81
+ const ne = mkOp('icmp_ne', { operands: [x, zero], results: [op.results[0]] }); // reuse the result Value
82
+ b.ops.splice(i, 1, c0, ne);
83
+ defs.set(op.results[0], ne);
84
+ i++; // skip past the inserted icmp_ne
85
+ changed = true;
86
+ }
87
+ }
88
+ return changed;
89
+ }
90
+
91
+ /** Collapse a simple boolean short-circuit diamond into one `logic_and`/`logic_or`, in place. */
92
+ export function recognizeShortCircuit(fn: Fn): boolean {
93
+ let changed = recognizeBoolNormalize(fn);
94
+ const term = (b: Block) => b.ops[b.ops.length - 1];
95
+ const constOf = (defs: Map<Value, Op>, v: Value): number | null => {
96
+ const d = defs.get(v);
97
+ return d && d.opcode === 'const' ? (d.attrs.value as number) : null;
98
+ };
99
+ const isBool = (defs: Map<Value, Op>, v: Value): boolean => {
100
+ const d = defs.get(v);
101
+ return !!d && BOOL_OPS.has(d.opcode);
102
+ };
103
+
104
+ let progress = true;
105
+ while (progress) {
106
+ progress = false;
107
+ const defs = defOpMap(fn);
108
+ const preds = predecessors(fn);
109
+ outer: for (const m of fn.blocks) {
110
+ if (m.params.length !== 1) {
111
+ continue;
112
+ }
113
+ if ((preds.get(m) ?? []).length < 2) {
114
+ continue;
115
+ }
116
+ // Find a diamond among M's predecessors: a `br` feeder B whose SOLE predecessor H is a cond_br
117
+ // whose two successors are exactly {M, B}. (Per-feeder search — M may have >2 preds in a chain.)
118
+ for (const bfeed of preds.get(m)!) {
119
+ const bt = term(bfeed);
120
+ if (bt.opcode !== 'br' || bt.successors[0]?.block !== m) {
121
+ continue;
122
+ }
123
+ const bp = preds.get(bfeed) ?? [];
124
+ if (bp.length !== 1) {
125
+ continue;
126
+ }
127
+ const h = bp[0];
128
+ const ht = term(h);
129
+ if (ht.opcode !== 'cond_br') {
130
+ continue;
131
+ }
132
+ const [s0, s1] = ht.successors; // [taken, fall]
133
+ const mIsTaken = s0.block === m && s1.block === bfeed;
134
+ const mIsFall = s1.block === m && s0.block === bfeed;
135
+ if (!mIsTaken && !mIsFall) {
136
+ continue;
137
+ } // H's successors must be exactly {M, B}
138
+ const c = constOf(defs, (mIsTaken ? s0 : s1).args[0]); // the H→M edge carries the short-circuit const
139
+ if (c !== 0 && c !== 1) {
140
+ continue;
141
+ }
142
+ const vb = bt.successors[0].args[0]; // the value B carries to M — a bool op or a 0/1 const
143
+ const vbConst = constOf(defs, vb);
144
+ if (vbConst === null && !isBool(defs, vb)) {
145
+ continue;
146
+ } // else `cond ? const : Vb` isn't a connective
147
+ // A const/const diamond is only a (negated) condition, not a constant: `cond?0:1`/`cond?1:0`.
148
+ if (vbConst !== null && !((c === 0 && vbConst === 1) || (c === 1 && vbConst === 0))) {
149
+ continue;
150
+ }
151
+ // Reduce a const/const diamond ONLY in CHAIN context (M has >2 preds — it feeds an outer connective).
152
+ // A STANDALONE boolean-producing diamond (`return a > b`, M has 2 preds) is left as a merge variable:
153
+ // folding it to a bare comparison can LOSE the spelling the compiler emitted (verified: `ult5`
154
+ // regresses), and the branch-sense candidate already spells the merge both ways.
155
+ if (vbConst !== null && preds.get(m)!.length <= 2) {
156
+ continue;
157
+ }
158
+ const cond = ht.operands[0];
159
+ const condDef = defs.get(cond);
160
+ if (!condDef || !NEGATE_ICMP[condDef.opcode]) {
161
+ continue;
162
+ } // head condition must be a negatable icmp
163
+
164
+ // The `cond`-side operand is negated iff `cond` guards the short-circuit (taken+0 / fall+1).
165
+ const wantNeg = (c === 0 && mIsTaken) || (c === 1 && mIsFall);
166
+ const before = (op: Op) => h.ops.splice(h.ops.length - 1, 0, op); // insert just before H's terminator
167
+ // B's body is hoisted UNCONDITIONALLY into H (H always executes), so it MUST be side-effect free:
168
+ // a `store`/`astore`/`call` in B's arm would then run even when the short-circuit does NOT take B
169
+ // (e.g. `a && ((*p = x) != 0)` would store even when `a` is false) — a silent miscompile. Pure
170
+ // value ops (arith, loads, icmp) are safe: the structurer inlines them back into the `&&`/`||` RHS
171
+ // expression, where C's own short-circuit re-guards them. Any side effect ⇒ DECLINE the fold — the
172
+ // merge-variable spelling the fall-through leaves is correct (the side effect stays in B's block),
173
+ // just possibly non-matching.
174
+ if (bfeed.ops.slice(0, -1).some((op) => SIDE_EFFECT.has(op.opcode))) {
175
+ continue;
176
+ }
177
+ bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
178
+ let condSide = cond;
179
+ if (wantNeg) {
180
+ condSide = mkValue(T.unk(32));
181
+ before(mkOp(NEGATE_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
182
+ }
183
+ // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
184
+ let res = condSide;
185
+ if (vbConst === null) {
186
+ res = mkValue(T.unk(32));
187
+ before(mkOp(c === 0 ? 'logic_and' : 'logic_or', { operands: [condSide, vb], results: [res] }));
188
+ }
189
+ // If M still has OTHER predecessors after this collapse (a longer chain), keep the phi and feed the
190
+ // recovered value as its incoming arg from H — a later iteration folds the rest. Only when this was
191
+ // the last pair (M drops to a single predecessor) do we retire the phi and rewrite its uses.
192
+ if (preds.get(m)!.length > 2) {
193
+ h.ops[h.ops.length - 1] = mkOp('br', { successors: [{ block: m, args: [res] }] });
194
+ } else {
195
+ h.ops[h.ops.length - 1] = mkOp('br', { successors: [{ block: m, args: [] }] });
196
+ replaceAllUsesWith(fn, m.params[0], res); // the phi becomes the recovered boolean value
197
+ m.params = [];
198
+ }
199
+ fn.blocks = fn.blocks.filter((x) => x !== bfeed);
200
+ changed = true;
201
+ progress = true;
202
+ break outer; // defs/preds are stale after mutation — recompute on the next iteration
203
+ }
204
+ }
205
+ }
206
+ return changed;
207
+ }
@@ -0,0 +1,62 @@
1
+ // asmlift — soft-division helper-call lowering (L1 recognition; agbcc/ARM-class targets).
2
+ //
3
+ // A target with no hardware divide lowers `a / b` (and unsigned / `%`) to a call to a compiler
4
+ // RUNTIME HELPER: agbcc (ARM EABI) emits `bl __divsi3` with the dividend in r0, divisor in r1.
5
+ // asmlift lifts that as an opaque `call{target:"__divsi3"}(a, b)`, which the backend can only spell
6
+ // as the uncompilable `__divsi3(a, b)`. This pass:
7
+ // (a) supplies the helper SIGNATURES (RUNTIME_HELPERS) so the two arguments ARE recovered — merged
8
+ // into the frontend's prototype lookup, reusing the existing signature-driven arg recovery; and
9
+ // (b) rewrites the recognised call to the EXISTING division op (`sdiv`/`udiv`/`smod`/`umod` — the
10
+ // same 2-operand form the hardware-divide path emits), so type recovery types the operands'
11
+ // signedness and the structurer lowers it to `a / b` / `a % b`.
12
+ // Re-emitting `a / b` recompiles to the same `bl __divsi3` byte-for-byte.
13
+ //
14
+ // Like array legalization (raise/arrays.ts), this is RECOGNITION the patterns-as-data idiom layer
15
+ // cannot state: its match keys on a `call`'s STRING `target` attr, which the numeric `attrEquals`
16
+ // cannot express. It is naturally inert on hardware-divide targets (which emit `div`/`divu`,
17
+ // never `bl __divsi3`).
18
+ import { Fn, mkOp } from '../ir/core';
19
+ import type { Opcode } from '../ir/opcodes';
20
+ import type { Prototypes } from '../proto';
21
+
22
+ // runtime helper symbol → { the division op it computes, its argument count }.
23
+ const SOFT_DIV: Record<string, { op: Opcode; params: number }> = {
24
+ __divsi3: { op: 'sdiv', params: 2 },
25
+ __udivsi3: { op: 'udiv', params: 2 },
26
+ __modsi3: { op: 'smod', params: 2 },
27
+ __umodsi3: { op: 'umod', params: 2 },
28
+ };
29
+
30
+ /** Signatures for the soft-division runtime helpers, so a `bl __divsi3` recovers both arguments.
31
+ * Consumed by the frontend's arity lookup BEHIND any caller-supplied prototype (headers win). */
32
+ export const RUNTIME_HELPERS: Prototypes = Object.fromEntries(
33
+ Object.entries(SOFT_DIV).map(([sym, h]) => [sym, { params: h.params }]),
34
+ );
35
+
36
+ /** Rewrite each recognised soft-division helper call to its division op, in place. Returns whether
37
+ * anything changed. Runs BEFORE type recovery so the new op's operands get signed/unsigned typing. */
38
+ export function recognizeSoftDiv(fn: Fn): boolean {
39
+ let changed = false;
40
+ for (const b of fn.blocks) {
41
+ for (let i = 0; i < b.ops.length; i++) {
42
+ const op = b.ops[i];
43
+ if (op.opcode !== 'call') {
44
+ continue;
45
+ }
46
+ const helper = SOFT_DIV[op.attrs.target as string];
47
+ if (!helper) {
48
+ continue;
49
+ }
50
+ // Fold only when BOTH arguments were recovered (the signature makes this the norm). A
51
+ // mis-recovered arity leaves the call untouched rather than fabricating a wrong divide.
52
+ if (op.operands.length !== helper.params || op.results.length !== 1) {
53
+ continue;
54
+ }
55
+ // Reuse the SAME result Value → every existing use already points at it (no RAUW needed).
56
+ const div = mkOp(helper.op, { operands: [...op.operands], results: [op.results[0]] });
57
+ b.ops.splice(i, 1, div);
58
+ changed = true;
59
+ }
60
+ }
61
+ return changed;
62
+ }
@@ -0,0 +1,257 @@
1
+ // Array-of-struct recovery: the scaledAddress extension for NON-scalar strides. Recognizes the
2
+ // element-pointer idiom `%elem = add(base, index * stride)` where the stride is read
3
+ // AUTHORITATIVELY from the `mul #C` / `shl #k` constant in the machine code (the inversion of
4
+ // m2c/Ghidra, which read stride from a supplied type). The element becomes a STRUCT whose fields
5
+ // are the residual offsets of the loads/stores off %elem, so `load %elem {off=K}` becomes
6
+ // `base[index].field_K`. Rewrites those into `aload`/`astore` carrying `fieldOff`. Byte-exact:
7
+ // the element struct is given `size == stride` so its `sizeof` reproduces the observed
8
+ // `mul #stride`.
9
+ //
10
+ // SCALAR-VS-STRUCT DISCRIMINATION (the question that kept this a prototype): a stride==width
11
+ // single-field-at-0 access is a plain scalar array and must stay recognizeArrays' shape, not a
12
+ // 1-field struct. Resolved by ORDER + the clean gate: this pass runs AFTER `arrays` in
13
+ // PRE_RECOVERY_PASSES, so every shl-scaled stride==width off-0 access is already an aload (its
14
+ // add is dead) before this pass looks; overlapping residues (off + width > stride) fail the
15
+ // clean gate. The one shape that still lands here — a mul-by-power-of-2 the compiler chose over
16
+ // shl, which real compilers do not emit — would recover as a 1-field struct: byte-identical
17
+ // address math, merely less idiomatic. The mul/shl stride constant is what makes base/index
18
+ // UNAMBIGUOUS here (the scaled operand is the index, read from the machine code) — the unscaled
19
+ // `add(x, y)` byte form stays out of scope (genuinely ambiguous without types).
20
+ import { Fn, Op, Value, defOpMap, mkOp } from '../ir/core';
21
+ import { IrType, StructField, T, scalarTypeForAccess } from '../ir/types';
22
+
23
+ interface Scaled {
24
+ base: Value;
25
+ index: Value;
26
+ stride: number;
27
+ }
28
+
29
+ // `%elem = add(base, index*stride)` — the scaled side is `mul(index, const)` or `shl(index, k)`.
30
+ // The `add` is commutative. Returns the base/index/stride, stride read from the constant.
31
+ function elementPointer(add: Op, defs: Map<Value, Op>): Scaled | null {
32
+ if (add.opcode !== 'add' || add.operands.length !== 2) {
33
+ return null;
34
+ }
35
+ for (const [s, o] of [
36
+ [0, 1],
37
+ [1, 0],
38
+ ] as const) {
39
+ const d = defs.get(add.operands[s]);
40
+ if (!d) {
41
+ continue;
42
+ }
43
+ if (d.opcode === 'mul' && d.operands.length === 2) {
44
+ const c0 = defs.get(d.operands[0]);
45
+ const c1 = defs.get(d.operands[1]);
46
+ if (c1?.opcode === 'const') {
47
+ return { base: add.operands[o], index: d.operands[0], stride: c1.attrs.value as number };
48
+ }
49
+ if (c0?.opcode === 'const') {
50
+ return { base: add.operands[o], index: d.operands[1], stride: c0.attrs.value as number };
51
+ }
52
+ }
53
+ if (d.opcode === 'shl' && d.operands.length === 1) {
54
+ return { base: add.operands[o], index: d.operands[0], stride: 1 << (d.attrs.imm as number) };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Byte width of a recovered field type (pointer word-sized; array = elem × count).
61
+ const byteSize = (t: IrType): number =>
62
+ t.kind === 'array' ? byteSize(t.elem) * t.count : t.kind === 'int' ? t.width / 8 : 4;
63
+
64
+ // Interleave `u8[N]` PAD fields into a sorted data-field list so every data field lands at its exact
65
+ // offset and the element's total size == stride. Makes the struct type SELF-DESCRIBING (no size-time
66
+ // synthesis in the backend). `char`-style raw bytes are `u8` in the decomp type vocabulary.
67
+ function withPadding(dataFields: StructField[], stride: number): StructField[] {
68
+ const out: StructField[] = [];
69
+ let cursor = 0,
70
+ pad = 0;
71
+ for (const f of dataFields) {
72
+ if (f.off > cursor) {
73
+ out.push({ off: cursor, type: T.array(T.u(8), f.off - cursor), name: `_pad${pad++}` });
74
+ }
75
+ out.push(f);
76
+ cursor = f.off + byteSize(f.type);
77
+ }
78
+ if (stride > cursor) {
79
+ out.push({ off: cursor, type: T.array(T.u(8), stride - cursor), name: `_pad${pad}` });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Recover array-of-struct element access. Returns the number of element-pointers recovered.
85
+ *
86
+ * Element pointers are recovered PER (base, stride) GROUP, not per add: a compiler freely
87
+ * rematerializes the same element address (several `add(base, i*stride)` ops for one logical
88
+ * array), and recovering them one-by-one would let the first claim the base and force its
89
+ * twins to decline — a mixed spelling that is worse than either pure form (found live on
90
+ * pokeemerald:GetGender, whose address is materialized twice). A base whose element pointers
91
+ * disagree on stride declines entirely: two strides over one base is a reinterpreted view or
92
+ * a 2D layout, genuinely ambiguous — decline over guess. */
93
+ export function recognizeStructArrays(fn: Fn): number {
94
+ const defs = defOpMap(fn);
95
+ let count = 0;
96
+
97
+ // group candidate element pointers by base, tracking each add's own index and stride
98
+ const byBase = new Map<Value, { add: Op; index: Value; stride: number }[]>();
99
+ for (const b of fn.blocks) {
100
+ for (const add of b.ops) {
101
+ const sc = elementPointer(add, defs);
102
+ if (sc && sc.stride > 0) {
103
+ const list = byBase.get(sc.base) ?? [];
104
+ list.push({ add, index: sc.index, stride: sc.stride });
105
+ byBase.set(sc.base, list);
106
+ }
107
+ }
108
+ }
109
+
110
+ for (const [base, elems] of byBase) {
111
+ // The base must be RETYPABLE: still `unknown` (this pass runs before recovery seeds it, and
112
+ // an already-recovered type must not be clobbered), agreed on ONE stride, and never itself a
113
+ // DIRECT memory base (`arr->x` alongside `arr[i].y`: the retype would make memAccess resolve
114
+ // the direct access against element fields it may not have).
115
+ if (base.type.kind !== 'unknown') {
116
+ continue;
117
+ }
118
+ const stride = elems[0].stride;
119
+ if (elems.some((e) => e.stride !== stride)) {
120
+ continue;
121
+ }
122
+ let clean = true;
123
+ for (const bb of fn.blocks) {
124
+ for (const op of bb.ops) {
125
+ if ((op.opcode === 'load' || op.opcode === 'store') && op.operands[0] === base) {
126
+ clean = false;
127
+ }
128
+ }
129
+ }
130
+
131
+ // Every use of every %elem — across ALL operand positions AND successor block-args (the
132
+ // IR's full use-set, adversarially learned: `store elem, elem {off}` hides elem at
133
+ // operand[1] of its own clean access; a branch can carry elem as a block arg the
134
+ // op-operand scan never sees, leaving a live sizeof-scaling add behind) — must be a
135
+ // load/store BASE with a field offset inside one element.
136
+ const elemSet = new Set(elems.map((e) => e.add.results[0]));
137
+ const indexOf = new Map(elems.map((e) => [e.add.results[0], e.index]));
138
+ const accesses: { op: Op; elem: Value; off: number; width: number; signed: boolean }[] = [];
139
+ for (const bb of fn.blocks) {
140
+ for (const op of bb.ops) {
141
+ const isMem = (op.opcode === 'load' || op.opcode === 'store') && elemSet.has(op.operands[0]);
142
+ if (op.operands.some((o, k) => elemSet.has(o) && (k > 0 || !isMem))) {
143
+ clean = false; // a non-base use — even inside an otherwise-clean access
144
+ }
145
+ if (op.successors.some((sx) => sx.args.some((a) => elemSet.has(a)))) {
146
+ clean = false; // carried into a block arg — a use the rewrite cannot see
147
+ }
148
+ if (isMem) {
149
+ const off = op.attrs.off as number,
150
+ width = op.attrs.width as number;
151
+ if (off < 0 || off + width > stride) {
152
+ clean = false;
153
+ }
154
+ accesses.push({
155
+ op,
156
+ elem: op.operands[0],
157
+ off,
158
+ width,
159
+ signed: op.opcode === 'load' ? (op.attrs.signed as boolean) : width === 4,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ if (!clean || accesses.length === 0) {
165
+ continue;
166
+ }
167
+
168
+ // Build the element struct over the UNION of the group's accesses: one field per distinct
169
+ // offset, SIZE = stride (so sizeof matches). The offset set must describe a real C struct —
170
+ // the guards mirror structs.ts:
171
+ // • same-offset accesses must agree on width (a conflict is a union view, not a field —
172
+ // collapsing widths deleted a store's byte-range in the adversarial round);
173
+ // • fields must not OVERLAP (withPadding assumes disjoint; an overlap silently shifts
174
+ // every later field's physical offset);
175
+ // • each field must be naturally aligned (off % width) and the stride divisible by the
176
+ // widest field's alignment — otherwise the DECLARED layout (which C aligns) diverges
177
+ // from the intended offsets and sizeof ≠ stride.
178
+ // Same-offset rules: widths must agree (any two accesses); LOAD signedness must agree —
179
+ // two loads reading one field with different extensions is a union view, and merging them
180
+ // silently drops one side's zero/sign-extension (adversarially learned: `arr[i].f +
181
+ // (u16)arr[i].f` lost its zext and a byte-exact match with it). A STORE's `signed` is the
182
+ // width===4 CONVENTION, not a machine fact, so it never conflicts — the field takes its
183
+ // signedness from the loads when any exist.
184
+ const byOff = new Map<number, { width: number; loadSigned: boolean | null }>();
185
+ for (const a of accesses) {
186
+ const isLoad = a.op.opcode === 'load';
187
+ const prev = byOff.get(a.off);
188
+ if (!prev) {
189
+ byOff.set(a.off, { width: a.width, loadSigned: isLoad ? a.signed : null });
190
+ } else if (prev.width !== a.width) {
191
+ clean = false;
192
+ } else if (isLoad) {
193
+ if (prev.loadSigned === null) {
194
+ prev.loadSigned = a.signed;
195
+ } else if (prev.loadSigned !== a.signed) {
196
+ clean = false;
197
+ }
198
+ }
199
+ }
200
+ const offs = [...byOff.entries()].sort(([x], [y]) => x - y);
201
+ let maxAlign = 1;
202
+ for (let i = 0; i < offs.length; i++) {
203
+ const [off, { width }] = offs[i];
204
+ if (off % width !== 0) {
205
+ clean = false;
206
+ }
207
+ if (i + 1 < offs.length && off + width > offs[i + 1][0]) {
208
+ clean = false;
209
+ }
210
+ maxAlign = Math.max(maxAlign, width);
211
+ }
212
+ if (stride % maxAlign !== 0) {
213
+ clean = false;
214
+ }
215
+ if (!clean) {
216
+ continue;
217
+ }
218
+ const dataFields: StructField[] = offs.map(([off, { width, loadSigned }]) => ({
219
+ off,
220
+ // store-only field: the width===4 convention, exactly what the old access carried
221
+ type: scalarTypeForAccess(width, loadSigned ?? width === 4),
222
+ name: `field_${off}`,
223
+ }));
224
+ const elemStruct = T.struct(`Elem${count}`, withPadding(dataFields, stride), stride);
225
+ base.type = T.ptr(elemStruct);
226
+
227
+ // Rewrite each field load/store into an aload/astore carrying base, ITS elem's index,
228
+ // elemSize, fieldOff. The aload REUSES the load's result value — minting a replacement and
229
+ // RAUW-ing killed a value that a LATER group's captured base/index still referenced (the
230
+ // groups were collected before any rewrite), crashing chained table indexing
231
+ // (`q = o[i].p; q[j].a`) with a use-of-undefined-value; reuse leaves every captured Value
232
+ // alive, so no group can go stale (adversarially learned).
233
+ for (const bb of fn.blocks) {
234
+ for (let i = 0; i < bb.ops.length; i++) {
235
+ const op = bb.ops[i];
236
+ if (!elemSet.has(op.operands[0])) {
237
+ continue;
238
+ }
239
+ const index = indexOf.get(op.operands[0])!;
240
+ if (op.opcode === 'load') {
241
+ bb.ops[i] = mkOp('aload', {
242
+ operands: [base, index],
243
+ results: [op.results[0]],
244
+ attrs: { elemSize: stride, signed: op.attrs.signed as boolean, fieldOff: op.attrs.off as number },
245
+ });
246
+ } else if (op.opcode === 'store') {
247
+ bb.ops[i] = mkOp('astore', {
248
+ operands: [base, index, op.operands[1]],
249
+ attrs: { elemSize: stride, fieldOff: op.attrs.off as number },
250
+ });
251
+ }
252
+ }
253
+ }
254
+ count++;
255
+ }
256
+ return count;
257
+ }