@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
@@ -4,11 +4,11 @@
4
4
  // 1. SSA destruction WITH COALESCING — a merge block-argument that already carries a
5
5
  // variable's value on one path is coalesced to that variable, so only the
6
6
  // non-identity paths emit an assignment (reproducing agbcc's register allocation:
7
+ // the clamp0 diamond becomes `if (x < 0) x = 0; return x;` rather than a temp copy).
7
8
  // NOTE the coupled INVERSE: l3/regspell.ts re-derives the UN-coalesced copy-carrying
8
9
  // spelling as a ranked candidate — its R1 template matches THIS pass's diamond output
9
10
  // shape, so a change to coalescing here can silently stop that lever firing (the
10
11
  // matching-suite regspell gate is what makes the coupling loud).
11
- // the clamp0 diamond becomes `if (x < 0) x = 0; return x;` rather than a temp copy).
12
12
  // Coalescing is INTERFERENCE-CHECKED against per-block value liveness, and
13
13
  // inline-at-use rendering carries an effect-ordering model: a call/load that cannot
14
14
  // soundly render at its use is MATERIALIZED as a named temp at its own program
@@ -32,32 +32,63 @@
32
32
  // Scope: reducible single-latch natural loops — GUARDED self-loop `while` (the guard-fusion
33
33
  // un-rotation), UNGUARDED self-loop `do-while` (single block, header === latch), test-at-top
34
34
  // `while`, bottom-test `do-while`, PROPERLY-nested loops, in-body `break`/early-`return`,
35
- // comparison-tree and jump-table `switch`. Still DECLINED (loud StructureError, never wrong
36
- // code): multi-latch headers, irreducible/overlapping loops, conditional `continue`, a `break`
37
- // whose exit copies would clobber, switch fall-through, and mixed-entry self-loops (a guarded
38
- // header also entered by a plain br).
39
- import { Block, Fn, Op, Value, defOpMap, successorsOf } from '../ir/core';
35
+ // comparison-tree and jump-table `switch`, and a switch arm that FALLS THROUGH into the next one
36
+ // (both regimes see `ArmExit` in switch-recover.ts). Still DECLINED (loud StructureError, never
37
+ // wrong code): multi-latch headers, irreducible/overlapping loops, conditional `continue`, a
38
+ // `break` whose exit copies would clobber, and mixed-entry self-loops (a guarded header also
39
+ // entered by a plain br). Fall-through carries two REFUSALS of its own rather than a decline: a
40
+ // target language whose `case` cannot fall through (`spellSwitchFallthrough` false) sends Regime A
41
+ // back to if-recovery, and arms that do not linearize into one chain — two arms falling into the
42
+ // same sibling, or a fall into the `default:` — refuse in `chainArms`, which answers null.
43
+ import { Block, Fn, Op, Successor, Value, defOpMap, dominators, mergeClasses, successorsOf } from '../ir/core';
44
+ import { CAST_WIDTHS, EFFECTFUL_OPS } from '../ir/opcodes';
40
45
  import { type IrType, T, scalarTypeForAccess, typeEquals } from '../ir/types';
41
- import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren, negateCond } from '../l3/ast';
42
- import { exprCType, ptrElemBytes } from '../l3/typing';
46
+ import {
47
+ BinOp,
48
+ Expr,
49
+ SFn,
50
+ Stmt,
51
+ SwitchCase,
52
+ exprChildren,
53
+ exprHasEffect,
54
+ gapReasonFor,
55
+ mapExprChildren,
56
+ mapStmtExprs,
57
+ negateCond,
58
+ stmtChildren,
59
+ walkExprs,
60
+ } from '../l3/ast';
61
+ import { type Gate, firstRejection } from '../l3/gates';
62
+ import { exprCType, provablyNonNegative, ptrElemBytes, renderedIntSignedness } from '../l3/typing';
43
63
  import { returnType } from '../raise/recover';
44
64
  import { collectStructs } from '../raise/structs';
45
65
  import {
46
66
  type DeclaredField,
47
67
  type SymbolInfo,
48
68
  type SymbolStructField,
49
- arrayInnerExtents,
50
69
  declaredFields,
51
70
  isArrayField,
52
71
  isBitfieldField,
72
+ isPtrField,
53
73
  isScalarCellSize,
54
74
  pointeeFields,
55
75
  scalarCellType,
56
76
  } from '../symbols';
57
77
  import { analyze } from './analysis';
58
- import { makeLoopHazards, updateWriteSet } from './hazards';
59
- import { analyzeLoops, dominators } from './loops';
60
- import { makeSwitchRecovery } from './switch-recover';
78
+ import { makeBitfieldSpelling } from './bitfields';
79
+ import {
80
+ addOffset,
81
+ addrIn,
82
+ bareArrayLead,
83
+ declaredSubscripts,
84
+ elementIndex,
85
+ globalByteBase,
86
+ globalOf,
87
+ } from './globalaccess';
88
+ import { makeLoopHazards, sunkCopyOverDroppedUndef, updateWriteSet } from './hazards';
89
+ import { type NaturalLoop, analyzeLoops } from './loops';
90
+ import { type NameMerge, coalesceNames } from './namecoalesce';
91
+ import { type ArmExit, makeSwitchRecovery } from './switch-recover';
61
92
 
62
93
  // Lower a constant-offset memory access to its lvalue/rvalue Expr. If the base was recovered as a
63
94
  // struct pointer (raise/structs.ts), the byte offset resolves to a NAMED field (`base->field_<off>`);
@@ -71,101 +102,14 @@ import { makeSwitchRecovery } from './switch-recover';
71
102
  // TODAY — carrying the struct name (resolved against SFn.structs) is the same move as width and
72
103
  // the named follow-up; until then no backend pays a tax for the tree cast (Pascal loud-fails
73
104
  // `field` regardless, C++ falls through its leaf hook to the shared C spelling).
74
- // `&gSym`, possibly wearing the value-context integer cast the additive lowering adds
75
- // (`(u32)&gSym` — see lowerDef's addr-intify): both spell the same link-time constant, so the
76
- // fold rules match through the cast and every access that CAN spell a named element still does.
77
- // WIDTH 32 ONLY — a NARROWING cast (`(u8)&gSym`, from a zext/sext lowering) is a different
78
- // VALUE (`addr & 0xFF`), and folding through it would read the named global at a wrong address
79
- // (the adversarial round's probe: `*(u8*)(u8)&gSym` must keep its truncation, never become
80
- // `*(u8*)&gSym` — let alone a confidently-named `gSym.field`).
81
- function addrIn(e: Expr): Extract<Expr, { k: 'addr' }> | null {
82
- if (e.k === 'addr') {
83
- return e;
84
- }
85
- if (e.k === 'cast' && e.to.kind === 'int' && e.to.width === 32 && e.e.k === 'addr') {
86
- return e.e;
87
- }
88
- return null;
89
- }
90
-
91
- // If `e` is a global address `&gSym` (optionally `+ index`), return the global name and the
92
- // element index (byte residual divided by the access width). `&gSym` alone → idx const 0;
93
- // `&gSym + i` → idx `i / width` (exact division only — a non-multiple residual is a mid-element
94
- // access this whole-global spelling can't express, so it declines to null and the caller casts).
95
- function globalOf(e: Expr, width: number): { name: string; idx: Expr } | null {
96
- const top = addrIn(e);
97
- if (top) {
98
- return { name: top.name, idx: { k: 'const', value: 0 } };
99
- }
100
- if (e.k === 'bin' && e.op === '+') {
101
- for (const [side, other] of [
102
- [e.l, e.r],
103
- [e.r, e.l],
104
- ] as const) {
105
- const addrSide = addrIn(side);
106
- if (addrSide) {
107
- const idx = elementIndex(other, width);
108
- return idx ? { name: addrSide.name, idx } : null;
109
- }
110
- }
111
- }
112
- return null;
113
- }
114
-
115
- // THE one gate on the BARE-NAME array-global spelling (`gSym[i]` rather than `((T *)&gSym)[i]`),
116
- // shared by the constant-offset and variable-index access paths so the two cannot disagree.
117
- // Returns the `index` node's `lead` fragment when the bare form is spellable, or null to fall
118
- // through to the always-valid `&gSym` cast form.
119
- //
120
- // Two facts are required, not one. The element WIDTH must match, as it always has. And the RANK
121
- // must be SPELLABLE, because one subscript reaches an element only on a rank-1 array: on `u16
122
- // g[4][0x400]`, `g[i]` is a ROW. Against the project's own header that is usually a type error,
123
- // but where the row address flows into an integer context it is merely a warning and the emitted C
124
- // then addresses a different object than the asm did — silently.
125
- //
126
- // A rank > 1 pins the leading dimensions at 0 and puts the whole flat element index in the last
127
- // subscript (`g[0][i]`) — the same address arithmetic, and the idiom decomp sources themselves use
128
- // when the split is not observable in the asm either (`gBgTilemapBufs[0][…]` in kleod,
129
- // `gNatureStatTable[nature][…]` in pokeemerald). A rank the map states but cannot spell (an unknown
130
- // inner extent) gets no bare form at all; `((T *)&gSym)[i]` is byte-identical and valid under ANY
131
- // declaration, which is why it is the safe fallback. See symbols.ts arrayInnerExtents for why an
132
- // ABSENT rank is read as 1 rather than as unknown.
133
- function bareArrayLead(si: SymbolInfo, width: number): { lead?: number[] } | null {
134
- if (si.shape !== 'array' || si.elemSize !== width) {
135
- return null;
136
- }
137
- const inner = arrayInnerExtents(si);
138
- return inner === null ? null : inner.length === 0 ? {} : { lead: new Array<number>(inner.length).fill(0) };
139
- }
140
105
 
141
- // A BYTE residual read as an ELEMENT index of `elemSize`-wide elements, or null when it is not one
142
- // the residual then addresses mid-element and no whole-element spelling can express it, so the
143
- // caller falls through to the honest cast forms. THE one copy of the rule, indexing the
144
- // `&gSym`-based array spelling: width 1 → the byte residual IS the index; wider → a constant
145
- // residual must divide exactly, and a non-constant one must already be element-scaled
146
- // (`i * elemSize` / `i << log2(elemSize)`), which is exactly what the asm's own index scaling
147
- // produced.
148
- function elementIndex(residual: Expr, elemSize: number): Expr | null {
149
- if (elemSize === 1) {
150
- return residual;
151
- }
152
- if (residual.k === 'const') {
153
- return residual.value % elemSize === 0 ? { k: 'const', value: residual.value / elemSize } : null;
154
- }
155
- if (residual.k === 'bin' && (residual.op === '*' || residual.op === '<<')) {
156
- const factor =
157
- residual.op === '<<'
158
- ? residual.r.k === 'const'
159
- ? 1 << residual.r.value
160
- : 0
161
- : residual.r.k === 'const'
162
- ? residual.r.value
163
- : 0;
164
- if (factor === elemSize) {
165
- return residual.l;
166
- }
167
- }
168
- return null;
106
+ /** "What does this project know about the global named `n`" the union of the project's own
107
+ * symbol map and the array shapes derived from this function's assembly, asked map-first. Only
108
+ * ever probed by name (never iterated or copied), which is what lets the union be a lookup rather
109
+ * than a merged Map. */
110
+ interface SymbolLookup {
111
+ get(name: string): SymbolInfo | undefined;
112
+ has(name: string): boolean;
169
113
  }
170
114
 
171
115
  /** The symbol-map rendering context threaded into memAccess/arrayAccess: shape facts per
@@ -174,6 +118,19 @@ function elementIndex(residual: Expr, elemSize: number): Expr | null {
174
118
  interface SymRenderCtx {
175
119
  info(name: string): SymbolInfo | undefined;
176
120
  noteGlobal(name: string, type: IrType): void;
121
+ /** may {@link ptrMemberElement} spell a whole-element subscript through a pointer member — the
122
+ * `/no-ptr-elem` arm's OFF switch, off the `spellPtrMemberElements` structure option. */
123
+ ptrElements: boolean;
124
+ /** may {@link declaredSubscripts} recover a multidimensional global's declared subscripts out of
125
+ * the byte residual — the `/flat-rank` arm's OFF switch, off the `spellDeclaredSubscripts`
126
+ * structure option. */
127
+ declRank: boolean;
128
+ /** The members a symbol's declaration seats — a struct global's own, or a pointer global's
129
+ * pointee's — MEMOIZED per symbol. `declaredFields` validates every member and returns a fresh
130
+ * sorted copy on every call, and `isPtrValue` asks it for both operands of every binary node
131
+ * lowered, so an uncached lookup is an O(n log n) allocation on a hot path — inside a
132
+ * `structure()` a ranked run repeats once per candidate, 17,856 times on the largest fan. */
133
+ fieldsOf(name: string): DeclaredField[] | null;
177
134
  }
178
135
 
179
136
  // ── interior spelling through a POINTER-shaped global ────────────────────────────────────────
@@ -278,6 +235,161 @@ function memberQualsAllow(f: SymbolStructField, containerConst: boolean | undefi
278
235
  return !(isStore && (f.const || containerConst));
279
236
  }
280
237
 
238
+ /** The map member a `field` node NAMES, or null when it names none — THE one resolver for "what
239
+ * does the declaration say about this member", for rules that must reason about a member's type
240
+ * after the access rules have already spelled it. Both named spellings resolve, through the same
241
+ * shared gate their spelling passed: `gSym.member` off a struct global's {@link declaredFields},
242
+ * `gPtr->member` off the pointee's ({@link pointeeFields}). A synthesized `field_K` — the
243
+ * recovered-struct spelling, which no map declares — resolves to null, and so does any base that
244
+ * is not a map-shaped global, which is what makes every caller refuse rather than guess.
245
+ *
246
+ * The pointee arm reaches only what the MAP lets it: `pointeeAccess` gates every `gPtr->member`
247
+ * spelling on `spellsAccessType(f.signed, …)`, so a pointer field declaring no signedness — which
248
+ * is every one in the corpus's vendored maps — is never named, and a pointer read one indirection
249
+ * down spells `((s32 *)gQ)[1]`. That is a fact about those maps, not about this code: `SymbolMap`
250
+ * is a caller-supplied input, and one field flips it (`signed: true` on a 4-byte pointer member
251
+ * of a pointee yields `(u8 *)gQ->pInner`, cast and all, pinned in pointer-members.test.ts). So
252
+ * the arm is live and tested, and the byte-arithmetic rule that reads this answer is correct for
253
+ * it — where resolving a pointee member to null would reopen the double-scaling hole silently. */
254
+ function declaredMemberOf(x: Expr, sym: SymRenderCtx | undefined): DeclaredField | null {
255
+ if (x.k !== 'field' || x.base.k !== 'var' || sym === undefined) {
256
+ return null;
257
+ }
258
+ return sym.fieldsOf(x.base.name)?.find((f) => f.name === x.name) ?? null;
259
+ }
260
+
261
+ /** The map member a `field` node names when the declaration makes it a POINTER — {@link
262
+ * isPtrField} being the shared two-fact test, so this and the synthesized declaration cannot
263
+ * disagree about what a member is. */
264
+ function ptrMemberDecl(x: Expr, sym: SymRenderCtx | undefined): DeclaredField | null {
265
+ const f = declaredMemberOf(x, sym);
266
+ return f !== null && isPtrField(f) ? f : null;
267
+ }
268
+
269
+ /** A map-declared POINTER MEMBER as the additive lowering renders its VALUE: the bare `gSym.pBuf`,
270
+ * or that member wearing the byte-pointer / u32 cast the arithmetic guard adds. Both denote the
271
+ * same address and add BYTES to it, so both fold here; a cast to any OTHER pointer type is not
272
+ * looked through — it would re-scale everything added after it. */
273
+ function ptrMemberValue(x: Expr, sym: SymRenderCtx): { expr: Expr; field: DeclaredField } | null {
274
+ let bare = x;
275
+ if (x.k === 'cast') {
276
+ const t = x.to;
277
+ const isBytePtr = t.kind === 'ptr' && t.to.kind === 'int' && t.to.width === 8;
278
+ if (!isBytePtr && !(t.kind === 'int' && t.width === 32)) {
279
+ return null;
280
+ }
281
+ bare = x.e;
282
+ }
283
+ const f = ptrMemberDecl(bare, sym);
284
+ return f !== null ? { expr: bare, field: f } : null;
285
+ }
286
+
287
+ /** Decompose an access base into "the VALUE of a map-declared POINTER MEMBER + a constant byte
288
+ * offset + at most ONE variable term" — the same decomposition {@link ptrGlobalBase} makes one
289
+ * indirection up, declining on the same conditions (two variable terms, no such member, a
290
+ * non-`+` operator), because only a single residual can be read as one element index. */
291
+ interface PtrMemberBase {
292
+ value: Expr;
293
+ field: DeclaredField;
294
+ byte: number;
295
+ idx: Expr | null;
296
+ }
297
+ function ptrMemberBase(e: Expr, sym: SymRenderCtx): PtrMemberBase | null {
298
+ const hits: { expr: Expr; field: DeclaredField }[] = [];
299
+ let byte = 0;
300
+ let idx: Expr | null = null;
301
+ let ok = true;
302
+ const visit = (x: Expr): void => {
303
+ if (!ok) {
304
+ return;
305
+ }
306
+ if (x.k === 'bin' && x.op === '+') {
307
+ visit(x.l);
308
+ visit(x.r);
309
+ return;
310
+ }
311
+ const m = hits.length === 0 ? ptrMemberValue(x, sym) : null;
312
+ if (m !== null) {
313
+ hits.push(m);
314
+ return;
315
+ }
316
+ if (x.k === 'const') {
317
+ byte += x.value;
318
+ return;
319
+ }
320
+ if (idx !== null) {
321
+ ok = false;
322
+ return;
323
+ }
324
+ idx = x;
325
+ };
326
+ visit(e);
327
+ return ok && hits.length === 1 ? { value: hits[0].expr, field: hits[0].field, byte, idx } : null;
328
+ }
329
+
330
+ /** `gSym.pBuf[i]` for an access through a POINTER MEMBER's VALUE — the spelling the project's own
331
+ * header makes available, where the byte arithmetic it replaces is the same address written the
332
+ * machine's way. The DECLARATION is what licenses it: `pointeeSize` says how wide an element is,
333
+ * so an access of exactly that width at a whole multiple of it IS the i-th element, and the
334
+ * reinterpret cast the backend adds strides by the same amount under any header.
335
+ *
336
+ * `carried` is an ELEMENT index the access already holds (an `aload`'s own index); everything
337
+ * else — a byte residual inside the base, the instruction's displacement `off` — is converted and
338
+ * summed into the one subscript, which is where the source put it.
339
+ *
340
+ * REFUSALS, and they are of TWO kinds.
341
+ *
342
+ * ADDRESS refusals — no whole-element spelling expresses the address at all: a variable residual
343
+ * that is not element-scaled, and a constant the element width does not divide. Both address
344
+ * MID-ELEMENT, so they fall through to the honest byte forms and must.
345
+ *
346
+ * REACH refusals — the address IS expressible and this rule declines anyway: an access of a
347
+ * DIFFERENT width than the declared element, and a sub-word access whose signedness the declared
348
+ * pointee does not carry. Neither is soundness. `exprCType` types a `field` node `undefined` (it
349
+ * types params and locals), so `derefStrideOk` is false for every base this rule produces and the
350
+ * backend ALWAYS emits the reinterpret cast — `((u16 *)gB.pMap)[i + 157]`, cast included, is what
351
+ * the accepted case spells. `((s32 *)gB.pMap)[i + K]` would be the same address and the same fill
352
+ * as the byte form those two clauses fall back to, and the byte form the width clause produces
353
+ * already spells `((s32 *)…)[157]` itself. What these two clauses encode is a REACH judgement — the
354
+ * map says this pointer addresses an array of THESE, so the source probably wrote a subscript of
355
+ * them — and a declaration-shaped access is where that judgement is most likely right. On a
356
+ * STORE the signedness clause has no premise at all (a store extends nothing), so it withholds
357
+ * the spelling from every `s8 *` / `s16 *` member store; that is left in place deliberately
358
+ * rather than widened, because this rule is not byte-neutral (see `spellPtrMemberElements`) and
359
+ * widening a non-neutral spelling's reach is a separate question a row has to ask.
360
+ *
361
+ * And the whole rule refuses when `/no-ptr-elem` turns it off — the axis, not a preference. */
362
+ function ptrMemberElement(
363
+ baseExpr: Expr,
364
+ carried: Expr | null,
365
+ off: number,
366
+ width: number,
367
+ signed: boolean,
368
+ sym: SymRenderCtx,
369
+ ): Extract<Expr, { k: 'index' }> | null {
370
+ if (!sym.ptrElements) {
371
+ return null;
372
+ }
373
+ const pm = ptrMemberBase(baseExpr, sym);
374
+ if (!pm || pm.field.pointeeSize !== width || (width < 4 && (pm.field.pointeeSigned ?? false) !== signed)) {
375
+ return null;
376
+ }
377
+ const varIdx = pm.idx === null ? null : elementIndex(pm.idx, width); // THE one copy of that rule
378
+ const total = pm.byte + off;
379
+ if ((pm.idx !== null && varIdx === null) || total % width !== 0) {
380
+ return null;
381
+ }
382
+ // Variable terms first, constant last — the `idxVal + off / width` order every other indexed
383
+ // spelling in this file uses, and the one the source's own subscript is written in.
384
+ const terms = [varIdx, carried].filter((t): t is Expr => t !== null);
385
+ const k = total / width;
386
+ if (k !== 0 || terms.length === 0) {
387
+ terms.push({ k: 'const', value: k });
388
+ }
389
+ const idx = terms.reduce((l, r) => ({ k: 'bin', op: '+', l, r }));
390
+ return { k: 'index', base: pm.value, idx, width, signed };
391
+ }
392
+
281
393
  // WHY THERE IS NO INDEXED `gPtr->arr[i]` SPELLING.
282
394
  //
283
395
  // Naming a member is only allowed where it is byte-identical to the cast form it replaces, and
@@ -387,6 +499,12 @@ function memAccess(
387
499
  // named field (`gSym.field` — the source spelling a folded literal can never match); an ARRAY
388
500
  // global indexes its BARE name (`gSym[i]`, see below). Exact field match only (offset AND
389
501
  // width) — anything else falls through to the honest cast forms, never a guessed field.
502
+ // The constant offset this access reached through the instruction's MEMORY OPERAND. The two
503
+ // facts are separate at L2 — `off` is the load/store's own immediate, any addend the address
504
+ // carried is already inside `baseExpr` — and folding them into one subscript below
505
+ // (`idxVal + off / width`) is what makes the two indistinguishable at L3, so the displacement
506
+ // is recorded before the fold destroys it (see the `operandOff` note in l3/ast.ts).
507
+ const fromOperand = off !== 0 ? ({ operandOff: off } as const) : {};
390
508
  if (sym) {
391
509
  const gb = globalConstByte(baseExpr, off);
392
510
  const si = gb ? sym.info(gb.name) : undefined;
@@ -415,6 +533,31 @@ function memAccess(
415
533
  return spelled;
416
534
  }
417
535
  }
536
+ // …and one step further out: an access through a POINTER MEMBER's value is an ELEMENT of the
537
+ // buffer that member points AT (see ptrMemberElement).
538
+ const elem = ptrMemberElement(baseExpr, null, off, width, signed, sym);
539
+ if (elem) {
540
+ return { ...elem, ...fromOperand };
541
+ }
542
+ }
543
+ // …and the MULTIDIMENSIONAL bare-name spelling, which needs the byte terms globalOf's division
544
+ // into elements has already merged: `g[r][i]`, where a term at the declared ROW stride is `r`.
545
+ // Tried before the flat spellings because those cannot express a recovered row at all — and off
546
+ // under `/flat-rank`, which is what puts those flat spellings back in the fan for the differ.
547
+ const gbb = sym?.declRank ? globalByteBase(baseExpr) : null;
548
+ const siMulti = gbb ? sym!.info(gbb.name) : undefined;
549
+ const multi = siMulti ? declaredSubscripts(siMulti, gbb!.residual, width, signed) : null;
550
+ if (multi) {
551
+ sym!.noteGlobal(gbb!.name, T.ptr(T.int(width * 8, siMulti!.elemSigned ?? false)));
552
+ return {
553
+ k: 'index',
554
+ base: { k: 'var', name: gbb!.name },
555
+ idx: addOffset(multi.idx, off / width),
556
+ width,
557
+ signed,
558
+ lead: multi.lead,
559
+ ...fromOperand,
560
+ };
418
561
  }
419
562
  const g = globalOf(baseExpr, width);
420
563
  if (g) {
@@ -428,22 +571,17 @@ function memAccess(
428
571
  if (off === 0 && idxVal.k === 'const' && idxVal.value === 0 && scalarGlobals.has(g.name)) {
429
572
  return { k: 'var', name: g.name };
430
573
  }
431
- const idx: Expr =
432
- off === 0
433
- ? idxVal
434
- : idxVal.k === 'const'
435
- ? { k: 'const', value: idxVal.value + off / width }
436
- : { k: 'bin', op: '+', l: idxVal, r: { k: 'const', value: off / width } };
574
+ const idx = addOffset(idxVal, off / width);
437
575
  // ARRAY-declared global (symbol map): index the bare name — `gSym[i]`, the spelling the
438
576
  // dogfood proved agbcc needs for ROM tables — with the element type registered in the env
439
577
  // so the stride check passes and no cast is added. Element-width match only.
440
578
  const siArr = sym?.info(g.name);
441
- const lead = siArr === undefined ? null : bareArrayLead(siArr, width);
579
+ const lead = siArr === undefined ? null : bareArrayLead(siArr, width, signed);
442
580
  if (lead !== null) {
443
581
  sym!.noteGlobal(g.name, T.ptr(T.int(width * 8, siArr!.elemSigned ?? false)));
444
- return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed, ...lead };
582
+ return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed, ...lead, ...fromOperand };
445
583
  }
446
- return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
584
+ return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed, ...fromOperand };
447
585
  }
448
586
  const bt = base.type;
449
587
  if (bt.kind === 'ptr' && bt.to.kind === 'struct') {
@@ -455,12 +593,14 @@ function memAccess(
455
593
  const ok = rt?.kind === 'ptr' && rt.to.kind === 'struct' && rt.to.name === bt.to.name && baseExpr.k !== 'index';
456
594
  return { k: 'field', base: ok ? baseExpr : { k: 'cast', to: bt, e: baseExpr }, name: `field_${off}` };
457
595
  }
458
- return { k: 'index', base: baseExpr, idx: { k: 'const', value: off / width }, width, signed };
596
+ return { k: 'index', base: baseExpr, idx: { k: 'const', value: off / width }, width, signed, ...fromOperand };
459
597
  }
460
598
 
461
- // A variable-index array access `base[index]`, or `base[index].field_K` when a `fieldOff` marks an
462
- // array-of-STRUCT element (raise/struct-arrays.ts). The `.field` on an array element prints
463
- // with `.` (the printer decides dot-vs-arrow from the base being an `index` node).
599
+ // A variable-index array access `base[index]`; `base[index].field_K` when a `fieldOff` marks an
600
+ // array-of-STRUCT element (raise/struct-arrays.ts); `base->field_K[index]` when a `memberOff` marks
601
+ // an array MEMBER of a struct (raise/memberarrays.ts). The `.field` on an array element prints
602
+ // with `.` (the printer decides dot-vs-arrow from the base being an `index` node); a member array's
603
+ // own `field` base is a pointer, so it prints with `->`.
464
604
  //
465
605
  // Scalar path: a width-carrying `index` node, no cast — the backend legalizes (see memAccess).
466
606
  // Struct-array path: like memAccess's struct path, the recovered struct pointer type is an L2
