@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -0,0 +1,594 @@
1
+ // asmlift — STRUCT-MEMBER ARRAY recovery (L1 → a struct whose member is an ARRAY).
2
+ //
3
+ // THE SHAPE. `d->name[i]` — a variable-index walk over an array that lives at a constant byte
4
+ // offset INSIDE a struct — reaches this pass as an `aload`/`astore` whose base is a materialized
5
+ // `add(P, K)`:
6
+ //
7
+ // add r5, r0, #0x4 %4 = add %0, {value=4} (the member's address, loop-invariant)
8
+ // ldrh r0, [r1, r0] %9 = aload %6, %8 {elemSize=2}
9
+ //
10
+ // raise/arrays.ts legalizes the access, but the `+K` stays an ordinary add, so the base is an
11
+ // untyped word and the backend spells the whole thing `((u16 *)(a0 + 4))[i]`. This pass reads the
12
+ // add as a MEMBER SELECTION instead: `P` points at a struct whose byte-K member is an array of
13
+ // `elemSize` elements, and the access is `P->field_K[i]`.
14
+ //
15
+ // A BYTE member is outside that shape in both directions, and the bound is arrays.ts's rather than
16
+ // this pass's: only the shift-scaled address is legalized, so a `u8[]` walk keeps its `add(base,
17
+ // index)` and mints no site here — and that surviving add is then an escaping use of the base,
18
+ // which refuses the WIDER members beside it too (`escaping-use`). A struct that mixes a byte member
19
+ // with u16/s32 members is therefore not reached at all; legalizing the unscaled form is what would
20
+ // reach it.
21
+ //
22
+ // WHY THE SPELLING IS NOT COSMETIC. agbcc puts the `+K` in a different place for each of the three
23
+ // C spellings of this access, and only one of them is the target's. Compiled with this benchmark's
24
+ // own agbcc and scored against `synthetic:membnarrow`'s own target object:
25
+ //
26
+ // `(a0 + 2)[i]` `+4` in the LOAD DISPLACEMENT, `ldrh r0,[r0,#0x4]`, 2 saves 11
27
+ // `u16 *v = a0 + 2; v[i]` preheader `add r4,r0,#0x4`, INDEX-first `add r2,r0,r4`, 2 saves 11
28
+ // `a0->field_4[i]` preheader `add r5,r0,#0x4`, BASE-first `add r2,r5,r0`, 3 saves 0
29
+ //
30
+ // So the evidence this pass reads is the materialized add itself: the cast spelling materializes no
31
+ // member address at all, and `arrays.ts` legalizes only an `off === 0` access, so that spelling
32
+ // produces no site here. `synthetic:basefold` is that control — its reference IS the cast spelling
33
+ // — and it MATCHES today, untouched.
34
+ //
35
+ // A DEFAULT, NOT A RANKED AXIS, and the reason is a measurement rather than a preference. An axis
36
+ // exists where two source spellings collapse onto one asm, so nothing but the differ can separate
37
+ // them. Here the asm separates them itself — line one folds `+K` into the memory operand and
38
+ // produces NO site — so `add(P, K)` feeding an off-0 scaled access is evidence AGAINST the spelling
39
+ // asmlift emits today, not a coin flip. And an axis at this level is a LIFT variant (`/setup-args`'s
40
+ // position in rank.ts), doubling the whole candidate product for every function with a site: swept
41
+ // over 2288 sa3 and 412 klonoa functions in both symbol-map configurations, the gates below admit
42
+ // two bases, in one function, which declines for an unrelated reason — so the price would be paid
43
+ // to referee a question the corpus never poses. What is NOT settled is line two, and that is a
44
+ // different question: where a base LOCAL goes is the L3 base-local levers' business, taken over
45
+ // whatever type the recovery mints, not a second answer to the TYPE this pass decides.
46
+ //
47
+ // WHY THE TRAILING COUNT IS A GATE RATHER THAN A GUESS, and it is the struct's SIZE that makes it
48
+ // one. The count itself is nearly invisible: on `synthetic:membnarrow`'s own target, declaring the
49
+ // walked member `u16 field_4[N]` scores 11 at N = 1 and 2 and MATCHES at every N from 3 to 100.
50
+ // What moved was `sizeof` — hold the count at 1 and pad the struct instead and the same threshold
51
+ // appears (size 6 and 8 score 11, size 10 and up match), because agbcc hoists the loop base out
52
+ // only once the object is big enough. So a count is not a fact to derive but a free parameter with
53
+ // a byte-observable LOWER BOUND, and inventing one invents a size. The count is therefore read off
54
+ // the loop that walks the member — `boundedCount` below — and the whole base DECLINES when no
55
+ // counted loop states one. An INTERIOR member's count is not a choice at all: it is forced by the
56
+ // member that follows it.
57
+ //
58
+ // THE SIBLING PASSES, and why this is a third one rather than a case inside either.
59
+ // raise/structs.ts recovers a struct from CONSTANT-offset accesses and leaves every variable-index
60
+ // base alone (its `arrayBases` set); raise/struct-arrays.ts recovers an ARRAY OF STRUCTS, where the
61
+ // index scales the stride and the constant offset is the field. This pass is the remaining corner —
62
+ // the constant offset selects the member and the index strides inside it.
63
+ //
64
+ // The three shapes are NOT disjoint, and the overlap is per-ACCESS rather than per-base:
65
+ // `P->tbl[i].f` is an array of structs living at a member offset, so it carries both this pass's
66
+ // address shape and struct-arrays'. Ordering alone does not deconflict them, because the two passes
67
+ // claim different VALUES — struct-arrays types the materialized `add(P, K)`, this pass groups on
68
+ // `P` — so each refuses what another has marked: `claimed-access` and `base-typed` here,
69
+ // `arrayBases` and the `unknown` check there. `field_K[i].f` is a layout this pass does not declare;
70
+ // the loud half of that refusal is in structure.ts's `arrayAccess`.
71
+ import { type Fn, type Op, type Value, defOpMap } from '../ir/core';
72
+ import { type IrType, type StructField, T, scalarTypeForAccess } from '../ir/types';
73
+ import { type Gate, firstRejection } from '../l3/gates';
74
+ import { firstFreeStructIndex } from './structs';
75
+
76
+ /** One variable-index access, resolved onto (base value, member offset). */
77
+ interface MemberAccess {
78
+ op: Op;
79
+ off: number;
80
+ elemSize: number;
81
+ /** loads only; a store carries structs.ts's `width === 4` convention */
82
+ signed: boolean;
83
+ isLoad: boolean;
84
+ index: Value;
85
+ /** the `add(base, off)` result, or null when the access sits straight on the base */
86
+ addr: Value | null;
87
+ }
88
+
89
+ /** One member of the synthesized struct: an offset, an element type, and the counts the loops
90
+ * walking it reach. */
91
+ interface Member {
92
+ off: number;
93
+ elemSize: number;
94
+ signed: boolean;
95
+ /** element counts derived from the walking loops' bounds — empty when none was derivable */
96
+ bounds: number[];
97
+ }
98
+
99
+ /** What the gates below judge: one base value and the member set its accesses describe. */
100
+ export interface MemberArrayCandidate {
101
+ /** the base is still untyped, so no earlier recovery has claimed it */
102
+ untypedBase: boolean;
103
+ /** no access, nor the member address it is taken off, was already claimed by an earlier recovery */
104
+ unclaimedAccesses: boolean;
105
+ /** the base reaches the address of a NAMED global, whose declaration is the project's own */
106
+ namedGlobalBase: boolean;
107
+ /** some access sits at a NONZERO member offset — a base walked only at offset 0 is a plain array */
108
+ hasNonZeroMember: boolean;
109
+ /** every member offset is small enough to BE an offset rather than an absolute address */
110
+ offsetsInRange: boolean;
111
+ /** no constant-offset `load`/`store` reads the base or a member address (raise/structs.ts's shape) */
112
+ noDirectAccess: boolean;
113
+ /** every use of the base and of every member address is a variable-index access BASE */
114
+ noEscape: boolean;
115
+ /** every member offset is non-negative and a multiple of its own element size */
116
+ aligned: boolean;
117
+ /** accesses sharing an offset agree on element size and on load signedness */
118
+ consistent: boolean;
119
+ /** natural C alignment seats every member at exactly its observed offset */
120
+ seatable: boolean;
121
+ /** the TRAILING member's element count is stated by a counted loop */
122
+ trailingBounded: boolean;
123
+ /** no member's walk runs past the member that follows it */
124
+ inBounds: boolean;
125
+ }
126
+
127
+ export const MEMBER_ARRAY_GATES: readonly Gate<MemberArrayCandidate>[] = [
128
+ {
129
+ id: 'base-typed',
130
+ why: 'an earlier recovery already typed this base from evidence of its own',
131
+ sound: false,
132
+ guardedBy: 'member-arrays.test.ts: an array-of-struct base declines',
133
+ rejects: (c) => !c.untypedBase,
134
+ },
135
+ {
136
+ // The evidence is per-ACCESS and the refusal is per-BASE. raise/struct-arrays.ts types the
137
+ // MATERIALIZED member address `add(P, K)` and marks the accesses it mints with its own
138
+ // `fieldOff`, while the base `P` this pass groups on stays untyped — so `P->tbl[i].f` reaches
139
+ // the gates as an ordinary member walk, and claiming it drops the field offset (an array of
140
+ // structs at a member offset is not the layout this pass declares) and re-spells the element
141
+ // STRIDE as a scalar width. One marked access refuses the whole base, which costs the members
142
+ // beside it: a struct walked at a member offset AND holding an array of structs elsewhere is
143
+ // refused entirely (measured at 4 points on such a shape). Claiming only the unmarked accesses
144
+ // would retype `P` underneath the accesses struct-arrays owns, so the narrower rule is a
145
+ // change to both passes rather than to this predicate.
146
+ id: 'claimed-access',
147
+ why: 'an access another recovery has already claimed carries an offset this pass would drop',
148
+ sound: true,
149
+ guardedBy: 'member-arrays.test.ts: an array-of-struct member declines',
150
+ rejects: (c) => !c.unclaimedAccesses,
151
+ },
152
+ {
153
+ // A named global's layout belongs to the project's headers, and the member spelling is the one
154
+ // spelling that cannot consult them: structure.ts's `arrayAccess` renders `&gSym` through the
155
+ // symbol context — the map's own array declaration, `gSym[i]` — only while no member offset is
156
+ // set, and takes the `(struct StructN *)&gSym` cast once one is. So claiming a global trades a
157
+ // declaration asmlift has evidence for against one it invents. The base is resolved through
158
+ // block parameters because a global merged onto a `?:` is still a global.
159
+ id: 'global-base',
160
+ why: "a named global's layout is the project's own declaration, not one to synthesize",
161
+ sound: false,
162
+ guardedBy: 'member-arrays.test.ts: a named global base declines',
163
+ rejects: (c) => c.namedGlobalBase,
164
+ },
165
+ {
166
+ id: 'no-member-offset',
167
+ why: 'a base walked only at offset 0 is a plain array — nothing states a struct around it',
168
+ sound: false,
169
+ guardedBy: 'member-arrays.test.ts: a bare array walk declines',
170
+ rejects: (c) => !c.hasNonZeroMember,
171
+ },
172
+ {
173
+ // A MATERIALIZED addend is not bounded the way a memory operand's immediate is. `add(P, K)`
174
+ // with K an absolute address — `ldr r0, =0x03000004` then `add r0, r0, rX` — is base-plus-index
175
+ // with the roles reversed, and reading it as a member selection declares a 48 MB struct. Nothing
176
+ // in the IR separates the two: raise/const.ts folds a pool-loaded literal and an add-immediate
177
+ // into the same `const`. So the separation is STATED here rather than derived, and the number is
178
+ // chosen where the two populations do not overlap: below every mapped GBA region base
179
+ // (0x02000000), and above the offsets this recognizer admits — 26 is the largest on the sa3 and
180
+ // klonoa corpora, and a hand-built struct is admitted at 40, 100 and 200. A target whose objects
181
+ // run past it is what would earn a declared field to read instead.
182
+ id: 'member-offset-range',
183
+ why: 'a materialized addend that large is an absolute address, not a member offset',
184
+ sound: false,
185
+ guardedBy: 'member-arrays.test.ts: an absolute-address addend declines',
186
+ rejects: (c) => !c.offsetsInRange,
187
+ },
188
+ {
189
+ id: 'direct-access',
190
+ why: 'a constant-offset access alongside the walk is the struct-POINTER shape raise/structs.ts owns',
191
+ sound: false,
192
+ guardedBy: 'member-arrays.test.ts: a base read at a constant offset declines',
193
+ rejects: (c) => !c.noDirectAccess,
194
+ },
195
+ {
196
+ // The byte member of the coverage bound above lands here too: an unlegalized `add(base, index)`
197
+ // is a use of the base that is not a variable-index access, so one `u8[]` member refuses every
198
+ // wider member of its struct.
199
+ id: 'escaping-use',
200
+ why: 'a use this rewrite cannot see keeps reading the base as a word the retype no longer spells',
201
+ sound: false,
202
+ guardedBy: 'member-arrays.test.ts: a member address forwarded on a branch declines',
203
+ rejects: (c) => !c.noEscape,
204
+ },
205
+ {
206
+ id: 'member-conflict',
207
+ why: 'one offset read at two widths or two extensions is a union view, not a member',
208
+ sound: false,
209
+ guardedBy: 'member-arrays.test.ts: two widths at one offset decline',
210
+ rejects: (c) => !c.consistent,
211
+ },
212
+ {
213
+ // SOUND, and it is the address that is at stake rather than the spelling: C aligns a member to
214
+ // its own element type, so declaring an `s32` member at byte 2 would seat it at byte 4 and
215
+ // every access through it would read four bytes off the observed address. A NEGATIVE offset is
216
+ // unseatable for a different reason — the base points into the middle of an object rather than
217
+ // at a struct — and the modulo alone does not catch it, because JS `-4 % 2` is `-0`.
218
+ id: 'member-align',
219
+ why: 'a member C cannot seat at its observed offset addresses different bytes than the asm did',
220
+ sound: true,
221
+ guardedBy: 'member-arrays.test.ts: a word member at byte 2 declines',
222
+ rejects: (c) => !c.aligned,
223
+ },
224
+ {
225
+ // SOUND for the same reason: the interior member counts are what place every later member, so
226
+ // a run that overlaps its successor or does not divide by its element size mislays it.
227
+ id: 'member-seat',
228
+ why: 'a member run that overlaps the next one shifts every later member off its observed offset',
229
+ sound: true,
230
+ guardedBy: 'member-arrays.test.ts: overlapping member runs decline',
231
+ rejects: (c) => !c.seatable,
232
+ },
233
+ {
234
+ id: 'trailing-unbounded',
235
+ why: "nothing but the walking loop's bound states the last member's element count, and a count invents a sizeof",
236
+ sound: false,
237
+ guardedBy: 'member-arrays.test.ts: a member walked to a runtime bound declines',
238
+ rejects: (c) => !c.trailingBounded,
239
+ },
240
+ {
241
+ id: 'member-overrun',
242
+ why: 'a walk running past the next member is evidence of a layout other than the one being declared',
243
+ sound: false,
244
+ guardedBy: 'member-arrays.test.ts: a walk overrunning its successor declines',
245
+ rejects: (c) => !c.inBounds,
246
+ },
247
+ ];
248
+
249
+ /** The envelope `member-offset-range` states — see that gate. */
250
+ const MAX_MEMBER_OFFSET = 0x10000;
251
+
252
+ /** Strip `sext`/`zext` wrappers — a narrow counter reaches its uses through them. */
253
+ function stripExt(v: Value, defs: Map<Value, Op>): Value {
254
+ for (;;) {
255
+ const d = defs.get(v);
256
+ if (d === undefined || (d.opcode !== 'sext' && d.opcode !== 'zext')) {
257
+ return v;
258
+ }
259
+ v = d.operands[0];
260
+ }
261
+ }
262
+
263
+ /** `add(x, const)` read as (other operand, constant), or null. The add is commutative. */
264
+ function constAddend(op: Op, defs: Map<Value, Op>): { of: Value; k: number } | null {
265
+ if (op.opcode !== 'add' || op.operands.length !== 2) {
266
+ return null;
267
+ }
268
+ for (const [ci, oi] of [
269
+ [0, 1],
270
+ [1, 0],
271
+ ] as const) {
272
+ const c = defs.get(op.operands[ci]);
273
+ if (c?.opcode === 'const') {
274
+ return { of: op.operands[oi], k: c.attrs.value as number };
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+
280
+ /** How many elements the loop walking `idx` reaches, or null when the shape is not a counted loop.
281
+ *
282
+ * THE ONE SHAPE THIS READS, and everything else declines: `idx` is a block parameter (through any
283
+ * extensions) of a header with exactly two in-edges, one carrying a non-negative constant and one
284
+ * the TRUE arm of a `cond_br` whose condition compares `param + 1` against a constant bound. The
285
+ * count is then the bound plus one for `<=`, the bound itself for `<`. A `>`/`>=` test, a step
286
+ * other than one, a bound that is not a constant, or a third edge into the header all decline —
287
+ * the answer is a DECLARED element count, so a shape whose last index is not read off the asm has
288
+ * no honest one. */
289
+ function boundedCount(fn: Fn, idx: Value, defs: Map<Value, Op>): number | null {
290
+ const param = stripExt(idx, defs);
291
+ const header = fn.blocks.find((b) => b.params.includes(param));
292
+ if (header === undefined) {
293
+ return null;
294
+ }
295
+ const slot = header.params.indexOf(param);
296
+ const edges: { op: Op; index: number }[] = [];
297
+ for (const b of fn.blocks) {
298
+ for (const op of b.ops) {
299
+ op.successors.forEach((s, i) => {
300
+ if (s.block === header) {
301
+ edges.push({ op, index: i });
302
+ }
303
+ });
304
+ }
305
+ }
306
+ if (edges.length !== 2) {
307
+ return null;
308
+ }
309
+ const entry = edges.find((e) => defs.get(e.op.successors[e.index].args[slot])?.opcode === 'const');
310
+ const back = edges.find((e) => e !== entry);
311
+ if (entry === undefined || back === undefined || back.op.opcode !== 'cond_br' || back.index !== 0) {
312
+ return null;
313
+ }
314
+ if ((defs.get(entry.op.successors[entry.index].args[slot])!.attrs.value as number) < 0) {
315
+ return null;
316
+ }
317
+ // the back edge carries `param + 1`, possibly re-extended by the counter's own truncation
318
+ const next = stripExt(back.op.successors[0].args[slot], defs);
319
+ const step = defs.get(next) === undefined ? null : constAddend(defs.get(next)!, defs);
320
+ if (step === null || step.k !== 1 || stripExt(step.of, defs) !== param) {
321
+ return null;
322
+ }
323
+ // …and the exit test compares that same value against a constant bound
324
+ const cond = defs.get(back.op.operands[0]);
325
+ if (cond === undefined || cond.operands.length !== 2) {
326
+ return null;
327
+ }
328
+ const inclusive = cond.opcode === 'icmp_sle' || cond.opcode === 'icmp_ule';
329
+ if (!inclusive && cond.opcode !== 'icmp_slt' && cond.opcode !== 'icmp_ult') {
330
+ return null;
331
+ }
332
+ if (stripExt(cond.operands[0], defs) !== next) {
333
+ return null;
334
+ }
335
+ const bound = defs.get(cond.operands[1]);
336
+ if (bound?.opcode !== 'const') {
337
+ return null;
338
+ }
339
+ const count = (bound.attrs.value as number) + (inclusive ? 1 : 0);
340
+ return count >= 1 ? count : null;
341
+ }
342
+
343
+ /** Every value that reaches the address of a NAMED global. A `gaddr` result is one; so is a block
344
+ * parameter ANY of whose in-edges carries one, because a base merged from `&gA` and `&gB` — or
345
+ * from `&gA` and something else — still spells at least one global's layout. */
346
+ function namedGlobals(fn: Fn, defs: Map<Value, Op>): Set<Value> {
347
+ const global = new Set<Value>();
348
+ const incoming = new Map<Value, Value[]>();
349
+ for (const b of fn.blocks) {
350
+ for (const op of b.ops) {
351
+ if (op.opcode === 'gaddr') {
352
+ global.add(op.results[0]);
353
+ }
354
+ for (const s of op.successors) {
355
+ s.block.params.forEach((param, i) => {
356
+ incoming.set(param, [...(incoming.get(param) ?? []), s.args[i]]);
357
+ });
358
+ }
359
+ }
360
+ }
361
+ for (let grew = true; grew;) {
362
+ grew = false;
363
+ for (const [param, args] of incoming) {
364
+ if (!global.has(param) && args.some((a) => global.has(a) || defs.get(a)?.opcode === 'gaddr')) {
365
+ global.add(param);
366
+ grew = true;
367
+ }
368
+ }
369
+ }
370
+ return global;
371
+ }
372
+
373
+ /** Group every variable-index access by the base it is taken off, resolving a `add(P, K)` address
374
+ * into (P, K). An offset-0 access contributes its own base. */
375
+ function accessesByBase(fn: Fn, defs: Map<Value, Op>): Map<Value, MemberAccess[]> {
376
+ const out = new Map<Value, MemberAccess[]>();
377
+ for (const b of fn.blocks) {
378
+ for (const op of b.ops) {
379
+ if (op.opcode !== 'aload' && op.opcode !== 'astore') {
380
+ continue;
381
+ }
382
+ const isLoad = op.opcode === 'aload';
383
+ const elemSize = op.attrs.elemSize as number;
384
+ const addrDef = defs.get(op.operands[0]);
385
+ const add = addrDef === undefined ? null : constAddend(addrDef, defs);
386
+ const base = add === null ? op.operands[0] : add.of;
387
+ const list = out.get(base) ?? [];
388
+ list.push({
389
+ op,
390
+ off: add === null ? 0 : add.k,
391
+ elemSize,
392
+ signed: isLoad ? (op.attrs.signed as boolean) : elemSize === 4,
393
+ isLoad,
394
+ index: op.operands[1],
395
+ addr: add === null ? null : op.operands[0],
396
+ });
397
+ out.set(base, list);
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /** One candidate base, as the gates read it, plus the members the rewrite would declare. */
404
+ export interface MemberArrayGroup {
405
+ base: Value;
406
+ c: MemberArrayCandidate;
407
+ /** Non-empty: `accessesByBase` only keys a base it recorded an access for, and every access puts
408
+ * an entry in `byOff`, whose values these are. The type carries it so `fieldsOf` and the
409
+ * trailing-member gate can index the ends without a guard the checker cannot see through. */
410
+ members: [Member, ...Member[]];
411
+ accesses: MemberAccess[];
412
+ }
413
+
414
+ /** Every base a variable-index access is taken off, with the member set its accesses describe.
415
+ * Exported so a corpus sweep can price the gate table over the bases it REFUSES. */
416
+ export function memberArrayCandidates(fn: Fn): MemberArrayGroup[] {
417
+ const defs = defOpMap(fn);
418
+ const global = namedGlobals(fn, defs);
419
+ const out: MemberArrayGroup[] = [];
420
+ const bounds = new Map<Value, number | null>();
421
+ for (const [base, accesses] of accessesByBase(fn, defs)) {
422
+ // one loop-bound read per index value — the two ends of a copy loop share it
423
+ const bound = (v: Value): number | null => {
424
+ if (!bounds.has(v)) {
425
+ bounds.set(v, boundedCount(fn, v, defs));
426
+ }
427
+ return bounds.get(v)!;
428
+ };
429
+ const addrs = new Set<Value>(accesses.flatMap((a) => (a.addr === null ? [] : [a.addr])));
430
+ // Every use of the base and of every member address must be a variable-index access base: the
431
+ // member addresses disappear in the rewrite, and the base takes a struct-pointer type every
432
+ // other use would have to spell.
433
+ let noEscape = true;
434
+ let noDirectAccess = true;
435
+ for (const b of fn.blocks) {
436
+ for (const op of b.ops) {
437
+ const isVarIndex = op.opcode === 'aload' || op.opcode === 'astore';
438
+ op.operands.forEach((o, k) => {
439
+ if (o !== base && !addrs.has(o)) {
440
+ return;
441
+ }
442
+ if (op.opcode === 'load' || op.opcode === 'store') {
443
+ noDirectAccess = false;
444
+ } else if (isVarIndex ? k !== 0 : !(o === base && addrs.has(op.results[0]))) {
445
+ noEscape = false;
446
+ }
447
+ });
448
+ for (const s of op.successors) {
449
+ if (s.args.some((a) => a === base || addrs.has(a))) {
450
+ noEscape = false;
451
+ }
452
+ }
453
+ }
454
+ }
455
+ // one member per distinct offset; a load's extension wins over a store's width convention
456
+ const byOff = new Map<number, Member>();
457
+ const loadSigned = new Map<number, boolean>();
458
+ let consistent = true;
459
+ let aligned = true;
460
+ for (const a of accesses) {
461
+ if (a.off < 0 || a.off % a.elemSize !== 0) {
462
+ aligned = false;
463
+ }
464
+ const prev = byOff.get(a.off);
465
+ const m = prev ?? { off: a.off, elemSize: a.elemSize, signed: a.signed, bounds: [] };
466
+ if (prev !== undefined && prev.elemSize !== a.elemSize) {
467
+ consistent = false;
468
+ }
469
+ if (a.isLoad) {
470
+ const seen = loadSigned.get(a.off);
471
+ if (seen !== undefined && seen !== a.signed) {
472
+ consistent = false;
473
+ }
474
+ loadSigned.set(a.off, a.signed);
475
+ m.signed = a.signed;
476
+ }
477
+ const n = bound(a.index);
478
+ if (n !== null) {
479
+ m.bounds.push(n);
480
+ }
481
+ byOff.set(a.off, m);
482
+ }
483
+ // `accesses` is non-empty (accessesByBase keys no base it recorded nothing for) and every
484
+ // access writes a `byOff` entry, so the assertion below is the loop's postcondition.
485
+ const members = [...byOff.values()].sort((x, y) => x.off - y.off) as [Member, ...Member[]];
486
+ // Natural C alignment must seat every member at its observed offset. An INTERIOR member's run
487
+ // reaches exactly the offset of the member that follows it, so the only pad this layout ever
488
+ // needs is the leading one and the only way to mislay a member is a span its element size does
489
+ // not divide.
490
+ let seatable = true;
491
+ let inBounds = true;
492
+ for (const [i, m] of members.entries()) {
493
+ const next = members[i + 1];
494
+ if (next === undefined) {
495
+ break;
496
+ }
497
+ const span = next.off - m.off;
498
+ if (span % m.elemSize !== 0) {
499
+ seatable = false;
500
+ } else if (m.bounds.some((n) => n > span / m.elemSize)) {
501
+ inBounds = false;
502
+ }
503
+ }
504
+ out.push({
505
+ base,
506
+ accesses,
507
+ members,
508
+ c: {
509
+ untypedBase: base.type.kind === 'unknown',
510
+ unclaimedAccesses: accesses.every(
511
+ (a) => a.op.attrs.fieldOff === undefined && (a.addr === null || a.addr.type.kind === 'unknown'),
512
+ ),
513
+ namedGlobalBase: global.has(base) || defs.get(base)?.opcode === 'gaddr',
514
+ hasNonZeroMember: members.some((m) => m.off > 0),
515
+ offsetsInRange: members.every((m) => m.off < MAX_MEMBER_OFFSET),
516
+ noDirectAccess,
517
+ noEscape,
518
+ aligned,
519
+ consistent,
520
+ seatable,
521
+ trailingBounded: members[members.length - 1].bounds.length > 0,
522
+ inBounds,
523
+ },
524
+ });
525
+ }
526
+ return out;
527
+ }
528
+
529
+ /** The declared field list for an admitted base: a leading `u8[N]` pad when the first member does
530
+ * not start at 0, then one array member per offset — interior counts forced by the member that
531
+ * follows, the trailing count read off its walking loop. */
532
+ function fieldsOf(members: Member[]): StructField[] {
533
+ const fields: StructField[] = [];
534
+ if (members[0].off > 0) {
535
+ fields.push({ off: 0, type: T.array(T.u(8), members[0].off), name: '_pad0' });
536
+ }
537
+ for (const [i, m] of members.entries()) {
538
+ const next = members[i + 1];
539
+ // `trailing-unbounded` is what makes the trailing member's `bounds` non-empty; the `1` is what
540
+ // an ABLATION of that gate declares rather than a count of nothing. Two loops walking one
541
+ // member to two different extents are not a contradiction the way two WIDTHS at one offset are
542
+ // (`member-conflict`): the member has to hold both, so the larger is the smallest count that
543
+ // seats them.
544
+ const count = next === undefined ? Math.max(1, ...m.bounds) : (next.off - m.off) / m.elemSize;
545
+ fields.push({
546
+ off: m.off,
547
+ type: T.array(scalarTypeForAccess(m.elemSize, m.signed), count),
548
+ name: `field_${m.off}`,
549
+ });
550
+ }
551
+ return fields;
552
+ }
553
+
554
+ /** Recover a struct whose MEMBER is an array from the variable-index accesses taken off a constant
555
+ * byte offset of one base. Runs after array legalization and array-of-struct recovery, before
556
+ * struct-pointer recovery. Returns the number of bases recovered.
557
+ *
558
+ * Bases sharing a layout share one declared struct — the two ends of a copy loop are one type,
559
+ * and two identical declarations under different names would be an artifact of the walk order. */
560
+ export function recognizeMemberArrays(
561
+ fn: Fn,
562
+ gates: readonly Gate<MemberArrayCandidate>[] = MEMBER_ARRAY_GATES,
563
+ ): number {
564
+ const admitted = memberArrayCandidates(fn).filter((g) => firstRejection(gates, g.c) === null);
565
+ if (admitted.length === 0) {
566
+ return 0;
567
+ }
568
+ let next = firstFreeStructIndex(fn);
569
+ const byLayout = new Map<string, IrType>();
570
+ for (const g of admitted) {
571
+ const fields = fieldsOf(g.members);
572
+ const key = JSON.stringify(fields);
573
+ let type = byLayout.get(key);
574
+ if (type === undefined) {
575
+ type = T.struct(`Struct${next++}`, fields);
576
+ byLayout.set(key, type);
577
+ }
578
+ g.base.type = T.ptr(type);
579
+ for (const a of g.accesses) {
580
+ // An ADDRESS operand, so no slot home moves with it (ir/core.ts `SlotHomes`): a home is
581
+ // stamped on the value a `str [sp,#k]` STORED, and what is swapped in here is the access's
582
+ // base pointer, which never reaches the builder under a slot key.
583
+ a.op.operands[0] = g.base;
584
+ a.op.attrs.memberOff = a.off;
585
+ if (!a.isLoad) {
586
+ // an astore carries no signedness of its own — the member's decides how the index node
587
+ // types, so the store spells through the same declaration the load does
588
+ a.op.attrs.signed = g.members.find((m) => m.off === a.off)!.signed;
589
+ }
590
+ }
591
+ }
592
+ // the now-dead `add`/`const` member addresses are reaped by the DRIVER's dce
593
+ return admitted.length;
594
+ }