@@ -473,18 +613,49 @@ function arrayAccess(
473
613
  baseExpr: Expr,
474
614
  idxExpr: Expr,
475
615
  fieldOff: number | undefined,
616
+ memberOff: number | undefined,
476
617
  elemSize: number,
477
618
  signed: boolean,
478
619
  ctype: (e: Expr) => IrType | undefined,
479
620
  sym?: SymRenderCtx,
480
621
  ): Expr {
622
+ // An indexed access through a POINTER MEMBER's value is an ELEMENT of what it points at, exactly
623
+ // as in memAccess — the index the aload already carries is the subscript, and any byte residual
624
+ // in the base is converted and summed into it. A fieldOff/memberOff access selects an interior
625
+ // of a STRUCT element instead, which this spelling has no place to put, so it is left alone.
626
+ if (sym && fieldOff === undefined && memberOff === undefined) {
627
+ const elem = ptrMemberElement(baseExpr, idxExpr, 0, elemSize, signed, sym);
628
+ if (elem) {
629
+ return elem;
630
+ }
631
+ }
481
632
  // A variable-index access off a global's address indexes the ADDRESS `&gSym` (the cast form
482
633
  // `((T *)&gSym)[i]` — valid for a struct global too, unlike casting the bare value). A
483
- // struct-array-of-globals (fieldOff) through `&gSym` is out of scope fall through.
484
- if (baseExpr.k === 'addr' && fieldOff === undefined) {
634
+ // struct-array-of-globals (fieldOff) or a member array (memberOff) through `&gSym` falls through:
635
+ // this spelling has no place to put the constant offset, so taking it would DROP the member and
636
+ // address the wrong bytes.
637
+ if (baseExpr.k === 'addr' && fieldOff === undefined && memberOff === undefined) {
485
638
  // ARRAY-declared global (symbol map): the bare-name spelling, same rule as memAccess.
486
639
  const si = sym?.info(baseExpr.name);
487
- const lead = si === undefined ? null : bareArrayLead(si, elemSize);
640
+ // NO declared-subscript recovery here, and the reason is the evidence, not the shape. The
641
+ // index reaching this spelling is already in ELEMENTS — the asm scaled the whole sum ONCE, at
642
+ // the end — and that single scale is what both candidate sources reduce to, so the term at the
643
+ // row's stride in elements is no longer evidence that the source named the row. Compiled, with
644
+ // the klonoa checkout's own agbcc command line:
645
+ //
646
+ // u16 g[4][0x400] — a POWER-OF-TWO row stride: `g[a][b]` is `lsl #0xb` + `lsl #0x1` added
647
+ // (the SEPARATE terms memAccess reads), `g[0][(a<<10)+b]` is `lsl #0xa; add; lsl #0x1`.
648
+ // DIFFERENT bytes — so the single-scale shape that arrives here is the FLAT spelling's own
649
+ // codegen, and recovering `g[a][b]` out of it emits a source that does not reproduce the
650
+ // input asm.
651
+ // u32 g[6][9] — a NON-power-of-two row stride: `g[a][b]` and `g[0][a*9+b]` are BYTE-
652
+ // IDENTICAL, because agbcc reassociates `(a*36)+(b*4)` into `((a*9)+b)*4` itself. Nothing
653
+ // referees the choice, which is the same reason `scaledBy` refuses a CONSTANT row term.
654
+ //
655
+ // Both cases say decline: in the first the evidence points the other way, in the second there
656
+ // is none. The recovery therefore lives on the byte residual alone (memAccess), where the two
657
+ // spellings are still distinguishable. Pinned by matching/array-rank-axis.test.ts.
658
+ const lead = si === undefined ? null : bareArrayLead(si, elemSize, signed);
488
659
  if (lead !== null) {
489
660
  sym!.noteGlobal(baseExpr.name, T.ptr(T.int(elemSize * 8, si!.elemSigned ?? false)));
490
661
  return { k: 'index', base: { k: 'var', name: baseExpr.name }, idx: idxExpr, width: elemSize, signed, ...lead };
@@ -492,6 +663,31 @@ function arrayAccess(
492
663
  return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
493
664
  }
494
665
  const bt = base.type;
666
+ // The member-array spelling: the constant offset selects a MEMBER and the index strides inside
667
+ // it. Same cast rule as memAccess's struct path — the recovered struct-pointer type is an L2 fact
668
+ // the AST cannot carry, so a base that does not render as THAT struct pointer is cast here.
669
+ if (memberOff !== undefined) {
670
+ // Both offsets on one access is `b->field_K[i].field_J` — an array of STRUCTS at a member
671
+ // offset, which raise/memberarrays.ts declares as an array of scalars and its `claimed-access`
672
+ // gate refuses. Spelling it through either offset alone addresses the wrong bytes, so the
673
+ // unreachable case declines loudly rather than dropping one.
674
+ if (fieldOff !== undefined) {
675
+ throw new StructureError(
676
+ `an array access carries both a member offset (${memberOff}) and a field offset (${fieldOff})`,
677
+ );
678
+ }
679
+ const structTo = bt.kind === 'ptr' && bt.to.kind === 'struct' ? bt.to : null;
680
+ const rt = ctype(baseExpr);
681
+ const ok = rt?.kind === 'ptr' && rt.to.kind === 'struct' && rt.to.name === structTo?.name && baseExpr.k !== 'index';
682
+ const b = ok || structTo === null ? baseExpr : { k: 'cast' as const, to: T.ptr(structTo), e: baseExpr };
683
+ return {
684
+ k: 'index',
685
+ base: { k: 'field', base: b, name: `field_${memberOff}` },
686
+ idx: idxExpr,
687
+ width: elemSize,
688
+ signed,
689
+ };
690
+ }
495
691
  if (fieldOff !== undefined) {
496
692
  const structTo = bt.kind === 'ptr' && bt.to.kind === 'struct' ? bt.to : null;
497
693
  const rt = ctype(baseExpr);
@@ -551,9 +747,11 @@ const ARITH_TO_BIN: Record<string, BinOp> = {
551
747
  sub: '-',
552
748
  mul: '*',
553
749
  sdiv: '/',
554
- udiv: '/',
750
+ // the UNSIGNED quotient/remainder — the C backend spells them `/`/`%` over an operand it casts
751
+ // unsigned (l3/ast.ts BinOp, backend/cfamily.ts C_SPELLING)
752
+ udiv: '/u',
555
753
  smod: '%',
556
- umod: '%',
754
+ umod: '%u',
557
755
  or: '|',
558
756
  and: '&',
559
757
  xor: '^',
@@ -564,6 +762,10 @@ const ARITH_TO_BIN: Record<string, BinOp> = {
564
762
  logic_or: '||', // short-circuit connectives (raise/shortcircuit.ts)
565
763
  };
566
764
 
765
+ // The operators whose operand order the machine does not fix — candidates for the def-order
766
+ // re-spelling in lowerDef. `&&`/`||` are excluded: short-circuit order IS semantics.
767
+ const COMMUTATIVE_BIN: ReadonlySet<BinOp> = new Set(['+', '*', '&', '|', '^']);
768
+
567
769
  // Recovered info for a self-loop header: its exit block and the per-parameter back-edge
568
770
  // arg it feeds (the value on the header→header edge). The back-edge arg is the "next"
569
771
  // value of the phi; mapping it back to the phi turns the latch test into the while test.
@@ -571,6 +773,21 @@ interface LoopInfo {
571
773
  header: Block;
572
774
  exit: Block;
573
775
  backArgOfParam: Value[]; // index-aligned with header.params
776
+ /** The PURE forwarding block between the guard and the header, when the compiler's own
777
+ * loop-invariant motion parked computations there (`mov r3,#0x80; lsl r3,#24` feeding the
778
+ * latch test). Its defs render inline wherever the loop reads them; the block itself is never
779
+ * structured — the guard's inits come from ITS edge into the header instead. */
780
+ preheader?: Block;
781
+ }
782
+
783
+ // One early-`return` exit out of a loop body, and the blocks of it the loop emits inside its body
784
+ // (`earlyReturnArm` decides which). Per-EDGE rather than flattened into per-loop sets: ownership is
785
+ // decided against `from`, so a second edge into the same target is a separate question. `owned` has
786
+ // one reader, `emitDoWhile` — a `while` hoists no update, so it has no pre-update reads to exempt.
787
+ interface LoopArm {
788
+ from: Block;
789
+ to: Block;
790
+ owned: Set<Block>;
574
791
  }
575
792
 
576
793
  // A test-at-top multi-block `while`. The header is a pure test whose cond_br enters `bodyEntry`
@@ -583,6 +800,82 @@ interface WhileLoopInfo {
583
800
  latch: Block; // the single block with the back-edge to header (its args = the update)
584
801
  forwardPreds: Block[]; // header preds outside the loop body (the entry/init side)
585
802
  body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
803
+ arms: LoopArm[]; // the early-`return` exits out of the body (`earlyReturnArm`)
804
+ }
805
+
806
+ // Opcodes whose NUMBER OF EXECUTIONS is observable. Moving one of these out of a loop changes what
807
+ // the program does — a call that ran per iteration would run once. A `load`/`aload` is deliberately
808
+ // NOT here: it is a pure read, so running it once instead of per-iteration is unobservable as long
809
+ // as it reads the same memory, which is exactly what the existing def→render barrier scan
810
+ // (structure/analysis.ts) already proves before it lets one inline at all.
811
+ const REPEATED_EFFECT = new Set(['call', 'opaque']);
812
+
813
+ /** No evidence about where this copy goes — NOT "written before everything". A destination the
814
+ * predecessor never wrote is an argument passing through, and both orders put those first, so the
815
+ * record answers no question the assembly put to it. Priced where the sort runs. */
816
+ const NO_RECORD = -1;
817
+ /** A MEASURED predecessor that recorded no destination of this edge — it wrote registers, none of
818
+ * them a key this edge copies. Distinct from an UNMEASURED pred, which has no record at all and
819
+ * falls back to the def-position proxy. */
820
+ const NO_WRITTEN_DESTINATIONS: ReadonlyMap<Value, number> = new Map<Value, number>();
821
+
822
+ /** Does the write-order record order some edge's copies differently from the def-position proxy?
823
+ * rank.ts's enumeration gate for `/copy-defpos`: where the two orders agree, the sibling is the
824
+ * same tree and the fan does not grow.
825
+ *
826
+ * A SUPERSET of the real sort, on purpose and in the safe direction — and the direction only
827
+ * holds because rank asks this of the SAME fn it then structures (a `variantGate`, evaluated on
828
+ * the variant's own fully-raised fn). Two gaps remain, both of which only say YES where the sort
829
+ * says nothing: `keepSlot`/`suppressedArgs` drop copies this still counts, and this asks of every
830
+ * edge where `preferDefPosCopyOrder` reorders only the acyclic ones (`copySetIsCyclic` needs the
831
+ * built copy list, which is not available here). So it can answer true for a pair that later
832
+ * collapses — the tree dedup then eats it — but never false for one that does not.
833
+ *
834
+ * ASKED ANY EARLIER THE CLAIM FAILS, in both directions. The answer moves with the SYMBOL MAP
835
+ * (klonoa's `UpdateHUDCollectibleCount` answers false under the map and true without it, off one
836
+ * asm — test/corpus/agbcc-hudcount.s) and with the STAGE, because `raise/latch.ts` rewrites the
837
+ * record this reads (klonoa's `EntityGravityAndFloorCheck`: false after `recoverTypes`, true
838
+ * after `foldEmptyLatches` — a RECORD of a measurement, not a live check: that function is in no
839
+ * corpus row and no fixture here, so the claim cannot be re-run from this repo).
840
+ *
841
+ * A SECOND, HAND-WRITTEN SPELLING of `edgeCopyRecords`' two comparators — including the stability
842
+ * that decides ties — not a call into them, and nothing forces the two to agree. A change to that
843
+ * sort has to be mirrored here BY HAND, or this gate keeps answering about an ordering the pass no
844
+ * longer produces. Unified deliberately not: this is `/copy-defpos`'s variantGate, so it decides
845
+ * which candidates are ENUMERATED and its predicate cannot move without moving rows. */
846
+ export function edgeCopyOrdersDiffer(fn: Fn): boolean {
847
+ const order = fn.writeOrder;
848
+ if (order === undefined) {
849
+ return false;
850
+ }
851
+ const defs = defOpMap(fn);
852
+ for (const pred of fn.blocks) {
853
+ if (!order.writes.has(pred)) {
854
+ continue;
855
+ }
856
+ const rec = order.lastWrite.get(pred) ?? NO_WRITTEN_DESTINATIONS;
857
+ const opAt = new Map(pred.ops.map((o, i) => [o, i] as const));
858
+ for (const op of pred.ops) {
859
+ for (const succ of op.successors) {
860
+ if (succ.args.length < 2) {
861
+ continue;
862
+ }
863
+ const slots = succ.args.map((_, i) => i);
864
+ const proxyPos = (i: number) => {
865
+ const d = defs.get(succ.args[i]);
866
+ return d !== undefined && opAt.has(d) ? opAt.get(d)! : NO_RECORD;
867
+ };
868
+ const byRecord = [...slots].sort(
869
+ (a, b) => (rec.get(succ.block.params[a]) ?? NO_RECORD) - (rec.get(succ.block.params[b]) ?? NO_RECORD),
870
+ );
871
+ const byProxy = [...slots].sort((a, b) => proxyPos(a) - proxyPos(b));
872
+ if (byRecord.some((v, i) => v !== byProxy[i])) {
873
+ return true;
874
+ }
875
+ }
876
+ }
877
+ }
878
+ return false;
586
879
  }
587
880
 
588
881
  // A bottom-tested `do { body } while(cond)`. The header is the body entry (entered before any
@@ -595,6 +888,86 @@ interface DoWhileInfo {
595
888
  exit: Block;
596
889
  forwardPreds: Block[];
597
890
  body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
891
+ arms: LoopArm[];
892
+ }
893
+
894
+ /** One carrier offered to one merge slot, as `FRESH_MERGE_GATES` judges it. */
895
+ export interface FreshMergeCarrier {
896
+ /** every in-edge passes the SAME value, so the merge is a pure alias of the carrier */
897
+ readonly allSame: boolean;
898
+ /** the carrier is an entry parameter, or a merge home this rule itself minted */
899
+ readonly paramRooted: boolean;
900
+ }
901
+
902
+ /** `freshParamMerge`'s admission: may this merge take its OWN home instead of the carrier's name?
903
+ * Nothing here is `sound` — both spellings are ordinary C over the same values, so `rank.ts`
904
+ * enumerates the ON one as `/fresh-merge` and the differ referees it. What keeps them two
905
+ * SPELLINGS rather than two PROGRAMS is `canTakeName`'s carrier width/sign check, which runs on
906
+ * the same carrier one step earlier.
907
+ *
908
+ * WHAT THE SPELLING BUYS: a parameter is live from entry, so its name pins the value to the
909
+ * register the ABI handed it; a fresh local is dead until its first arm, so the compiler may place
910
+ * the copy where the asm has it. Which merges actually needed a register of their own is not
911
+ * recoverable from the C, and the rule is ALL-OR-NOTHING over a function's param-rooted slots. On
912
+ * `max3` (agbcc -O2, scored against its own target) only the SECOND of the two chained merges is
913
+ * load-bearing: re-homing just the first is byte-identical to re-homing neither (score 5), and
914
+ * re-homing just the second is byte-identical to re-homing both (score 0, MATCH). Splitting the
915
+ * choice per slot would be a fan of 2^slots, so the axis offers the whole-function spelling.
916
+ *
917
+ * `param-rooted` is a SCOPE, not a derivation. A chain rooted in an ordinary merge home is left
918
+ * alone, and widening to one is a different, unmeasured axis: 293 of 721 map-less corpus rows
919
+ * carry at least one conditional merge slot (925 slots), of which the param rooting admits 109 —
920
+ * `LoadBGTilemapData` is one of the other 184, which is why the axis has no reach there — a RECORD
921
+ * of a measurement, not a live check: that function is in no corpus row and no fixture here (its
922
+ * attribution evidence is docs/lbg-attribution.md), so it cannot be re-run from this repo.
923
+ *
924
+ * The rooting is over carrier VALUES, and two shapes carry a parameter's value past it under
925
+ * another name, both reach-only: a redundant phi keeps the parameter's own name (`redundant-phi`)
926
+ * and its result is then an ordinary carrier, which fires on 0 of 721 lifted corpus rows; and the
927
+ * minted-home set records only merges that ended up FRESH, so a merge that refuses a parameter and
928
+ * then adopts an ordinary local is not recorded — 336 firings over 6 rows. */
929
+ export const FRESH_MERGE_GATES: readonly Gate<FreshMergeCarrier>[] = [
930
+ {
931
+ id: 'redundant-phi',
932
+ why: 'a merge every edge feeds the same value overwrites nothing, so its own home buys a copy',
933
+ sound: false,
934
+ guardedBy: 'fresh-merge.test.ts: a redundant phi over one parameter keeps the parameter',
935
+ rejects: (c) => c.allSame,
936
+ },
937
+ {
938
+ id: 'param-rooted',
939
+ why: "the rule's scope — a chain rooted in an ordinary merge home is a separate, unmeasured axis",
940
+ sound: false,
941
+ guardedBy: 'fresh-merge.test.ts: a merge over ordinary locals is untouched',
942
+ rejects: (c) => !c.paramRooted,
943
+ },
944
+ ];
945
+
946
+ /** Whether any merge slot could reach `FRESH_MERGE_GATES` at all: an in-edge argument list that
947
+ * differs and includes an entry parameter — what `rank.ts` gates enumeration of `/fresh-merge` on.
948
+ * An OVER-approximation, which is the safe direction for a gate that only decides whether to
949
+ * enumerate: the walk can still refuse
950
+ * the carrier (`carriesPreUpdate`, `canTakeName`), and a slot whose carrier is a home this rule
951
+ * mints is not visible here at all. */
952
+ export function hasParamRootedMerge(fn: Fn): boolean {
953
+ const entryParams = new Set<Value>(fn.blocks[0].params);
954
+ return fn.blocks.slice(1).some((b) =>
955
+ b.params.some((_, i) => {
956
+ const args = fn.blocks.flatMap((pr) =>
957
+ pr.ops[pr.ops.length - 1].successors.filter((sx) => sx.block === b).map((sx) => sx.args[i]),
958
+ );
959
+ return args.some((a) => a !== args[0]) && args.some((a) => entryParams.has(a));
960
+ }),
961
+ );
962
+ }
963
+
964
+ /** True when the merge takes its own home. The table is a parameter so a test can drop one gate and
965
+ * re-run the real pass (`StructureHooks.freshMergeGates`). */
966
+ function reHomesParamMerge(
967
+ c: FreshMergeCarrier,
968
+ gates: readonly Gate<FreshMergeCarrier>[] = FRESH_MERGE_GATES,
969
+ ): boolean {
970
+ return firstRejection(gates, c) === null;
598
971
  }
599
972
 
600
973
  // Structuring levers, threaded as DATA so a new one is a field here + its consumer, not a new
@@ -602,23 +975,119 @@ interface DoWhileInfo {
602
975
  // returnsVoid — from the function's own prototype (suppress phantom r0 return);
603
976
  // coalesceLoopInit — keep the induction var in its arg register;
604
977
  // preserveDivergentBranchSense — reproduce source branch direction on divergent ifs;
605
- // orderArgCopiesByComputation — order edge copies by computation order in the predecessor.
978
+ // orderArgCopiesByWriteOrder — order edge copies by the order the pred WROTE their
979
+ // destinations.
606
980
  // The last three are `compilerBehaviors` (target.ts) — this pass stays target-AGNOSTIC: it reads
607
981
  // booleans, never a compiler name.
982
+ //
983
+ // WHAT A FIELD DOC BELOW HOLDS, narrowly: what the option MEANS to `structure()`, and the suffix of
984
+ // the axis that enumerates it. An AXIS's rationale, and any figure pricing its marginal value, live
985
+ // ONCE at its `STRUCTURING_AXES` entry in rank.ts — restated here the two copies rot separately,
986
+ // and only the rank.ts one sits next to the enumeration that could refute it. Figures pricing a
987
+ // DEFAULT this pass owns (the edge-copy ordering, `spellDeclaredSubscripts`) do belong here.
608
988
  export interface StructureOptions {
609
989
  returnsVoid?: boolean;
610
990
  coalesceLoopInit?: boolean;
611
991
  preserveDivergentBranchSense?: boolean;
612
- orderArgCopiesByComputation?: boolean;
992
+ // Spell a JOINED two-armed if with the negated condition and swapped arms: the asm branched
993
+ // forward to the taken block and fell through to the other, so a compiler that preserves
994
+ // source branch direction saw the FALL-THROUGH arm as `then` — the same layout evidence the
995
+ // divergent case reads (preserveDivergentBranchSense), which post-dominance hides here because
996
+ // both arms reconverge. Defaults to preserveDivergentBranchSense rather than to a constant, so a
997
+ // target that opts out of the divergent claim opts out of this one; target.ts says how.
998
+ //
999
+ // This is the ZERO POINT of rank.ts's `/flip-join` axis, not a per-compiler fact that closes
1000
+ // the question — docs/level-tower.md wants a default only where the mapping is a FUNCTION, and
1001
+ // benchmark rows still reach their winning spelling through the axis rather than through this
1002
+ // default. Read it forward only: it says which sense to emit ABSENT evidence of an inversion,
1003
+ // never that the asm's layout WAS the source's sense. What agbcc contributes is the refusals —
1004
+ // its gcc Makefile SRCS compiles neither sched.c nor reorg.c and toplev.c never sets
1005
+ // flag_schedule_insns, and gcse.c runs one_code_hoisting_pass only `if (optimize_size)`, which
1006
+ // toplev.c sets for -Os alone — so no scheduler and no hoister moves an arm's body across the
1007
+ // branch after stmt.c laid the arms out in source order.
1008
+ //
1009
+ // Three mechanisms DO invert the sense, and each is per-SITE where this lever is per-function,
1010
+ // so no value here is right in every `if` of a function that holds several: a short-circuit
1011
+ // fold picks which successor is `taken` from the asm's branch polarity, which on Thumb the
1012
+ // branch RANGE decides (raise/shortcircuit.ts); a relay past a branch's reach inverts to jump
1013
+ // around the long form; and a rotated loop's zero-trip guard is an `if` no source wrote at all
1014
+ // (`synthetic:fib`, `for(i=0;i<n;i++)`, emits `if (0 >= a0) … else do{…}while`), so there no
1015
+ // spelling is the faithful one and only the differ can choose.
1016
+ negateJoinedBranchSense?: boolean;
1017
+ orderArgCopiesByWriteOrder?: boolean;
1018
+ /** Order a measured edge's ACYCLIC copy set by the def-position proxy instead — the
1019
+ * `/copy-defpos` ranked sibling of the write-order spelling (rank.ts). A CYCLIC set keeps the
1020
+ * record either way: there an instruction names the compiler's temp, and no arm of the fan may
1021
+ * contradict it (`copySetIsCyclic`). Not a target field and deliberately not one: which order a
1022
+ * compiler laid out is two-sided inside one compiler, so the differ referees it per row. Inert
1023
+ * on an unmeasured pred, where the proxy is already what runs. */
1024
+ preferDefPosCopyOrder?: boolean;
613
1025
  // Comparison-tree switch recovery: treat an `x != K` test as a case (the EQUAL side is a case
614
1026
  // body). GCC freely uses `!=`; IDO prefers `==`/`<`. A per-compiler DATA lever, not an `arch ==`
615
1027
  // branch — default true (permissive; the decline path keeps it sound either way).
616
1028
  switchAllowsNeqCase?: boolean;
1029
+ // Comparison-tree switch recovery: treat a relational test whose BRANCH admits exactly one
1030
+ // scrutinee value as that case rather than as navigation. A per-compiler DATA lever declared in
1031
+ // TargetDescription.compilerBehaviors — a compiler opts in on evidence that its dispatch jumps
1032
+ // straight to a bounded subtree's body. Default false: absent, every relational edge navigates.
1033
+ switchAllowsBoundCase?: boolean;
1034
+ // Comparison-tree switch recovery: emit the case arms in the order the ASSEMBLY lays their
1035
+ // bodies out, rather than sorted by ascending case value. A per-compiler DATA lever declared in
1036
+ // TargetDescription.compilerBehaviors — a compiler opts in on evidence that it neither reorders
1037
+ // basic blocks nor schedules across them, so the layout it produced IS the order the source
1038
+ // wrote. Default false: absent, the arms keep the ascending spelling.
1039
+ switchArmsFollowLayout?: boolean;
1040
+ // Does the TARGET LANGUAGE spell a `switch` arm that runs on into the next one? Set from the
1041
+ // caller's LanguageBackend (`spellsSwitchFallthrough`), not from the compiler target: it is a
1042
+ // property of what the emitted source may say, and the only reason the structurer needs it is
1043
+ // that a fall-through switch has a SECOND, behaviourally identical recovery. Regime A declines
1044
+ // to if-recovery when it is false; Regime B, having no fallback, fails loud. Default true.
1045
+ spellSwitchFallthrough?: boolean;
1046
+ // Which way this compiler hands out frame slots against DECLARATION RANK: `ascending` = the
1047
+ // earlier-declared spilled local takes the LOWER `[sp,#k]`. A per-compiler DATA lever declared
1048
+ // in TargetDescription.compilerBehaviors, carried to the backend on `SFn.slotOrder` and applied
1049
+ // by `l3/slotorder.ts` at emit time. ABSENT means the ordering refuses — there is no default
1050
+ // direction, because a wrong one reorders declarations for no reason.
1051
+ //
1052
+ // TWO STATES, NOT THREE. `TargetDescription` spells a third, `'unknown'`, and it earns its place
1053
+ // there: it separates a direction that was measured and deliberately not shipped from one nobody
1054
+ // ever asked about, which is a fact about the target worth writing down. Nobody reads a
1055
+ // STRUCTURER option to learn what was measured, so carrying it here would be a second way to
1056
+ // spell "no" on a public option type. The translation happens once, in `structureOptionsFor`
1057
+ // (target.ts), which is where the target-to-structurer mapping lives.
1058
+ spillSlotOrder?: 'ascending' | 'descending';
1059
+ // Commutative load pairs re-spell in def (evaluation) order — see the swap in lowerDef. Default
1060
+ // true; verified byte-exact on agbcc and IDO. A per-compiler DATA lever declared in
1061
+ // TargetDescription.compilerBehaviors: the first compiler whose scheduler is shown re-ordering
1062
+ // independent loads flips it there, not in a code branch. A per-FUNCTION machine-order fallback
1063
+ // candidate is deliberately deferred until a row demands it.
1064
+ defOrderLoadPairs?: boolean;
617
1065
  // Anchor a constant merge copy at its const op's ORIGINAL position instead of at the CFG edge:
618
1066
  // `movs r9, #0` at entry ahead of a single-armed overwrite emits as a pre-initialization above
619
1067
  // the `if`, not as its else-arm. A differ-refereed candidate axis (rank.ts `/defsite`), never a
620
1068
  // default — see the refusal conditions where it is computed.
621
1069
  anchorConstCopies?: boolean;
1070
+ // WIDEN `anchorConstCopies` to a LOOP HEADER's entry constant — `int s = 0;` hoisted above the
1071
+ // `if` that guards the loop, rather than written on the edge into it. A second placement
1072
+ // decision, so a second axis point (rank.ts `/defsite/loop-entry`) rather than a widening of
1073
+ // the first: on a function carrying both kinds of anchorable const, folding them into one flag
1074
+ // would make "anchor the plain ones, leave the loop's at its edge" — a spelling `/defsite`
1075
+ // emits today — unreachable. Inert unless `anchorConstCopies` is also on.
1076
+ //
1077
+ // AN AXIS RATHER THAN AN EXTENSION OF `l3/initfirst.ts`, whose header opens on the same rewrite
1078
+ // (`if (0 < n) { v = 0; … }` → `v = 0; if (v < n) { … }`) at a fraction of the price: a
1079
+ // re-spelling adds candidates only where it fires, while this multiplies every candidate below
1080
+ // it. The fork is REACH, and it is a hard one. `initfirst` MOVES A STATEMENT, and an edge copy
1081
+ // is not a statement — structuring mints it, choosing between the edge and the const op's own
1082
+ // position, and the IR that holds those positions is gone by the time L3 runs. So the shapes
1083
+ // where the two coincide are initfirst's for free — except that they never do: its guard
1084
+ // re-spelling wants an ELSE-LESS `if` and rewrites the condition to read the hoisted variable
1085
+ // (`if (v < n)`), where anchoring leaves the condition alone, so the two emit different sources.
1086
+ // Measured, not argued: `/initfirst` rides every spelling rank.ts enumerates, so it is scored on
1087
+ // every benchmark row already, and on each row this axis wins its `/initfirst`-only sibling is
1088
+ // either not enumerated at all or not byte-identical to the anchored source — the substitution
1089
+ // reaches none of them. Take the axis only while rows demand that; the price is in rank.ts.
1090
+ anchorLoopEntryConsts?: boolean;
622
1091
  // HARDWARE fact from TargetDescription.capabilities.endianness, threaded by structureOptionsFor:
623
1092
  // the bitfield extract recognizer solves an LSB-first equation, so it only runs on little-endian
624
1093
  // data. The provider already refuses to EMIT bitfield facts for a big-endian ELF; this is the
@@ -627,8 +1096,104 @@ export interface StructureOptions {
627
1096
  // Spell `(x << a) >> b` extracts of a struct global as the map's named bitfield member. On by
628
1097
  // default; rank.ts enumerates the OFF spelling as the `/no-bitfield` axis, because the named
629
1098
  // read recompiles at the DECLARATION's access width — where that diverges from the asm's load
630
- // width the honest shift spelling is the one that matches, and the differ referees.
1099
+ // width the honest shift spelling is the one that matches, and the differ referees. Only the map
1100
+ // carries the names, so with no `symbols` this is normalized to false whatever a caller passes.
631
1101
  spellBitfieldMembers?: boolean;
1102
+ // Spell an element-scaled offset through a map-declared POINTER MEMBER as a whole-element
1103
+ // subscript of it (`gBg.pMap[i + 157]`) rather than as the byte arithmetic it replaces. On by
1104
+ // default; rank.ts enumerates the OFF spelling as the `/no-ptr-elem` axis.
1105
+ //
1106
+ // IT IS AN AXIS AND NOT A DEFAULT BECAUSE IT IS NOT BYTE-NEUTRAL — the bar the block comment
1107
+ // above `spellablePointee` sets for a member spelling. Compiled against agbcc (`-mthumb-interwork -Wimplicit -O2 -fhex-asm
1108
+ // -fprologue-bugfix`), `((u16 *)gB.pMap)[i + K]` and `*(u16 *)((i << 1) + (u8 *)gB.pMap + 2K)`
1109
+ // are the same address and the same instruction COUNT at K = 0, 1 and 157 — and different
1110
+ // objects at all three, differing in which register the `add` targets. Which side matches is
1111
+ // per-function knowledge the asm does not carry, so both are emitted and the differ referees.
1112
+ // Only the map declares a pointee width, so with no `symbols` this is normalized to false.
1113
+ spellPtrMemberElements?: boolean;
1114
+ // Recover a multidimensional array global's DECLARED subscripts (`g[r][i]`) from a byte residual
1115
+ // carrying a term at the declared ROW stride, rather than spelling the whole residual as the
1116
+ // `*(T *)(… + (u32)&g)` cast it replaces. On by default; rank.ts enumerates the OFF spelling as
1117
+ // the `/flat-rank` axis.
1118
+ //
1119
+ // IT IS AN AXIS AND NOT A DEFAULT BECAUSE THE ASM DOES NOT DETERMINE IT, and the evidence that
1120
+ // it does not is the same compile the recovery's own premise rests on, read against the spelling
1121
+ // the recovery DISPLACES rather than against the flat one it refuses. For `u16 g[4][0x400]`:
1122
+ //
1123
+ // agbcc g[a][b] lsl #0xb ; lsl #0x1 ; add ; add | *(u16 *)((a<<11)+(b<<1)+(u32)&g)
1124
+ // md5 f58a694f… | md5 051bf506… DIFFERENT, and the
1125
+ // difference is WHERE THE POOL LOAD SITS — the shift structure the recovery
1126
+ // reads is IDENTICAL in both, so the residual is evidence about the row and
1127
+ // NOT about which of these two spellings wrote it.
1128
+ // kmc / mwcc the same: separate scales both sides, differing only in scheduling.
1129
+ // IDO the two are BYTE-IDENTICAL (md5 2b55b493…) — and IDO also distributes the
1130
+ // FLAT sum into the same separate scales, so on that compiler the recovery's
1131
+ // own premise is false and the flat spelling reaches it too.
1132
+ //
1133
+ // WHICH ROW OF THAT TABLE IS PINNED: the agbcc pair, by
1134
+ // packages/cli/test/matching/array-rank-axis.test.ts, which compiles both spellings through the
1135
+ // klonoa checkout's own template. The other three were measured by hand and nothing re-runs
1136
+ // them, so treat them as the record of a measurement rather than as a live check.
1137
+ //
1138
+ // So both are emitted and the differ referees, exactly as for `/no-ptr-elem`. Only the map
1139
+ // declares a rank, so with no `symbols` this is normalized to false.
1140
+ spellDeclaredSubscripts?: boolean;
1141
+ // Let a read of a named global render at its use across writes that PROVABLY cannot reach it
1142
+ // (a store to a different named global), instead of caching it in a local. Off by default;
1143
+ // rank.ts enumerates the ON spelling as the `/reread-globals` axis — see analysis.ts
1144
+ // AnalyzeOptions for why this is a differ-refereed lever and not a fix.
1145
+ rereadGlobals?: boolean;
1146
+ // Materialize a load that feeds a `cond_br` join arg, so the naming walk can home the join in
1147
+ // it and the identity arm elides to a one-sided in-place `if`. Off by default; rank.ts
1148
+ // enumerates the ON spelling as the `/inplace` axis — see analysis.ts AnalyzeOptions.
1149
+ materializeJoinFeeds?: boolean;
1150
+ // Materialize a pure computed address shared by 2+ memory accesses, and the multi-render loads
1151
+ // through it, reproducing the source's pointer-local + scalar-temp spelling. Off by default;
1152
+ // rank.ts enumerates the ON spelling as the `/addr-home` axis — see analysis.ts AnalyzeOptions.
1153
+ homeSharedAddresses?: boolean;
1154
+ // Materialize a pure value with 2+ distinct consumers, at least one of them inside a loop the
1155
+ // def sits outside — the register the compiler holds across the iterations. Off by default;
1156
+ // rank.ts enumerates the ON spelling as the `/expr-home` axis — see analysis.ts AnalyzeOptions.
1157
+ homeLoopExprs?: boolean;
1158
+ // Materialize a pure value with 2+ consumers standing on a memory read — the register the asm
1159
+ // carried the DERIVED value in, where the read's own home is a register that died at the
1160
+ // computation. Off by default; rank.ts enumerates the ON spelling as the `/derived-home` axis —
1161
+ // see analysis.ts AnalyzeOptions.
1162
+ homeDerivedReads?: boolean;
1163
+ // Materialize a pure value that one join's incoming edges render into the SAME parameter slot
1164
+ // from 2+ places — the value the source computed once above the branch and the copy machinery
1165
+ // sinks into every arm. Off by default; rank.ts enumerates the ON spelling as the `/merge-home`
1166
+ // axis — see analysis.ts AnalyzeOptions.
1167
+ homeMergeFeeds?: boolean;
1168
+ // Emit a memory read as a named temp in ITS OWN block when every place it renders sits in a
1169
+ // block that block strictly dominates. A per-compiler DATA lever (TargetDescription
1170
+ // .compilerBehaviors), not a differ-refereed axis: where the compiler has neither a scheduler
1171
+ // nor a code hoister, the sunk spelling is one it could not have emitted from this asm, so there
1172
+ // is nothing to referee. Absent ⇒ off — the target field carries the evidence a compiler owes,
1173
+ // analysis.ts AnalyzeOptions the refusals.
1174
+ readsStayWhereWritten?: boolean;
1175
+ // Spell unsigned compares unsigned: cast an icmp_u* operand where the rendered operands do not
1176
+ // guarantee it, and reconcile a mixed-claimant declaration to u32 when nothing under the name
1177
+ // needs signed. Off by default: a signed spelling that byte-matched was PROVED non-negative by
1178
+ // the compiler (it emits the unsigned branch from signed compares only then), so which spelling
1179
+ // the source used is genuinely ambiguous at emission — rank.ts enumerates the ON spelling as
1180
+ // the `/uns-cmp` axis and the differ referees.
1181
+ unsignedCompareSpelling?: boolean;
1182
+ // Merge two variables that a merge copy would join, when the values under them never interfere
1183
+ // (structure/namecoalesce.ts). Off by default; rank.ts enumerates the ON spelling as the
1184
+ // `/merge-names` axis. Which variables the compiler's own coalescer shared is not derivable from
1185
+ // the naming, and removing a copy is worth less than it looks — the compiler coalesces most of
1186
+ // them itself. What moves the score is which values share a register, and that splits per
1187
+ // function.
1188
+ coalesceMergeNames?: boolean;
1189
+ // Give a merge whose carrier is a FUNCTION PARAMETER its own local, instead of assigning back
1190
+ // into the parameter's name. Off by default; rank.ts enumerates the ON spelling as the
1191
+ // `/fresh-merge` axis, and `FRESH_MERGE_GATES` holds the admission and the argument for it.
1192
+ //
1193
+ // NOT `materializeJoinFeeds` widened to parameters. That axis reaches its shape by giving the
1194
+ // join's feed a NAME to adopt, and materialization is keyed on the defining `Op` — a parameter
1195
+ // has none, so there is nothing to key.
1196
+ freshParamMerge?: boolean;
632
1197
  // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
633
1198
  // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
634
1199
  // the PROCESS);
@@ -640,21 +1205,269 @@ export interface StructureOptions {
640
1205
  * bare `gSym[i]` form; `shape:'struct'`+layout spells interiors as `gSym.field`. Absent (or
641
1206
  * a symbol not in the map) ⇒ today's usage-inferred behavior, byte-identical. */
642
1207
  symbols?: Map<string, SymbolInfo>;
1208
+ /** ARRAY SHAPES DERIVED FROM THE INPUT ASSEMBLY (raise/globalshape.ts) for globals the project
1209
+ * map does not describe — same `SymbolInfo` shape, same readers, LOWER precedence: `symbols`
1210
+ * wins every name it knows, because a project declaration knows more than an inference off one
1211
+ * function's strides.
1212
+ *
1213
+ * A SEPARATE FIELD rather than a pre-merged map, and the separation is load-bearing twice.
1214
+ * `spellBitfieldMembers` is normalized against `symbols` alone, so a derived shape can never
1215
+ * switch the named-bitfield spelling on for a map-less row (which would silently delete the
1216
+ * `/no-bitfield` axis's decline — see bitfield-members.test.ts). And it keeps the derivation
1217
+ * ATTRIBUTABLE: everything the map does stays keyed on the map. */
1218
+ inferredSymbols?: Map<string, SymbolInfo>;
1219
+ /** THE ORDER HALF of the same derivation (raise/globalshape.ts `orderLicensedGlobals`): the
1220
+ * globals whose address the assembly materialized BEFORE it scaled the index. Not a shape and
1221
+ * not a declaration — nothing is spelled from it here — it is stamped onto every `index` node it
1222
+ * reaches (`Expr.baseOrdered`, l3/ast.ts) for the L3 pass that decides whether a base gets a
1223
+ * HOME.
1224
+ *
1225
+ * A SUPERSET of `inferredSymbols`' names, and its own field for exactly that reason: a struct
1226
+ * element is licensed here and has no shape there. */
1227
+ orderLicensedGlobals?: ReadonlySet<string>;
1228
+ }
1229
+
1230
+ /** The named global an `index` node's base denotes DIRECTLY, or undefined: the bare `&gSym` an
1231
+ * ordinary element access indexes, and the `(struct S *)&gSym` an array-of-struct element indexes
1232
+ * — the reinterpret cast is the base's spelling, not a different base.
1233
+ *
1234
+ * Deliberately not a search. A symbol buried inside a base's arithmetic is a base this licence has
1235
+ * nothing to say about — the order fact is about the address the pool word materialized, and what
1236
+ * a home would bind there is the sum, not the symbol — so it goes unstamped, which costs a
1237
+ * candidate and never a wrong one. The same two shapes are what `l3/basecse.ts` can hoist. */
1238
+ function indexBaseSymbol(e: Expr): string | undefined {
1239
+ if (e.k === 'addr') {
1240
+ return e.name;
1241
+ }
1242
+ return e.k === 'cast' && e.e.k === 'addr' ? e.e.name : undefined;
1243
+ }
1244
+
1245
+ /** Stamp `baseOrdered` on every `index` whose base names an order-licensed global — the ONE place
1246
+ * the L1 order fact enters the L3 tree.
1247
+ *
1248
+ * A post-pass over the finished body rather than a `...fromOrder` at each construction site: the
1249
+ * index node is built across `ptrMemberElement`, `memAccess` and `arrayAccess` at more sites than
1250
+ * `operandOff` itself reaches, the licence is per SYMBOL rather than per site, and a walk is
1251
+ * exhaustive by construction where a new site would silently carry no evidence. */
1252
+ function stampOrderedBases(body: Stmt[], licensed: ReadonlySet<string> | undefined): Stmt[] {
1253
+ if (licensed === undefined || licensed.size === 0) {
1254
+ return body;
1255
+ }
1256
+ const stamp = (e: Expr): Expr => {
1257
+ const mapped = mapExprChildren(e, stamp);
1258
+ if (mapped.k !== 'index') {
1259
+ return mapped;
1260
+ }
1261
+ const sym = indexBaseSymbol(mapped.base);
1262
+ return sym !== undefined && licensed.has(sym) ? { ...mapped, baseOrdered: true as const } : mapped;
1263
+ };
1264
+ return body.map((s) => mapStmtExprs(s, stamp));
1265
+ }
1266
+
1267
+ /** Test-only seams. SEPARATE from `StructureOptions` on purpose: `structureOptionsFor` builds that
1268
+ * one by spreading a target's `compilerBehaviors`, whose fields map 1:1 onto it, so a hook living
1269
+ * there would be settable from a TargetDescription. */
1270
+ export interface StructureHooks {
1271
+ /** `coalesceMergeNames`'s admission rules, so a test can run the pass with one gate DROPPED —
1272
+ * the ablation as a value rather than as a flag compiled into the shipped path. */
1273
+ nameCoalesceGates?: readonly Gate<NameMerge>[];
1274
+ /** `freshParamMerge`'s admission rules, ablatable the same way. */
1275
+ freshMergeGates?: readonly Gate<FreshMergeCarrier>[];
1276
+ }
1277
+
1278
+ /** A CANDIDATE SPELLING MUST NEVER UNLOCK A FUNCTION THE PRIMARY DECLINES. `varName` is not only
1279
+ * how values are spelled — the loop emitters' hazard predicates read it, and several ask "does
1280
+ * this edge copy survive identity elision", which merging two names quietly answers `no`. A pass
1281
+ * that made a hazard invisible would trade a loud decline for a silent wrong answer, so the
1282
+ * lever-less structuring runs first and its refusal stands. That is the whole invariant, rather
1283
+ * than a list of individually patched guards, and it costs one extra structuring — nothing next to
1284
+ * the compile the candidate exists to feed.
1285
+ *
1286
+ * It rests on `structure()` not mutating `fn`, which `structure-purity.test.ts` pins.
1287
+ *
1288
+ * SCOPE: refusals thrown by `structure()` itself. A decline can also come from `structureChecked`'s
1289
+ * boundary contracts, which run OUTSIDE it — `rank.ts` closes that half, where the contracts are. */
1290
+ function assertPrimaryAccepts(fn: Fn, opts: StructureOptions, hooks: StructureHooks): void {
1291
+ structure(
1292
+ fn,
1293
+ {
1294
+ ...opts,
1295
+ coalesceMergeNames: false,
1296
+ freshParamMerge: false,
1297
+ materializeJoinFeeds: false,
1298
+ homeSharedAddresses: false,
1299
+ homeLoopExprs: false,
1300
+ homeDerivedReads: false,
1301
+ homeMergeFeeds: false,
1302
+ anchorConstCopies: false,
1303
+ anchorLoopEntryConsts: false,
1304
+ },
1305
+ hooks,
1306
+ );
643
1307
  }
644
1308
 
645
- export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1309
+ /** What {@link earlyReturnArm} reads beyond its arguments: block dominance and forward
1310
+ * reachability, both of them whole-function facts computed once per `structure()`. */
1311
+ interface EarlyReturnArmDeps {
1312
+ dom: Map<Block, Set<Block>>;
1313
+ reachFrom: (b: Block) => Set<Block>;
1314
+ }
1315
+
1316
+ function isRet(blk: Block): boolean {
1317
+ return blk.ops[blk.ops.length - 1]?.opcode === 'ret';
1318
+ }
1319
+ // An early `return` out of the loop: forward-walking from `to` WITHOUT re-entering the loop `body`,
1320
+ // every path terminates in a `ret`. agbcc/gcc merge every `return` into ONE epilogue block and each
1321
+ // return site just sets the return register and branches there, so a second body exit that lands on
1322
+ // such a chain is an early RETURN, not a break to a live merge — which is what lets two returns
1323
+ // merged through a shared `bx lr` recover as a `while` with an in-body early `return` instead of
1324
+ // declining as "multi-exit".
1325
+ //
1326
+ // The arm is structured AT the edge, so any block in it that a second path also reaches is emitted
1327
+ // twice. A duplicated `return v` is harmless — both copies sit on mutually exclusive paths, so this
1328
+ // is a FIDELITY rule, not a soundness one: a store or call written twice is source no compiler
1329
+ // would have produced from this asm, and the region it drags along is unbounded. A block escapes
1330
+ // that on two counts. `from` dominates `to` and `to` dominates the block, so every path reaching it
1331
+ // runs this edge's predecessor and then this region — and only one edge into `to` can satisfy the
1332
+ // first half, since two predecessors cannot both dominate it. And the region must not be reachable
1333
+ // from the loop's own exit, which dominance does NOT rule out: an arm landing straight on the
1334
+ // post-loop join dominates itself, and claiming it would emit the epilogue on both paths. Testing
1335
+ // `to` covers the whole region — a block dominated by `to` that the exit reached would mean the
1336
+ // exit reached `to`. The shared epilogue an arm branches to still has to be pure.
1337
+ //
1338
+ // Returns the blocks the arm OWNS — its exclusive part, which the loop emits inside its body, ahead
1339
+ // of the update — or null when this is not an early-`return` arm.
1340
+ function earlyReturnArm(
1341
+ { dom, reachFrom }: EarlyReturnArmDeps,
1342
+ from: Block,
1343
+ to: Block,
1344
+ body: Set<Block>,
1345
+ exit: Block,
1346
+ ): Set<Block> | null {
1347
+ const entryOwned = dom.get(to)!.has(from) && to !== exit && !reachFrom(exit).has(to);
1348
+ const owned = new Set<Block>();
1349
+ const seen = new Set<Block>();
1350
+ const stack = [to];
1351
+ while (stack.length) {
1352
+ const bb = stack.pop()!;
1353
+ if (seen.has(bb)) {
1354
+ continue;
1355
+ }
1356
+ seen.add(bb);
1357
+ if (body.has(bb)) {
1358
+ return null;
1359
+ } // re-enters the loop → not an exit
1360
+ if (entryOwned && dom.get(bb)!.has(to)) {
1361
+ owned.add(bb);
1362
+ } else if (bb.ops.some((op) => EFFECTFUL_OPS.has(op.opcode))) {
1363
+ return null;
1364
+ }
1365
+ const t = bb.ops[bb.ops.length - 1];
1366
+ if (t.opcode === 'ret') {
1367
+ continue;
1368
+ }
1369
+ if (t.opcode === 'br' || t.opcode === 'cond_br') {
1370
+ for (const s of t.successors) {
1371
+ stack.push(s.block);
1372
+ }
1373
+ continue;
1374
+ }
1375
+ return null; // switch_br / unknown terminator → decline
1376
+ }
1377
+ return owned;
1378
+ }
1379
+
1380
+ /** The two facts a merge carrier's declared type contributes to `canTakeName` — see the rule's own
1381
+ * comment there for why the signedness half is part of the width at sub-word sizes. */
1382
+ function carrierWidth(t: IrType | undefined): number {
1383
+ return t?.kind === 'int' ? t.width : 32;
1384
+ }
1385
+ function carrierSign(t: IrType | undefined): boolean | undefined {
1386
+ return t?.kind === 'int' && t.width < 32 ? t.signed : undefined;
1387
+ }
1388
+
1389
+ export function structure(fn: Fn, opts: StructureOptions = {}, hooks: StructureHooks = {}): SFn {
646
1390
  const {
647
1391
  returnsVoid = false,
648
1392
  coalesceLoopInit = false,
649
1393
  preserveDivergentBranchSense = true,
650
- orderArgCopiesByComputation = true,
1394
+ negateJoinedBranchSense = preserveDivergentBranchSense,
1395
+ orderArgCopiesByWriteOrder = true,
1396
+ preferDefPosCopyOrder = false,
651
1397
  switchAllowsNeqCase = true,
1398
+ switchAllowsBoundCase = false,
1399
+ switchArmsFollowLayout = false,
1400
+ spellSwitchFallthrough = true,
1401
+ spillSlotOrder,
1402
+ defOrderLoadPairs = true,
652
1403
  anchorConstCopies = false,
1404
+ anchorLoopEntryConsts = false,
653
1405
  littleEndian = true,
654
- spellBitfieldMembers = true,
1406
+ spellBitfieldMembers: bitfieldSpellingWanted = true,
1407
+ spellPtrMemberElements: ptrElementSpellingWanted = true,
1408
+ spellDeclaredSubscripts: declRankSpellingWanted = true,
1409
+ rereadGlobals = false,
1410
+ materializeJoinFeeds = false,
1411
+ homeSharedAddresses = false,
1412
+ homeLoopExprs = false,
1413
+ homeDerivedReads = false,
1414
+ homeMergeFeeds = false,
1415
+ readsStayWhereWritten = false,
1416
+ unsignedCompareSpelling = false,
1417
+ coalesceMergeNames = false,
1418
+ freshParamMerge = false,
655
1419
  onGap = 'strict',
656
- symbols,
1420
+ symbols: mapSymbols,
1421
+ inferredSymbols,
1422
+ orderLicensedGlobals,
657
1423
  } = opts;
1424
+ // THE shape dictionary the rendering context asks, map-first. Built as a lookup rather than a
1425
+ // merged Map because the map is the PROJECT's and is asked by name for a whole project's worth
1426
+ // of symbols — copying it per structuring is work proportional to the project, and a ranked run
1427
+ // structures one function thousands of times (the same argument the `laddr` name minter makes).
1428
+ const symbols: SymbolLookup | undefined =
1429
+ mapSymbols !== undefined || (inferredSymbols !== undefined && inferredSymbols.size > 0)
1430
+ ? {
1431
+ get: (n) => mapSymbols?.get(n) ?? inferredSymbols?.get(n),
1432
+ has: (n) => mapSymbols?.has(n) === true || inferredSymbols?.has(n) === true,
1433
+ }
1434
+ : undefined;
1435
+ // Only the MAP makes the named bitfield spelling available, so with no map this is not a choice.
1436
+ // Normalized once here rather than left to each reader's own `symCtx &&` guard, because rank.ts's
1437
+ // `/no-bitfield` decline rests on both arms structuring the IDENTICAL tree without a map — a
1438
+ // second reader added outside that guard would otherwise delete a candidate silently, and nothing
1439
+ // reports a candidate that was never enumerated (bitfield-members.test.ts).
1440
+ // Against the PROJECT MAP alone, never the derived shapes: only a map carries bitfield members,
1441
+ // and keying this on the union would flip the `/no-bitfield` axis's zero point on a map-less row.
1442
+ const spellBitfieldMembers = mapSymbols !== undefined && bitfieldSpellingWanted;
1443
+ // These levers all change which edge copies elide as identities (extra materialization does
1444
+ // too), which the loop emitters' hazard predicates read — so the invariant above covers each.
1445
+ // A per-compiler DEFAULT is not among them, however much it materializes: the primary IS this
1446
+ // target's defaults, so resetting one would probe a spelling asmlift never emits here.
1447
+ //
1448
+ // THREE OF rank.ts's TEN `STRUCTURING_AXES` ARE DELIBERATE NON-MEMBERS, each for its own reason,
1449
+ // and the list here is the half of the split this side owns:
1450
+ // - `/reread-globals` (rereadGlobals) is an ANALYSIS option, and it only ever RELAXES: it
1451
+ // widens a load's render positions and narrows the write set that bars it, so it removes
1452
+ // materializations rather than minting them. Extra materialization is what this guard is
1453
+ // about (see above), and this axis adds none;
1454
+ // - `/uns-cmp` (unsignedCompareSpelling) writes `varType` and inserts casts at compares. It
1455
+ // touches no name and no copy, so no edge copy changes its elision under it;
1456
+ // - `/copy-defpos` (preferDefPosCopyOrder) REORDERS the copies of one edge and adds or drops
1457
+ // none. rank.ts states the same thing from the axis side, in the terms that matter there: a
1458
+ // reordering cannot rescue a spelling whose OFF sibling failed the boundary contracts.
1459
+ if (
1460
+ coalesceMergeNames ||
1461
+ freshParamMerge ||
1462
+ materializeJoinFeeds ||
1463
+ homeSharedAddresses ||
1464
+ homeLoopExprs ||
1465
+ homeDerivedReads ||
1466
+ homeMergeFeeds ||
1467
+ anchorConstCopies
1468
+ ) {
1469
+ assertPrimaryAccepts(fn, opts, hooks);
1470
+ }
658
1471
  const defs = defOpMap(fn);
659
1472
  const preds = predecessorBlocks(fn);
660
1473
  const ipdom = postDominators(fn);
@@ -664,6 +1477,22 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
664
1477
  const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween } = analyze(
665
1478
  fn,
666
1479
  returnsVoid,
1480
+ {
1481
+ defs,
1482
+ dom,
1483
+ rereadGlobals,
1484
+ materializeJoinFeeds,
1485
+ homeSharedAddresses,
1486
+ homeLoopExprs,
1487
+ homeDerivedReads,
1488
+ homeMergeFeeds,
1489
+ readsStayWhereWritten,
1490
+ // the map's own declaration truth: a volatile object's read may not be duplicated or moved
1491
+ volatileGlobal: (n) => {
1492
+ const si = symbols?.get(n);
1493
+ return si?.volatile === true || (si?.layout ?? []).some((f) => f.volatile === true);
1494
+ },
1495
+ },
667
1496
  );
668
1497
 
669
1498
  // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
@@ -676,13 +1505,80 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
676
1505
  // widths is a union/type-pun, which the downstream struct-layout recovery rejects LOUD
677
1506
  // ("overlapping fields ... unions not modelled") before this classification is consumed — so a
678
1507
  // width collision at off-0 declines honestly rather than reaching a wrong bare-`gSym` emission.
1508
+ // FRAME-LOCAL OBJECT NAMES (laddr). Minted HERE, not in the frontend, because identifiers live
1509
+ // in this layer's namespace: params, locals, every gaddr symbol, and the project's symbol map —
1510
+ // none of which the frontend can see. A frontend-chosen `sp0` silently shadowed a project global
1511
+ // of the same name. `sp<off>` uniquified with underscores until free; one name per offset.
1512
+ // `undef` locals are minted in the same pass off the same `taken` set — they need the same
1513
+ // protection from the symbol map, gaddr symbols and callee names that `laddr` names do.
1514
+ //
1515
+ // The map is CONSULTED, never copied in: `taken` is only probed and added to, so asking the
1516
+ // name-keyed map answers the same question for every name in it. Copying it is work
1517
+ // proportional to the whole PROJECT's symbol count on every structuring, and a ranked run
1518
+ // structures one function thousands of times.
1519
+ const { laddr: laddrName, undef: undefName } = (() => {
1520
+ const taken = new Set<string>();
1521
+ const isTaken = (n: string): boolean => taken.has(n) || symbols?.has(n) === true;
1522
+ for (const b of fn.blocks) {
1523
+ for (const op of b.ops) {
1524
+ if (op.opcode === 'gaddr') {
1525
+ taken.add(op.attrs.sym as string);
1526
+ } else if (op.opcode === 'call') {
1527
+ // a CALLEE's name is in this namespace too: a function really named sp0 would be
1528
+ // shadowed by the minted local, and `sp0()` on a u16 object is a compile error
1529
+ taken.add(op.attrs.target as string);
1530
+ }
1531
+ }
1532
+ }
1533
+ const mint = (base: string): string => {
1534
+ let n = base;
1535
+ while (isTaken(n)) {
1536
+ n += '_';
1537
+ }
1538
+ taken.add(n);
1539
+ return n;
1540
+ };
1541
+ const byOff = new Map<number, string>();
1542
+ const names = new Map<Op, string>();
1543
+ const undefNames = new Map<Op, string>();
1544
+ for (const b of fn.blocks) {
1545
+ for (const op of b.ops) {
1546
+ if (op.opcode === 'laddr') {
1547
+ const off = op.attrs.off as number;
1548
+ let n = byOff.get(off);
1549
+ if (n === undefined) {
1550
+ n = mint(`sp${off}`);
1551
+ byOff.set(off, n);
1552
+ }
1553
+ names.set(op, n);
1554
+ } else if (op.opcode === 'undef') {
1555
+ // Named from the key, like laddr's `sp<off>`: `uninit_sp8` says which frame slot to look
1556
+ // at in the assembly, and it stays put where a running counter would renumber every local
1557
+ // when an unrelated edit changed the order ops are minted in.
1558
+ undefNames.set(op, mint(`uninit_${String(op.attrs.key).replace('@', '')}`));
1559
+ }
1560
+ }
1561
+ }
1562
+ return { laddr: names, undef: undefNames };
1563
+ })();
1564
+
679
1565
  const scalarGlobals = new Set<string>();
680
1566
  {
681
1567
  const offsets = new Map<string, Set<number>>();
682
1568
  const bumpAgg = (sym: string) => offsets.set(sym, new Set([-1])); // -1 marks "variable index"
683
1569
  for (const b of fn.blocks) {
684
1570
  for (const op of b.ops) {
685
- const gaddrSym = (v: Value) => (defs.get(v)?.opcode === 'gaddr' ? (defs.get(v)!.attrs.sym as string) : null);
1571
+ const gaddrSym = (v: Value) => {
1572
+ const dv = defs.get(v);
1573
+ // laddr participates identically: `sp0` is scalar-spelled at off 0 and cast-spelled
1574
+ // anywhere else, exactly as a global of its shape would be — by its MINTED name
1575
+ // (laddrName): the op carries no name attr, that namespace is this layer's
1576
+ return dv?.opcode === 'gaddr'
1577
+ ? (dv.attrs.sym as string)
1578
+ : dv?.opcode === 'laddr'
1579
+ ? (laddrName.get(dv) ?? null)
1580
+ : null;
1581
+ };
686
1582
  if (op.opcode === 'load' || op.opcode === 'store') {
687
1583
  const s = gaddrSym(op.operands[0]);
688
1584
  if (s) {
@@ -716,9 +1612,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
716
1612
  // Declaration-shape OVERRIDE (symbol map): a project-declared array/struct global is an
717
1613
  // AGGREGATE whatever the usage inference saw — a lone off-0 access to `extern u16 tbl[]`
718
1614
  // must still spell through the aggregate/array forms, never the bare scalar `tbl`.
1615
+ // Driven from the FUNCTION's own globals, for the same reason the name minter above consults
1616
+ // the map instead of copying it: a name outside `scalarGlobals` has nothing to override.
719
1617
  if (symbols) {
720
- for (const [n, si] of symbols) {
721
- if (si.shape === 'array' || si.shape === 'struct') {
1618
+ for (const n of [...scalarGlobals]) {
1619
+ const shape = symbols.get(n)?.shape;
1620
+ if (shape === 'array' || shape === 'struct') {
722
1621
  scalarGlobals.delete(n);
723
1622
  }
724
1623
  }
@@ -728,22 +1627,45 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
728
1627
  // Symbol-map rendering context (memAccess/arrayAccess): shape lookups + the env registry for
729
1628
  // array-shaped globals actually referenced (they surface as SFn.globals — typed, undeclared).
730
1629
  const shapedGlobalTypes = new Map<string, IrType>();
1630
+ const declaredFieldCache = new Map<string, DeclaredField[] | null>();
731
1631
  const symCtx: SymRenderCtx | undefined = symbols
732
- ? { info: (n) => symbols.get(n), noteGlobal: (n, t) => shapedGlobalTypes.set(n, t) }
1632
+ ? {
1633
+ info: (n) => symbols.get(n),
1634
+ noteGlobal: (n, t) => shapedGlobalTypes.set(n, t),
1635
+ ptrElements: ptrElementSpellingWanted,
1636
+ declRank: declRankSpellingWanted,
1637
+ fieldsOf: (n) => {
1638
+ const hit = declaredFieldCache.get(n);
1639
+ if (hit !== undefined) {
1640
+ return hit;
1641
+ }
1642
+ const si = symbols.get(n);
1643
+ const fields =
1644
+ si?.shape === 'struct'
1645
+ ? declaredFields(si.layout)
1646
+ : si?.shape === 'pointer'
1647
+ ? pointeeFields(si.pointee)
1648
+ : null;
1649
+ declaredFieldCache.set(n, fields);
1650
+ return fields;
1651
+ },
1652
+ }
733
1653
  : undefined;
734
1654
 
735
- /** A bare `gSym` naming a map-declared POINTER global the VALUE of a pointer cell. Load,
736
- * store and compare of that 4-byte cell are identical for any object-pointer type, so the
737
- * declared pointee never matters to THEM; arithmetic on the loaded value is the opposite case,
738
- * where the pointee's size scales what is added and every stride must therefore be made
1655
+ /** A value the MAP declares a pointer: a bare `gSym` naming a pointer global (the VALUE of a
1656
+ * pointer cell), or a named MEMBER whose declaration is a pointer (`gSym.pBuf`, `gPtr->pBuf`).
1657
+ * Load, store and compare of such a 4-byte cell are identical for any object-pointer type, so
1658
+ * the declared pointee never matters to THEM; arithmetic on the loaded value is the opposite
1659
+ * case, where the pointee's size scales what is added and every stride must therefore be made
739
1660
  * explicit (`(u8 *)gPtr + K`). `ctype` cannot see any of this: it types only params/locals, so
740
- * a pointer global renders `undefined` there. */
741
- const isPtrGlobal = (x: Expr): boolean => x.k === 'var' && symCtx?.info(x.name)?.shape === 'pointer';
1661
+ * both spellings render `undefined` there. */
1662
+ const isPtrValue = (x: Expr): boolean =>
1663
+ (x.k === 'var' && symCtx?.info(x.name)?.shape === 'pointer') || ptrMemberDecl(x, symCtx) !== null;
742
1664
 
743
1665
  /** Operands `-`/`~` cannot take as spelled: a rendered pointer, a bare `&gSym`, a pointer
744
1666
  * global's value. All three are ill-formed C under a unary arithmetic operator — the asm did
745
1667
  * 32-bit integer math on the address, so that is what gets spelled. */
746
- const needsIntSpelling = (x: Expr): boolean => ctype(x)?.kind === 'ptr' || x.k === 'addr' || isPtrGlobal(x);
1668
+ const needsIntSpelling = (x: Expr): boolean => ctype(x)?.kind === 'ptr' || x.k === 'addr' || isPtrValue(x);
747
1669
 
748
1670
  // --- loop discovery (loops.ts): natural loops via dominator back-edges + the nesting forest ---
749
1671
  const forest = analyzeLoops(fn, dom);
@@ -774,11 +1696,46 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
774
1696
  // (entered by a plain br / fall-through) is a bottom-tested loop whose body always runs
775
1697
  // once — a single-block do-while — and is claimed by the structured-loop discovery below
776
1698
  // instead (each header lives in exactly ONE map, so seeding stays single-pass).
777
- if (!(preds.get(b) ?? []).some((pr) => isGuardShapedPred(pr, b, exit))) {
1699
+ const direct = (preds.get(b) ?? []).some((pr) => isGuardShapedPred(pr, b, exit));
1700
+ // Or THROUGH a pure preheader: the compiler's loop-invariant motion parks computations in a
1701
+ // forwarding block between the guard and the header (the mask re-materialization of a busy
1702
+ // poll). The block must be PURE and unmaterialized — its defs then render inline wherever
1703
+ // the loop reads them and nothing about it needs a statement position of its own — with a
1704
+ // single in-edge and a plain `br` into the header, so the guard's branch is still the only
1705
+ // decision. Anything else keeps the unguarded do-while recovery.
1706
+ // The preheader claim is limited to loops whose header→exit edge carries NO args: with
1707
+ // nothing riding the exit, the fusion site's exit-copy obligations (staleExit, the sink) are
1708
+ // vacuous. The condition and zero-trip hazards stay live there, so redirecting a loop from
1709
+ // the do-while path yields the new shape or a LOUD decline — never silent wrong C.
1710
+ const exitCarriesNothing = (successorTo(b, exit)?.args ?? []).length === 0;
1711
+ const preheader =
1712
+ direct || !exitCarriesNothing
1713
+ ? undefined
1714
+ : (preds.get(b) ?? []).find((P) => {
1715
+ const pt = P.ops[P.ops.length - 1];
1716
+ return (
1717
+ P !== b &&
1718
+ pt?.opcode === 'br' &&
1719
+ P.params.length === 0 &&
1720
+ P.ops.every((o) => !EFFECTFUL_OPS.has(o.opcode) && !materialize.has(o)) &&
1721
+ // at least one def the LOOP BODY reads — the loop-invariant-motion shape this claim
1722
+ // exists for. A block that only computes the init args is the do-while path's
1723
+ // ordinary entry chain, and that path's sink machinery handles it better.
1724
+ P.ops.some((o) => o.results.some((r) => (useSitesOf.get(r) ?? []).some((site) => site.blk === b))) &&
1725
+ (preds.get(P) ?? []).length === 1 &&
1726
+ isGuardShapedPred(preds.get(P)![0], P, exit)
1727
+ );
1728
+ });
1729
+ if (!direct && !preheader) {
778
1730
  continue;
779
1731
  }
780
1732
  const back = successorTo(b, b)!;
781
- loops.set(b, { header: b, exit, backArgOfParam: b.params.map((_, i) => back.args[i]) });
1733
+ loops.set(b, {
1734
+ header: b,
1735
+ exit,
1736
+ backArgOfParam: b.params.map((_, i) => back.args[i]),
1737
+ ...(preheader ? { preheader } : {}),
1738
+ });
782
1739
  }
783
1740
 
784
1741
  // --- structured natural loops (test-at-top `while` / bottom-test `do-while`) ---
@@ -787,48 +1744,6 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
787
1744
  // targets) are allowed in-body. The shape then splits on WHERE the exit lives: the HEADER exits
788
1745
  // (pure test-at-top) → `while`; the LATCH exits (body-first) → `do-while`. Anything that fails
789
1746
  // declines to plain if-recovery, which re-enters the header and fails loud via `onStack`.
790
- const isRet = (blk: Block) => blk.ops[blk.ops.length - 1]?.opcode === 'ret';
791
- // A pure "return trampoline" out of the loop: forward-walking from `start` WITHOUT re-entering the
792
- // loop `body`, every path terminates in a `ret` and no block on the way carries an OBSERVABLE side
793
- // effect (store/astore/call/opaque). agbcc/gcc merge every `return` into ONE epilogue block and each
794
- // return site just sets the return register and branches there — so a second body exit that lands on
795
- // such a chain is an early RETURN, not a break to a live merge. Structuring it on more than one exit
796
- // path is sound precisely because it is side-effect-free (a duplicated `return v` is harmless).
797
- // This lets two returns merged through a shared `bx lr` recover as a `while` with an in-body early
798
- // `return` instead of declining as "multi-exit".
799
- const leadsToReturnOnly = (start: Block, body: Set<Block>): boolean => {
800
- const seen = new Set<Block>();
801
- const stack = [start];
802
- while (stack.length) {
803
- const bb = stack.pop()!;
804
- if (seen.has(bb)) {
805
- continue;
806
- }
807
- seen.add(bb);
808
- if (body.has(bb)) {
809
- return false;
810
- } // re-enters the loop → not a pure exit
811
- if (
812
- bb.ops.some(
813
- (op) => op.opcode === 'store' || op.opcode === 'astore' || op.opcode === 'call' || op.opcode === 'opaque',
814
- )
815
- ) {
816
- return false;
817
- }
818
- const t = bb.ops[bb.ops.length - 1];
819
- if (t.opcode === 'ret') {
820
- continue;
821
- }
822
- if (t.opcode === 'br' || t.opcode === 'cond_br') {
823
- for (const s of t.successors) {
824
- stack.push(s.block);
825
- }
826
- continue;
827
- }
828
- return false; // switch_br / unknown terminator → decline
829
- }
830
- return true;
831
- };
832
1747
  const whileLoops = new Map<Block, WhileLoopInfo>();
833
1748
  const doWhileLoops = new Map<Block, DoWhileInfo>();
834
1749
  for (const nl of forest.byHeader.values()) {
@@ -884,14 +1799,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
884
1799
  // whose result also feeds the body would be evaluated twice per iteration). A `load` is fine —
885
1800
  // but NOT a materialized one: its temp assignment renders only via sideEffects(), which a
886
1801
  // condition-only header never emits, so its uses would read an unassigned variable.
887
- const headerPure = !h.ops.some(
888
- (op) =>
889
- op.opcode === 'store' ||
890
- op.opcode === 'astore' ||
891
- op.opcode === 'opaque' ||
892
- op.opcode === 'call' ||
893
- materialize.has(op),
894
- );
1802
+ const headerPure = !h.ops.some((op) => EFFECTFUL_OPS.has(op.opcode) || materialize.has(op));
895
1803
 
896
1804
  let exitFrom: Block,
897
1805
  exit: Block,
@@ -912,14 +1820,25 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
912
1820
  continue; // neither a clean pre-tested nor bottom-tested single-exit shape
913
1821
  }
914
1822
  // Single loop exit (ret-aware): the chosen exit is the ONE real exit; every OTHER edge leaving
915
- // the body must be an early `return` a ret-terminated target OR a pure return-trampoline chain
916
- // (agbcc's merged epilogue; `leadsToReturnOnly`). A second exit that lands on a LIVE non-return
917
- // merge is a genuine `break`/second structured exit decline.
918
- if (
919
- nl.exitEdges.some(
920
- (e) => !(e.from === exitFrom && e.to === exit) && !isRet(e.to) && !leadsToReturnOnly(e.to, nl.body),
921
- )
922
- ) {
1823
+ // the body must be an early `return` (`earlyReturnArm`) or a ret-terminated target. A second exit
1824
+ // that lands on a LIVE non-return merge is a genuine `break`/second structured exit → decline.
1825
+ // The arms are kept: emission needs to know which edges out of the body end an iteration rather
1826
+ // than continue it.
1827
+ const arms: LoopArm[] = [];
1828
+ let singleExit = true;
1829
+ for (const e of nl.exitEdges) {
1830
+ if (e.from === exitFrom && e.to === exit) {
1831
+ continue;
1832
+ }
1833
+ const owned = earlyReturnArm({ dom, reachFrom }, e.from, e.to, nl.body, exit);
1834
+ if (owned) {
1835
+ arms.push({ from: e.from, to: e.to, owned });
1836
+ } else if (!isRet(e.to)) {
1837
+ singleExit = false;
1838
+ break;
1839
+ }
1840
+ }
1841
+ if (!singleExit) {
923
1842
  continue;
924
1843
  }
925
1844
 
@@ -931,9 +1850,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
931
1850
  latch,
932
1851
  forwardPreds: nl.forwardPreds,
933
1852
  body: nl.body,
1853
+ arms,
934
1854
  });
935
1855
  } else {
936
- doWhileLoops.set(h, { header: h, latch, exit, forwardPreds: nl.forwardPreds, body: nl.body });
1856
+ doWhileLoops.set(h, { header: h, latch, exit, forwardPreds: nl.forwardPreds, body: nl.body, arms });
937
1857
  }
938
1858
  }
939
1859
 
@@ -949,12 +1869,27 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
949
1869
  varName.set(p, `a${i}`);
950
1870
  varType.set(`a${i}`, p.type);
951
1871
  });
1872
+ /** The function's own parameters, as VALUES — `FRESH_MERGE_GATES`' `param-rooted` half. A name
1873
+ * test would not do: a loop variable that adopted `a0` carries the name without being the
1874
+ * parameter. */
1875
+ const entryParams = new Set<Value>(entry.params);
1876
+ /** Merge params that took a fresh home because `param-rooted` refused their parameter carrier —
1877
+ * the set's other half, so a further merge carrying one of them is refused too. It records the
1878
+ * merges that ended up FRESH and no others: one that refuses a parameter and then adopts an
1879
+ * ordinary local passes the value on under that local's name (336 firings over 6 corpus rows),
1880
+ * which is the chain-rooted widening rather than this rule. */
1881
+ const paramSeededMerges = new Set<Value>();
952
1882
  const backArgName = new Map<Value, string>();
953
1883
  // The C static type of a rendered expression, over the declared variable types — what decides
954
1884
  // whether a memory access's base may be dereferenced as spelled (memAccess/arrayAccess).
955
- const ctype = (e0: Expr): IrType | undefined => exprCType(e0, (n) => varType.get(n));
1885
+ const vtEnv = (n: string): IrType | undefined => varType.get(n);
1886
+ const ctype = (e0: Expr): IrType | undefined => exprCType(e0, vtEnv);
956
1887
 
957
- /** `&gSym` assigned to a `T *` local: the address of an AGGREGATE is not a pointer to its
1888
+ /** A value assigned into a TEMP THIS PASS DECLARES, spelled so the assignment is legal against
1889
+ * that declaration. Two inhabitants, one argument: the value's type comes from somewhere this
1890
+ * pass does not control, and the temp's comes from here.
1891
+ *
1892
+ * `&gSym` assigned to a `T *` local: the address of an AGGREGATE is not a pointer to its
958
1893
  * element. `&gArr` is `T (*)[n]`, `&gStruct` is `struct S *`, and neither is assignable to
959
1894
  * `T *` — yet the IR's `gaddr` value legitimately has type `T *`, because that is what the asm
960
1895
  * loaded. The bare spelling therefore states a type the project's own header contradicts.
@@ -963,21 +1898,38 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
963
1898
  * computes the right address anyway. That leniency is not something to rely on: the Klonoa
964
1899
  * project's own build template treats these as fatal, so the row's emitted C does not build
965
1900
  * where its author would put it. The cast is the always-valid spelling — the same fallback
966
- * `bareArrayLead` documents for the indexed form — and it is byte-identical (measured on
967
- * kleod:UpdateHUDCounterDisplay: 81 with and without).
1901
+ * `bareArrayLead` documents for the indexed form — and it is byte-identical, so no benchmark row
1902
+ * moves either way and the rule that decides it is pinned in test/deref-typing.test.ts instead.
968
1903
  *
969
1904
  * The test is whether `&gSym`'s rendered type PROVABLY equals the destination's, not whether the
970
- * symbol looks like an aggregate. A shape enumeration got this wrong three ways, each a real
971
- * miss: `shape:'pointer'` declares a pointer cell (`void *gSym`, or `struct Tag *gSym` when the
972
- * pointee has a declarable layout), so `&gSym` is a pointer-to-pointer either way; a `shape:'scalar'`
1905
+ * symbol looks like an aggregate. A SHAPE ENUMERATION MISSES THREE WAYS, each real:
1906
+ * `shape:'pointer'` declares a pointer cell (`void *gSym`, or `struct Tag *gSym` when the pointee
1907
+ * has a declarable layout), so `&gSym` is a pointer-to-pointer either way; a `shape:'scalar'`
973
1908
  * whose width differs from the destination's pointee gives `s32 *` for a `u16 *` slot; and a
974
- * NAME-ONLY symbol is synthesized as `extern u32 gSym;` (declare.ts), which is `u32 *` not the
975
- * `T *` the older comment here claimed. So the default is to CAST, and the cast is omitted only
1909
+ * NAME-ONLY symbol is synthesized as `extern u32 gSym;` (declare.ts), which is `u32 *`. So the
1910
+ * default is to CAST, and the cast is omitted only
976
1911
  * where the declared cell type is known and matches exactly. Byte-identical either way, so the
977
1912
  * cost of casting one time too many is a redundant `(T *)`, never a wrong address. */
978
- const castAggregateAddr = (name: string, value: Expr): Expr => {
1913
+ const intoDeclaredTemp = (name: string, value: Expr): Expr => {
979
1914
  const t = varType.get(name);
980
- if (t?.kind !== 'ptr' || value.k !== 'addr') {
1915
+ if (t === undefined) {
1916
+ return value;
1917
+ }
1918
+ // ── the MAP's pointer values, whose type this side of the program does not own ──────────────
1919
+ // A `gaddr` at least states a type the IR knows. `gSym.pBuf` and a pointer global's own value
1920
+ // state one only the MAP knows, and `ctype` — which types params and locals — reads them as
1921
+ // `undefined`, so the test above cannot see them at all. Assigning one bare declares that the
1922
+ // temp's type and the project's declaration of that pointer are the same type, which nothing
1923
+ // here established: the project's header says `struct Unk_03005284 *` where the recovered temp
1924
+ // says `struct Struct0 *`, and `-Werror` makes the mismatch fatal in the tree the source is
1925
+ // pasted into. The destination's type is the one this pass DID choose, so unlike the
1926
+ // map-declared cells below it can be named exactly rather than defused through `void *`. That
1927
+ // holds for an INTEGER temp too, and there the diagnostic is the mirror one, `assignment makes
1928
+ // integer from pointer without a cast` — same site, same argument, same `(T)` answer.
1929
+ if (isPtrValue(value)) {
1930
+ return { k: 'cast', to: t, e: value };
1931
+ }
1932
+ if (t.kind !== 'ptr' || value.k !== 'addr') {
981
1933
  return value;
982
1934
  }
983
1935
  // The only provably-redundant case: a NON-VOLATILE scalar cell whose DECLARED type is the
@@ -1032,7 +1984,35 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1032
1984
  paramBlock.set(pv, blk);
1033
1985
  }
1034
1986
  }
1987
+ // AND THE NAME MUST BE EXACTLY AS WIDE AS `p`. Every edge into `B` copies its argument into
1988
+ // `name`, which is a C assignment through `name`'s declaration, and the name's type is fixed by
1989
+ // its FIRST claimant — so a width mismatch loses a truncation in one direction or the other:
1990
+ // • name NARROWER than the merged value truncates it. `u8 a0` (raise/paramwidth.ts) adopted by
1991
+ // a merge of `a0` with `0x1234` emits `a0 = 4660`, which agbcc compiles to 52.
1992
+ // • name WIDER than a narrow carrier drops the extension the carrier's declaration WAS. A
1993
+ // narrow block parameter (raise/narrowlocal.ts) has had its one reading extension deleted
1994
+ // against the promise that reading the local re-applies it, so adopting an `s32` name emits
1995
+ // `*out = a0` where the graph says `*out = (s16)a0` — a silent wrong answer, not a worse one.
1996
+ // Register-width carriers are unaffected — a pointer or an `unknown` is a full word, so the two
1997
+ // narrow-typing passes are the only producers either half can see.
1998
+ //
1999
+ // AND AT A SUB-WORD WIDTH, THE SIGNEDNESS IS PART OF THE WIDTH. A narrow declaration is where
2000
+ // the extension went, and `u8` re-applies a DIFFERENT extension than `s8` — same bytes in the
2001
+ // variable, different value at every read. `sa3`'s `sub_80B4654` (the IR is built in
2002
+ // test/narrow-local.test.ts) merges a `zext8` arm with its own `u8` parameter and reads the merge
2003
+ // through `lsls #24 / asrs #24`: the carrier is `s8`, and
2004
+ // letting it adopt the `u8` parameter's name emits `sub_80B4FA8(a0, a1, …)`, which passes 144
2005
+ // where the target passes -112 for every byte with bit 7 set. Width alone said 8 === 8 and
2006
+ // admitted it — a silent wrong answer, not a worse score. At 32 bits the two spellings ARE the
2007
+ // same bytes at a read, which is the mismatch `structure/namecoalesce.ts`'s header names and this
2008
+ // rule deliberately still tolerates.
1035
2009
  const canTakeName = (p: Value, B: Block, name: string, pureAlias = false): boolean => {
2010
+ if (carrierWidth(varType.get(name)) !== carrierWidth(p.type)) {
2011
+ return false;
2012
+ }
2013
+ if (carrierSign(varType.get(name)) !== carrierSign(p.type)) {
2014
+ return false;
2015
+ }
1036
2016
  if (B.params.some((q) => q !== p && varName.get(q) === name)) {
1037
2017
  return false;
1038
2018
  }
@@ -1063,8 +2043,68 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1063
2043
  return false;
1064
2044
  }
1065
2045
  }
2046
+ // AND A VALUE NOBODY NAMED IS STILL A READER OF THIS NAME. The loop above asks which NAMED
2047
+ // values are live at `B`; an unnamed one is not stored anywhere, it is RE-DERIVED at its use
2048
+ // from whatever its operands are called then — so a value live into `B` whose inlined
2049
+ // expression mentions `name` reads the merge's assignment instead of what it was defined from.
2050
+ // `s32 t = a - b; if (a > 0) { a = a + b; } return a + t;` is the whole shape: `t` is inlined,
2051
+ // the merge takes `a0`, and the emitted `return a0 + (a0 - a1)` computes 13 where the asm
2052
+ // computes 10 — agbcc emits exactly that asm, so this is not a generated-IR curiosity. The
2053
+ // walk stops at any value with a name of its own (it reads THAT name) and at a materialized
2054
+ // def (it is assigned at its own position, which the clause above already judges).
2055
+ if (!pureAlias) {
2056
+ const reDerives = (w: Value, seen: Set<Value>): boolean => {
2057
+ if (w === p || seen.has(w)) {
2058
+ return false;
2059
+ }
2060
+ seen.add(w);
2061
+ const nm = varName.get(w);
2062
+ if (nm !== undefined) {
2063
+ return nm === name;
2064
+ }
2065
+ const d = defs.get(w);
2066
+ if (!d || materialize.has(d)) {
2067
+ return false;
2068
+ }
2069
+ return d.operands.some((o) => reDerives(o, seen));
2070
+ };
2071
+ for (const w of lin) {
2072
+ if (reDerives(w, new Set())) {
2073
+ return false;
2074
+ }
2075
+ }
2076
+ }
1066
2077
  return true;
1067
2078
  };
2079
+ // Does the edge `pr -> b` hand `c` over as a loop variable's PRE-update value? True when `c` is a
2080
+ // loop header's own param and the edge leaves the loop from a latch of an emitter that places
2081
+ // the update at the BOTTOM of the body — the self-loop `while` and the `do-while`. `c` there
2082
+ // means the value the variable held at the top of the exiting iteration, so a merge param
2083
+ // sharing its name would read one iteration on, silently. Keeping the names apart is also what
2084
+ // lets those emitters sink the copy into the body; sharing them would make it a self-assignment.
2085
+ //
2086
+ // Four shapes are NOT this, and must keep coalescing:
2087
+ // - the loop is emitted by neither of those two. A test-at-top `while` puts its exit copies in
2088
+ // the sibling arm of the latch's `cond_br`, AHEAD of the update, so the name is still the
2089
+ // top-of-iteration value there (`while (p) { if (p->key == k) return p; p = p->next; }`);
2090
+ // - the edge leaves from the HEADER rather than a latch — same reason;
2091
+ // - the back-edge arg for `c`'s own slot is `c`: that slot is never rewritten;
2092
+ // - `c` is itself a back-edge arg — the compiler already carries the trailing value in a
2093
+ // second loop variable, so the un-rotation substitution reads it under that one's name.
2094
+ // Same exemption `readsClobbered` makes for a `sub`-mapped value.
2095
+ const carriesPreUpdate = (c: Value, pr: Block, b: Block): boolean => {
2096
+ const header = paramBlock.get(c);
2097
+ const nl = header && forest.byHeader.get(header);
2098
+ if (!nl || nl.body.has(b) || !nl.body.has(pr)) {
2099
+ return false;
2100
+ }
2101
+ if (!loops.has(header!) && doWhileLoops.get(header!)?.latch !== pr) {
2102
+ return false;
2103
+ }
2104
+ const back = successorTo(pr, header!);
2105
+ const k = header!.params.indexOf(c);
2106
+ return !!back && k >= 0 && back.args[k] !== c && !back.args.includes(c);
2107
+ };
1068
2108
  // ONE seeding routine for self-loop and structured-loop headers. On a coalesceLoopInit target,
1069
2109
  // keep the induction variable in its entry (forward-edge) value's register — reproducing a
1070
2110
  // compiler that mutates the arg register across the loop instead of copying to a fresh local,
@@ -1090,6 +2130,29 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1090
2130
  }
1091
2131
  }
1092
2132
  }
2133
+ // A MATERIALIZED back-edge arg is this variable's in-place update (`add r4, r4, r0`
2134
+ // mutates the same register the param lives in) — adopt its name so the def assigns the
2135
+ // loop variable directly and the update copy elides. Sound only when every read of the
2136
+ // param sits at-or-before the def in the header (the def's own operands included): the
2137
+ // assignment splits the iteration into old-value-before / new-value-after, and a later
2138
+ // read of the OLD value would silently get the new one — position-granular, because
2139
+ // liveness is block-granular and the param is killed from its own block's liveIn.
2140
+ if (name === undefined) {
2141
+ const ba = backArgs?.[i];
2142
+ const d = ba !== undefined ? defs.get(ba) : undefined;
2143
+ const nm = ba !== undefined ? varName.get(ba) : undefined;
2144
+ if (
2145
+ nm !== undefined &&
2146
+ !exclude.has(nm) &&
2147
+ d !== undefined &&
2148
+ materialize.has(d) &&
2149
+ opBlock.get(d) === header &&
2150
+ (useSitesOf.get(p) ?? []).every((s) => s.blk === header && s.idx <= opIndex.get(d)!) &&
2151
+ canTakeName(p, header, nm)
2152
+ ) {
2153
+ name = nm;
2154
+ }
2155
+ }
1093
2156
  name ??= `v${fresh++}`;
1094
2157
  varName.set(p, name);
1095
2158
  if (!varType.has(name)) {
@@ -1097,6 +2160,15 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1097
2160
  }
1098
2161
  }
1099
2162
  exclude.add(varName.get(p)!);
2163
+ // THE BACK-EDGE ARGUMENT TAKES THE HEADER'S NAME, UNCONDITIONALLY — so if `p` is declared
2164
+ // NARROW, every other reader of that argument reads it through the truncation. This site
2165
+ // does not check that, and deliberately: the promise is a precondition of producing a narrow
2166
+ // block parameter at all, held by `raise/narrowlocal.ts`'s `edge-reader` gate (every in-edge
2167
+ // value is read nowhere but through an extension no wider than the declaration keeps). A
2168
+ // width test HERE would be the wrong shape — it would refuse the loop's own update copy and
2169
+ // silently un-coalesce it. Any future producer of a sub-word block parameter owes the same
2170
+ // gate; without it this line emits `s16 v; do { … v = v + 1; } while (v < 32768);`, which
2171
+ // agbcc compiles to an unconditional branch.
1100
2172
  if (backArgs) {
1101
2173
  backArgName.set(backArgs[i], varName.get(p)!);
1102
2174
  }
@@ -1168,32 +2240,52 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1168
2240
  if (varName.has(p)) {
1169
2241
  return;
1170
2242
  }
1171
- // EVERY in-edge record, not successorTo (which returns only the FIRST record to `b` — a
1172
- // terminator with two edges to the same block would hide the second edge's args here).
1173
- const incoming: Value[] = [];
1174
- for (const pr of new Set(preds.get(b) ?? [])) {
1175
- for (const s of pr.ops[pr.ops.length - 1].successors) {
1176
- if (s.block === b) {
1177
- incoming.push(s.args[i]);
1178
- }
1179
- }
2243
+ const incoming: { v: Value; pr: Block }[] = [];
2244
+ for (const { pred, succ } of inEdgeRecords(preds, b)) {
2245
+ incoming.push({ v: succ.args[i], pr: pred });
1180
2246
  }
1181
2247
  // A redundant phi (every edge passes the SAME value) is a pure alias of it — sharing the
1182
2248
  // name is sound even while the value stays live (they are equal on every path). This
1183
2249
  // waives only the LIVENESS half of canTakeName; the sibling-param check always applies.
1184
- const allSame = incoming.length > 0 && incoming.every((v) => v === incoming[0]);
2250
+ const allSame = incoming.length > 0 && incoming.every((c) => c.v === incoming[0].v);
1185
2251
  // prefer a carrier that already has a name; then a loop var whose update this receives —
1186
2252
  // but only one whose name survives the C3 interference check (else the edge copies into
1187
2253
  // the name would clobber a still-live value).
1188
2254
  let name: string | undefined;
1189
- for (const c of [...incoming.filter((v) => varName.has(v)), ...incoming.filter((v) => backArgName.has(v))]) {
1190
- const nm = varName.get(c) ?? backArgName.get(c)!;
1191
- if (canTakeName(p, b, nm, allSame)) {
1192
- name = nm;
1193
- break;
2255
+ /** Set when `FRESH_MERGE_GATES` refused a carrier here not "the one reason this merge
2256
+ * went unnamed", since the rejections above it `continue` without recording. Over-marking
2257
+ * costs one downstream merge an extra fresh home, a spelling the differ referees. */
2258
+ let refusedParamCarrier = false;
2259
+ for (const c of [
2260
+ ...incoming.filter((c) => varName.has(c.v)),
2261
+ ...incoming.filter((c) => backArgName.has(c.v)),
2262
+ ]) {
2263
+ const nm = varName.get(c.v) ?? backArgName.get(c.v)!;
2264
+ if (carriesPreUpdate(c.v, c.pr, b) || !canTakeName(p, b, nm, allSame)) {
2265
+ continue;
2266
+ }
2267
+ // `freshParamMerge` (the `/fresh-merge` axis): this merge takes its own home rather
2268
+ // than the carrier's name — `FRESH_MERGE_GATES` above holds the admission and the
2269
+ // argument for it. Absent the option nothing changes.
2270
+ if (
2271
+ freshParamMerge &&
2272
+ reHomesParamMerge(
2273
+ { allSame, paramRooted: entryParams.has(c.v) || paramSeededMerges.has(c.v) },
2274
+ hooks.freshMergeGates,
2275
+ )
2276
+ ) {
2277
+ refusedParamCarrier = true;
2278
+ continue;
2279
+ }
2280
+ name = nm;
2281
+ break;
2282
+ }
2283
+ if (name === undefined) {
2284
+ name = `v${fresh++}`;
2285
+ if (refusedParamCarrier) {
2286
+ paramSeededMerges.add(p);
1194
2287
  }
1195
2288
  }
1196
- name ??= `v${fresh++}`;
1197
2289
  varName.set(p, name);
1198
2290
  if (!varType.has(name)) {
1199
2291
  varType.set(name, p.type);
@@ -1203,6 +2295,113 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1203
2295
  }
1204
2296
  }
1205
2297
 
2298
+ // ── copy coalescing over the interference graph (namecoalesce.ts) ────────────────────────────
2299
+ // The walk above adopts a name only BACKWARD along an edge, once, in address order — so a merge
2300
+ // parameter whose arguments were still unnamed took a fresh one and kept it. With every name now
2301
+ // settled, `coalesceNames` asks which two of them a would-be copy joins and whether the values
2302
+ // under them ever interfere. Applied here, before anything reads the names: `anchorConstCopies`
2303
+ // below counts the values under a name, and emission spells them.
2304
+ if (coalesceMergeNames) {
2305
+ const { renames } = coalesceNames(
2306
+ {
2307
+ blocks: fn.blocks,
2308
+ entry,
2309
+ preds,
2310
+ liveIn,
2311
+ opBlock,
2312
+ opIndex,
2313
+ useSitesOf,
2314
+ defs,
2315
+ materialize,
2316
+ varName,
2317
+ varType,
2318
+ loops: [...forest.byHeader.values()].map((nl) => ({ header: nl.header, body: nl.body })),
2319
+ },
2320
+ hooks.nameCoalesceGates,
2321
+ );
2322
+ for (const [v, n] of varName) {
2323
+ const r = renames.get(n);
2324
+ if (r !== undefined) {
2325
+ varName.set(v, r);
2326
+ }
2327
+ }
2328
+ for (const [v, n] of backArgName) {
2329
+ const r = renames.get(n);
2330
+ if (r !== undefined) {
2331
+ backArgName.set(v, r);
2332
+ }
2333
+ }
2334
+ }
2335
+
2336
+ // ── declaration-signedness reconciliation ──────────────────────────────────────────────────
2337
+ // A name's declared type is its FIRST claimant's, and the first claimant of a u32 loop counter
2338
+ // is often an s32-typed sibling (the pre-increment value, a const's copy) — so the counter
2339
+ // declares s32, every compare through it renders signed, and an unsigned icmp silently
2340
+ // compiles to the compare the machine never did (the compare-cast at CMP_TO_BIN patches the
2341
+ // sites it can see, but a later re-spell can substitute the var into a compare that needed no
2342
+ // cast at emission — the initfirst guard swap). The declaration is the honest fix: a name
2343
+ // flips to u32 when SOME value under it is u32-typed and NO value under it carries signed-use
2344
+ // evidence (the transitive input cone of any icmp_s* / sdiv / smod / shr_s). Only int32
2345
+ // declarations reconcile; the flip is byte-invariant everywhere but the unsigned compares it
2346
+ // corrects (+/-/*/&/|/^/<< are sign-blind; the signedness-carrying pairs — `>>`/`>>>` and
2347
+ // `/`/`%` against `/u`/`%u` — say which they are through the backend's operand pin, so no
2348
+ // declaration can change what they render; and SIGNED division is evidence-blocked).
2349
+ if (unsignedCompareSpelling) {
2350
+ // Signed-use evidence is the TRANSITIVE INPUT CONE of every signed op — a claimant can feed
2351
+ // an icmp_slt through an inline `sub` and the flip would still render that compare unsigned
2352
+ // (`v - 5 >= 0`, always true in C), so direct operands are not enough. The cone also crosses
2353
+ // edge arg↔param identities in BOTH directions: a kept-guard's substitution (gsub) renders an
2354
+ // init ARG under the loop variable's name, so a signed guard over the arg is evidence against
2355
+ // the name even though the arg claims it in neither naming map. Over-tainting only blocks
2356
+ // flips: conservative-safe.
2357
+ const SIGNED_USE = new Set(['icmp_slt', 'icmp_sle', 'icmp_sgt', 'icmp_sge', 'sdiv', 'smod', 'shr_s']);
2358
+ // The arg↔param identity is `ir/core.ts`'s `mergeClasses` — a CFG/SSA fact rather than a rule
2359
+ // of this pass, and a hand-rolled copy here is one more thing free to drift from it. The walk
2360
+ // below is transitive, so seeding it with the whole CLASS is the same closure as seeding it
2361
+ // with the direct peers.
2362
+ const edgePeers = mergeClasses(fn);
2363
+ const signedEvidence = new Set<Value>();
2364
+ const work: Value[] = [];
2365
+ for (const b of fn.blocks) {
2366
+ for (const op of b.ops) {
2367
+ if (SIGNED_USE.has(op.opcode)) {
2368
+ work.push(...op.operands);
2369
+ }
2370
+ }
2371
+ }
2372
+ while (work.length) {
2373
+ const v = work.pop()!;
2374
+ if (!signedEvidence.has(v)) {
2375
+ signedEvidence.add(v);
2376
+ const d = defs.get(v);
2377
+ if (d) {
2378
+ work.push(...d.operands);
2379
+ }
2380
+ work.push(...(edgePeers.get(v) ?? []));
2381
+ }
2382
+ }
2383
+ // Params never reconcile: their declarations come from p.type, not varType, so a flip here
2384
+ // would only desync the cast site's view from the emitted declaration — and param signedness
2385
+ // is the sign-pin axis's dimension.
2386
+ const paramNames = new Set(entry.params.map((_, i) => `a${i}`));
2387
+ const claimants = new Map<string, Value[]>();
2388
+ for (const [v, n] of [...varName, ...backArgName]) {
2389
+ (claimants.get(n) ?? claimants.set(n, []).get(n)!).push(v);
2390
+ }
2391
+ for (const [n, vs] of claimants) {
2392
+ const t = varType.get(n);
2393
+ if (paramNames.has(n) || t?.kind !== 'int' || t.width !== 32 || !t.signed) {
2394
+ continue;
2395
+ }
2396
+ if (
2397
+ vs.some((v) => v.type.kind === 'int' && v.type.width === 32 && !v.type.signed) &&
2398
+ vs.every((v) => !signedEvidence.has(v))
2399
+ ) {
2400
+ varType.set(n, T.u(32));
2401
+ }
2402
+ }
2403
+ }
2404
+
1206
2405
  // ── def-site anchoring of constant merge copies (anchorConstCopies) ──────────────────────────
1207
2406
  // An edge copy `v = K` places the constant where the EDGE is, but the asm often materialized K
1208
2407
  // earlier: `movs r9, #0` at entry ahead of a single-armed overwrite, `movs r5, #1` at the top
@@ -1215,7 +2414,15 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1215
2414
  // REFUSAL CONDITIONS — each keeps the edge placement, never producing a different write:
1216
2415
  // - the arg is not an UNNAMED `const` op (only a rematerializable constant carries
1217
2416
  // unambiguous placement evidence; a named value's position is its materialized def's);
1218
- // - the merge is a loop header (loop copies have their own placement discipline);
2417
+ // - the merge is a LOOP HEADER, unless `anchorLoopEntryConsts` is also on and the loop is
2418
+ // entered through ONE preheader with no value living outside the body under the same name.
2419
+ // A loop header's entry arg is the one merge arg whose def sits legitimately far above its
2420
+ // edge — `int s = 0;` ahead of a `for` accumulating into it — and the carried values that
2421
+ // share its name are what the name rule below would refuse it on. Anchoring holds exactly
2422
+ // when the name is written nowhere outside the loop but at the anchored site: the entry
2423
+ // copy runs once, before the body, and every other write to it is a back-edge copy strictly
2424
+ // after. Its own flag because it is a SECOND placement decision, and one boolean covering
2425
+ // both would delete a spelling rather than add one (see the option's doc);
1219
2426
  // - the const's block does not dominate every edge source passing it (the anchored write
1220
2427
  // must precede the edge on every path);
1221
2428
  // - the const's block or any edge source sits inside ANY loop. Block-level dominance does
@@ -1223,11 +2430,31 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1223
2430
  // suppressed edge in iteration 2 with the variable overwritten in between, the /preinit
1224
2431
  // sticky-arm failure class (PR #13) — so in-loop shapes are declined outright;
1225
2432
  // - the merge variable names any OTHER SSA value (a shared name has readers and writers
1226
- // between the def site and the edge that edge placement respects and anchoring would not);
2433
+ // between the def site and the edge that edge placement respects and anchoring would not).
2434
+ // A loop header's carried value is the exception above: its other claimants all live in
2435
+ // the body. `freshParamMerge` WIDENS what this admits — a minted home is sole by
2436
+ // construction — which is the pairing `/fresh-merge` and `/defsite` match `clampu8` on;
1227
2437
  // - another anchored const of the same variable lies on a path from this one to this one's
1228
2438
  // edge (the later write would clobber this arg's value; both stay at their edges instead).
1229
- const anchoredAt = new Map<Op, { name: string; arg: Value }[]>();
2439
+ //
2440
+ // What the list does NOT cover is whether the def site is RENDERED at all; an anchor that lands
2441
+ // in an elided block declines the whole function instead, at the postcondition after the render.
2442
+ /** Where a value is WRITTEN: its block for a param, its def op's block otherwise — the two
2443
+ * halves of "where is this defined" asked as one question. Used by def-site anchoring below to
2444
+ * order a write against an edge, and by Regime-A switch recovery (through `SwitchRecoverDeps`)
2445
+ * to ask whether a hoisted argument is available at the dispatch root; passed there rather than
2446
+ * rebuilt, since `paramBlock` and `opBlock` are this scope's own. */
2447
+ const blockOf = (v: Value): Block | undefined => {
2448
+ const d = defs.get(v);
2449
+ return paramBlock.get(v) ?? (d === undefined ? undefined : opBlock.get(d));
2450
+ };
2451
+ type AnchoredWrite = { name: string; arg: Value };
2452
+ const anchoredAt = new Map<Op, AnchoredWrite[]>();
1230
2453
  const suppressedArgs = new Map<object, Set<number>>();
2454
+ /** Every anchored write `sideEffects` actually rendered. The other half of the postcondition
2455
+ * below: suppressing an edge copy is a PROMISE that the def site emits the write instead, and
2456
+ * only the render can report that the promise was kept. */
2457
+ const anchorsEmitted = new Set<AnchoredWrite>();
1231
2458
  if (anchorConstCopies) {
1232
2459
  const nameCount = new Map<string, number>();
1233
2460
  for (const n of varName.values()) {
@@ -1244,27 +2471,47 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1244
2471
  // conservative "a write in `a` may execute between one in `b` and `b`'s terminator": same
1245
2472
  // block counts (op order refined by the caller where it matters), else CFG reachability
1246
2473
  const mayFollow = (a: Block, b: Block): boolean => a === b || reachFrom(a).has(b);
2474
+ // A loop entered from ONE preheader — what makes "every write to the name outside the anchored
2475
+ // one is a back-edge copy" statable: the premise is over ONE entry edge. CONSERVATIVE: two
2476
+ // entry consts anchored at their own def sites would still order correctly, and this refuses
2477
+ // the shape rather than reasoning about it. The other half of "entered only through its
2478
+ // header" needs no test — `analyzeLoops` builds the body as the backward closure from the
2479
+ // latches with the header already in it (loops.ts), so a body block's predecessors are all in
2480
+ // the body by construction.
2481
+ const singleEntry = (nl: NaturalLoop): boolean => nl.forwardPreds.length === 1;
1247
2482
  for (const M of fn.blocks) {
1248
- if (M === entry || M.params.length === 0 || forest.byHeader.has(M)) {
2483
+ if (M === entry || M.params.length === 0) {
2484
+ continue;
2485
+ }
2486
+ const loop = anchorLoopEntryConsts ? forest.byHeader.get(M) : undefined;
2487
+ if (forest.byHeader.has(M) && (loop === undefined || !singleEntry(loop))) {
1249
2488
  continue;
1250
2489
  }
1251
2490
  M.params.forEach((p, i) => {
1252
2491
  const name = varName.get(p)!;
1253
- if (nameCount.get(name) !== 1) {
2492
+ // the name belongs to this merge alone — or, at a loop header, to this merge and the
2493
+ // body's own carried values, which are written only on the back edge
2494
+ const soleClaimant =
2495
+ nameCount.get(name) === 1 ||
2496
+ (loop !== undefined &&
2497
+ [...varName].every(([v, n]) => {
2498
+ if (n !== name || v === p) {
2499
+ return true;
2500
+ }
2501
+ const b = blockOf(v);
2502
+ return b !== undefined && loop.body.has(b);
2503
+ }));
2504
+ if (!soleClaimant) {
1254
2505
  return;
1255
2506
  }
1256
2507
  // every in-edge record into M, grouped by the SSA value it passes for param i
1257
2508
  const groups = new Map<Value, { rec: { block: Block; args: Value[] }; src: Block }[]>();
1258
- for (const pr of new Set(preds.get(M) ?? [])) {
1259
- for (const s of pr.ops[pr.ops.length - 1].successors) {
1260
- if (s.block === M) {
1261
- const g = groups.get(s.args[i]);
1262
- if (g) {
1263
- g.push({ rec: s, src: pr });
1264
- } else {
1265
- groups.set(s.args[i], [{ rec: s, src: pr }]);
1266
- }
1267
- }
2509
+ for (const { pred, succ } of inEdgeRecords(preds, M)) {
2510
+ const g = groups.get(succ.args[i]);
2511
+ if (g) {
2512
+ g.push({ rec: succ, src: pred });
2513
+ } else {
2514
+ groups.set(succ.args[i], [{ rec: succ, src: pred }]);
1268
2515
  }
1269
2516
  }
1270
2517
  const candidates: { arg: Value; def: Op; defBlock: Block; edges: { rec: object; src: Block }[] }[] = [];
@@ -1314,144 +2561,29 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1314
2561
  }
1315
2562
  }
1316
2563
 
1317
- // ── BITFIELD member reads (symbol map) ──────────────────────────────────────────────────────
1318
- // The `(x << a) >> b` extract of a struct global's loaded bytes IS a bitfield access when the
1319
- // map declares a bitfield at exactly those bits: spelled `gSym.field`, the source form, whose
1320
- // declared `u32 field : n` then makes C's own integer promotion reproduce the signedness every
1321
- // downstream operator compiled with (a 7-bit unsigned field promotes to signed int sdiv
1322
- // renders `/` and recompiles to __divsi3, where the raw-shift spelling stays u32).
1323
- //
1324
- // Semantically EXACT, never approximate: the window must lie inside the loaded bytes (so the
1325
- // load's extension bits cannot reach it), the field's position, width and signedness must all
1326
- // match the extract (a logical shift is an unsigned read, an arithmetic one a signed read —
1327
- // a signless field never matches), and the member must be nameable at all (memberQualsAllow;
1328
- // the map only carries bitfield facts for little-endian ELFs — see SymbolStructField). Any
1329
- // mismatch keeps the honest shift spelling.
1330
- //
1331
- // Precomputed over the ops (not folded during rendering) for the load's sake: a load whose
1332
- // EVERY use is a spelled extract chain must not also emit its materialized `v = *(u16 *)&g;`
1333
- // temp — the compiler CSEs the repeated member reads back to one load, but the leftover temp
1334
- // would be a second one. A VOLATILE container refuses the whole fold: N member reads are N
1335
- // volatile accesses where the asm did one load. (Byte-level residual, differ-refereed: a load
1336
- // only PARTIALLY absorbed — one extract spelled, another use kept — emits both the temp and
1337
- // the named reads, one load more than the asm; semantics hold, the score decides.)
1338
- //
1339
- // ORDERING GATE (adversarial round, CRITICAL 1 — twice): the named spelling replaces a
1340
- // REGISTER value — the bits captured at the load's program position — with a fresh memory
1341
- // read at each render position. Every other memory read in this file goes through the
1342
- // materialization model (analysis.ts) for exactly that hazard, so the fold clears the SAME
1343
- // bar with the SAME machinery: `emitPos` resolves where each extract actually renders
1344
- // (transitively through its inlining consumers — an unresolvable position refuses), and
1345
- // `memWriteBetween` walks every def-avoiding load→render path for a call, an opaque, or a
1346
- // store not provably to a DIFFERENT named global. Path-based on purpose: the second audit
1347
- // pass broke the first fix's linear-position scan with a block laid out AFTER the render in
1348
- // address order but executing between load and render on the taken path — fn.blocks order is
1349
- // address order, not topological order.
1350
- const bitfieldSpelling = new Map<Op, { global: string; field: string }>();
1351
- const absorbedLoads = new Set<Op>();
1352
- if (symCtx && littleEndian && spellBitfieldMembers) {
1353
- // the (name, byte) of a load's address when it resolves through defs alone — `gaddr` or
1354
- // `add(gaddr, const)`; anything else (a materialized base, a variable index) declines
1355
- const loadTargets = new Map<Op, { name: string; byte: number }>();
1356
- const addrOf = (v: Value, off: number): { name: string; byte: number } | null => {
1357
- const d0 = defs.get(v);
1358
- if (d0?.opcode === 'gaddr') {
1359
- return { name: d0.attrs.sym as string, byte: off };
1360
- }
1361
- if (d0?.opcode === 'add' && d0.operands.length === 2) {
1362
- for (const [x, y] of [
1363
- [d0.operands[0], d0.operands[1]],
1364
- [d0.operands[1], d0.operands[0]],
1365
- ] as const) {
1366
- const g0 = defs.get(x);
1367
- const c0 = defs.get(y);
1368
- if (g0?.opcode === 'gaddr' && c0?.opcode === 'const') {
1369
- return { name: g0.attrs.sym as string, byte: (c0.attrs.value as number) + off };
1370
- }
1371
- }
1372
- }
1373
- return null;
1374
- };
1375
- // A write for the fold's purposes: calls and opaques always; a store/astore unless its base
1376
- // resolves to a global PROVABLY different from the folded one. (Name comparison suffices:
1377
- // the pool promotion picks one canonical name per address, so one cell cannot appear under
1378
- // two names within a function.)
1379
- const mayWrite =
1380
- (sym: string) =>
1381
- (x: Op): boolean => {
1382
- if (x.opcode === 'call' || x.opcode === 'opaque') {
1383
- return true;
1384
- }
1385
- if (x.opcode !== 'store' && x.opcode !== 'astore') {
1386
- return false;
1387
- }
1388
- const t = addrOf(x.operands[0], 0);
1389
- return !(t && t.name !== sym);
1390
- };
1391
- for (const blk of fn.blocks) {
1392
- for (const op of blk.ops) {
1393
- if ((op.opcode !== 'shr_u' && op.opcode !== 'shr_s') || op.operands.length !== 1) {
1394
- continue;
1395
- }
1396
- const b = op.attrs.imm as number | undefined;
1397
- const inner = defs.get(op.operands[0]);
1398
- if (typeof b !== 'number' || b <= 0 || b >= 32 || inner?.opcode !== 'shl' || inner.operands.length !== 1) {
1399
- continue;
1400
- }
1401
- const a = inner.attrs.imm as number | undefined;
1402
- if (typeof a !== 'number' || a < 0 || b < a) {
1403
- continue;
1404
- }
1405
- const w = 32 - b; // extract width
1406
- const lo = b - a; // low bit within the loaded value
1407
- const load = defs.get(inner.operands[0]);
1408
- if (load?.opcode !== 'load' || lo + w > (load.attrs.width as number) * 8) {
1409
- continue;
1410
- }
1411
- // a materialized shl would still emit its `v = x << a` temp reading the load — the fold
1412
- // would then ADD member reads on top of it; rare, refuse
1413
- if (materialize.has(inner)) {
1414
- continue;
1415
- }
1416
- const gb = addrOf(load.operands[0], load.attrs.off as number);
1417
- const si = gb ? symCtx.info(gb.name) : undefined;
1418
- if (!gb || si?.shape !== 'struct' || si.volatile) {
1419
- continue;
1420
- }
1421
- // where does the member read RENDER? at the extract's own position when materialized,
1422
- // else wherever each of its consumers ultimately renders (emitPos, transitively —
1423
- // unresolvable refuses); every load→render path must be write-free
1424
- const renders = materialize.has(op)
1425
- ? [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }]
1426
- : [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))].map((c) => emitPos(c));
1427
- const writes = mayWrite(gb.name);
1428
- if (renders.some((r) => r === null) || renders.some((r) => memWriteBetween(load, r!, writes))) {
1429
- continue;
1430
- }
1431
- const signedRead = op.opcode === 'shr_s';
1432
- const fld = declaredFields(si.layout)?.find(
1433
- (f) => f.bitWidth === w && f.offset * 8 + f.bitOffset! === gb.byte * 8 + lo && f.signed === signedRead,
1434
- );
1435
- if (fld && memberQualsAllow(fld, si.const, false)) {
1436
- bitfieldSpelling.set(op, { global: gb.name, field: fld.name });
1437
- loadTargets.set(load, gb);
1438
- }
1439
- }
1440
- }
1441
- // a load is ABSORBED when every use is an shl whose every use is a spelled extract
1442
- for (const load of loadTargets.keys()) {
1443
- const shls = useSitesOf.get(load.results[0]) ?? [];
1444
- const absorbed =
1445
- shls.length > 0 &&
1446
- shls.every(
1447
- (u) =>
1448
- u.op.opcode === 'shl' && (useSitesOf.get(u.op.results[0]) ?? []).every((v) => bitfieldSpelling.has(v.op)),
1449
- );
1450
- if (absorbed) {
1451
- absorbedLoads.add(load);
1452
- }
1453
- }
1454
- }
2564
+ // ── BITFIELD member spelling (structure/bitfields.ts) ───────────────────────────────────────
2565
+ // The read fold, the mask-and-insert WRITE fold and the absorbed-load set, precomputed over the
2566
+ // ops before any rendering. Extracted behind an explicit-deps factory: every dependency below is
2567
+ // READ-only, so this cannot participate in the naming pipeline this file's remainder is built
2568
+ // around. The module header states what each fold recognizes and every refusal it carries.
2569
+ const {
2570
+ spelling: bitfieldSpelling,
2571
+ stores: bitfieldStore,
2572
+ absorbed: absorbedLoads,
2573
+ } = makeBitfieldSpelling({
2574
+ fn,
2575
+ defs,
2576
+ materialize,
2577
+ useSitesOf,
2578
+ opBlock,
2579
+ opIndex,
2580
+ emitPos,
2581
+ memWriteBetween,
2582
+ sym: symCtx,
2583
+ littleEndian,
2584
+ enabled: spellBitfieldMembers,
2585
+ memberQualsAllow,
2586
+ });
1455
2587
 
1456
2588
  // An unresolvable value: strict mode keeps the `"?"` sentinel AND records the reason — the
1457
2589
  // decline thrown below names the actual gaps ("unmodelled instruction 'adde'"), the same
@@ -1492,24 +2624,118 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1492
2624
  // sign-agnostic ==/!=) spell `(u32)&gSym`, signed compares `(s32)&gSym` — exactly the
1493
2625
  // compare the asm did. The deref folds never see a compare operand, so no named spelling is
1494
2626
  // lost; a NARROWING cast (`(u8)&gSym`) is not a bare `addr` and keeps its truncation.
1495
- // SCOPE (adversarial review): this closes the hole for BARE addr operands only. An
1496
- // addr-carrying arithmetic tree (`(u32)&gSym + 4`, spelled by intifyAddr below) under an
1497
- // icmp_s* still compares unsigned in C (u32 wins the usual-arithmetic-conversions) the
1498
- // same pre-existing wrongness the old ptr-vs-int spelling had, surfacing as a scoring
1499
- // nonmatch, never a silent regression of a formerly-correct compare. Rare shape; an outer
1500
- // signed cast on addr-carrying trees is the follow-up if it ever costs a row.
2627
+ // SCOPE: this handles BARE addr operands. An addr-carrying arithmetic tree
2628
+ // (`(u32)&gSym + 4`, spelled by intifyAddr below) renders unsigned and would compare
2629
+ // unsigned under an icmp_s*; the signed operand pin at the end of this block catches it as
2630
+ // one case of the general rule, needing no addr-specific reasoning.
1501
2631
  const t = /^icmp_s/.test(d.opcode) ? T.s(32) : T.u(32);
1502
2632
  const intifyAddrCmp = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: t, e: x } : x);
1503
- return {
1504
- k: 'bin',
1505
- op: CMP_TO_BIN[d.opcode],
1506
- l: intifyAddrCmp(e(d.operands[0])),
1507
- r: intifyAddrCmp(e(d.operands[1])),
2633
+ let l = intifyAddrCmp(e(d.operands[0]));
2634
+ let r = intifyAddrCmp(e(d.operands[1]));
2635
+ // The same signedness hole for ORDINARY operands: an icmp_u* whose operands both render
2636
+ // as signed-promoting C (an s32-declared var carrying a u32 value — declarations take the
2637
+ // FIRST claimant's type; an inline `16 << t`, whose C type is the left operand's `int`)
2638
+ // compiles to the SIGNED compare the machine did not do. When neither side provably
2639
+ // promotes unsigned, one operand takes a (u32) cast — the side whose recovered VALUE type
2640
+ // is unsigned, the honest one — and the usual arithmetic conversions make the compare
2641
+ // unsigned exactly as the opcode says. A provably-unsigned operand leaves the spelling
2642
+ // alone, so correctly-typed compares never churn — as does a compare whose operands both
2643
+ // provably sit in [0, 2^31) (a `(u8)x > 4` byte test): there the signed spelling is
2644
+ // value-faithful and the compiler already picks the unsigned branch itself, and so does a
2645
+ // pointer-rendered side: `p < end` already compares unsigned, and `(u32)p` against a
2646
+ // pointer is the int-vs-ptr constraint violation the strict backends reject. ==/!= are
2647
+ // sign-agnostic; the icmp_s* direction is pinned below.
2648
+ const ptrSide = (x: Expr): boolean => {
2649
+ const t2 = ctype(x);
2650
+ return t2?.kind === 'ptr' || t2?.kind === 'array';
1508
2651
  };
2652
+ if (
2653
+ unsignedCompareSpelling &&
2654
+ /^icmp_u/.test(d.opcode) &&
2655
+ !ptrSide(l) &&
2656
+ !ptrSide(r) &&
2657
+ renderedIntSignedness(l, vtEnv) !== false &&
2658
+ renderedIntSignedness(r, vtEnv) !== false &&
2659
+ !(provablyNonNegative(l, vtEnv) && provablyNonNegative(r, vtEnv))
2660
+ ) {
2661
+ const irUnsigned = (v: Value): boolean => v.type.kind === 'int' && !v.type.signed;
2662
+ if (!irUnsigned(d.operands[0]) && irUnsigned(d.operands[1])) {
2663
+ r = { k: 'cast', to: T.u(32), e: r };
2664
+ } else {
2665
+ l = { k: 'cast', to: T.u(32), e: l };
2666
+ }
2667
+ }
2668
+ // The SIGNED direction of the same hole, and a DEFAULT rather than an arm of that axis —
2669
+ // not because nothing underdetermines, but because the underdetermination is INERT. An
2670
+ // unsigned source compare can reach a signed opcode when the compiler proves the test is
2671
+ // the sign bit (`u32 a; a < 0x80000000` compiles to `cmp r0, #0; bge`; kmc-gcc and gcc
2672
+ // 2.7.2 fold it to `slti`), so an icmp_s* has more than one source — but the pinned
2673
+ // spelling reproduces that branch too (`(s32)a >= 0` is the same `cmp r0, #0; bge`), so
2674
+ // both sources reach ONE candidate and an axis would have doubled the fan to referee a
2675
+ // question with one answer. Where the spellings genuinely diverge they diverge the way the
2676
+ // opcode says, on every toolchain: an operand that renders unsigned makes C compare
2677
+ // unsigned (agbcc `bls`, IDO/kmc-gcc/gcc 2.7.2 `sltu`/`sltiu` against `slt`/`slti`, mwcc
2678
+ // `neg;or` against `neg;andc` in its branchless form), and against a constant the test
2679
+ // folds away entirely and takes the surrounding computation with it — `(u32)a / b < 0`
2680
+ // becomes `mov r0, #0`, deleting the `__udivsi3` call.
2681
+ //
2682
+ // `undefined` takes the cast exactly as a definite `false` does (see
2683
+ // renderedIntSignedness): a call's signedness is the project header's, not this function's.
2684
+ // A POINTER-rendered side is the one operand left alone, and not out of caution — `p < q`
2685
+ // is already the unsigned compare C gives two addresses, where `(s32)p < (s32)q` would
2686
+ // compare them signed.
2687
+ if (/^icmp_s/.test(d.opcode)) {
2688
+ const pinSigned = (x: Expr): Expr =>
2689
+ renderedIntSignedness(x, vtEnv) === true || ptrSide(x) ? x : { k: 'cast', to: T.s(32), e: x };
2690
+ l = pinSigned(l);
2691
+ r = pinSigned(r);
2692
+ }
2693
+ return { k: 'bin', op: CMP_TO_BIN[d.opcode], l, r };
1509
2694
  }
1510
2695
  if (ARITH_TO_BIN[d.opcode]) {
1511
2696
  let l = e(d.operands[0]);
1512
2697
  let r = d.operands.length === 2 ? e(d.operands[1]) : ({ k: 'const', value: d.attrs.imm as number } as Expr);
2698
+ // Commutative LOAD-PAIR operands re-spell in EVALUATION order. A commutative instruction's
2699
+ // operand order is an allocator artifact (`mul r0, r0, r2` reads dst-first, so the lift's
2700
+ // l/r is whichever load landed in the dst), but the order the compiler EVALUATED the
2701
+ // operands is still visible: their defs' order in the instruction stream. gcc 2.9 and IDO
2702
+ // both emit `w * h`'s loads w-first, so def order IS source order — verified byte-identical
2703
+ // on both (the bg_area rows). Scope, one gate per way the signal fails: BOTH root defs must
2704
+ // be same-block memory reads (a const already has its side; arithmetic defs get combined
2705
+ // out of source order — an ldmia-fed add reads def-reordered; cross-block positions do not
2706
+ // order evaluation), neither stamped `listOrder` (an ldmia-expanded load's own position is
2707
+ // LIST order), both operand VALUES un-named (see below), no pointer side (load-bearing for
2708
+ // the stride rules below), and no effect moves (call, marker).
2709
+ if (COMMUTATIVE_BIN.has(ARITH_TO_BIN[d.opcode]) && d.operands.length === 2) {
2710
+ const [da, db] = [defs.get(d.operands[0]), defs.get(d.operands[1])];
2711
+ if (
2712
+ defOrderLoadPairs &&
2713
+ da &&
2714
+ db &&
2715
+ (da.opcode === 'load' || da.opcode === 'aload') &&
2716
+ (db.opcode === 'load' || db.opcode === 'aload') &&
2717
+ da.attrs.listOrder !== true &&
2718
+ db.attrs.listOrder !== true &&
2719
+ // NAMED values decline: a value that renders as a name here — a materialized def
2720
+ // (varName), a loop-carried value in a post-loop region (activeSub) — was evaluated at
2721
+ // its def statement, so re-ordering the reference re-orders nothing and only churns the
2722
+ // spelling away from the machine order the allocator saw. An inlined def — a deref, a
2723
+ // field, a bare scalar global (whose `var` node lowerDef itself mints) — evaluates at
2724
+ // THIS site, wherever recovery spells it.
2725
+ !varName.has(d.operands[0]) &&
2726
+ !varName.has(d.operands[1]) &&
2727
+ activeSub?.has(d.operands[0]) !== true &&
2728
+ activeSub?.has(d.operands[1]) !== true &&
2729
+ ctype(l)?.kind !== 'ptr' &&
2730
+ ctype(r)?.kind !== 'ptr' &&
2731
+ !exprHasEffect(l) &&
2732
+ !exprHasEffect(r) &&
2733
+ opBlock.get(da) === opBlock.get(db) &&
2734
+ opIndex.get(da)! > opIndex.get(db)!
2735
+ ) {
2736
+ [l, r] = [r, l];
2737
+ }
2738
+ }
1513
2739
  // Pointer stride: C pointer arithmetic is ELEMENT-scaled, but the asm added a BYTE
1514
2740
  // constant — `addi p,4` on an `s32*` walks 1 element, yet C `p + 4` walks 4. Divide the byte
1515
2741
  // constant by the pointee size so the walk recompiles to the same address math.
@@ -1518,21 +2744,76 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1518
2744
  // the type of the expression it actually sees, and the two diverge exactly like memAccess's
1519
2745
  // deref bases (a value recovered `s32*` can render as an int-typed tree — C then does NO
1520
2746
  // element scaling, so pre-dividing the constant would bake in a WRONG address that the
1521
- // deref cast downstream turns into silently-wrong bytes; found by the adversarial round).
2747
+ // deref cast downstream turns into silently-wrong bytes).
1522
2748
  // An int-rendered walk keeps its raw byte constant and derefs through the access-width cast.
1523
2749
  // Fires only for a rendered pointer whose element size (>1) DIVIDES the constant exactly;
1524
2750
  // otherwise raw (a misaligned/struct-array stride is left as-is; a `u8*` is size 1 so
1525
2751
  // unchanged). Since C `(K/es) + p == p + (K/es)`, scaling the const on whichever side it
1526
2752
  // sits fixes the bytes: `add` is commutative so the pointer may be either operand; `sub` is
1527
2753
  // not, so only operand[0] (the minuend) may be the pointer.
1528
- const scale = (t: IrType | undefined, c: Extract<Expr, { k: 'const' }>): Expr => {
1529
- const es = t?.kind === 'ptr' ? ptrElemBytes(t.to) : 0;
1530
- return es > 1 && c.value % es === 0 ? { k: 'const', value: c.value / es } : c;
2754
+ // A rendered pointer that CANNOT express the byte constant as a whole number of elements —
2755
+ // an inexact residual, or a pointee whose size is not knowable here (a struct) has no
2756
+ // scaled spelling at all, and leaving the raw byte count is the silent wrongness the
2757
+ // pointer-global rule below refuses in the same words: C multiplies it back, so `p + 62` on
2758
+ // an `s32 *` addresses byte 248 and `p + 38` on a `struct S *` addresses byte 38 * sizeof(S).
2759
+ // Same answer as there — CAST THEN ADD, `(u8 *)p + 62`, the same address in every world.
2760
+ const bytePtr = (x: Expr): Expr => ({ k: 'cast', to: T.ptr(T.u(8)), e: x });
2761
+ // Set when a byte-pointer cast was applied for the ADDRESS math alone: the sum is cast back
2762
+ // to the pointer type it started as, so the walk changes the arithmetic and nothing else. A
2763
+ // bare `u8 *` sum would be a different C type from the slot it lands in (`v4 = (u8 *)a0 +
2764
+ // (v1 << 2)` into an `s32 *` — an mwcc error) and from the bases the deref rules read.
2765
+ let restoreTo: IrType | undefined;
2766
+ const walk = (base: Expr, c: Extract<Expr, { k: 'const' }>): { base: Expr; off: Expr } => {
2767
+ const t = ctype(base);
2768
+ if (t?.kind !== 'ptr') {
2769
+ return { base, off: c }; // an int-rendered walk: C scales nothing, the bytes are right
2770
+ }
2771
+ const es = ptrElemBytes(t.to);
2772
+ if (es === 1) {
2773
+ return { base, off: c }; // already a byte pointer
2774
+ }
2775
+ return es > 1 && c.value % es === 0
2776
+ ? { base, off: { k: 'const', value: c.value / es } }
2777
+ : { base: bytePtr(base), off: c };
2778
+ };
2779
+ // A RUNTIME byte offset has no element spelling at all — not even an inexact one to reject,
2780
+ // since the residual is unknown until the program runs. The asm added bytes, so the same
2781
+ // answer as the inexact constant above: cast then add. Without it a `u16 *` walked by a
2782
+ // computed offset addresses TWICE the intended byte, and nothing downstream can see the
2783
+ // error — in the sa3 decomp that address is what a `CpuSet` call writes THROUGH.
2784
+ //
2785
+ // KNOWN GAP: `ptr ± ptr` is excluded, and the intify rules below do not make it right
2786
+ // either — `ptr + ptr` becomes `l + (s32)r`, which C scales, and a same-pointee `ptr - ptr`
2787
+ // is C's ELEMENT difference where the asm subtracted bytes. Both want the same cast-then-add
2788
+ // treatment; both are byte-identical to what this emitted before, and three functions in the
2789
+ // agbcc corpus carry one.
2790
+ //
2791
+ // KNOWN GAP: the inexact-CONSTANT branch above casts its base and does NOT cast the sum
2792
+ // back, so `v1 = (u8 *)a0 + 2` still lands in an `s32 *` slot. Copying the restore up churns
2793
+ // the common case, where the sum is consumed by a deref that supplies its own cast — the
2794
+ // real predicate is the CONSUMER, and deciding it here at the producer is what these two
2795
+ // branches would have to stop doing.
2796
+ const walkVar = (x: Expr): IrType | undefined => {
2797
+ const t = ctype(x);
2798
+ return t?.kind === 'ptr' && ptrElemBytes(t.to) !== 1 ? t : undefined;
1531
2799
  };
1532
2800
  if ((d.opcode === 'add' || d.opcode === 'sub') && r.k === 'const') {
1533
- r = scale(ctype(l), r);
2801
+ ({ base: l, off: r } = walk(l, r));
1534
2802
  } else if (d.opcode === 'add' && d.operands.length === 2 && l.k === 'const') {
1535
- l = scale(ctype(r), l); // commuted `const + ptr`
2803
+ ({ base: r, off: l } = walk(r, l)); // commuted `const + ptr`
2804
+ } else if (d.opcode === 'add' || d.opcode === 'sub') {
2805
+ // ONE side only. `ptr - ptr` is C's element difference and `ptr + ptr` is not C at all;
2806
+ // both are the intify rules' business below, and casting both operands here would hide
2807
+ // the shape from them.
2808
+ const lp = ctype(l)?.kind === 'ptr';
2809
+ const rp = d.operands.length === 2 && ctype(r)?.kind === 'ptr';
2810
+ if (lp && !rp) {
2811
+ restoreTo = walkVar(l);
2812
+ l = restoreTo ? bytePtr(l) : l;
2813
+ } else if (rp && !lp && d.opcode === 'add') {
2814
+ restoreTo = walkVar(r);
2815
+ r = restoreTo ? bytePtr(r) : r;
2816
+ }
1536
2817
  }
1537
2818
  // C rejects a pointer operand outright under the non-additive operators (& | ^ << >> * / %),
1538
2819
  // under `ptr + ptr`, and as the subtrahend of `int - ptr` — the asm just does 32-bit integer
@@ -1561,42 +2842,45 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1561
2842
  const intifyAddr = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: T.u(32), e: x } : x);
1562
2843
  l = intifyAddr(l);
1563
2844
  r = intifyAddr(r);
1564
- // The SAME hazard one level down, for a POINTER-shaped global's VALUE (`gPtr`, isPtrGlobal):
1565
- // C scales `gPtr + K` by sizeof(*gPtr) — 1 under the map's synthesized `void *`, but
1566
- // whatever the PROJECT's header declares (a 0x5C-byte struct, say) in the world a user
1567
- // actually recompiles in. The asm added BYTES, so the honest spelling makes the stride
1568
- // explicit: CAST-THEN-ADD, `(u8 *)gPtr + K`, the same address in EVERY world. Add-then-cast
1569
- // (`(u8 *)(gPtr + K)`, what the backend's deref legalization would otherwise produce) is
1570
- // byte-correct in exactly one of them — a silent wrongness, the class this project refuses.
2845
+ // The SAME hazard one level down, for a value the MAP declares a pointer (`gPtr`, `gSym.pBuf`
2846
+ // — isPtrValue): C scales `gPtr + K` by sizeof(*gPtr) — 1 under the map's synthesized
2847
+ // `void *`, but whatever the PROJECT's header declares (a `u16 *` member, a 0x5C-byte
2848
+ // struct) in the world a user actually recompiles in. The asm added BYTES, so the honest
2849
+ // spelling makes the stride explicit: CAST-THEN-ADD, `(u8 *)gPtr + K`, the same address in
2850
+ // EVERY world. Add-then-cast (`(u8 *)(gPtr + K)`, what the backend's deref legalization
2851
+ // would otherwise produce) is byte-correct in exactly one of them — a silent wrongness, the
2852
+ // class this project refuses. A MEMBER is the case with no world in which the raw spelling
2853
+ // is right: the map declares the pointee width, so `bytes + gSym.pBuf` on a `u16 *` scales
2854
+ // the residual a SECOND time and addresses twice the byte the asm did.
1571
2855
  // NOT foldable into the deref index either: `((u8 *)gPtr)[K + off]` re-scales K by the
1572
2856
  // ACCESS width, a different address whenever that width is not 1.
1573
2857
  // Under the non-additive operators C rejects a pointer outright, so there the honest
1574
2858
  // spelling is integer math on the cell — exactly intifyAddr's `(u32)&gSym` rule.
1575
- const bytePtr = (x: Expr): Expr => ({ k: 'cast', to: T.ptr(T.u(8)), e: x });
1576
- const intifyPtrGlobal = (x: Expr): Expr => ({ k: 'cast', to: T.u(32), e: x });
2859
+ const intifyPtrValue = (x: Expr): Expr => ({ k: 'cast', to: T.u(32), e: x });
1577
2860
  if (op === '+' || op === '-') {
1578
2861
  // `ptr ± int` and `ptr - ptr` are byte arithmetic once both sides are byte pointers;
1579
2862
  // `ptr + ptr` and `int - ptr` are not C at all, so the second pointer goes integer.
1580
- const bothPtr = isPtrGlobal(l) && isPtrGlobal(r);
1581
- if (isPtrGlobal(l)) {
2863
+ const bothPtr = isPtrValue(l) && isPtrValue(r);
2864
+ if (isPtrValue(l)) {
1582
2865
  l = bytePtr(l);
1583
2866
  }
1584
- if (isPtrGlobal(r)) {
1585
- r = bothPtr && op === '-' ? bytePtr(r) : op === '+' && !bothPtr ? bytePtr(r) : intifyPtrGlobal(r);
2867
+ if (isPtrValue(r)) {
2868
+ r = bothPtr && op === '-' ? bytePtr(r) : op === '+' && !bothPtr ? bytePtr(r) : intifyPtrValue(r);
1586
2869
  }
1587
2870
  } else if (op !== '&&' && op !== '||') {
1588
2871
  // (`&&`/`||` take a pointer operand legally — a truth test, no arithmetic.)
1589
- l = isPtrGlobal(l) ? intifyPtrGlobal(l) : l;
1590
- r = isPtrGlobal(r) ? intifyPtrGlobal(r) : r;
2872
+ l = isPtrValue(l) ? intifyPtrValue(l) : l;
2873
+ r = isPtrValue(r) ? intifyPtrValue(r) : r;
1591
2874
  }
1592
- // (The two right shifts stay DISTINCT ops here — `>>>` logical, `>>` arithmetic. Which token
1593
- // a language spells each with, and what cast pins the choice, is a BACKEND decision; see
1594
- // l3/ast.ts BinOp and backend/cfamily.ts's shift rule.)
2875
+ // (The signedness-carrying pairs stay DISTINCT ops here — `>>>`/`>>` and `/u` `%u`/`/` `%`.
2876
+ // Which token a language spells each with, and what cast pins the choice, is a BACKEND
2877
+ // decision; see l3/ast.ts BinOp and backend/cfamily.ts's C_SPELLING.)
1595
2878
  // SCOPE: this and intifyAddr cover the ARITHMETIC escapes. A pointer global under a
1596
2879
  // COMPARISON (`gPtr < K` — C compares unsigned whatever the asm's icmp_s* said) is the same
1597
2880
  // class as intifyAddrCmp's `addr` rule and is deliberately left alone here: it is valid C
1598
2881
  // today, so closing it would churn spellings for a signedness case no row exercises.
1599
- return { k: 'bin', op, l, r };
2882
+ const sum: Expr = { k: 'bin', op, l, r };
2883
+ return restoreTo ? { k: 'cast', to: restoreTo, e: sum } : sum;
1600
2884
  }
1601
2885
  // `-`/`~` on a pointer rendering is equally not C — same honest integer cast as above.
1602
2886
  if (d.opcode === 'rotr' || d.opcode === 'rotl') {
@@ -1639,19 +2923,36 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1639
2923
  return { k: 'un', op: '~', e: needsIntSpelling(x) ? { k: 'cast', to: T.s(32), e: x } : x };
1640
2924
  }
1641
2925
  // Width-narrowing casts: `zext`/`sext` widen a `width`-bit value back to 32 → C `(u8)e`/`(s8)e`.
1642
- if (d.opcode === 'zext') {
1643
- return { k: 'cast', to: T.int(d.attrs.width as number, false), e: e(d.operands[0]) };
1644
- }
1645
- if (d.opcode === 'sext') {
1646
- return { k: 'cast', to: T.int(d.attrs.width as number, true), e: e(d.operands[0]) };
2926
+ // The width is in BITS, and outside `CAST_WIDTHS` there is no C type to cast to — a GAP, not
2927
+ // the `(u2)` the backend would otherwise print and no compiler accept. No frontend produces
2928
+ // one (the cast idioms and PPC's `extsb`/`extsh` emit 8 and 16), so this is the loud floor
2929
+ // under hand-written IR; `raise/narrowlocal.ts` and `raise/paramwidth.ts` refuse to NARROW on
2930
+ // such a width, which is a different decision from spelling one.
2931
+ if (d.opcode === 'zext' || d.opcode === 'sext') {
2932
+ const w = d.attrs.width as number;
2933
+ return CAST_WIDTHS.has(w)
2934
+ ? { k: 'cast', to: T.int(w, d.opcode === 'sext'), e: e(d.operands[0]) }
2935
+ : mkGap(`no C type for a ${w}-bit '${d.opcode}'`, [e(d.operands[0])]);
1647
2936
  }
1648
2937
  if (d.opcode === 'call') {
1649
2938
  return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
1650
2939
  }
2940
+ if (d.opcode === 'laddr') {
2941
+ // gaddr's local twin: the address of the frame-local object the Thumb frontend PROVED
2942
+ // (frame-object audit — width/signed are stamped machine facts). The NAME is this layer's:
2943
+ // see laddrName. Renders `&sp0`; the object itself is declared in `locals`.
2944
+ return { k: 'addr', name: laddrName.get(d)! };
2945
+ }
2946
+ if (d.opcode === 'undef') {
2947
+ // An uninitialised local. NEVER emit a definition for it: the declaration in `locals` is the
2948
+ // entire recovery, and `sideEffects` skips an `undef` (neither effectful nor materialized),
2949
+ // which is what leaves it bare.
2950
+ return { k: 'var', name: undefName.get(d)! };
2951
+ }
1651
2952
  if (d.opcode === 'gaddr') {
1652
2953
  // A promoted CODE symbol (frontend `code: true`) is a function pointer stored as an
1653
- // integer: spelled `(u32)Name` — the source idiom — never `&Name` (defect G of the
1654
- // dogfood report; the & form compiles but is a different, non-matching spelling).
2954
+ // integer: spelled `(u32)Name` — the source idiom — never `&Name` (the & form compiles,
2955
+ // but it is a different and non-matching spelling).
1655
2956
  if (d.attrs.code === true) {
1656
2957
  return { k: 'cast', to: T.int(32, false), e: { k: 'var', name: d.attrs.sym as string } };
1657
2958
  }
@@ -1677,6 +2978,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1677
2978
  e(d.operands[0]),
1678
2979
  e(d.operands[1]),
1679
2980
  d.attrs.fieldOff as number | undefined,
2981
+ d.attrs.memberOff as number | undefined,
1680
2982
  d.attrs.elemSize as number,
1681
2983
  (d.attrs.signed as boolean) ?? false,
1682
2984
  ctype,
@@ -1684,7 +2986,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1684
2986
  );
1685
2987
  }
1686
2988
  return d.opcode === 'opaque'
1687
- ? mkGap(`unmodelled instruction '${(d.attrs.mnemonic as string) ?? '?'}'`, d.operands.map(e))
2989
+ ? mkGap(gapReasonFor(d.attrs.mnemonic), d.operands.map(e))
1688
2990
  : mkGap(`no lowering for op '${d.opcode}'`, d.operands.map(e));
1689
2991
  };
1690
2992
 
@@ -1707,9 +3009,20 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1707
3009
  };
1708
3010
  // The loop-emission hazard checks (readsClobbered / loopEscapeHazard / loopUpdateHazard) —
1709
3011
  // pure decline-or-emit predicates, extracted to hazards.ts behind the explicit-deps factory.
1710
- // `varName` is captured as a live reference: it is still being populated here in the naming
1711
- // pipeline, and each check reads the names that exist when EMISSION calls it.
1712
- const { loopUpdateHazard } = makeLoopHazards({ defs, varName, useSitesOf });
3012
+ // `varName` is captured as a live REFERENCE rather than copied, so each check reads the names
3013
+ // EMISSION sees. The map is already FINAL at this point every write to it sits above, in the
3014
+ // naming walk and the coalescing that follows it, and these checks only read it — so today the
3015
+ // two readings coincide; the reference is what keeps them coinciding if a write ever moves down
3016
+ // here.
3017
+ const { readsClobbered, loopUpdateHazard, sinkablePreUpdateSlots, sameAtEntry, loopWriteSet } = makeLoopHazards({
3018
+ defs,
3019
+ varName,
3020
+ useSitesOf,
3021
+ liveIn,
3022
+ opBlock,
3023
+ materialize,
3024
+ respelledDefs: bitfieldSpelling,
3025
+ });
1713
3026
 
1714
3027
  // A POST-LOOP substitution active while structuring a loop's exit region: a loop-carried value (a
1715
3028
  // latch back-edge arg) is held in its loop-variable NAME after the loop, so any post-loop use must
@@ -1732,63 +3045,335 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1732
3045
  // value. `sub` (used for the emitWhile un-rotation's exit copies) substitutes back-edge args to
1733
3046
  // their header-param NAMES — post-loop the params already hold their updated values, so a merged
1734
3047
  // exit value is read as `v` not `v-1`.
3048
+ //
3049
+ // AN UNDEFINED ARGUMENT CARRIES NOTHING, so it gets no copy — WHERE THE DESTINATION IS ITSELF
3050
+ // UNDEFINED THERE. `undef` is storage nothing wrote on this path (ir/opcodes.ts), so
3051
+ // `w = uninit_sp0;` spells a read of storage that was never written: a statement the asm has no
3052
+ // instruction for, whose cost is the register the undefined value then occupies across the merge.
3053
+ // Dropping it leaves the variable holding whatever it held — the same thing exactly when nothing
3054
+ // ever wrote it before this edge, and a DIFFERENT FUNCTION otherwise. That second case is real:
3055
+ // a merge that adopted an incoming parameter's name emits `if (a0 == 0) a0 = uninit_sp0;`, where
3056
+ // dropping the copy would substitute the parameter's defined value for the undefined one.
3057
+ //
3058
+ // So the test is over the destination's whole name class: no value spelled with that name may
3059
+ // have a definition able to execute before this edge — its home block being this predecessor, or
3060
+ // reaching it, which through a back edge is also how an earlier iteration's write is caught.
3061
+ // Unsure keeps the copy. Edge copies only: a `store` or a `ret` of an undef value is a real
3062
+ // instruction and emits.
3063
+ //
3064
+ // A value's home is where the copies into it run, and anchoring MOVES one: an anchored const is
3065
+ // written at the const's own def site instead of on the edges (anchorConstCopies, above), and
3066
+ // that site dominates them. Its block is a write site for the name like any other, and without it
3067
+ // `v0 = 0; if (c) { } store v0;` drops the undefined arm's copy and stores 0 where the machine
3068
+ // stores whatever the arm left — the same substitution the parameter case makes, one axis over.
3069
+ //
3070
+ // THE SECOND RELOCATION goes the other way, and this test cannot see it at all. A SUNK pre-update
3071
+ // exit copy (preUpdateCopies) writes the loop EXIT's param at the top of the loop BODY, so the
3072
+ // home this reads for it — `paramBlock`, the exit block — sits strictly LATER in the CFG than
3073
+ // where the copy lands. What keeps the two apart is not this test but `dest-free-inside-loop`
3074
+ // (hazards.ts): a merge inside the body under the exit param's name is a block param
3075
+ // `definedInBody` sees, so the slot is never sunk in the first place. That gate carries its own
3076
+ // KNOWN GAP, so the pair is a conjecture rather than a proof — `sunkCopyOverDroppedUndef` re-checks
3077
+ // it per function once both records below are complete, which is the only point at which they can
3078
+ // be: the do-while path structures its body BEFORE it mints its sunk copies.
3079
+ const anchoredHome = new Map<string, Block[]>();
3080
+ for (const [def, entries] of anchoredAt) {
3081
+ for (const { name } of entries) {
3082
+ (anchoredHome.get(name) ?? anchoredHome.set(name, []).get(name)!).push(opBlock.get(def)!);
3083
+ }
3084
+ }
3085
+ /** Every copy `undefCarriesNothing` dropped, with the edge it was dropped from. */
3086
+ const droppedUndefCopies: { name: string; pred: Block }[] = [];
3087
+ /** Every sunk pre-update exit copy, homed where it LANDS (the loop header whose body opens with
3088
+ * it) rather than where its destination param lives. These two lists are the postcondition's
3089
+ * whole input. */
3090
+ const sunkCopyHomes: { name: string; home: Block }[] = [];
3091
+ const undefCarriesNothing = (arg: Value, name: string, pred: Block): boolean => {
3092
+ if (defs.get(arg)?.opcode !== 'undef') {
3093
+ return false;
3094
+ }
3095
+ const writesBefore = (home: Block | undefined): boolean =>
3096
+ home === undefined || home === pred || reachFrom(home).has(pred);
3097
+ for (const [v, n] of varName) {
3098
+ if (n === name && writesBefore(paramBlock.get(v) ?? opBlock.get(defs.get(v)!))) {
3099
+ return false;
3100
+ }
3101
+ }
3102
+ return !(anchoredHome.get(name) ?? []).some(writesBefore);
3103
+ };
1735
3104
  const tempCounter = { n: 0 }; // per-function swap-cycle temp names (sequentialize)
1736
- // The copies for ONE specific successor record the workhorse behind argAssigns, taken
1737
- // directly by the switch_br path, whose duplicate case targets successorTo cannot
1738
- // disambiguate.
1739
- const argAssignsFor = (
3105
+ /** One record per parameter slot an edge ACTUALLY carries, in the order the copies are to be
3106
+ * laid out everything `argAssignsFor` does except `sequentialize`. Split out because Regime-A
3107
+ * switch recovery merges the records of a whole DISPATCH's edges and sequentializes the union
3108
+ * ONCE (`hoistedDispatchAssigns` below); sequentializing per edge and then deduping would mint
3109
+ * a swap-cycle temp per edge and spell a different program. */
3110
+ const edgeCopyRecords = (
1740
3111
  pred: Block,
1741
3112
  succ: { block: Block; args: Value[] },
1742
3113
  sub: Map<Value, string> | null = null,
1743
- ): Stmt[] => {
3114
+ keepSlot: (i: number) => boolean = () => true,
3115
+ ): { name: string; value: Expr; arg: Value; param: Value }[] => {
1744
3116
  const target = succ.block;
1745
3117
  const argExpr = sub ? exprWith(sub) : expr;
1746
- const copies: { name: string; value: Expr; arg: Value }[] = [];
3118
+ const copies: { name: string; value: Expr; arg: Value; param: Value }[] = [];
1747
3119
  const suppressed = suppressedArgs.get(succ);
1748
3120
  target.params.forEach((p, i) => {
1749
- if (suppressed?.has(i)) {
3121
+ if (suppressed?.has(i) || !keepSlot(i)) {
1750
3122
  return;
1751
- } // anchored at its const's def site — the write already ran before this edge
3123
+ } // anchored at its const's def site (or emitted elsewhere) this edge does not carry it
1752
3124
  const name = varName.get(p)!;
1753
3125
  const arg = succ.args[i];
1754
3126
  if ((sub?.get(arg) ?? varName.get(arg)) === name) {
1755
3127
  return;
1756
3128
  } // identity copy — coalesced away
1757
- copies.push({ name, value: castAggregateAddr(name, argExpr(arg)), arg });
3129
+ if (undefCarriesNothing(arg, name, pred)) {
3130
+ droppedUndefCopies.push({ name, pred });
3131
+ return;
3132
+ }
3133
+ copies.push({ name, value: intoDeclaredTemp(name, argExpr(arg)), arg, param: p });
1758
3134
  });
1759
- // Emit in the order the args are COMPUTED in `pred` — a compiler that lays the defining ops
1760
- // (and thus the copies that read them) out in that order matches with no spurious arg-swap.
1761
- // This is a per-compiler behavior (orderArgCopiesByComputation), not a universal: a compiler
1762
- // that emits copies in source/param order sets it false. Dependency ordering (sequentialize)
1763
- // still has the final say regardless.
1764
- if (orderArgCopiesByComputation) {
1765
- // opIndex is only valid for a def IN this block; a def elsewhere keeps indexOf's -1 (sorts first).
1766
- const pos = (v: Value) => {
1767
- const d = defs.get(v);
1768
- return d && opBlock.get(d) === pred ? opIndex.get(d)! : -1;
1769
- };
1770
- copies.sort((a, b) => pos(a.arg) - pos(b.arg));
3135
+ // Emit in the order the pred WROTE the destinations (ir/core.ts `WriteOrder` — the frontend's
3136
+ // measurement; the value graph cannot show it, because a copy is the same SSA value under a new
3137
+ // key). This is a per-compiler behavior (orderArgCopiesByWriteOrder), not a universal: a
3138
+ // compiler that emits copies in source/param order sets it false and no sort runs at all.
3139
+ //
3140
+ // THE RULE IS TWO CLAIMS, and only the first is licensed by an instruction:
3141
+ //
3142
+ // 1. CYCLIC copy sets. `sequentialize` spills the FIRST pending destination, and the record
3143
+ // names the destination whose FINAL value the compiler established earliest — the one whose
3144
+ // old value had to be saved elsewhere, since every later reader of that old value reads it
3145
+ // after the register stopped holding it. That is the compiler's own temp.
3146
+ // READ THE QUANTITY AS THE LAST WRITE, not the first overwrite: a pred commonly writes a
3147
+ // key several times (1,867 of 5,283 edge-destination records over klonoa, marioparty3 and
3148
+ // af, in 312 of the 464 functions that record anything). Both alternatives cost matches
3149
+ // over the 736 synthetic rows and gain none: recording the FIRST write loses `bgsplit`,
3150
+ // `bgswitch`, `bgswsplit` and `hipress` (all agbcc, 482 → 478); REFUSING the record for a
3151
+ // key written more than once loses those four plus `dmascope2` and `swmulti` (482 → 476).
3152
+ // 2. ACYCLIC copy sets — a SECOND, separately-licensed claim: that the compiler LAID THE
3153
+ // COPIES OUT in the order it wrote them. No instruction states it, and it is two-sided, so
3154
+ // the def-position spelling is enumerated beside the record's as a ranked candidate
3155
+ // (`/copy-defpos`, rank.ts) instead of being declared here. Restricting the record to
3156
+ // cyclic sets as the DEFAULT was measured over the 736 synthetic rows: it takes
3157
+ // `armfall:agbcc` 11 → 8, `memcpy1:mwcc` 23 → 19 and `memset1:mwcc` 21 → 19 — and LOSES
3158
+ // `gcd:agbcc` (the cyclic row's own entry edge is acyclic) and `structarr:agbcc` to
3159
+ // nonmatch. The CANDIDATE spans that second claim only (`preferDefPosCopyOrder`: the proxy
3160
+ // on acyclic sets, the record on cyclic ones), so no arm of the fan spells a cycle against
3161
+ // the instruction that licenses it, and the pair differs exactly where the evidence runs
3162
+ // out — which is what lets one function take the record on its back edge and the proxy on
3163
+ // its entry edge, as `gcd:agbcc` does.
3164
+ //
3165
+ // IT REACHES FURTHER THAN `sequentialize`, because it decides which copy is an arm's LAST
3166
+ // statement: `l3/tailmerge.ts` peels only a common last statement, so an arm-varying copy
3167
+ // ordered last hides an agreeing one behind it (measured there, on klonoa
3168
+ // `CountCollectedGems`); and `recognizeForLoops` recovers a `for` only when the induction
3169
+ // update is last, so this sort also decides `for` vs `while` (goldens in
3170
+ // `structure-goldens.test.ts`). That is why `synthetic:gcd:agbcc` is a `while` — agbcc wrote
3171
+ // the dividend's register last, so the modulo update is not last.
3172
+ //
3173
+ // A destination with NO RECORD keeps `NO_RECORD`, sorting first, which is where the proxy also
3174
+ // puts an argument the pred did not compute: on those copies the two orders AGREE rather than
3175
+ // one replacing the other. Pinning unwritten destinations at their param-order slot instead
3176
+ // loses `armdef:agbcc`, `loopfall:agbcc`, `loopset:agbcc` and `structarr:agbcc` over the same
3177
+ // 736 rows, and buys back only `armfall` 11 → 7 and `ucmp:kmc` 15 → 14.
3178
+ if (orderArgCopiesByWriteOrder) {
3179
+ // UNMEASURED (undefined) is not "wrote nothing": a parsed or hand-built fn carries no record
3180
+ // at all and its edges keep the def-position proxy, while a MEASURED pred that recorded no
3181
+ // destination of this edge gets an empty record. The two cannot meet inside one fn —
3182
+ // measurement is all-or-nothing per function and `ir/verify.ts` enforces it — so this asks
3183
+ // per block only to spell the wholly unmeasured fn.
3184
+ const predIsMeasured = fn.writeOrder?.writes.has(pred) ?? false;
3185
+ // The `/copy-defpos` candidate asking for the proxy on an edge the frontend DID measure —
3186
+ // the one place the two questions ("is there a record" and "use it") come apart.
3187
+ const record =
3188
+ predIsMeasured && (!preferDefPosCopyOrder || copySetIsCyclic(copies))
3189
+ ? (fn.writeOrder!.lastWrite.get(pred) ?? NO_WRITTEN_DESTINATIONS)
3190
+ : undefined;
3191
+ const rank =
3192
+ record !== undefined
3193
+ ? (c: (typeof copies)[number]) => record.get(c.param) ?? NO_RECORD
3194
+ : // opIndex is only valid for a def IN this block; a def elsewhere gets NO_RECORD too.
3195
+ (c: (typeof copies)[number]) => {
3196
+ const d = defs.get(c.arg);
3197
+ return d && opBlock.get(d) === pred ? opIndex.get(d)! : NO_RECORD;
3198
+ };
3199
+ copies.sort((a, b) => rank(a) - rank(b));
3200
+ }
3201
+ return copies;
3202
+ };
3203
+ // The copies for ONE specific successor record — the workhorse behind argAssigns, taken
3204
+ // directly by the switch_br path, whose duplicate case targets successorTo cannot
3205
+ // disambiguate.
3206
+ const argAssignsFor = (
3207
+ pred: Block,
3208
+ succ: { block: Block; args: Value[] },
3209
+ sub: Map<Value, string> | null = null,
3210
+ keepSlot: (i: number) => boolean = () => true,
3211
+ ): Stmt[] =>
3212
+ sequentialize(
3213
+ edgeCopyRecords(pred, succ, sub, keepSlot).map(({ name, value }) => ({ name, value })),
3214
+ varType,
3215
+ tempCounter,
3216
+ fn.name,
3217
+ );
3218
+ /** THE DISPATCH HOIST (Regime-A switch recovery). Recovering a comparison tree COLLAPSES its
3219
+ * test blocks, and an edge's only emission is its parallel copy — so every copy the dispatch's
3220
+ * edges carried would be discarded with the tree. This re-emits them above the `switch`, ONCE
3221
+ * PER NAME the dispatch binds — which is not once per machine write: `sw_fall`'s three arms
3222
+ * each take the accumulator under a name of their own, so this tree emits `v0 = 0; v1 = 0;
3223
+ * v2 = 0;` where agbcc has a single `mov r1, #0`, and the one-local spelling that byte-matches
3224
+ * comes from the `/merge-home` ranked axis, not from here. What the position is for is the
3225
+ * fall-through chain: per-arm copies RE-RUN on the fall path and overwrite what the falling arm
3226
+ * computed, the hazard Regime B states at its own `switch_br` refusal.
3227
+ *
3228
+ * Null (⇒ the caller declines to if-recovery, which spells every copy the asm performs) on the
3229
+ * two things one hoisted statement cannot say. Both are stated over NAMES, because the name is
3230
+ * what the statement writes. NEITHER FIRES ON A CORPUS ROW: over the synthetic tier the only
3231
+ * functions offering the hoist a param-carrying dispatch edge are the `sw_fall*` family (four
3232
+ * of them, 13 edges under 10 names), it emits on all four, and what refuses is the caller's own
3233
+ * `availableAtRoot`. Both refusals below are pinned by hand-written `.s` in
3234
+ * `test/switch-arms.test.ts` and by nothing else — read them as invariants, not as evidence:
3235
+ *
3236
+ * - DISAGREEING EDGES. Two edges binding one name to different values: which one runs depends
3237
+ * on which test fell through, and a single statement above the tree cannot depend on that.
3238
+ * Order-independent, so no emission order hides it.
3239
+ * - A CLOBBERED LIVE NAME. The hoisted write runs on EVERY path through the dispatch,
3240
+ * including into arms whose own edge did not carry it. If any value under that name is
3241
+ * live where the switch begins, or into one of its arms, the hoist would overwrite a value
3242
+ * those readers still want. THE SCAN REACHES values read at or after the listed blocks'
3243
+ * entries, and by construction NOT: the parameters of those blocks (`analysis.ts` kills a
3244
+ * block's params at its own entry), a value defined in the root whose only use is a
3245
+ * dispatch edge arg, or a binding elided upstream (identity copy, `undefCarriesNothing`, a
3246
+ * suppressed anchored slot), which contribute no name to compare. Binding an arm's
3247
+ * parameters is what the hoist is FOR, so that blindness is wanted; the other three are
3248
+ * gaps nobody has turned into a wrong answer.
3249
+ *
3250
+ * `anchorConstCopies` (above) relocates an edge copy too, and states two clauses this does not;
3251
+ * neither absence is an oversight. Its LOOP clause does not transfer: anchoring moves a write to
3252
+ * the const's DEF site, which may sit outside the loop the edge is in (block dominance is not
3253
+ * per-iteration precedence — see that clause's own statement above), while the hoist moves a
3254
+ * write from a dispatch's edges to the head of that same dispatch, same iteration every time
3255
+ * (`switch-arms.test.ts` pins a param-carrying dispatch inside a `do`-`while`). Its NAME-COUNT
3256
+ * clause — refuse a name several SSA values carry — cannot be adopted, because that is this
3257
+ * mechanism's whole subject: one name per arm taking the accumulator. As a refusal it declines
3258
+ * the empty-arm fall-through switch of `retsink.test.ts` outright, for no row's benefit.
3259
+ *
3260
+ * ORDER ACROSS EDGES IS UNLICENSED. Within one edge, `edgeCopyRecords` sorts by the pred's
3261
+ * measured `WriteOrder`; the union below keeps the first record per name in TREE-WALK order,
3262
+ * which no compiler measured — there is no per-pred record spanning writes that different preds
3263
+ * performed. It is deterministic, and `switch-arms.test.ts` pins the three-name order `sw_fall`
3264
+ * emits so that a change to the walk fails loudly rather than silently re-spelling a match.
3265
+ *
3266
+ * What it does NOT re-check is that evaluating an arg AT THE ROOT rather than on its edge is
3267
+ * value-preserving. That rests on the collapsed blocks being pure (switch-recover.ts's PRE4)
3268
+ * and on every hoisted arg being available at the root, which the caller establishes before
3269
+ * calling — the hoist depends on those rules, it does not weaken them. */
3270
+ const hoistedDispatchAssigns = (
3271
+ edges: readonly { pred: Block; succ: { block: Block; args: Value[] } }[],
3272
+ liveAt: readonly Block[],
3273
+ ): Stmt[] | null => {
3274
+ // `edgeCopyRecords` is not a pure query: an `undef` arg whose copy it drops is APPENDED to
3275
+ // `droppedUndefCopies`, which a loud postcondition below reads. This caller is SPECULATIVE — it
3276
+ // asks every dispatch edge and may still decline — so the records are rolled back on the
3277
+ // refusal paths, leaving the audit trail describing the program actually emitted (the caller
3278
+ // then declines to if-recovery, which re-walks the same edges through `argAssignsFor` and
3279
+ // records them there). `undefCarriesNothing` judges PER PRED, against what that predecessor
3280
+ // wrote, while the hoist emits at the ROOT — so the record belongs to the edge walk that
3281
+ // survives, not to this one. The rollback covers the refusals THIS function owns: a hoist that
3282
+ // succeeds and is declined further down (an arm refusal in switch-recover.ts) still leaves its
3283
+ // records, as it leaves any swap-cycle temp name `sequentialize` minted.
3284
+ const auditMark = droppedUndefCopies.length;
3285
+ const rollBack = (): null => {
3286
+ droppedUndefCopies.length = auditMark;
3287
+ return null;
3288
+ };
3289
+ const merged = new Map<string, { name: string; value: Expr; arg: Value; param: Value }>();
3290
+ for (const { pred, succ } of edges) {
3291
+ for (const c of edgeCopyRecords(pred, succ)) {
3292
+ const prev = merged.get(c.name);
3293
+ if (prev === undefined) {
3294
+ merged.set(c.name, c);
3295
+ } else if (prev.arg !== c.arg) {
3296
+ return rollBack(); // disagreeing edges
3297
+ }
3298
+ }
3299
+ }
3300
+ if (merged.size === 0) {
3301
+ return [];
3302
+ }
3303
+ const bound = new Set([...merged.values()].map((c) => c.param));
3304
+ for (const blk of liveAt) {
3305
+ for (const v of liveIn.get(blk) ?? []) {
3306
+ const nm = varName.get(v);
3307
+ if (nm !== undefined && merged.has(nm) && !bound.has(v)) {
3308
+ return rollBack(); // a clobbered live name
3309
+ }
3310
+ }
1771
3311
  }
1772
3312
  return sequentialize(
1773
- copies.map(({ name, value }) => ({ name, value })),
3313
+ [...merged.values()].map(({ name, value }) => ({ name, value })),
1774
3314
  varType,
1775
3315
  tempCounter,
1776
3316
  fn.name,
1777
3317
  );
1778
3318
  };
1779
- const argAssigns = (pred: Block, target: Block, sub: Map<Value, string> | null = null): Stmt[] => {
3319
+ const argAssigns = (
3320
+ pred: Block,
3321
+ target: Block,
3322
+ sub: Map<Value, string> | null = null,
3323
+ keepSlot?: (i: number) => boolean,
3324
+ ): Stmt[] => {
1780
3325
  const succ = successorTo(pred, target);
1781
- return succ ? argAssignsFor(pred, succ, sub) : [];
3326
+ return succ ? argAssignsFor(pred, succ, sub, keepSlot) : [];
3327
+ };
3328
+
3329
+ // Side-effecting ops of a block, emitted as statements in program order: memory stores; any
3330
+ // EFFECTFUL op whose result nothing consumes (a void/discarded call, and an `opaque` standing for
3331
+ // an instruction asmlift could not model); and MATERIALIZED defs — a call/load whose value cannot
3332
+ // soundly render at its use is assigned to its named temp here, at its own program position.
3333
+ /** A pointer VALUE assigned into a map-declared pointer CELL, spelled so the assignment is legal
3334
+ * against ANY declaration of that cell. The byte-arithmetic guard renders such a right-hand
3335
+ * side `(u8 *)gS.pBuf + K` — the right ADDRESS in every world, which is the whole point of it,
3336
+ * and a `u8 *` where the declaration says `u16 *`. READING one is fine (C converts an object
3337
+ * pointer freely under a deref, a call argument or a compare); ASSIGNING one is `warning:
3338
+ * assignment from incompatible pointer type`, and this project's own `-Werror` compiler
3339
+ * template makes that FATAL — so a source that scores clean here fails to build in the tree a
3340
+ * user pastes it into, which no score gate can observe.
3341
+ *
3342
+ * `void *`, NOT the map's declared pointee: it is the one target assignment-compatible with any
3343
+ * object-pointer declaration, the same "same answer in every world" property the guard that
3344
+ * made the `u8 *` exists for. Trusting the map's pointee would put the spelling back in one
3345
+ * world. It costs nothing: agbcc compiles `p = (void *)((u8 *)p + 4)` and the warning-carrying
3346
+ * `p = (u8 *)p + 4` to BYTE-IDENTICAL objects (`-mthumb-interwork -Wimplicit -O2 -fhex-asm
3347
+ * -fprologue-bugfix`), so the fix is invisible to the differ and visible to the compiler. */
3348
+ const intoPtrCell = (lval: Expr, value: Expr): Expr => {
3349
+ const cell = lval.k === 'var' ? symCtx?.info(lval.name)?.shape === 'pointer' : ptrMemberDecl(lval, symCtx) !== null;
3350
+ if (!cell) {
3351
+ return value;
3352
+ }
3353
+ const vt = ctype(value);
3354
+ // BOTH ways a pointer value reaches here. `ctype` types params and locals, so it sees the
3355
+ // guard's own `(u8 *)…` and nothing else: a bare `gSym.pBuf` or a pointer global's value —
3356
+ // the population this rule exists for — reads `undefined` there. An already-`void *` value is
3357
+ // assignable as it stands.
3358
+ const isPtr = isPtrValue(value) || (vt?.kind === 'ptr' && vt.to.kind !== 'void');
3359
+ return isPtr ? { k: 'cast', to: T.ptr(T.void()), e: value } : value;
1782
3360
  };
1783
3361
 
1784
- // Side-effecting ops of a block, emitted as statements in program order: memory stores,
1785
- // calls whose return value nothing consumes (a void/discarded call), and MATERIALIZED defs
1786
- // — a call/load whose value cannot soundly render at its use is assigned to its named
1787
- // temp here, at its own program position.
1788
3362
  const sideEffects = (b: Block): Stmt[] => {
1789
3363
  const out: Stmt[] = [];
1790
3364
  for (const op of b.ops) {
1791
3365
  if (op.opcode === 'store') {
3366
+ // a mask-and-insert recognized over the ops (see the precompute above): the member
3367
+ // assignment, not the read-mask-or-store chain
3368
+ const bfs = bitfieldStore.get(op);
3369
+ if (bfs) {
3370
+ out.push({
3371
+ k: 'store',
3372
+ lval: { k: 'field', base: { k: 'var', name: bfs.global }, name: bfs.field, dot: true },
3373
+ value: expr(bfs.value),
3374
+ });
3375
+ continue;
3376
+ }
1792
3377
  // A store whose lvalue is a bare global (`gSym = v`, from an `&gSym` base at off 0) emits
1793
3378
  // as an ASSIGN, not a store — memAccess returns a `var` node for that case.
1794
3379
  const width = op.attrs.width as number;
@@ -1805,12 +3390,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1805
3390
  );
1806
3391
  if (lval0.k === 'var') {
1807
3392
  globalNames.add(lval0.name);
1808
- out.push({ k: 'assign', name: lval0.name, value: expr(op.operands[1]) });
3393
+ out.push({ k: 'assign', name: lval0.name, value: intoPtrCell(lval0, expr(op.operands[1])) });
1809
3394
  continue;
1810
3395
  }
1811
3396
  // signedness mirrors recoverTypes' store seed (word ⇒ signed, narrow ⇒ unsigned), so an
1812
3397
  // inserted cast declares the same scalar the recovered pointee would have.
1813
- out.push({ k: 'store', lval: lval0, value: expr(op.operands[1]) });
3398
+ out.push({ k: 'store', lval: lval0, value: intoPtrCell(lval0, expr(op.operands[1])) });
1814
3399
  } else if (op.opcode === 'astore') {
1815
3400
  const elemSize = op.attrs.elemSize as number;
1816
3401
  out.push({
@@ -1820,24 +3405,38 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1820
3405
  expr(op.operands[0]),
1821
3406
  expr(op.operands[1]),
1822
3407
  op.attrs.fieldOff as number | undefined,
3408
+ op.attrs.memberOff as number | undefined,
1823
3409
  elemSize,
1824
- elemSize === 4,
3410
+ // a member array's store spells through the member's OWN declaration, which the
3411
+ // recognizer records; every other astore keeps the width===4 convention
3412
+ (op.attrs.signed as boolean | undefined) ?? elemSize === 4,
1825
3413
  ctype,
1826
3414
  symCtx,
1827
3415
  ),
1828
3416
  value: expr(op.operands[2]),
1829
3417
  });
1830
- } else if (op.opcode === 'call' && op.results.length && !useSitesOf.has(op.results[0])) {
3418
+ } else if (EFFECTFUL_OPS.has(op.opcode) && op.results.length && !useSitesOf.has(op.results[0])) {
3419
+ // An effectful op whose result nobody reads is still an execution. `store`/`astore` have no
3420
+ // result and were handled above, so what reaches here is `call` and `opaque` — and an
3421
+ // `opaque` missing from this walk is an instruction the frontend could not model
3422
+ // disappearing with no diagnostic, which is the one thing this project refuses to do.
3423
+ //
3424
+ // Keyed on EFFECTFUL_OPS rather than the two opcode names: the deciding property is "has an
3425
+ // effect the result does not account for", which is what the flag already means, so the next
3426
+ // op to acquire it needs no edit here. Statement, not expression — `expr` on the result
3427
+ // routes through `lowerDef`, already where `opaque` becomes the gap, so this reuses the SAME
3428
+ // degradation a live opaque gets rather than inventing a second way to be loud.
1831
3429
  out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1832
3430
  } else if (materialize.has(op) && !absorbedLoads.has(op)) {
1833
3431
  // (an absorbed load's every consumer spells a named bitfield read — emitting its temp
1834
3432
  // here would recompile to a second load the asm does not have)
1835
3433
  const nm = varName.get(op.results[0])!;
1836
- out.push({ k: 'assign', name: nm, value: castAggregateAddr(nm, lowerDef(op, expr)) });
3434
+ out.push({ k: 'assign', name: nm, value: intoDeclaredTemp(nm, lowerDef(op, expr)) });
1837
3435
  }
1838
3436
  // a merge copy anchored at this const's original position (anchorConstCopies, above)
1839
3437
  for (const a of anchoredAt.get(op) ?? []) {
1840
3438
  out.push({ k: 'assign', name: a.name, value: expr(a.arg) });
3439
+ anchorsEmitted.add(a);
1841
3440
  }
1842
3441
  }
1843
3442
  return out;
@@ -1855,7 +3454,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1855
3454
  // to `header` is a conditional continue; its other edge, when it leaves the loop, is an early exit
1856
3455
  // (a `break` to `exit`, or an early `return` through a trampoline). Null outside any loop body —
1857
3456
  // the early-exit branch in structureBlock is inert there.
1858
- type LoopFrame = { header: Block; exit: Block; body: Set<Block> };
3457
+ type LoopFrame = { header: Block; exit: Block; body: Set<Block>; arms: LoopArm[] };
1859
3458
  let loopCtx: LoopFrame | null = null;
1860
3459
  const withLoop = <R>(frame: LoopFrame, run: () => R): R => {
1861
3460
  const prev = loopCtx;
@@ -1887,8 +3486,16 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1887
3486
  };
1888
3487
 
1889
3488
  // ── Regime-A switch recovery (structure/switch-recover.ts): the recognizer's case bodies call
1890
- // back into structureRegion, and Regime B (switch_br, below) shares its fall-through predicate.
1891
- const { recognizeSwitch, caseRegionReachesSibling } = makeSwitchRecovery({
3489
+ // back into structureRegion, and Regime B (switch_br, below) reads FOUR things from it — the
3490
+ // per-arm exit, the layout index, the chain linearization and where the `default:` label goes —
3491
+ // so neither regime states any of those four facts twice.
3492
+ //
3493
+ // `isNamed` reads `varName` LIVE, and by here THE NAMING WALK IS COMPLETE: every write to
3494
+ // `varName` and `backArgName` sits above, in the walk and the coalescing that follows it, and the
3495
+ // two places the map is handed to (`coalesceNames`, `makeLoopHazards`) only read it. So the
3496
+ // recognizer sees one settled naming however late a case body calls back into `structureRegion`,
3497
+ // and nothing here has to be rebuilt against a naming that moves underneath it.
3498
+ const { recognizeSwitch, analyzeArmExit, layoutIndex, defaultLayoutPos, chainArms } = makeSwitchRecovery({
1892
3499
  fn,
1893
3500
  defs,
1894
3501
  dom,
@@ -1897,7 +3504,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1897
3504
  isNamed: (v) => varName.has(v),
1898
3505
  isCmpOpcode: (opcode) => !!CMP_TO_BIN[opcode],
1899
3506
  switchAllowsNeqCase,
1900
- emitsAnchoredWrite: (blk) => blk.ops.some((o) => anchoredAt.has(o)),
3507
+ switchAllowsBoundCase,
3508
+ switchArmsFollowLayout,
3509
+ spellSwitchFallthrough,
3510
+ emitsOwnStatement: (blk) => blk.ops.some((o) => anchoredAt.has(o) || materialize.has(o)),
3511
+ blockOf,
3512
+ hoistDispatchCopies: (edges, liveAt) => hoistedDispatchAssigns(edges, liveAt),
1901
3513
  expr: (v) => expr(v),
1902
3514
  structureRegion: (b, stop) => structureRegion(b, stop),
1903
3515
  });
@@ -1926,23 +3538,18 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1926
3538
  }
1927
3539
  // Regime B: a `switch_br` (jump-table dispatch) lowers directly to the `switch` node — scrutinee,
1928
3540
  // per-successor case value, last successor = default. Case bodies delegate to structureRegion (as in
1929
- // Regime A). Fall-through between jump-table cases is not yet handled: if a case body reaches another
1930
- // case/default block inside the region, fail LOUD rather than duplicate it.
3541
+ // Regime A). Two table slots naming ONE block are one arm carrying both `case` labels; an arm whose
3542
+ // region flows into the NEXT arm is C fall-through (no `break`). Any other shape needs a `goto` and
3543
+ // fails LOUD rather than being duplicated or silently closed.
1931
3544
  if (term.opcode === 'switch_br') {
1932
3545
  const merge = ipdom.get(b) ?? stop;
1933
3546
  const succ = term.successors;
1934
3547
  const caseVals = term.attrs.cases as number[];
1935
- const targets = new Set<Block>(succ.map((s) => s.block));
1936
- if (caseRegionReachesSibling(targets, b, merge)) {
1937
- throw new StructureError(
1938
- `cannot structure '${fn.name}': fall-through between jump-table cases is not yet supported`,
1939
- );
1940
- }
1941
3548
  // Switch edges CARRY phi args (frontend/ssa.ts appends them terminator-generically) — each
1942
3549
  // case/default body must open with its edge's copies, exactly as cond_br edges do; dropping
1943
3550
  // them leaves the target's params uninitialized on the switch path. Two case values sharing
1944
3551
  // a target must agree on their args (else the copies are ambiguous → loud decline); the
1945
- // shared body is then structured per case entry.
3552
+ // shared body is then emitted ONCE under both labels.
1946
3553
  const argsSeen = new Map<Block, Value[]>();
1947
3554
  for (const s of succ) {
1948
3555
  const prev = argsSeen.get(s.block);
@@ -1953,16 +3560,146 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1953
3560
  }
1954
3561
  argsSeen.set(s.block, s.args as Value[]);
1955
3562
  }
1956
- const outCases: SwitchCase[] = succ.slice(0, -1).map((s, i) => ({
1957
- values: [caseVals[i]],
1958
- body: [...argAssignsFor(b, s), ...structureRegion(s.block, merge)],
1959
- fallsThrough: false,
1960
- }));
3563
+ // Group the case slots by target block, in TABLE order — `case 5: case 6:` is one arm with two
3564
+ // labels, not two copies of one body. Emission order is the array order (load-bearing: see the
3565
+ // l3/ast.ts fall-through note), so the adjacency check below is against this same order.
3566
+ const defEdge = succ[succ.length - 1];
3567
+ const arms: { entry: Block; edge: (typeof succ)[number]; values: number[] }[] = [];
3568
+ const armOf = new Map<Block, (typeof arms)[number]>();
3569
+ succ.slice(0, -1).forEach((s, i) => {
3570
+ let a = armOf.get(s.block);
3571
+ if (!a) {
3572
+ a = { entry: s.block, edge: s, values: [] };
3573
+ armOf.set(s.block, a);
3574
+ arms.push(a);
3575
+ }
3576
+ a.values.push(caseVals[i]);
3577
+ });
3578
+ // The blocks an arm could fall INTO. The default block counts only when it is a block of its
3579
+ // own: when it IS the merge, "the default" is just where the switch ends, and an arm reaching
3580
+ // it is a plain `break`.
3581
+ const siblings = new Set<Block>(arms.map((a) => a.entry));
3582
+ if (defEdge.block !== merge) {
3583
+ siblings.add(defEdge.block);
3584
+ }
3585
+ // ARM ORDER. Grouping the table's slots by target walks them in TABLE order, which for a dense
3586
+ // table is ascending case value — the neutral spelling, and the source's order only by
3587
+ // accident. Where the compiler has declared that its block layout IS the order the arms were
3588
+ // written (TargetDescription.switchArmsFollowLayout — the same fact and the same evidence
3589
+ // Regime A reads, and agbcc's jump tables carry it too: 8 dense arms written 5,2,0,4,1,3,6,7
3590
+ // lay their bodies out in that order under an ascending table), the bodies' layout is the
3591
+ // evidence and the arms take it.
3592
+ //
3593
+ // The policy orders the chain HEADS. A FALLING arm's position is not free — emission order is
3594
+ // load-bearing for correctness (the l3/ast.ts non-neutrality note), so it must sit directly
3595
+ // above the arm it falls into — and `chainArms` below re-threads it for exactly those, which
3596
+ // is the same per-SITE reading Regime A makes of the same declaration. `analyzeArmExit` does
3597
+ // not depend on emission order, so the exits can be settled first and reused below. Grouping
3598
+ // by target already gives every arm a DISTINCT entry block, so no two sort keys can be equal
3599
+ // and the tie-break Regime A needs on shared bodies has nothing to decide here.
3600
+ const exitOf = new Map<Block, ArmExit>();
3601
+ for (const entry of [...arms.map((a) => a.entry), defEdge.block]) {
3602
+ if (!exitOf.has(entry)) {
3603
+ exitOf.set(entry, analyzeArmExit(entry, b, merge, siblings));
3604
+ }
3605
+ }
3606
+ // A language whose `case` cannot fall through (Pascal) has no spelling for this shape, and
3607
+ // Regime B has no second recovery to fall back on — so it fails LOUD, as it does for every
3608
+ // other unspellable jump table. (Regime A, which does have a fallback, declines to
3609
+ // if-recovery instead: switch-recover.ts's PRE2.)
3610
+ if (!spellSwitchFallthrough && [...exitOf.values()].some((e) => e.kind === 'fallthrough')) {
3611
+ throw new StructureError(
3612
+ `cannot structure '${fn.name}': a jump-table case runs on into the next case, and the target ` +
3613
+ `language has no fall-through in its case statement`,
3614
+ );
3615
+ }
3616
+ if (switchArmsFollowLayout) {
3617
+ arms.sort((x, y) => layoutIndex(x.entry) - layoutIndex(y.entry));
3618
+ }
3619
+ // …and then the CHAIN re-threads that order for the falling arms, exactly as it does for the
3620
+ // comparison tree: ONE definition (`chainArms`) for the linearization, the adjacency it
3621
+ // guarantees, and the three shapes no linear order spells. The arm-order policy above is read
3622
+ // for the chain HEADS only — the same per-SITE reading Regime A makes of the same
3623
+ // declaration, rather than a whole-switch gate on every arm being closed.
3624
+ const preChain = arms.map((a) => a.entry);
3625
+ // A table whose default block is ALSO a case target is that case's arm, not a separate one:
3626
+ // it is already in `preChain`, and `defaultLayoutPos` withholds the label's position for it.
3627
+ const dfltArm = defEdge.block !== merge && !armOf.has(defEdge.block) ? defEdge.block : null;
3628
+ const chained = chainArms(preChain, dfltArm, exitOf);
3629
+ if (chained === null) {
3630
+ throw new StructureError(
3631
+ `cannot structure '${fn.name}': the jump table's case arms do not linearize — two arms fall ` +
3632
+ `into one, the default arm falls into a case, or the fall-through is a cycle`,
3633
+ );
3634
+ }
3635
+ const armByEntry = new Map(arms.map((a) => [a.entry, a]));
3636
+ const ordered = chained.map((e) => armByEntry.get(e)!);
3637
+ const orderIntact = chained.every((e, i) => e === preChain[i]);
3638
+ // The `default:` arm carries that evidence too, and a table hands it over the same way: the
3639
+ // range check BRANCHES to the default (`bhi .Ldefault`), so its block is never one the
3640
+ // dispatch ran into — measured, a 5-arm table lays the default's body at each of the six
3641
+ // positions the source can write it in exactly there. `defaultLayoutPos` states the refusals.
3642
+ const defaultAt =
3643
+ defEdge.block !== merge
3644
+ ? defaultLayoutPos(
3645
+ defEdge.block,
3646
+ ordered.map((a) => ({ entry: a.entry, fallsThrough: exitOf.get(a.entry)!.kind === 'fallthrough' })),
3647
+ { placedByDispatch: false, orderIntact },
3648
+ )
3649
+ : undefined;
3650
+ // ONE emission order for the whole statement: the case arms, then the default. Adjacency is
3651
+ // read off this array, so "falls into the next arm" needs no separate rule for a case that
3652
+ // falls into the default (it is the arm after the last case, and legal C). Where the LABEL is
3653
+ // printed is `defaultAt`, which the emission order does not follow.
3654
+ const emitOrder = [...ordered, { entry: defEdge.block, edge: defEdge, values: null as number[] | null }];
3655
+ // Each arm's switch-edge copies, computed ONCE and in emission order: `argAssignsFor` mints
3656
+ // swap-cycle temp names, so calling it twice for one edge burns a temp number and changes the
3657
+ // output (the same reason emitDoWhile reuses its `updates`).
3658
+ const edgeCopies = emitOrder.map((a) => argAssignsFor(b, a.edge));
3659
+ const bodies = emitOrder.map((a, i) => {
3660
+ const exit = exitOf.get(a.entry)!;
3661
+ if (exit.kind === 'unstructurable') {
3662
+ throw new StructureError(`cannot structure '${fn.name}': ${exit.why}`);
3663
+ }
3664
+ const ft = exit.kind === 'fallthrough';
3665
+ const next = emitOrder[i + 1];
3666
+ // Re-read off the EMISSION array rather than trusted from `chainArms` — the seam where a
3667
+ // position acquires control-flow meaning, checked on the same side of it as Regime A's.
3668
+ if (ft && next?.entry !== exit.to) {
3669
+ throw new StructureError(
3670
+ `cannot structure '${fn.name}': ${a.values ? `case ${a.values.join('/')}` : 'the default arm'} falls ` +
3671
+ `through into an arm that is not the next one emitted — C fall-through only reaches the arm below`,
3672
+ );
3673
+ }
3674
+ // The arm fallen INTO opens with its own switch-edge copies, which are how the dispatch hands
3675
+ // it its block parameters. On the fall-through path those copies would RE-RUN and overwrite
3676
+ // what the falling arm just computed (`case 0: v=7; case 1: v=5;` — the case-0 path calling
3677
+ // with 5). They cannot simply be dropped either: entering that arm by its own case value
3678
+ // needs them. Hoisting them above the switch is possible but not always safe (another arm may
3679
+ // read the same name first), so this shape declines LOUD; recovering it is future work.
3680
+ if (ft && edgeCopies[i + 1].length) {
3681
+ throw new StructureError(
3682
+ `cannot structure '${fn.name}': the case fallen into takes a value from the switch edge, ` +
3683
+ `which the fall-through path would re-run`,
3684
+ );
3685
+ }
3686
+ return {
3687
+ // A falling-through arm stops AT its successor arm, which then emits that body once under
3688
+ // its own labels; a closed arm runs to the merge as before.
3689
+ body: [...edgeCopies[i], ...structureRegion(a.entry, ft ? exit.to : merge)],
3690
+ fallsThrough: ft,
3691
+ };
3692
+ });
3693
+ const outCases: SwitchCase[] = ordered.map((a, i) => ({ values: a.values, ...bodies[i] }));
3694
+ // An EMPTY default arm is not a default at all: it is where the switch ends, which is where
3695
+ // an unmatched scrutinee goes anyway. Emitting the label with nothing under it says nothing
3696
+ // and is not even valid C89 (a label needs a statement).
3697
+ const defBody = bodies[bodies.length - 1].body;
1961
3698
  const sw: Stmt = {
1962
3699
  k: 'switch',
1963
3700
  scrutinee: expr(term.operands[0]),
1964
3701
  cases: outCases,
1965
- default: [...argAssignsFor(b, succ[succ.length - 1]), ...structureRegion(succ[succ.length - 1].block, merge)],
3702
+ ...(defBody.length ? { default: defBody, ...(defaultAt !== undefined ? { defaultAt } : {}) } : {}),
1966
3703
  };
1967
3704
  out.push(sw);
1968
3705
  if (merge && merge !== stop) {
@@ -1992,21 +3729,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1992
3729
  return out;
1993
3730
  }
1994
3731
 
1995
- // guard-fused loop: this cond_br decides "enter loop header h vs its exit". Emit the
1996
- // inits unconditionally, then a while whose own test subsumes this guard. Never fuse when `b`
1997
- // is itself the header (a guard-LESS single-block do-while would emit the update once before a
3732
+ // guarded self-loop: this cond_br decides "enter loop header h vs its exit". Emit the inits
3733
+ // unconditionally, then either a `while` whose own test subsumes this guard (fused only under
3734
+ // the guard proof below), or the guard kept as its own `if` around a bottom-tested `do-while`
3735
+ // (gcc's "guard + do-while" shape, emitted as itself). Never claim when `b` is itself the
3736
+ // header (a guard-LESS single-block do-while would emit the update once before a
1998
3737
  // wrongly-`while` loop) — require a DISTINCT dominating guard block.
1999
3738
  for (const h of [takenB, fallB]) {
2000
- const li = loops.get(h);
2001
- if (li && h !== b && (takenB === li.exit || fallB === li.exit)) {
2002
- // A MATERIALIZED header op's temp is assigned only inside the body (sideEffects), but the
2003
- // un-rotated `while` condition renders BEFORE the body ever ran reading the temp
2004
- // uninitialized on the first test. Mirror of the headerPure gate: decline loud.
2005
- if (li.header.ops.some((o) => materialize.has(o))) {
2006
- throw new StructureError(
2007
- `cannot structure '${fn.name}': loop header holds a materialized def its condition would read uninitialized`,
2008
- );
2009
- }
3739
+ // `h` may be the header itself or its PURE PREHEADER (the LoopInfo records which): the
3740
+ // guard's branch enters the loop either way, and the preheader's defs render inline.
3741
+ const li = loops.get(h) ?? [...loops.values()].find((l) => l.preheader === h);
3742
+ if (li && h !== b && li.header !== b && (takenB === li.exit || fallB === li.exit)) {
2010
3743
  // Self-loop emitter hazards: the while condition, the header→exit args, and every
2011
3744
  // post-loop use of a header-computed value render under the un-rotation sub — sound only
2012
3745
  // when their loop-variable reads go through sub-mapped back-edge args (post-update). A
@@ -2014,36 +3747,206 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2014
3747
  // holds → decline LOUD, never emit wrong code.
2015
3748
  const sub = loopSub(li);
2016
3749
  const updates = argAssigns(li.header, li.header);
2017
- const updateWrites = updateWriteSet(updates);
3750
+ const updateWrites = loopWriteSet(updates, [li.header], li.header);
2018
3751
  const hterm = li.header.ops[li.header.ops.length - 1];
2019
3752
  const hexitArgs = (successorTo(li.header, li.exit)?.args ?? []) as Value[];
3753
+ // FUSION IS ONLY SOUND WHEN EVERYTHING IT DROPS IS REDUNDANT. `isGuardShapedPred` asks about
3754
+ // the SHAPE alone — branches to the header, branches to the exit — and an `if` on something
3755
+ // else entirely has that shape too. `entryVals` maps each header param and back-edge arg to
3756
+ // the arg the guard passes into the loop, so reading through it models the first iteration:
3757
+ // the state the guard tested. Two claims are checked against it, both LOUD on failure.
3758
+ const guardExit = successorTo(b, li.exit)!; // the fusion condition above guarantees this edge
3759
+ // The loop's init edge: the guard's own edge into the header, or the preheader's `br`
3760
+ // when one stands between — its args are what the first iteration actually receives.
3761
+ const initFrom = li.preheader ?? b;
3762
+ const initArgs = (successorTo(initFrom, li.header)?.args ?? []) as Value[];
3763
+ // Params first, BACK-EDGE ARGS SECOND: one value can be both — param `i+1` of a shifting
3764
+ // pair is also the back-edge arg of param `i` — and the emitted expression renders it
3765
+ // under the un-rotation substitution, so that reading is the one that must win.
3766
+ const entryVals = new Map<Value, Value>();
3767
+ li.header.params.forEach((p, i) => initArgs[i] !== undefined && entryVals.set(p, initArgs[i]));
3768
+ li.header.params.forEach(
3769
+ (_, i) => initArgs[i] !== undefined && entryVals.set(li.backArgOfParam[i], initArgs[i]),
3770
+ );
3771
+ // (1) Is the guard PROVABLY the loop's own test? Fusing DELETES it and lets the `while`
3772
+ // re-test, so an unproven fuse would lose the `if` outright and run a loop the source
3773
+ // skipped. Structural equality is a sufficient proof, never a necessary one: `str[0]` and
3774
+ // `str[i]` at `i = 0`, or a signed `e <= 0` beside an unsigned `e != 0`, are the same test
3775
+ // spelled differently, and normalising those needs a canonical form this pass does not
3776
+ // have. An unproven guard therefore KEEPS its `if`, with the loop as a `do-while` inside it
3777
+ // — every test the asm performs is emitted as itself. The proof still gates the SINK below:
3778
+ // a sunk copy runs once per iteration, and only the proof (fused form) or the kept `if`
3779
+ // makes the zero-trip path skip it. The kept `if` would widen the sink's key to unproven
3780
+ // guards too; no row needs that yet, so it stays keyed on the proof.
3781
+ const enterIsTaken = takenB === h;
3782
+ const contIsTaken = hterm.successors[0].block === li.header;
3783
+ const guardProven = sameAtEntry(hterm.operands[0], term.operands[0], entryVals, enterIsTaken !== contIsTaken);
3784
+ // Fused `while` additionally requires a header free of MATERIALIZED defs: their temps are
3785
+ // assigned only inside the body (sideEffects), and an un-rotated `while` condition renders
3786
+ // BEFORE the body ever ran — reading a temp uninitialized on the first test. The kept-guard
3787
+ // `do-while` tests after the body, so it carries no such restriction.
3788
+ const fused = guardProven && !li.header.ops.some((o) => materialize.has(o));
3789
+ // A pre-update exit copy is repairable rather than fatal: emitted at the TOP of the body it
3790
+ // captures the value one iteration before the update, which is what the exit edge carries.
3791
+ // Its post-loop copy is then dropped, so the ZERO-TRIP path needs the guard→exit edge as a
3792
+ // seed — the only edge holding the never-entered value. That is why the sink demands the
3793
+ // proof above: it makes the fused zero-trip path load-bearing.
3794
+ const sunk = guardProven
3795
+ ? sinkablePreUpdateSlots(li.header, li.exit, hexitArgs, new Set([li.header]), sub, updateWrites)
3796
+ : new Set<number>();
3797
+ // (2) Every exit copy the fused form KEEPS renders after the loop, on the zero-trip path
3798
+ // too — where the loop variables still hold their init values. It must therefore produce
3799
+ // what the guard→exit edge carries, since that edge is dropped. A sunk slot is exempt: its
3800
+ // seed and its body copy cover the two paths separately.
3801
+ const staleExit = hexitArgs.findIndex(
3802
+ (a, j) => !sunk.has(j) && !sameAtEntry(a, guardExit.args[j] as Value, entryVals),
3803
+ );
3804
+ if (staleExit >= 0) {
3805
+ throw new StructureError(
3806
+ `cannot structure '${fn.name}': the fused guard's exit edge carries a value the post-loop ` +
3807
+ `copies do not reproduce on a zero-trip run`,
3808
+ );
3809
+ }
3810
+ const sunkSlot = (j: number) => sunk.has(j);
3811
+ const keptSlot = (j: number) => !sunk.has(j);
2020
3812
  if (
2021
3813
  loopUpdateHazard(
2022
3814
  hterm.operands[0],
2023
- hexitArgs,
3815
+ hexitArgs.filter((_, j) => keptSlot(j)),
2024
3816
  new Set([li.header]),
2025
3817
  sub,
2026
3818
  updateWrites,
2027
3819
  null,
2028
- new Set(li.header.params),
2029
3820
  )
2030
3821
  ) {
2031
3822
  throw new StructureError(
2032
3823
  `cannot structure '${fn.name}': loop condition or a post-loop value reads a pre-update loop variable`,
2033
3824
  );
2034
3825
  }
2035
- out.push(...argAssigns(b, h)); // loop-variable initialisation
2036
- out.push(emitWhile(li, updates));
3826
+ // The seed and the loop init are two copy groups from the SAME block, emitted back to back,
3827
+ // and nothing sequentializes them against each other: a seed that writes a name the init
3828
+ // then reads hands the loop the wrong starting value. Computed once — a second
3829
+ // `argAssigns` for one edge burns a swap-cycle temp number and changes the output.
3830
+ const seed = argAssigns(b, li.exit, null, sunkSlot);
3831
+ const seedWrites = new Set(seed.filter((st) => st.k === 'assign').map((st) => st.name));
3832
+ if (initArgs.some((a) => readsClobbered(a, new Map(), seedWrites))) {
3833
+ throw new StructureError(
3834
+ `cannot structure '${fn.name}': seeding the zero-trip value would overwrite a value the ` +
3835
+ `loop initialisation reads`,
3836
+ );
3837
+ }
3838
+ // ZERO-TRIP reads (kept-guard form only): the body sits inside `if (guard)`, so a
3839
+ // materialized header def's temp is assigned only when the guard held. A kept exit arg or
3840
+ // a post-loop read reaching such a temp — by its NAME, which is how a materialized def
3841
+ // renders — reads it uninitialized on the guard-false path, silently. Loop-variable names
3842
+ // are exempt (the inits write them unconditionally, and staleExit already proved their
3843
+ // zero-trip values); so is anything named outside the header. Decline loud.
3844
+ if (!fused) {
3845
+ const paramNames = new Set(li.header.params.map((q) => varName.get(q)));
3846
+ const bodyTemp = (v: Value): boolean => {
3847
+ const d = defs.get(v);
3848
+ return (
3849
+ d !== undefined &&
3850
+ materialize.has(d) &&
3851
+ opBlock.get(d) === li.header &&
3852
+ varName.has(v) &&
3853
+ !paramNames.has(varName.get(v))
3854
+ );
3855
+ };
3856
+ const reachesBodyTemp = (root: Value): boolean => {
3857
+ const stack = [root];
3858
+ const seen = new Set<Value>();
3859
+ while (stack.length) {
3860
+ const v = stack.pop()!;
3861
+ if (seen.has(v)) {
3862
+ continue;
3863
+ }
3864
+ seen.add(v);
3865
+ if (sub.has(v)) {
3866
+ continue; // renders as its loop variable's name — unconditionally initialized
3867
+ }
3868
+ if (bodyTemp(v)) {
3869
+ return true;
3870
+ }
3871
+ if (varName.has(v)) {
3872
+ continue; // named outside the guarded body — assigned on both paths
3873
+ }
3874
+ const d = defs.get(v);
3875
+ if (d) {
3876
+ stack.push(...d.operands);
3877
+ }
3878
+ }
3879
+ return false;
3880
+ };
3881
+ if (
3882
+ hexitArgs.some((a, j) => !sunk.has(j) && reachesBodyTemp(a)) ||
3883
+ [...(liveIn.get(li.exit) ?? [])].some(bodyTemp)
3884
+ ) {
3885
+ throw new StructureError(
3886
+ `cannot structure '${fn.name}': a post-loop read reaches a temp the guarded body may never assign`,
3887
+ );
3888
+ }
3889
+ }
3890
+ const inits = argAssigns(initFrom, li.header);
3891
+ const loopStmt = emitWhile(
3892
+ li,
3893
+ updates,
3894
+ preUpdateCopies(li.exit, hexitArgs, sunk, li.header),
3895
+ fused ? 'while' : 'dowhile',
3896
+ );
3897
+ // The guard-read substitution: an init arg reads as its loop variable's NAME. The inits
3898
+ // just assigned them (value-identical), and that is the source spelling — `if (n > 0)`
3899
+ // tests the loop variable, which is also the parked register the target's guard reads.
3900
+ // Not an UNMATERIALIZED const: the guard compared an immediate (`cmp rX, #0`), and an
3901
+ // immediate is what re-spelling it as the counter's name would un-spell.
3902
+ const gsub = new Map<Value, string>();
3903
+ li.header.params.forEach((p, i) => {
3904
+ const a = initArgs[i];
3905
+ const ad = a !== undefined ? defs.get(a) : undefined;
3906
+ if (a !== undefined && !(ad?.opcode === 'const' && !materialize.has(ad))) {
3907
+ gsub.set(a, varName.get(p)!);
3908
+ }
3909
+ });
3910
+ if (!fused) {
3911
+ // The kept guard's condition renders AFTER the seed and the inits, but tests the state
3912
+ // BEFORE them — a read of a just-(non-identity-)written name is sound only through
3913
+ // `gsub`, whose mapped values the inits deliberately hold.
3914
+ const writes = new Set([...seedWrites, ...updateWriteSet(inits)]);
3915
+ if (readsClobbered(term.operands[0], gsub, writes)) {
3916
+ throw new StructureError(
3917
+ `cannot structure '${fn.name}': the kept guard's condition reads a name the loop initialisation overwrites`,
3918
+ );
3919
+ }
3920
+ }
3921
+ out.push(...seed); // zero-trip value for the sunk copies
3922
+ out.push(...inits); // loop-variable initialisation
3923
+ if (fused) {
3924
+ out.push(loopStmt);
3925
+ } else {
3926
+ let gcond = exprWith(gsub)(term.operands[0]);
3927
+ if (!enterIsTaken) {
3928
+ gcond = negateCond(gcond);
3929
+ } // entering the loop must be `taken`
3930
+ out.push(mkIf(gcond, [loopStmt], []));
3931
+ }
2037
3932
  // The header→exit edge may carry non-identity phi args (the exit param merges the guard-false
2038
3933
  // value with the loop's final value). Emit those copies after the loop — dropping them returns
2039
3934
  // a stale value. Read under the un-rotation substitution (post-loop the params hold their
2040
3935
  // updated values), and structure the exit region under the same substitution so a post-loop
2041
3936
  // use of a loop value reads its name.
2042
- out.push(...withSub(sub, () => [...argAssigns(li.header, li.exit, sub), ...structureRegion(li.exit, stop)]));
3937
+ out.push(
3938
+ ...withSub(sub, () => [...argAssigns(li.header, li.exit, sub, keptSlot), ...structureRegion(li.exit, stop)]),
3939
+ );
2043
3940
  return out;
2044
3941
  }
2045
3942
  }
2046
3943
 
3944
+ // Is THIS edge one of the enclosing loop's admitted early-`return` arms? Keyed on the edge
3945
+ // because ownership is: a second edge into the same block is a separate question. No shape today
3946
+ // separates it from keying on the target alone — this holds the invariant, it does not fix an
3947
+ // observed bug.
3948
+ const isArm = (t: Block) => !!loopCtx && loopCtx.arms.some((a) => a.from === b && a.to === t);
3949
+
2047
3950
  // Conditional latch / in-body early exit: one edge of this cond_br is the loop back-edge (a
2048
3951
  // continue to `loopCtx.header`); the other LEAVES the loop. When the leaving edge lands on the
2049
3952
  // loop's own exit block it is a `break`; when it trampolines to a `return` it is an early `return`.
@@ -2076,26 +3979,19 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2076
3979
  // `readsClobbered` distinguishes the two at the VALUE level; on a hazard, decline (fall
2077
3980
  // through → honest loud fail) rather than emit wrong code.
2078
3981
  const updateCopies = argAssigns(b, loopCtx.header);
2079
- const updateWrites = updateWriteSet(updateCopies);
3982
+ const updateWrites = loopWriteSet(updateCopies, loopCtx.body, loopCtx.header);
2080
3983
  const exitArgs = (successorTo(b, exitB)?.args ?? []) as Value[];
2081
3984
  // The exit ARM may also read loop-body-computed values directly (an exitB dominated by `b`
2082
3985
  // — e.g. its `ret` operand), not just through edge args: apply the same escape test to the
2083
3986
  // arm's region (blocks reachable from exitB outside the loop body).
3987
+ //
3988
+ // WHAT THE REGION IS FOR: this arm renders INSTEAD of the next iteration, behind an update
3989
+ // already emitted, so a read of a loop variable here wanted the value it had before that —
3990
+ // `if (found) { *out = i; return; }` would store `i + 1`. Handing the arm's own region makes
3991
+ // the escape check judge exactly those reads.
2084
3992
  const exitRegion = new Set([exitB, ...reachFrom(exitB)].filter((x) => !loopCtx!.body.has(x)));
2085
- const hazard = loopUpdateHazard(
2086
- term.operands[0],
2087
- exitArgs,
2088
- loopCtx.body,
2089
- sub,
2090
- updateWrites,
2091
- exitRegion,
2092
- new Set(loopCtx.header.params),
2093
- );
2094
- if (
2095
- !hazard &&
2096
- !loopCtx.body.has(exitB) &&
2097
- ((isBreak && breakSafe) || (!isBreak && leadsToReturnOnly(exitB, loopCtx.body)))
2098
- ) {
3993
+ const hazard = loopUpdateHazard(term.operands[0], exitArgs, loopCtx.body, sub, updateWrites, exitRegion);
3994
+ if (!hazard && !loopCtx.body.has(exitB) && ((isBreak && breakSafe) || (!isBreak && isArm(exitB)))) {
2099
3995
  out.push(...updateCopies); // the loop update, RAW (i++, p>>=1, …)
2100
3996
  let leaveCond = exprWith(sub)(term.operands[0]);
2101
3997
  if (contIsTaken) {
@@ -2120,7 +4016,27 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2120
4016
 
2121
4017
  const cond = expr(term.operands[0]);
2122
4018
  const ipd = ipdom.get(b) ?? null; // null ⇒ the arms diverge (both reach EXIT), no join
2123
- const merge = ipd ?? stop;
4019
+ // Inside a loop body, a join OUTSIDE that body is not this `if`'s join: an arm that leaves the
4020
+ // loop `return`s and never comes back, so what is left reconverges at the loop's own
4021
+ // continuation. Post-dominance cannot see that — agbcc/gcc merge every `return` into one
4022
+ // epilogue, which the early return and the post-loop path both reach — and structuring the
4023
+ // in-loop arm towards it walks through the latch and back into the header (a loud `onStack`
4024
+ // decline).
4025
+ //
4026
+ // `stop` stands in for the real join, so it has to BE the real join: one side must already end
4027
+ // this region — an admitted arm (its `return` terminates that path) or `stop` itself. Two sides
4028
+ // meeting somewhere further down instead would each re-emit everything from there to the bottom,
4029
+ // doubling per nesting level; those keep the CFG join and the old loud decline. Lifting that
4030
+ // needs a post-dominator computed over the body alone with the arms DELETED — treating them as
4031
+ // exits does not work, since post-dominance cannot express "where the paths that did not
4032
+ // return meet".
4033
+ const clampToLoop =
4034
+ loopCtx !== null &&
4035
+ ipd !== null &&
4036
+ !loopCtx.body.has(ipd) &&
4037
+ term.successors.some((sc) => isArm(sc.block) || sc.block === stop) &&
4038
+ term.successors.every((sc) => loopCtx!.body.has(sc.block) || isArm(sc.block));
4039
+ const merge = clampToLoop ? stop : (ipd ?? stop);
2124
4040
  // Per-successor records, NOT successorTo(b, block): a cond_br whose two edges reach the SAME
2125
4041
  // block with different args would otherwise give both arms the first edge's copies.
2126
4042
  const thenS = [...argAssignsFor(b, term.successors[0]), ...structureRegion(takenB, merge)];
@@ -2136,6 +4052,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2136
4052
  out.push({ k: 'if', cond: negateCond(cond), then: elseS, else: thenS });
2137
4053
  return out;
2138
4054
  }
4055
+ if (negateJoinedBranchSense && ipd !== null && thenS.length && elseS.length) {
4056
+ // JOINED arms only (`ipd !== null` — a divergent if belongs to preserveDivergentBranchSense
4057
+ // above, and without the check a /flip-branch variant would fall through here and get
4058
+ // flipped BACK, collapsing the {divergent flipped × joined flipped} combination), and both
4059
+ // arms real: the flipped spelling is a genuine sibling, not noise on a one-armed if
4060
+ out.push({ k: 'if', cond: negateCond(cond), then: elseS, else: thenS });
4061
+ if (merge && merge !== stop) {
4062
+ out.push(...structureRegion(merge, stop));
4063
+ }
4064
+ return out;
4065
+ }
2139
4066
  out.push(mkIf(cond, thenS, elseS));
2140
4067
  if (merge && merge !== stop) {
2141
4068
  out.push(...structureRegion(merge, stop));
@@ -2153,19 +4080,66 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2153
4080
  };
2154
4081
  const loopSub = (li: LoopInfo): Map<Value, string> => subFor(li.header.params, li.backArgOfParam);
2155
4082
 
2156
- // Un-rotate a header's do-while latch into a `while`: the test reads the header's own
2157
- // params (back-edge args substituted back), and the body is the header's SIDE EFFECTS in
2158
- // program order followed by its parallel update. The side effects are required a copies-only
2159
- // body would silently delete every store/discarded call in the header. Effect order is right by
2160
- // construction: statements read pre-update names, the updates land after.
2161
- const emitWhile = (li: LoopInfo, updates?: Stmt[]): Stmt => {
4083
+ // The sunk exit copies, as body statements: `dest = <the arg, rebuilt here>`. The sink's gates
4084
+ // are stated against `exprWith(null)` every name it stops at holds, at the top of the body,
4085
+ // what it held where the edge read it so the arg is spelled with no substitution. Slot order
4086
+ // keeps it deterministic.
4087
+ //
4088
+ // NOT `expr`: an ambient `activeSub` is an ENCLOSING loop's post-loop naming, which the sink's
4089
+ // walk does not model, so a REBUILT tree here would be spelled under names that substitution has
4090
+ // redefined. DEFENSIVE — no input reaches it: instrumenting this line across the whole suite,
4091
+ // `activeSub` is null at every call. It stays because the failure it names is silent (a wrong
4092
+ // value, not a gap) and the cost of keeping it is one branch.
4093
+ //
4094
+ // The destination name is read as an invariant, not checked: every block param carries one.
4095
+ // `assertResolved` is what catches a widening that breaks that.
4096
+ //
4097
+ // `home` is the loop HEADER — the block whose body these copies open, and so the block from which
4098
+ // the write is reachable. Recorded rather than inferred because `exit` says the opposite (see
4099
+ // `sunkCopyHomes`).
4100
+ const preUpdateCopies = (exit: Block, exitArgs: readonly Value[], sunk: Set<number>, home: Block): Stmt[] =>
4101
+ [...sunk]
4102
+ .sort((x, y) => x - y)
4103
+ .map((j) => {
4104
+ if (activeSub !== null && !varName.has(exitArgs[j])) {
4105
+ throw new StructureError(
4106
+ `cannot structure '${fn.name}': a pre-update exit copy would rebuild a computed value ` +
4107
+ `inside a loop nested in another loop's post-loop naming`,
4108
+ );
4109
+ }
4110
+ const name = varName.get(exit.params[j])!;
4111
+ sunkCopyHomes.push({ name, home });
4112
+ return {
4113
+ k: 'assign' as const,
4114
+ name,
4115
+ value: exprWith(null)(exitArgs[j]),
4116
+ };
4117
+ });
4118
+
4119
+ // A guarded self-loop's body and latch test. The test reads the header's own params (back-edge
4120
+ // args substituted back), and the body is any sunk trailing copies, then the header's SIDE
4121
+ // EFFECTS in program order, then its parallel update. The side effects are required — a
4122
+ // copies-only body would silently delete every store/discarded call in the header. Effect order
4123
+ // is right by construction: statements read pre-update names, the updates land after, and a sunk
4124
+ // copy reads the top-of-iteration value it is there to capture.
4125
+ //
4126
+ // The SAME cond/body serve both forms: as a `while` (guard fused away — the un-rotation), the
4127
+ // first test reads the just-emitted init values; as a `dowhile` (guard kept as its own `if`),
4128
+ // every test runs after the update wrote the params' next values. Both are what that form's
4129
+ // source spelling means.
4130
+ const emitWhile = (
4131
+ li: LoopInfo,
4132
+ updates?: Stmt[],
4133
+ sunkCopies: Stmt[] = [],
4134
+ kind: 'while' | 'dowhile' = 'while',
4135
+ ): Stmt => {
2162
4136
  const term = li.header.ops[li.header.ops.length - 1];
2163
4137
  let cond = exprWith(loopSub(li))(term.operands[0]);
2164
4138
  if (term.successors[0].block !== li.header) {
2165
4139
  cond = negateCond(cond);
2166
4140
  } // loop-continue must be `taken`
2167
- const body = [...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
2168
- return { k: 'while', cond, body };
4141
+ const body = [...sunkCopies, ...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
4142
+ return kind === 'while' ? { k: 'while', cond, body } : { k: 'dowhile', cond, body };
2169
4143
  };
2170
4144
 
2171
4145
  // Test-at-top `while`: the header's cond_br is the loop condition. The body is a region that stops
@@ -2183,7 +4157,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2183
4157
  // Mirror the br/cond_br cases: argAssigns then structureRegion. Structure the body under this
2184
4158
  // loop's frame so an in-body conditional exit (break / early return) is recognised instead of
2185
4159
  // tripping the header-re-entry `onStack` guard.
2186
- const body = withLoop({ header: wl.header, exit: wl.exit, body: wl.body }, () => [
4160
+ const body = withLoop({ header: wl.header, exit: wl.exit, body: wl.body, arms: wl.arms }, () => [
2187
4161
  ...argAssigns(wl.header, wl.bodyEntry),
2188
4162
  ...structureRegion(wl.bodyEntry, wl.header),
2189
4163
  ]);
@@ -2210,14 +4184,91 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2210
4184
  // path applies; on a hazard, decline LOUD.
2211
4185
  const sub = latchSub(dw);
2212
4186
  const updates = argAssigns(dw.latch, dw.header);
2213
- const updateWrites = updateWriteSet(updates);
4187
+ const updateWrites = loopWriteSet(updates, dw.body, dw.header);
2214
4188
  const lterm = dw.latch.ops[dw.latch.ops.length - 1];
4189
+ // KNOWN GAP, and the reason the sink stands down rather than repairing anything. A body
4190
+ // block's param may adopt a LOOP VARIABLE's name (canTakeName waives the liveness half for a
4191
+ // pure alias, an argument that does not carry when the name came from `backArgName` — it is
4192
+ // then a different value's). The arm's copy into it is a real write partway through the body,
4193
+ // and everything rendered after it reads the name RAW: the update, the bottom test, the
4194
+ // header's own ops, the latch's side effects.
4195
+ //
4196
+ // The refusal is on the NAME, not on who reads it: the readers are every statement the loop
4197
+ // emits, which is not a set worth enumerating when the name alone is the whole signal.
4198
+ //
4199
+ // The emitted C is wrong whenever this shape occurs, sink or no sink — it is a naming-pipeline
4200
+ // defect, not this pass's, and repairing the exit copy does not touch it. What IS this pass's
4201
+ // is not to UNLOCK such a loop: with no sink these functions decline on the pre-update hazard,
4202
+ // so standing down keeps them loud rather than trading a decline for a silent wrong answer.
4203
+ const headerNames = new Set(dw.header.params.map((p) => varName.get(p)));
4204
+ const bodyRebinds = new Set<string>();
4205
+ for (const bb of dw.body) {
4206
+ if (bb === dw.header) {
4207
+ continue;
4208
+ }
4209
+ bb.params.forEach((pv, i) => {
4210
+ const n = varName.get(pv);
4211
+ if (n === undefined) {
4212
+ return;
4213
+ }
4214
+ // Only a copy that SURVIVES argAssigns' identity elision writes anything, so an edge whose
4215
+ // arg already carries the name does not count.
4216
+ for (const { succ } of inEdgeRecords(preds, bb)) {
4217
+ if (varName.get(succ.args[i]) !== n) {
4218
+ bodyRebinds.add(n);
4219
+ }
4220
+ }
4221
+ });
4222
+ }
4223
+ const rebindHazard = [...bodyRebinds].some((n) => headerNames.has(n));
2215
4224
  const exitArgs = (successorTo(dw.latch, dw.exit)?.args ?? []) as Value[];
2216
- if (loopUpdateHazard(lterm.operands[0], exitArgs, dw.body, sub, updateWrites, null, new Set(dw.header.params))) {
4225
+ // A pre-update exit copy moves to the TOP of the body, where the loop variables still hold
4226
+ // their top-of-iteration values. No zero-trip seed here: a `do-while` always runs its body, so
4227
+ // any other predecessor of the exit is an ordinary edge some enclosing `if` already emits.
4228
+ const sunk = rebindHazard
4229
+ ? new Set<number>()
4230
+ : sinkablePreUpdateSlots(dw.header, dw.exit, exitArgs, dw.body, sub, updateWrites);
4231
+ // The post-loop region the escaped-value check judges: everything the loop does not emit itself.
4232
+ // An early-`return` arm the loop OWNS renders inside the body, ahead of the update, so a read of
4233
+ // a loop variable there is the pre-update value it wants — counting it as post-loop would decline
4234
+ // the loop for a hazard that cannot happen. Blocks the arm merely reaches are not `owned`, and stay
4235
+ // post-loop where the other path really does read them after the update.
4236
+ const owned = new Set(dw.arms.flatMap((a) => [...a.owned]));
4237
+ const postLoop = new Set(fn.blocks.filter((bb) => !dw.body.has(bb) && !owned.has(bb)));
4238
+ if (
4239
+ loopUpdateHazard(
4240
+ lterm.operands[0],
4241
+ exitArgs.filter((_, j) => !sunk.has(j)),
4242
+ dw.body,
4243
+ sub,
4244
+ updateWrites,
4245
+ postLoop,
4246
+ )
4247
+ ) {
2217
4248
  throw new StructureError(
2218
4249
  `cannot structure '${fn.name}': do-while condition or a post-loop value reads a pre-update loop variable`,
2219
4250
  );
2220
4251
  }
4252
+ // The exit copies render AFTER the `dowhile` statement, but the analysis judged where each
4253
+ // value they carry may inline as if the copies sat on the latch's terminator — INSIDE the loop.
4254
+ // For a pure def that is only a naming question (the pre-update guard above covers the rest);
4255
+ // for an EFFECTFUL one it moves the effect out of the loop: a call that ran once per iteration
4256
+ // would render once, after it. Nothing can re-place it here — `materialize` was decided before
4257
+ // emission — so decline LOUD rather than emit a plausible loop that calls the wrong number of
4258
+ // times.
4259
+ const movedEffect = exitArgs.find((v) => {
4260
+ const d = defs.get(v);
4261
+ return (
4262
+ d && REPEATED_EFFECT.has(d.opcode) && dw.body.has(opBlock.get(d)!) && !materialize.has(d) && !varName.has(v)
4263
+ );
4264
+ });
4265
+ if (movedEffect) {
4266
+ const d = defs.get(movedEffect)!;
4267
+ throw new StructureError(
4268
+ `cannot structure '${fn.name}': a post-loop value inlines a '${d.opcode}' from inside the loop, ` +
4269
+ `which would move that effect out of it`,
4270
+ );
4271
+ }
2221
4272
  dwActive.add(dw.header);
2222
4273
  // structure the header's own block up to the latch. Call structureBlock DIRECTLY (not
2223
4274
  // structureRegion): the header is already on `onStack` from the caller's structureRegion, so
@@ -2228,25 +4279,95 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2228
4279
  const inner =
2229
4280
  dw.header === dw.latch
2230
4281
  ? [] // single-block self-loop: the header IS the latch — its ops render via sideEffects below
2231
- : withLoop({ header: dw.header, exit: dw.exit, body: dw.body }, () => structureBlock(dw.header, dw.latch)); // header..latch (exclusive of latch)
4282
+ : withLoop({ header: dw.header, exit: dw.exit, body: dw.body, arms: dw.arms }, () =>
4283
+ structureBlock(dw.header, dw.latch),
4284
+ ); // header..latch (exclusive of latch)
2232
4285
  dwActive.delete(dw.header);
2233
4286
  // The UPDATE is RAW (`v = v - 1`) — it IS the decrement; applying `sub` would make it look like the
2234
4287
  // identity `v = v` and drop it. Only the CONDITION and EXIT copies use `sub` (post-update the param
2235
4288
  // already holds the next value, so the latch-computed test reads `v`, not `v - 1`). `updates`
2236
4289
  // reuses the hazard check's computation — a second argAssigns call would burn a spurious
2237
4290
  // swap-cycle temp number.
2238
- const body = [...inner, ...sideEffects(dw.latch), ...updates];
4291
+ const body = [
4292
+ ...preUpdateCopies(dw.exit, exitArgs, sunk, dw.header),
4293
+ ...inner,
4294
+ ...sideEffects(dw.latch),
4295
+ ...updates,
4296
+ ];
2239
4297
  let cond = exprWith(sub)(lterm.operands[0]);
2240
4298
  if (lterm.successors[1].block === dw.header) {
2241
4299
  cond = negateCond(cond);
2242
4300
  } // continue edge must be `taken`
2243
4301
  const out: Stmt[] = [{ k: 'dowhile', cond, body }];
2244
4302
  // The exit region reads latch back-edge values under `sub` (post-loop they live in the loop vars).
2245
- out.push(...withSub(sub, () => [...argAssigns(dw.latch, dw.exit, sub), ...structureRegion(dw.exit, stop)]));
4303
+ out.push(
4304
+ ...withSub(sub, () => [
4305
+ ...argAssigns(dw.latch, dw.exit, sub, (j) => !sunk.has(j)),
4306
+ ...structureRegion(dw.exit, stop),
4307
+ ]),
4308
+ );
2246
4309
  return out;
2247
4310
  };
2248
4311
 
2249
- const body = recognizeForLoops(structureRegion(entry, null));
4312
+ const body = stampOrderedBases(recognizeForLoops(structureRegion(entry, null)), orderLicensedGlobals);
4313
+ // THE OBLIGATION `anchorConstCopies` CANNOT DISCHARGE ON ITS OWN, checked now that the render is
4314
+ // complete. Anchoring is two edits that must both land: `suppressedArgs` DELETES a write from its
4315
+ // edge, and `anchoredAt` owes it back at the const's def site. Its refusal conditions establish
4316
+ // that the def block DOMINATES every suppressed edge — but dominating a block is not being
4317
+ // rendered, and a block whose every op renders inline gets no statement position at all. A loop's
4318
+ // claimed pure preheader is one (LoopInfo.preheader: the guarded-loop emitter takes its EDGE args
4319
+ // and never structures it), and there is no reason to believe it is the last. A promise half-kept
4320
+ // leaves the merge variable read where nothing wrote it — the one wrongness the byte differ
4321
+ // rewards rather than catches, since the candidate compiles, scores, and can win.
4322
+ //
4323
+ // OBSERVABLE is the test, not "emitted". A merge name the rendered body never mentions has no
4324
+ // reader to see the missing write and no second writer to disagree with it, and its declaration
4325
+ // goes with it (l3/dce.ts prunes a local nothing references) — so the copy and the value it
4326
+ // carried are gone together, which is a program the edge spelling can also produce. Only a name
4327
+ // the body still uses is a broken promise, or a GLOBAL, whose store is observed outside this
4328
+ // function whatever this body does with it. klonoa's `MPlayContinue` is the live inhabitant: its
4329
+ // `/defsite` spelling is correct and main enumerates it — a RECORD of a measurement, not a live
4330
+ // check, since that function is in no corpus row and no fixture here.
4331
+ const owed = [...anchoredAt.values()].flat().filter((w) => !anchorsEmitted.has(w));
4332
+ if (owed.length > 0) {
4333
+ const mentioned = new Set<string>(globalNames);
4334
+ for (const e of walkExprs(body)) {
4335
+ if (e.k === 'var' || e.k === 'addr') {
4336
+ mentioned.add(e.name);
4337
+ }
4338
+ }
4339
+ const assignTargets = (ss: Stmt[]): void => {
4340
+ for (const st of ss) {
4341
+ if (st.k === 'assign') {
4342
+ mentioned.add(st.name);
4343
+ }
4344
+ assignTargets(stmtChildren(st));
4345
+ }
4346
+ };
4347
+ assignTargets(body);
4348
+ const broken = owed.find((w) => mentioned.has(w.name));
4349
+ if (broken !== undefined) {
4350
+ throw new StructureError(
4351
+ `cannot structure '${fn.name}': the merge copy into '${broken.name}' was suppressed from ` +
4352
+ `its edge for a def-site anchor that no rendered position emitted`,
4353
+ );
4354
+ }
4355
+ }
4356
+ // THE OBLIGATION `undefCarriesNothing` CANNOT DISCHARGE ON ITS OWN, checked now that both records
4357
+ // are complete. That test reads each write site off `paramBlock`/`opBlock`, and a sunk pre-update
4358
+ // exit copy is written somewhere else than either map says. What keeps them apart today is
4359
+ // `dest-free-inside-loop`, a gate stated about something else entirely and carrying its own KNOWN
4360
+ // GAP — so the day a widening lets the two meet, the emitted C substitutes a DEFINED value for the
4361
+ // undefined one the machine leaves in place. Loud, because that failure has no other symptom.
4362
+ const collided = sunkCopyOverDroppedUndef(droppedUndefCopies, sunkCopyHomes, (home, pred) =>
4363
+ reachFrom(home).has(pred),
4364
+ );
4365
+ if (collided !== null) {
4366
+ throw new StructureError(
4367
+ `cannot structure '${fn.name}': a pre-update exit copy sunk into a loop body writes '${collided}' ` +
4368
+ `ahead of an edge whose undefined argument's copy was dropped as carrying nothing`,
4369
+ );
4370
+ }
2250
4371
  // Strict-mode gaps decline HERE, naming the reasons — the same text annotate's markers
2251
4372
  // carry, so the two mode surfaces report the same decline (the reproduction scripts run
2252
4373
  // strict; the benchmark rows store annotate markers — fidelity holds them against each
@@ -2260,11 +4381,158 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2260
4381
  const localNames = [...new Set([...varName.values(), ...[...varType.keys()].filter((n) => /^t\d+$/.test(n))])].filter(
2261
4382
  (n) => /^[vt]\d+$/.test(n) && !globalNames.has(n),
2262
4383
  );
4384
+ // THE FRAME COORDINATES OF EACH DECLARED LOCAL (ir/core.ts `SlotHomes`, stamped by the SSA
4385
+ // builder). The naming walk is what makes it a per-DECLARATION fact: several spilled values can
4386
+ // land under one name, and the local the source declared is the name, not the value. Where a
4387
+ // name covers several homes it takes the UNION and picks NOTHING — which offset is the earlier
4388
+ // declaration rank depends on the frame's direction, and `l3/slotorder.ts` is the one place that
4389
+ // holds it. Sorted, so the list is deterministic rather than insertion-ordered.
4390
+ //
4391
+ // A NAME COVERING NO HOMED VALUE GETS NO ENTRY, so `slots` is absent rather than `[]` on the
4392
+ // locals the asm kept in registers. Values named `a<n>` (parameters) never reach here:
4393
+ // `localNames` is the `v*`/`t*` set, and a parameter's storage is the caller's question.
4394
+ const slotsOfName = new Map<string, Set<number>>();
4395
+ const isLocalName = new Set(localNames);
4396
+ for (const [v, offs] of fn.slotHomes ?? []) {
4397
+ const n = varName.get(v);
4398
+ if (n === undefined || !isLocalName.has(n)) {
4399
+ continue;
4400
+ }
4401
+ const prev = slotsOfName.get(n);
4402
+ if (prev === undefined) {
4403
+ slotsOfName.set(n, new Set(offs));
4404
+ } else {
4405
+ offs.forEach((off) => prev.add(off));
4406
+ }
4407
+ }
4408
+ // The machine's static access counts for one frame object: every `load`/`store` rooted on an
4409
+ // `laddr` at the same offset. Counted over the L2 blocks, so it is the access set the asm had,
4410
+ // before any L3 readability pass could drop or duplicate one. Summed across the offset's
4411
+ // `laddr` ops — the frontend's frame-object audit re-roots accesses onto per-offset captures
4412
+ // and holds one object per offset, so several ops can name the same storage.
4413
+ //
4414
+ // NO record at all when the address reaches ANYTHING ELSE — a block argument, an offset
4415
+ // computation, a call. The count is then a floor rather than the access set, and it is read as
4416
+ // the access set (the l3/volatileval.ts gate), so it refuses instead of reporting a number that
4417
+ // undercounts. Reached rather than theoretical: an address-escaped frame scratch takes it —
4418
+ // `synthetic:dma_fill_uninit` and `kleod:ProcessInputAndUpdateEntities` both lose their record
4419
+ // here.
4420
+ const frameRecord = (at: Op): { frame?: { loads: number; stores: number } } => {
4421
+ const off = at.attrs.off as number;
4422
+ const roots = new Set<Value>();
4423
+ for (const b of fn.blocks) {
4424
+ for (const op of b.ops) {
4425
+ if (op.opcode === 'laddr' && (op.attrs.off as number) === off) {
4426
+ roots.add(op.results[0]);
4427
+ }
4428
+ }
4429
+ }
4430
+ let loads = 0;
4431
+ let stores = 0;
4432
+ for (const b of fn.blocks) {
4433
+ for (const op of b.ops) {
4434
+ const access = op.opcode === 'load' || op.opcode === 'store';
4435
+ if (access && roots.has(op.operands[0])) {
4436
+ if (op.opcode === 'load') {
4437
+ loads++;
4438
+ } else {
4439
+ stores++;
4440
+ }
4441
+ }
4442
+ // every OTHER mention of the address, operands and branch arguments alike
4443
+ const elsewhere = op.operands.some((v, i) => roots.has(v) && !(access && i === 0));
4444
+ if (elsewhere || op.successors.some((sx) => sx.args.some((v) => roots.has(v)))) {
4445
+ return {};
4446
+ }
4447
+ }
4448
+ }
4449
+ return { frame: { loads, stores } };
4450
+ };
4451
+ // ONE `laddr` op per minted NAME, last one wins. Same answer as feeding every op's finished entry
4452
+ // to `new Map` — each entry was a pure function of its own op, and the Map kept the LAST value
4453
+ // under a key at its FIRST key's position — but stated rather than left to be read off `new Map`'s
4454
+ // semantics, and `frameRecord` (which walks every op of the function) then runs once per name
4455
+ // instead of once per op. NOT a measured speedup: over `packages/core/test` every one of the
4456
+ // 1,057 structurings that reach here has ops === names, so the dedup fires zero times there. It
4457
+ // is a bound, not a win: `laddrName` mints one name per OFFSET, and the frontend's frame-object
4458
+ // audit can re-root several ops onto one offset (see `frameRecord`'s own note).
4459
+ const lastLaddrOf = new Map<string, Op>();
4460
+ for (const op of fn.blocks.flatMap((b) => b.ops).filter((op) => op.opcode === 'laddr')) {
4461
+ lastLaddrOf.set(laddrName.get(op)!, op);
4462
+ }
2263
4463
  const structs = collectStructs(fn);
2264
4464
  return {
2265
4465
  name: fn.name,
2266
4466
  params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
2267
- locals: localNames.map((n) => ({ name: n, type: varType.get(n)! })),
4467
+ locals: [
4468
+ ...localNames.map((n) => ({
4469
+ name: n,
4470
+ type: varType.get(n)!,
4471
+ ...(slotsOfName.has(n) ? { slots: [...slotsOfName.get(n)!].sort((x, y) => x - y) } : {}),
4472
+ })),
4473
+ // frame-local objects (laddr): declared with EXACTLY the access type the machine used —
4474
+ // the frontend's frame-object audit proved all accesses agree, so this is a fact, not a guess
4475
+ //
4476
+ // NO `slots` IS STAMPED HERE, and the offset is in hand (`op.attrs.off`), so this is a
4477
+ // refusal and not an oversight. An RTL-time frame object is allocated at its declaration
4478
+ // and sits BELOW every spill slot, so its offset is not evidence of its rank among the
4479
+ // spilled locals — and the one row in the corpus that carries such an object next to real
4480
+ // spills (`synthetic:dma_fill_uninit`, a `volatile` scratch at offset 0) compiles to the
4481
+ // same object whether that local is sorted with the spills or left where the naming walk
4482
+ // put it, measured both ways. So no row can refute an ordering over these, and an ordering
4483
+ // no row can refute does not earn its place. THE FLIP CONDITION: the first row whose object
4484
+ // is decided by the declaration order of two `laddr` locals, or of an `laddr` against a
4485
+ // spill, is the referee — stamp them then, and `l3/slotorder.ts` needs no change to use it.
4486
+ ...[...lastLaddrOf].map(([name, op]) => ({
4487
+ name,
4488
+ type: T.int((op.attrs.width as number) * 8, op.attrs.signed as boolean),
4489
+ // the asm materialized this slot's address, and this is how many times it
4490
+ // loaded and stored through it — both asm facts, and the gate the
4491
+ // l3/volatileval.ts lever reads (see the SFn.locals doc)
4492
+ ...frameRecord(op),
4493
+ // an ESCAPED address makes every store observable (the DMA hardware reads it), and
4494
+ // the source spells the scratch volatile for that reason — see the stamp site in
4495
+ // frontend/thumb.ts for why it is the SPELLING that matters and not dead-store
4496
+ // elimination, which keeps the store either way
4497
+ ...(op.attrs.volatile === true ? { volatile: true as const } : {}),
4498
+ })),
4499
+ // uninitialised locals (undef): declared, never assigned, typed by whatever recovery settled
4500
+ // on for the value. NO `slots` either, on the same footing as the frame objects above.
4501
+ //
4502
+ // THE REASON IS UNDECIDABILITY, not absence. One frame slot then yields TWO declarations:
4503
+ // the `undef` read of the storage and the value stored into it. They are one object in the
4504
+ // source, and the asm does not say which of the two took the rank — so ranking one of the
4505
+ // pair while leaving the other pinned would invent an answer. Refusing the undef half keeps
4506
+ // the stored half's rank, which is the one the spill evidence is about.
4507
+ //
4508
+ // AND IT IS PRECAUTIONARY: NO ROW REACHES IT. The co-existence is real in the tree this
4509
+ // function returns — `uninit_spill` declares `uninit_sp0/sp4/sp8` beside the slotted `v4@0
4510
+ // v5@4 v6@8`, `dma_fill_uninit` declares `uninit_sp4/sp8/sp12` beside `v4@4 v6@8 v8@12` —
4511
+ // but that tree is never emitted: `structure()` has exactly one caller, `structureChecked`
4512
+ // (pipeline.ts), and `l3/slotorder.ts` runs inside `emit`, downstream of it on every emit
4513
+ // path. On the tree `structureChecked` returns neither row has an `uninit` local left —
4514
+ // `eliminateDeadStores` (l3/dce.ts, whose last line keeps only locals the body still
4515
+ // references) removes all eight on `uninit_spill` and all seven on `dma_fill_uninit`,
4516
+ // leaving the slot-carrying locals untouched. Instrumented one spine stage at a time, not
4517
+ // inferred: raw 8 → mergeCommonTails 8 → eliminateDeadStores 0.
4518
+ //
4519
+ // The refusal stays HERE rather than at the ordering because this is where the stamp is
4520
+ // decided, and an `undef` local that survived DCE — address-taken, `volatile`, or genuinely
4521
+ // read — would otherwise carry it into `emit`. FLIP CONDITION, and it is not "the first
4522
+ // uninit local", since four rows already have them in `structure()`'s tree and none in
4523
+ // `structureChecked`'s: it is the first row where a slot-keyed `undef` local SURVIVES
4524
+ // dead-store elimination beside a slotted local at the same offset, and compile shows the
4525
+ // two to be two source declarations rather than one. `spill-slot-order.test.ts` pins both
4526
+ // halves, so a change to either fails there.
4527
+ ...fn.blocks
4528
+ .flatMap((b) => b.ops)
4529
+ .filter((op) => op.opcode === 'undef')
4530
+ .map((op) => ({
4531
+ name: undefName.get(op)!,
4532
+ type: varType.get(undefName.get(op)!) ?? op.results[0].type,
4533
+ uninit: true as const,
4534
+ })),
4535
+ ],
2268
4536
  ...(shapedGlobalTypes.size
2269
4537
  ? {
2270
4538
  globals: [...shapedGlobalTypes]
@@ -2275,6 +4543,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2275
4543
  retType: returnsVoid ? T.void() : returnType(fn),
2276
4544
  body,
2277
4545
  ...(structs.length ? { structs } : {}),
4546
+ // absent stays absent: the backend asks "is a direction known?", and there is no third state
4547
+ // at this boundary — `structureOptionsFor` dropped the target's `'unknown'` already.
4548
+ ...(spillSlotOrder !== undefined ? { slotOrder: spillSlotOrder } : {}),
2278
4549
  };
2279
4550
  }
2280
4551
 
@@ -2360,6 +4631,26 @@ function recognizeForLoops(stmts: Stmt[]): Stmt[] {
2360
4631
  // still-pending assignment reads; break a cycle by spilling one destination to a temp.
2361
4632
  // `tmp` is the per-FUNCTION temp counter (threaded from structure()) so two independent
2362
4633
  // parallel copies never reuse a temp name against conflicting types.
4634
+ /** Does this edge's copy set contain a CYCLE — no copy emittable without overwriting a destination
4635
+ * another still reads, so `sequentialize` has to spill one into a temp? `sequentialize`'s own
4636
+ * emittability loop, run without emitting, so the two can never disagree about which sets are
4637
+ * which.
4638
+ *
4639
+ * It is the LICENCE BOUNDARY the edge-copy sort draws: on a cyclic set an instruction names the
4640
+ * compiler's temp and the write-order record reproduces it, while on an acyclic one the record is
4641
+ * only an assumption about layout order. `preferDefPosCopyOrder` asks for the proxy on the second
4642
+ * kind only. */
4643
+ function copySetIsCyclic(copies: { name: string; value: Expr }[]): boolean {
4644
+ const pending = copies.map((c) => ({ name: c.name, reads: exprVars(c.value) }));
4645
+ for (;;) {
4646
+ const i = pending.findIndex((a) => !pending.some((b) => b !== a && b.reads.has(a.name)));
4647
+ if (i < 0) {
4648
+ return pending.length > 0;
4649
+ }
4650
+ pending.splice(i, 1);
4651
+ }
4652
+ }
4653
+
2363
4654
  function sequentialize(
2364
4655
  copies: { name: string; value: Expr }[],
2365
4656
  varType: Map<string, IrType>,
@@ -2443,6 +4734,32 @@ function successorTo(pred: Block, target: Block) {
2443
4734
  const term = pred.ops[pred.ops.length - 1];
2444
4735
  return term.successors.find((s) => s.block === target);
2445
4736
  }
4737
+ /** EVERY in-edge record into `b` — which is what {@link successorTo} cannot give: it returns only
4738
+ * the FIRST record to a block, so a terminator with two edges to the same block hides the second
4739
+ * edge's args. Callers that ask "what does every path pass for param i" need all of them.
4740
+ *
4741
+ * ORDER IS LOAD-BEARING and is `preds` insertion order, then terminator-successor order: the
4742
+ * naming walk takes the FIRST admissible carrier, so a different order is a different spelling.
4743
+ *
4744
+ * AND IT IS GUARDED, not merely warned about — measured, not reasoned: rewriting this walk to call
4745
+ * `successorTo` turns 12 tests across 5 files of `packages/core/test` red. The path it would lose
4746
+ * is reached constantly, 326,305 times over that suite on a `cond_br` whose two arms land on one
4747
+ * block and 6 more on a `switch_br`.
4748
+ *
4749
+ * The terminator is read UNGUARDED (`ops[ops.length - 1]`) on purpose: every block of a verified
4750
+ * Fn has one, and a guard would turn a malformed Fn's throw into a silent missing edge.
4751
+ *
4752
+ * `hasParamRootedMerge` does not go through this, deliberately: it is a pre-structuring query over
4753
+ * a raw Fn and walks `fn.blocks` rather than a predecessor map it would first have to build. */
4754
+ function* inEdgeRecords(preds: Map<Block, Block[]>, b: Block): Generator<{ pred: Block; succ: Successor }> {
4755
+ for (const pred of new Set(preds.get(b) ?? [])) {
4756
+ for (const succ of pred.ops[pred.ops.length - 1].successors) {
4757
+ if (succ.block === b) {
4758
+ yield { pred, succ };
4759
+ }
4760
+ }
4761
+ }
4762
+ }
2446
4763
 
2447
4764
  // Immediate post-dominators. EXIT is represented as `null`; ret-blocks post-lead to it.
2448
4765
  function postDominators(fn: Fn): Map<Block, Block | null> {