@asmlift/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/package.json +14 -0
  4. package/src/backend/c.ts +20 -0
  5. package/src/backend/cfamily.ts +352 -0
  6. package/src/backend/cpp.ts +145 -0
  7. package/src/backend/pascal.ts +279 -0
  8. package/src/contracts.ts +131 -0
  9. package/src/detect.ts +12 -0
  10. package/src/frontend/asmdata.ts +170 -0
  11. package/src/frontend/disasm.ts +102 -0
  12. package/src/frontend/emit.ts +57 -0
  13. package/src/frontend/errors.ts +14 -0
  14. package/src/frontend/format.ts +47 -0
  15. package/src/frontend/frontend.ts +22 -0
  16. package/src/frontend/mips.ts +875 -0
  17. package/src/frontend/opaque.ts +82 -0
  18. package/src/frontend/ppc.ts +990 -0
  19. package/src/frontend/registry.ts +34 -0
  20. package/src/frontend/ssa.ts +214 -0
  21. package/src/frontend/thumb.ts +1419 -0
  22. package/src/ir/core.ts +104 -0
  23. package/src/ir/opcodes.ts +143 -0
  24. package/src/ir/parse.ts +221 -0
  25. package/src/ir/print.ts +77 -0
  26. package/src/ir/types.ts +106 -0
  27. package/src/ir/verify.ts +221 -0
  28. package/src/l3/ast.ts +301 -0
  29. package/src/l3/basecse.ts +218 -0
  30. package/src/l3/dce.ts +256 -0
  31. package/src/l3/regspell.ts +331 -0
  32. package/src/l3/reindex.ts +447 -0
  33. package/src/l3/typing.ts +145 -0
  34. package/src/mangle.ts +135 -0
  35. package/src/pattern/engine.ts +392 -0
  36. package/src/pipeline.ts +272 -0
  37. package/src/proto.ts +42 -0
  38. package/src/raise/arrays.ts +84 -0
  39. package/src/raise/const.ts +52 -0
  40. package/src/raise/errors.ts +10 -0
  41. package/src/raise/magicdiv.ts +386 -0
  42. package/src/raise/pre-recovery.ts +71 -0
  43. package/src/raise/recover.ts +215 -0
  44. package/src/raise/retsink.ts +72 -0
  45. package/src/raise/shortcircuit.ts +207 -0
  46. package/src/raise/softdiv.ts +62 -0
  47. package/src/raise/struct-arrays.ts +257 -0
  48. package/src/raise/structs.ts +223 -0
  49. package/src/rank.ts +208 -0
  50. package/src/structure/analysis.ts +410 -0
  51. package/src/structure/hazards.ts +142 -0
  52. package/src/structure/loops.ts +169 -0
  53. package/src/structure/structure.ts +1726 -0
  54. package/src/structure/switch-recover.ts +410 -0
  55. package/src/target.ts +140 -0
  56. package/src/trace.ts +233 -0
@@ -0,0 +1,1726 @@
1
+ // asmlift — L2→L3 structuring: CFG + block-argument SSA → a structured AST.
2
+ //
3
+ // Three jobs:
4
+ // 1. SSA destruction WITH COALESCING — a merge block-argument that already carries a
5
+ // variable's value on one path is coalesced to that variable, so only the
6
+ // non-identity paths emit an assignment (reproducing agbcc's register allocation:
7
+ // NOTE the coupled INVERSE: l3/regspell.ts re-derives the UN-coalesced copy-carrying
8
+ // spelling as a ranked candidate — its R1 template matches THIS pass's diamond output
9
+ // shape, so a change to coalescing here can silently stop that lever firing (the
10
+ // 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
+ // Coalescing is INTERFERENCE-CHECKED against per-block value liveness, and
13
+ // inline-at-use rendering carries an effect-ordering model: a call/load that cannot
14
+ // soundly render at its use is MATERIALIZED as a named temp at its own program
15
+ // position. Where no correct spelling exists, the structurer declines loud
16
+ // (StructureError) — never silent wrong code.
17
+ // 2. If-recovery over the CFG using immediate post-dominators as merge points, with an
18
+ // empty-then peephole (negate + swap).
19
+ // 3. Loop-recovery: a back-edge (edge into a dominating header) is recognised, and the
20
+ // gcc "guard + do-while" lowering is un-rotated back into a `while` — the loop
21
+ // condition is the latch test read on the header's OWN parameters (back-edge args
22
+ // substituted back to the phi they feed), and the loop body is the header's
23
+ // parallel block-argument update, sequentialised so an assignment never clobbers a
24
+ // value a later one still needs.
25
+ //
26
+ // Module layout: loop DISCOVERY is loops.ts; the pure ANALYSIS phase (use registry, liveness,
27
+ // materialization) is analysis.ts; comparison-tree switch recovery is switch-recover.ts
28
+ // (explicit-deps factory). THIS file keeps the mutually-entangled remainder: SSA-destruction
29
+ // coalescing (canTakeName + seeding) and emission (structureBlock + the loop emitters) — they
30
+ // share varName/backArgName mutation and the activeSub/loopCtx dynamic state.
31
+ //
32
+ // Scope: reducible single-latch natural loops — GUARDED self-loop `while` (the guard-fusion
33
+ // un-rotation), UNGUARDED self-loop `do-while` (single block, header === latch), test-at-top
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';
40
+ import { type IrType, T } from '../ir/types';
41
+ import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren } from '../l3/ast';
42
+ import { exprCType, ptrElemBytes } from '../l3/typing';
43
+ import { returnType } from '../raise/recover';
44
+ import { collectStructs } from '../raise/structs';
45
+ import { analyze } from './analysis';
46
+ import { makeLoopHazards, updateWriteSet } from './hazards';
47
+ import { analyzeLoops, dominators } from './loops';
48
+ import { makeSwitchRecovery } from './switch-recover';
49
+
50
+ // Lower a constant-offset memory access to its lvalue/rvalue Expr. If the base was recovered as a
51
+ // struct pointer (raise/structs.ts), the byte offset resolves to a NAMED field (`base->field_<off>`);
52
+ // otherwise it stays the width-scaled array index (`base[off/width]`, `*base` for offset 0).
53
+ //
54
+ // The scalar path builds a WIDTH-CARRYING `index` node and inserts NO cast: each backend
55
+ // legalizes the base itself from the node's width (the C family inserts the reinterpret cast at
56
+ // print time when the base's rendered type does not stride the width — derefStrideOk, l3/typing —
57
+ // and Pascal loud-declines instead). The struct path still bakes its cast into the tree: the
58
+ // legalization target is the RECOVERED struct pointer type, which the `field` node does not carry
59
+ // TODAY — carrying the struct name (resolved against SFn.structs) is the same move as width and
60
+ // the named follow-up; until then no backend pays a tax for the tree cast (Pascal loud-fails
61
+ // `field` regardless, C++ falls through its leaf hook to the shared C spelling).
62
+ // If `e` is a global address `&gSym` (optionally `+ index`), return the global name and the
63
+ // element index (byte residual divided by the access width). `&gSym` alone → idx const 0;
64
+ // `&gSym + i` → idx `i / width` (exact division only — a non-multiple residual is a mid-element
65
+ // access this whole-global spelling can't express, so it declines to null and the caller casts).
66
+ function globalOf(e: Expr, width: number): { name: string; idx: Expr } | null {
67
+ if (e.k === 'addr') {
68
+ return { name: e.name, idx: { k: 'const', value: 0 } };
69
+ }
70
+ if (e.k === 'bin' && e.op === '+') {
71
+ for (const [addrSide, other] of [
72
+ [e.l, e.r],
73
+ [e.r, e.l],
74
+ ] as const) {
75
+ if (addrSide.k === 'addr') {
76
+ // width 1 → the byte residual IS the index; width>1 → a constant residual divides, a
77
+ // non-constant residual must already be element-scaled (`i * width`) to divide exactly.
78
+ if (width === 1) {
79
+ return { name: addrSide.name, idx: other };
80
+ }
81
+ if (other.k === 'const') {
82
+ return other.value % width === 0
83
+ ? { name: addrSide.name, idx: { k: 'const', value: other.value / width } }
84
+ : null;
85
+ }
86
+ if (other.k === 'bin' && (other.op === '*' || other.op === '<<')) {
87
+ const factor =
88
+ other.op === '<<'
89
+ ? other.r.k === 'const'
90
+ ? 1 << other.r.value
91
+ : 0
92
+ : other.r.k === 'const'
93
+ ? other.r.value
94
+ : 0;
95
+ if (factor === width) {
96
+ return { name: addrSide.name, idx: other.l };
97
+ }
98
+ }
99
+ return null; // a non-element-aligned residual — decline the global-array spelling
100
+ }
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function memAccess(
107
+ base: Value,
108
+ baseExpr: Expr,
109
+ off: number,
110
+ width: number,
111
+ signed: boolean,
112
+ ctype: (e: Expr) => IrType | undefined,
113
+ scalarGlobals: Set<string>,
114
+ ): Expr {
115
+ // A deref of a global's address collapses to the bare global: `*(&gSym)` at off 0 is `gSym`;
116
+ // at off N the global is an array — `gSym[N/width]` (a C global name decays to a pointer, so
117
+ // the index reproduces the offset). A `+`-tree base holding `&gSym` is a global-ARRAY element
118
+ // `*(&gSym + i)` → `gSym[i + off/width]` (byte offset `i` peeled from the tree; for a u8 global
119
+ // the residual IS the index). This is what makes an agbcc `.word gSym` pool access a named
120
+ // global read/element rather than a phantom-pointer deref.
121
+ const g = globalOf(baseExpr, width);
122
+ if (g) {
123
+ const idxVal = g.idx;
124
+ // off-0 access of a SCALAR global (accessed only at offset 0, per scalarGlobals) → the BARE
125
+ // global `gSym` (byte-exact, matches the source spelling). Any other access — a non-zero
126
+ // offset, a variable index, or an AGGREGATE global (accessed at multiple offsets) — indexes
127
+ // the global's ADDRESS `&gSym`, NOT the bare value: a struct global does not decay, so
128
+ // `((s32 *)gSym)[i]` is invalid C, but `((s32 *)&gSym)[i]` reinterpret-casts the address and
129
+ // strides correctly for BOTH a struct and an array global.
130
+ if (off === 0 && idxVal.k === 'const' && idxVal.value === 0 && scalarGlobals.has(g.name)) {
131
+ return { k: 'var', name: g.name };
132
+ }
133
+ const idx: Expr =
134
+ off === 0
135
+ ? idxVal
136
+ : idxVal.k === 'const'
137
+ ? { k: 'const', value: idxVal.value + off / width }
138
+ : { k: 'bin', op: '+', l: idxVal, r: { k: 'const', value: off / width } };
139
+ return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
140
+ }
141
+ const bt = base.type;
142
+ if (bt.kind === 'ptr' && bt.to.kind === 'struct') {
143
+ const rt = ctype(baseExpr);
144
+ // `->` requires the base to render as a pointer to THIS struct (field names resolve against
145
+ // its declaration) AND as a non-`index` node (the printer spells an index-node base with `.`,
146
+ // the array-element form — wrong for a pointer). Anything else is cast to the recovered
147
+ // struct pointer type; the cast node prints with `->`.
148
+ const ok = rt?.kind === 'ptr' && rt.to.kind === 'struct' && rt.to.name === bt.to.name && baseExpr.k !== 'index';
149
+ return { k: 'field', base: ok ? baseExpr : { k: 'cast', to: bt, e: baseExpr }, name: `field_${off}` };
150
+ }
151
+ return { k: 'index', base: baseExpr, idx: { k: 'const', value: off / width }, width, signed };
152
+ }
153
+
154
+ // A variable-index array access `base[index]`, or `base[index].field_K` when a `fieldOff` marks an
155
+ // array-of-STRUCT element (raise/struct-arrays.ts). The `.field` on an array element prints
156
+ // with `.` (the printer decides dot-vs-arrow from the base being an `index` node).
157
+ //
158
+ // Scalar path: a width-carrying `index` node, no cast — the backend legalizes (see memAccess).
159
+ // Struct-array path: like memAccess's struct path, the recovered struct pointer type is an L2
160
+ // fact the AST cannot carry, so a base that does not render as THAT struct pointer is cast here
161
+ // (C then scales the index by the struct size, exactly the aload/astore element stride); the
162
+ // `index` node's width is the struct size only nominally — strideOk never fires on struct
163
+ // pointees, so the C backend leaves a struct-typed base uncast and the tree-level cast governs.
164
+ function arrayAccess(
165
+ base: Value,
166
+ baseExpr: Expr,
167
+ idxExpr: Expr,
168
+ fieldOff: number | undefined,
169
+ elemSize: number,
170
+ signed: boolean,
171
+ ctype: (e: Expr) => IrType | undefined,
172
+ ): Expr {
173
+ // A variable-index access off a global's address indexes the ADDRESS `&gSym` (the cast form
174
+ // `((T *)&gSym)[i]` — valid for a struct global too, unlike casting the bare value). A
175
+ // struct-array-of-globals (fieldOff) through `&gSym` is out of scope — fall through.
176
+ if (baseExpr.k === 'addr' && fieldOff === undefined) {
177
+ return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
178
+ }
179
+ const bt = base.type;
180
+ if (fieldOff !== undefined) {
181
+ const structTo = bt.kind === 'ptr' && bt.to.kind === 'struct' ? bt.to : null;
182
+ const rt = ctype(baseExpr);
183
+ const ok = structTo !== null && rt?.kind === 'ptr' && rt.to.kind === 'struct' && rt.to.name === structTo.name;
184
+ // an ill-typed struct-array base with no recovered struct type has no derivable cast target:
185
+ // left as rendered — assertDerefsTyped's FIELD rule flags the definite violations at the
186
+ // stage boundary (the dot-form field types non-struct there).
187
+ const b = ok || structTo === null ? baseExpr : { k: 'cast' as const, to: T.ptr(structTo), e: baseExpr };
188
+ const index: Expr = { k: 'index', base: b, idx: idxExpr, width: elemSize, signed };
189
+ return { k: 'field', base: index, name: `field_${fieldOff}` };
190
+ }
191
+ return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
192
+ }
193
+
194
+ // Raised when the CFG contains control flow the structurer cannot recover (see the module scope
195
+ // note above for what IS recovered vs declined). It is an explicit, catchable "out of scope"
196
+ // signal — NOT a bug — so callers fail loud with a diagnostic instead of stack-overflowing.
197
+ export class StructureError extends Error {
198
+ constructor(message: string) {
199
+ super(message);
200
+ this.name = 'StructureError';
201
+ }
202
+ }
203
+
204
+ // THE one copy of the guard shape (like fieldSpellsDot/derefStrideOk): a predecessor whose
205
+ // cond_br decides "enter `header` vs its `exit`". The self-loop DISCOVERY classifies ownership
206
+ // with it, and the guard-FUSION site consumes the same shape (its `takenB/fallB === li.exit`
207
+ // check is this predicate from the branch's own viewpoint) — a drift between the two is
208
+ // fail-safe (traced: either an onStack decline or an unfused `if (g) do…while` spelling, never
209
+ // wrong code) but wastes capability, so both keep pointing here.
210
+ function isGuardShapedPred(pred: Block, header: Block, exit: Block): boolean {
211
+ if (pred === header) {
212
+ return false;
213
+ }
214
+ const t = pred.ops[pred.ops.length - 1];
215
+ return (
216
+ t.opcode === 'cond_br' &&
217
+ t.successors.some((sx) => sx.block === header) &&
218
+ t.successors.some((sx) => sx.block === exit)
219
+ );
220
+ }
221
+
222
+ const CMP_TO_BIN: Record<string, BinOp> = {
223
+ icmp_slt: '<',
224
+ icmp_sle: '<=',
225
+ icmp_sgt: '>',
226
+ icmp_sge: '>=',
227
+ icmp_ult: '<',
228
+ icmp_ule: '<=',
229
+ icmp_ugt: '>',
230
+ icmp_uge: '>=', // unsignedness is in the operand types
231
+ icmp_eq: '==',
232
+ icmp_ne: '!=',
233
+ };
234
+ const ARITH_TO_BIN: Record<string, BinOp> = {
235
+ add: '+',
236
+ sub: '-',
237
+ mul: '*',
238
+ sdiv: '/',
239
+ udiv: '/',
240
+ smod: '%',
241
+ umod: '%',
242
+ or: '|',
243
+ and: '&',
244
+ xor: '^',
245
+ shl: '<<',
246
+ shr_u: '>>',
247
+ shr_s: '>>',
248
+ logic_and: '&&',
249
+ logic_or: '||', // short-circuit connectives (raise/shortcircuit.ts)
250
+ };
251
+ const NEGATE: Record<string, BinOp> = { '<': '>=', '>=': '<', '>': '<=', '<=': '>', '==': '!=', '!=': '==' };
252
+
253
+ // Recovered info for a self-loop header: its exit block and the per-parameter back-edge
254
+ // arg it feeds (the value on the header→header edge). The back-edge arg is the "next"
255
+ // value of the phi; mapping it back to the phi turns the latch test into the while test.
256
+ interface LoopInfo {
257
+ header: Block;
258
+ exit: Block;
259
+ backArgOfParam: Value[]; // index-aligned with header.params
260
+ }
261
+
262
+ // A test-at-top multi-block `while`. The header is a pure test whose cond_br enters `bodyEntry`
263
+ // (inside the loop) or leaves to `exit` (the single loop exit). Unlike LoopInfo the condition reads
264
+ // the header's params directly (top-of-iteration values) — no back-edge substitution.
265
+ interface WhileLoopInfo {
266
+ header: Block;
267
+ bodyEntry: Block;
268
+ exit: Block;
269
+ latch: Block; // the single block with the back-edge to header (its args = the update)
270
+ forwardPreds: Block[]; // header preds outside the loop body (the entry/init side)
271
+ body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
272
+ }
273
+
274
+ // A bottom-tested `do { body } while(cond)`. The header is the body entry (entered before any
275
+ // test); the LATCH holds the loop condition and the single exit. Body = header..latch structured, then
276
+ // the latch's own ops + the loop-update; the latch test is the do-while condition. The condition is
277
+ // read under the latch back-edge substitution (post-update the params hold their next-iteration value).
278
+ interface DoWhileInfo {
279
+ header: Block;
280
+ latch: Block;
281
+ exit: Block;
282
+ forwardPreds: Block[];
283
+ body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
284
+ }
285
+
286
+ // Structuring levers, threaded as DATA so a new one is a field here + its consumer, not a new
287
+ // positional boolean widened across every call site:
288
+ // returnsVoid — from the function's own prototype (suppress phantom r0 return);
289
+ // coalesceLoopInit — keep the induction var in its arg register;
290
+ // preserveDivergentBranchSense — reproduce source branch direction on divergent ifs;
291
+ // orderArgCopiesByComputation — order edge copies by computation order in the predecessor.
292
+ // The last three are `compilerBehaviors` (target.ts) — this pass stays target-AGNOSTIC: it reads
293
+ // booleans, never a compiler name.
294
+ export interface StructureOptions {
295
+ returnsVoid?: boolean;
296
+ coalesceLoopInit?: boolean;
297
+ preserveDivergentBranchSense?: boolean;
298
+ orderArgCopiesByComputation?: boolean;
299
+ // Comparison-tree switch recovery: treat an `x != K` test as a case (the EQUAL side is a case
300
+ // body). GCC freely uses `!=`; IDO prefers `==`/`<`. A per-compiler DATA lever, not an `arch ==`
301
+ // branch — default true (permissive; the decline path keeps it sound either way).
302
+ switchAllowsNeqCase?: boolean;
303
+ // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
304
+ // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
305
+ // the PROCESS);
306
+ // "annotate" — a `marker` node that spells as the undefined ASMLIFT_ERROR(...) symbol (loud in
307
+ // the ARTIFACT: the function emits complete, but cannot compile un-acknowledged).
308
+ onGap?: 'strict' | 'annotate';
309
+ }
310
+
311
+ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
312
+ const {
313
+ returnsVoid = false,
314
+ coalesceLoopInit = false,
315
+ preserveDivergentBranchSense = true,
316
+ orderArgCopiesByComputation = true,
317
+ switchAllowsNeqCase = true,
318
+ onGap = 'strict',
319
+ } = opts;
320
+ const defs = defOpMap(fn);
321
+ const preds = predecessorBlocks(fn);
322
+ const ipdom = postDominators(fn);
323
+ const dom = dominators(fn);
324
+
325
+ // ── analysis phase (structure/analysis.ts): use registry, liveness, materialization ──
326
+ const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom } = analyze(fn, returnsVoid);
327
+
328
+ // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
329
+ // global → the bare name `gSym` (byte-exact, matches the source). A symbol accessed at any
330
+ // non-zero offset (or via a variable index) is an array/struct global → EVERY access uses the
331
+ // `((T *)&gSym)[i]` address-cast form (a struct value does not decay, so casting the bare name is
332
+ // invalid C; casting the address is valid and byte-exact). Computed once here.
333
+ //
334
+ // Only the offset set is tracked, not width: a single symbol read at off-0 with two DIFFERENT
335
+ // widths is a union/type-pun, which the downstream struct-layout recovery rejects LOUD
336
+ // ("overlapping fields ... unions not modelled") before this classification is consumed — so a
337
+ // width collision at off-0 declines honestly rather than reaching a wrong bare-`gSym` emission.
338
+ const scalarGlobals = new Set<string>();
339
+ {
340
+ const offsets = new Map<string, Set<number>>();
341
+ const bumpAgg = (sym: string) => offsets.set(sym, new Set([-1])); // -1 marks "variable index"
342
+ for (const b of fn.blocks) {
343
+ for (const op of b.ops) {
344
+ const gaddrSym = (v: Value) => (defs.get(v)?.opcode === 'gaddr' ? (defs.get(v)!.attrs.sym as string) : null);
345
+ if (op.opcode === 'load' || op.opcode === 'store') {
346
+ const s = gaddrSym(op.operands[0]);
347
+ if (s) {
348
+ (offsets.get(s) ?? offsets.set(s, new Set()).get(s)!).add(op.attrs.off as number);
349
+ }
350
+ // a `+`-tree base holding a gaddr (global array element) is aggregate
351
+ const d = defs.get(op.operands[0]);
352
+ if (d?.opcode === 'add') {
353
+ for (const o of d.operands) {
354
+ const s2 = gaddrSym(o);
355
+ if (s2) {
356
+ bumpAgg(s2);
357
+ }
358
+ }
359
+ }
360
+ } else if (op.opcode === 'aload' || op.opcode === 'astore') {
361
+ const s = gaddrSym(op.operands[0]);
362
+ if (s) {
363
+ bumpAgg(s);
364
+ }
365
+ }
366
+ }
367
+ }
368
+ for (const [sym, offs] of offsets) {
369
+ if (offs.size === 1 && offs.has(0)) {
370
+ scalarGlobals.add(sym);
371
+ }
372
+ }
373
+ }
374
+
375
+ // --- loop discovery (loops.ts): natural loops via dominator back-edges + the nesting forest ---
376
+ const forest = analyzeLoops(fn, dom);
377
+
378
+ // GUARDED self-loop headers (a block that is its own successor, entered through a guard-shaped
379
+ // cond_br) — recovered by the guard-fusion + emitWhile un-rotation path below (the gcc "guard +
380
+ // do-while" → `while` shape; countdown/shifts). UNGUARDED self-loops register as single-block
381
+ // do-whiles in the structured-loop discovery instead. A self-loop's
382
+ // test and update live in ONE block, so the latch test reads the UPDATED value → emitWhile substitutes
383
+ // the back-edge arg back to the header param. This is DISTINCT from a test-at-top multi-block `while`
384
+ // (whileLoops, below) whose header is a pure test read on entry values.
385
+ const loops = new Map<Block, LoopInfo>();
386
+ for (const nl of forest.byHeader.values()) {
387
+ if (!nl.selfLoop) {
388
+ continue;
389
+ } // multi-block loops go through whileLoops
390
+ const b = nl.header;
391
+ const term = b.ops[b.ops.length - 1];
392
+ if (term.opcode !== 'cond_br') {
393
+ continue;
394
+ } // a many-way self-terminator has no single "exit"
395
+ const exit = term.successors.find((s) => s.block !== b)?.block;
396
+ if (!exit) {
397
+ continue;
398
+ }
399
+ // GUARDED self-loops only: some forward pred's cond_br decides "enter b vs its exit" — the
400
+ // shape the guard-fusion + emitWhile un-rotation below consumes. An UNGUARDED self-loop
401
+ // (entered by a plain br / fall-through) is a bottom-tested loop whose body always runs
402
+ // once — a single-block do-while — and is claimed by the structured-loop discovery below
403
+ // instead (each header lives in exactly ONE map, so seeding stays single-pass).
404
+ if (!(preds.get(b) ?? []).some((pr) => isGuardShapedPred(pr, b, exit))) {
405
+ continue;
406
+ }
407
+ const back = successorTo(b, b)!;
408
+ loops.set(b, { header: b, exit, backArgOfParam: b.params.map((_, i) => back.args[i]) });
409
+ }
410
+
411
+ // --- structured natural loops (test-at-top `while` / bottom-test `do-while`) ---
412
+ // Both share the fail-closed preconditions: single latch, properly-nested inner loops only,
413
+ // reducible single-entry body, and a SINGLE real (non-ret) exit — early returns (ret-terminated
414
+ // targets) are allowed in-body. The shape then splits on WHERE the exit lives: the HEADER exits
415
+ // (pure test-at-top) → `while`; the LATCH exits (body-first) → `do-while`. Anything that fails
416
+ // declines to plain if-recovery, which re-enters the header and fails loud via `onStack`.
417
+ const isRet = (blk: Block) => blk.ops[blk.ops.length - 1]?.opcode === 'ret';
418
+ // A pure "return trampoline" out of the loop: forward-walking from `start` WITHOUT re-entering the
419
+ // loop `body`, every path terminates in a `ret` and no block on the way carries an OBSERVABLE side
420
+ // effect (store/astore/call/opaque). agbcc/gcc merge every `return` into ONE epilogue block and each
421
+ // return site just sets the return register and branches there — so a second body exit that lands on
422
+ // such a chain is an early RETURN, not a break to a live merge. Structuring it on more than one exit
423
+ // path is sound precisely because it is side-effect-free (a duplicated `return v` is harmless).
424
+ // This lets two returns merged through a shared `bx lr` recover as a `while` with an in-body early
425
+ // `return` instead of declining as "multi-exit".
426
+ const leadsToReturnOnly = (start: Block, body: Set<Block>): boolean => {
427
+ const seen = new Set<Block>();
428
+ const stack = [start];
429
+ while (stack.length) {
430
+ const bb = stack.pop()!;
431
+ if (seen.has(bb)) {
432
+ continue;
433
+ }
434
+ seen.add(bb);
435
+ if (body.has(bb)) {
436
+ return false;
437
+ } // re-enters the loop → not a pure exit
438
+ if (
439
+ bb.ops.some(
440
+ (op) => op.opcode === 'store' || op.opcode === 'astore' || op.opcode === 'call' || op.opcode === 'opaque',
441
+ )
442
+ ) {
443
+ return false;
444
+ }
445
+ const t = bb.ops[bb.ops.length - 1];
446
+ if (t.opcode === 'ret') {
447
+ continue;
448
+ }
449
+ if (t.opcode === 'br' || t.opcode === 'cond_br') {
450
+ for (const s of t.successors) {
451
+ stack.push(s.block);
452
+ }
453
+ continue;
454
+ }
455
+ return false; // switch_br / unknown terminator → decline
456
+ }
457
+ return true;
458
+ };
459
+ const whileLoops = new Map<Block, WhileLoopInfo>();
460
+ const doWhileLoops = new Map<Block, DoWhileInfo>();
461
+ for (const nl of forest.byHeader.values()) {
462
+ const h = nl.header;
463
+ if (nl.selfLoop && loops.has(h)) {
464
+ continue;
465
+ } // guarded self-loops use emitWhile (above); UNGUARDED ones are single-block do-whiles
466
+ if (!nl.selfLoop && nl.latches.length !== 1) {
467
+ continue;
468
+ } // single latch only
469
+ const latch = nl.selfLoop ? h : nl.latches[0];
470
+ // Nested loops: an inner loop whose header sits in this body is fine ONLY if it is PROPERLY
471
+ // nested — its ENTIRE body is contained in ours (a forest descendant). Structuring then recurses
472
+ // naturally: when the outer body reaches the inner header, structureBlock dispatches to the inner's
473
+ // own emitWhile/emitDoWhile. An OVERLAPPING loop (shared blocks, neither containing the other →
474
+ // irreducible) DECLINES. If a contained inner is itself unstructurable, the outer's body
475
+ // structuring loud-fails at the inner back-edge (onStack) — a safe decline, not a miscompile.
476
+ if (
477
+ [...forest.byHeader.keys()].some(
478
+ (h2) => h2 !== h && nl.body.has(h2) && ![...forest.byHeader.get(h2)!.body].every((b) => nl.body.has(b)),
479
+ )
480
+ ) {
481
+ continue;
482
+ }
483
+ // Reducible entry (single-entry): every body block except the header is entered ONLY from
484
+ // inside the body — no jump into the loop interior.
485
+ let reducible = true;
486
+ for (const bb of nl.body) {
487
+ if (bb === h) {
488
+ continue;
489
+ }
490
+ if ((preds.get(bb) ?? []).some((p) => !nl.body.has(p))) {
491
+ reducible = false;
492
+ break;
493
+ }
494
+ }
495
+ if (!reducible) {
496
+ continue;
497
+ }
498
+
499
+ // Identify the loop's single STRUCTURAL exit — where the loop-condition sends control when it
500
+ // fails. It may itself be a ret block (a loop ending in `return`), so it CANNOT be found by
501
+ // filtering ret targets (that would hide the exit of every `while(*p){} return q;`). It is the
502
+ // header's non-body edge (test-at-top `while`) or the latch's non-body edge (bottom-test
503
+ // `do-while`). `while` is tried first; `do-while` only when the header keeps BOTH edges in-body.
504
+ const hTerm = h.ops[h.ops.length - 1];
505
+ const lTerm = latch.ops[latch.ops.length - 1];
506
+ const hInBody = hTerm.opcode === 'cond_br' ? hTerm.successors.filter((s) => nl.body.has(s.block)) : [];
507
+ const hOut = hTerm.opcode === 'cond_br' ? hTerm.successors.filter((s) => !nl.body.has(s.block)) : [];
508
+ const lOut = lTerm.opcode === 'cond_br' ? lTerm.successors.filter((s) => !nl.body.has(s.block)) : [];
509
+ // Header purity — the header block is KEPT as the re-evaluated `while` condition, so no
510
+ // store/astore/opaque, and no `call` (expr() inlines a result at every use with no CSE → a call
511
+ // whose result also feeds the body would be evaluated twice per iteration). A `load` is fine —
512
+ // but NOT a materialized one: its temp assignment renders only via sideEffects(), which a
513
+ // condition-only header never emits, so its uses would read an unassigned variable.
514
+ const headerPure = !h.ops.some(
515
+ (op) =>
516
+ op.opcode === 'store' ||
517
+ op.opcode === 'astore' ||
518
+ op.opcode === 'opaque' ||
519
+ op.opcode === 'call' ||
520
+ materialize.has(op),
521
+ );
522
+
523
+ let exitFrom: Block,
524
+ exit: Block,
525
+ kind: 'while' | 'dowhile',
526
+ bodyEntry: Block | null = null;
527
+ if (!nl.selfLoop && hTerm.opcode === 'cond_br' && hInBody.length === 1 && hOut.length === 1 && headerPure) {
528
+ kind = 'while';
529
+ exitFrom = h;
530
+ exit = hOut[0].block;
531
+ bodyEntry = hInBody[0].block;
532
+ } else if (lTerm.opcode === 'cond_br' && lOut.length === 1 && lTerm.successors.some((s) => s.block === h)) {
533
+ // a SELF-loop always lands here: its ops run before its bottom test (body-first), so the
534
+ // faithful spelling is `do { ops; updates } while (cond)` with header === latch
535
+ kind = 'dowhile';
536
+ exitFrom = latch;
537
+ exit = lOut[0].block;
538
+ } else {
539
+ continue; // neither a clean pre-tested nor bottom-tested single-exit shape
540
+ }
541
+ // Single loop exit (ret-aware): the chosen exit is the ONE real exit; every OTHER edge leaving
542
+ // the body must be an early `return` — a ret-terminated target OR a pure return-trampoline chain
543
+ // (agbcc's merged epilogue; `leadsToReturnOnly`). A second exit that lands on a LIVE non-return
544
+ // merge is a genuine `break`/second structured exit → decline.
545
+ if (
546
+ nl.exitEdges.some(
547
+ (e) => !(e.from === exitFrom && e.to === exit) && !isRet(e.to) && !leadsToReturnOnly(e.to, nl.body),
548
+ )
549
+ ) {
550
+ continue;
551
+ }
552
+
553
+ if (kind === 'while') {
554
+ whileLoops.set(h, {
555
+ header: h,
556
+ bodyEntry: bodyEntry!,
557
+ exit,
558
+ latch,
559
+ forwardPreds: nl.forwardPreds,
560
+ body: nl.body,
561
+ });
562
+ } else {
563
+ doWhileLoops.set(h, { header: h, latch, exit, forwardPreds: nl.forwardPreds, body: nl.body });
564
+ }
565
+ }
566
+
567
+ // --- coalesce SSA values to variable names ---
568
+ const varName = new Map<Value, string>();
569
+ const varType = new Map<string, IrType>();
570
+ // Global symbols referenced by name (agbcc pool `.word gSym`, lowered by the global read/write
571
+ // paths below). They print as bare `gSym`, declared by the project headers — so they are
572
+ // EXCLUDED from the emitted local declarations (localNames below).
573
+ const globalNames = new Set<string>();
574
+ const entry = fn.blocks[0];
575
+ entry.params.forEach((p, i) => {
576
+ varName.set(p, `a${i}`);
577
+ varType.set(`a${i}`, p.type);
578
+ });
579
+ const backArgName = new Map<Value, string>();
580
+ // The C static type of a rendered expression, over the declared variable types — what decides
581
+ // whether a memory access's base may be dereferenced as spelled (memAccess/arrayAccess).
582
+ const ctype = (e0: Expr): IrType | undefined => exprCType(e0, (n) => varType.get(n));
583
+ let fresh = 0;
584
+ // Materialized defs are named FIRST: the temp is the register the compiler held the
585
+ // value in, so downstream coalescing (loop inits, merge params) may adopt it — subject to the
586
+ // same interference check as any other name.
587
+ for (const b of fn.blocks) {
588
+ for (const op of b.ops) {
589
+ if (materialize.has(op)) {
590
+ const r = op.results[0];
591
+ const name = `v${fresh++}`;
592
+ varName.set(r, name);
593
+ varType.set(name, r.type);
594
+ }
595
+ }
596
+ }
597
+ // Interference check: may block-param `p` of block B adopt `name`? The in-edge copies into
598
+ // `name` execute just before B, and inside/after B the name means p — so it is a silent
599
+ // clobber if ANY other value already under that name is still LIVE at B's entry (the textbook
600
+ // "two live values merged into one variable"), or if a SIBLING param of B claimed it (one
601
+ // edge would then write the name twice).
602
+ //
603
+ // AND THE CONVERSE: the name must not be WRITTEN anywhere `p` itself is live. Every other
604
+ // block param under the name is such a write — its in-edge copies execute at each
605
+ // predecessor's end, and a LOOP header's update copy is emitted inside the loop body, where it
606
+ // also runs on the final (exiting) iteration — so the test is `p` live into the writer's block
607
+ // OR live out of any of its predecessors (the conservative union covers that placement). A
608
+ // materialized def under the name writes at its own block. This applies even to a
609
+ // redundant-phi alias (`pureAlias` waives only the value-at-B check: aliasing is sound at B's
610
+ // entry, but a later write to the shared name still splits them — e.g. a saved pre-increment
611
+ // `i` read post-loop).
612
+ const paramBlock = new Map<Value, Block>();
613
+ for (const blk of fn.blocks) {
614
+ for (const pv of blk.params) {
615
+ paramBlock.set(pv, blk);
616
+ }
617
+ }
618
+ const canTakeName = (p: Value, B: Block, name: string, pureAlias = false): boolean => {
619
+ if (B.params.some((q) => q !== p && varName.get(q) === name)) {
620
+ return false;
621
+ }
622
+ const lin = liveIn.get(B)!;
623
+ for (const [v, n] of varName) {
624
+ if (n !== name || v === p) {
625
+ continue;
626
+ }
627
+ if (!pureAlias && lin.has(v)) {
628
+ return false;
629
+ } // v still live at B → p's copies clobber it
630
+ const wblk = paramBlock.get(v);
631
+ if (wblk && wblk !== entry) {
632
+ // v is a param → `name` written at wblk's edges
633
+ if (liveIn.get(wblk)!.has(p)) {
634
+ return false;
635
+ }
636
+ for (const pr of preds.get(wblk) ?? []) {
637
+ for (const s of successorsOf(pr)) {
638
+ if (liveIn.get(s)!.has(p)) {
639
+ return false;
640
+ }
641
+ }
642
+ }
643
+ }
644
+ const d = defs.get(v);
645
+ if (d && materialize.has(d) && liveIn.get(opBlock.get(d)!)!.has(p)) {
646
+ return false;
647
+ }
648
+ }
649
+ return true;
650
+ };
651
+ // ONE seeding routine for self-loop and structured-loop headers. On a coalesceLoopInit target,
652
+ // keep the induction variable in its entry (forward-edge) value's register — reproducing a
653
+ // compiler that mutates the arg register across the loop instead of copying to a fresh local,
654
+ // so the init copy vanishes. The loop mutates the adopted name every iteration — canTakeName
655
+ // declines it when any value under it is still live at the header. `exclude` are names never to
656
+ // adopt (enclosing loops' induction vars — the cross-level collision below); every seeded
657
+ // param's name is ADDED to it, so sibling params can't collapse.
658
+ const seedLoopParams = (
659
+ header: Block,
660
+ forwardPreds: Block[],
661
+ backArgs: readonly Value[] | null,
662
+ exclude: Set<string>,
663
+ ): void => {
664
+ header.params.forEach((p, i) => {
665
+ if (!varName.has(p)) {
666
+ let name: string | undefined;
667
+ if (coalesceLoopInit) {
668
+ for (const fp of forwardPreds) {
669
+ const nm = varName.get(successorTo(fp, header)?.args[i] as Value);
670
+ if (nm && !exclude.has(nm) && canTakeName(p, header, nm)) {
671
+ name = nm;
672
+ break;
673
+ }
674
+ }
675
+ }
676
+ name ??= `v${fresh++}`;
677
+ varName.set(p, name);
678
+ if (!varType.has(name)) {
679
+ varType.set(name, p.type);
680
+ }
681
+ }
682
+ exclude.add(varName.get(p)!);
683
+ if (backArgs) {
684
+ backArgName.set(backArgs[i], varName.get(p)!);
685
+ }
686
+ });
687
+ };
688
+ // Self-loop headers seed FIRST, with an EMPTY exclusion set. KNOWN GAP: a nested self-loop
689
+ // (a guard-fused inner loop inside a structured loop DOES structure) seeds before the
690
+ // enclosing loop's induction name exists to exclude — the outermost-first discipline below
691
+ // does not cover this ordering. canTakeName's liveness/write-site checks are the only guard
692
+ // against a cross-level name adoption here.
693
+ for (const li of loops.values()) {
694
+ const fwdPreds = (preds.get(li.header) ?? []).filter((pr) => pr !== li.header);
695
+ seedLoopParams(li.header, fwdPreds, li.backArgOfParam, new Set());
696
+ }
697
+ // Seed structured-loop (`while`/`do-while`) header params: same discipline as self-loops. On
698
+ // coalesceLoopInit, keep the loop variable in its forward-edge (init) register; else a fresh local
699
+ // (agbcc copies the init to a new reg). Never reuse a name already taken by a SIMULTANEOUSLY-LIVE
700
+ // sibling header param — two loop-carried values seeded from one source must not collapse (a silent
701
+ // clobber). The latch's back-edge arg carries the param's name so the loop update assigns it.
702
+ const structuredLoops = [
703
+ ...[...whileLoops.values()].map((l) => ({
704
+ header: l.header,
705
+ latch: l.latch,
706
+ forwardPreds: l.forwardPreds,
707
+ body: l.body,
708
+ })),
709
+ ...[...doWhileLoops.values()].map((l) => ({
710
+ header: l.header,
711
+ latch: l.latch,
712
+ forwardPreds: l.forwardPreds,
713
+ body: l.body,
714
+ })),
715
+ ];
716
+ // Cross-level collision: with nesting, an outer loop's induction variable is LIVE across the inner
717
+ // loop (the outer latch reads it after). If the inner var is coalesced onto the outer var's name
718
+ // (its init reads the outer var), the inner loop would MUTATE the outer variable — a silent
719
+ // miscompile. Process OUTERMOST-first (so an enclosing loop is named first) and, per loop, exclude
720
+ // the names of every enclosing loop's header params from the coalescing candidates.
721
+ // `enclosingNames(l)` = names of params of headers whose natural body strictly contains `l.header`.
722
+ structuredLoops.sort((a, b) => b.body.size - a.body.size); // outermost first
723
+ const enclosingNames = (l: { header: Block; body: Set<Block> }): Set<string> => {
724
+ const names = new Set<string>();
725
+ for (const nl2 of forest.byHeader.values()) {
726
+ if (nl2.header !== l.header && nl2.body.has(l.header)) {
727
+ // nl2 strictly encloses l
728
+ for (const p of nl2.header.params) {
729
+ const nm = varName.get(p);
730
+ if (nm) {
731
+ names.add(nm);
732
+ }
733
+ }
734
+ }
735
+ }
736
+ return names;
737
+ };
738
+ for (const l of structuredLoops) {
739
+ const back = successorTo(l.latch, l.header);
740
+ // exclusion seeded with enclosing-loop names → never coalesce onto them
741
+ seedLoopParams(l.header, l.forwardPreds, back ? back.args : null, enclosingNames(l));
742
+ }
743
+ let changed = true;
744
+ while (changed) {
745
+ changed = false;
746
+ for (const b of fn.blocks) {
747
+ if (b === entry) {
748
+ continue;
749
+ }
750
+ b.params.forEach((p, i) => {
751
+ if (varName.has(p)) {
752
+ return;
753
+ }
754
+ // EVERY in-edge record, not successorTo (which returns only the FIRST record to `b` — a
755
+ // terminator with two edges to the same block would hide the second edge's args here).
756
+ const incoming: Value[] = [];
757
+ for (const pr of new Set(preds.get(b) ?? [])) {
758
+ for (const s of pr.ops[pr.ops.length - 1].successors) {
759
+ if (s.block === b) {
760
+ incoming.push(s.args[i]);
761
+ }
762
+ }
763
+ }
764
+ // A redundant phi (every edge passes the SAME value) is a pure alias of it — sharing the
765
+ // name is sound even while the value stays live (they are equal on every path). This
766
+ // waives only the LIVENESS half of canTakeName; the sibling-param check always applies.
767
+ const allSame = incoming.length > 0 && incoming.every((v) => v === incoming[0]);
768
+ // prefer a carrier that already has a name; then a loop var whose update this receives —
769
+ // but only one whose name survives the C3 interference check (else the edge copies into
770
+ // the name would clobber a still-live value).
771
+ let name: string | undefined;
772
+ for (const c of [...incoming.filter((v) => varName.has(v)), ...incoming.filter((v) => backArgName.has(v))]) {
773
+ const nm = varName.get(c) ?? backArgName.get(c)!;
774
+ if (canTakeName(p, b, nm, allSame)) {
775
+ name = nm;
776
+ break;
777
+ }
778
+ }
779
+ name ??= `v${fresh++}`;
780
+ varName.set(p, name);
781
+ if (!varType.has(name)) {
782
+ varType.set(name, p.type);
783
+ }
784
+ changed = true;
785
+ });
786
+ }
787
+ }
788
+
789
+ // An unresolvable value: strict mode keeps the `"?"` sentinel (assertResolved trips at the
790
+ // boundary — loud in the PROCESS); annotate mode emits a marker (the undefined ASMLIFT_ERROR
791
+ // symbol — loud in the ARTIFACT, function still complete).
792
+ const mkGap = (reason: string, args: Expr[]): Expr =>
793
+ onGap === 'annotate' ? { k: 'marker', reason, args } : { k: 'var', name: '?' };
794
+
795
+ // Lower ONE def's operation to an Expr, rendering operands through `e`. Shared between the
796
+ // inline-at-use path (exprWith) and the materialized-temp path (sideEffects), so both spell a
797
+ // given op identically.
798
+ const lowerDef = (d: Op, e: (v: Value) => Expr): Expr => {
799
+ if (d.opcode === 'const') {
800
+ return { k: 'const', value: d.attrs.value as number };
801
+ }
802
+ if (CMP_TO_BIN[d.opcode]) {
803
+ return { k: 'bin', op: CMP_TO_BIN[d.opcode], l: e(d.operands[0]), r: e(d.operands[1]) };
804
+ }
805
+ if (ARITH_TO_BIN[d.opcode]) {
806
+ let l = e(d.operands[0]);
807
+ let r = d.operands.length === 2 ? e(d.operands[1]) : ({ k: 'const', value: d.attrs.imm as number } as Expr);
808
+ // Pointer stride: C pointer arithmetic is ELEMENT-scaled, but the asm added a BYTE
809
+ // constant — `addi p,4` on an `s32*` walks 1 element, yet C `p + 4` walks 4. Divide the byte
810
+ // constant by the pointee size so the walk recompiles to the same address math.
811
+ //
812
+ // Keyed on the operand's RENDERED C type, never the IR value's recovered type: C scales by
813
+ // the type of the expression it actually sees, and the two diverge exactly like memAccess's
814
+ // deref bases (a value recovered `s32*` can render as an int-typed tree — C then does NO
815
+ // element scaling, so pre-dividing the constant would bake in a WRONG address that the
816
+ // deref cast downstream turns into silently-wrong bytes; found by the adversarial round).
817
+ // An int-rendered walk keeps its raw byte constant and derefs through the access-width cast.
818
+ // Fires only for a rendered pointer whose element size (>1) DIVIDES the constant exactly;
819
+ // otherwise raw (a misaligned/struct-array stride is left as-is; a `u8*` is size 1 so
820
+ // unchanged). Since C `(K/es) + p == p + (K/es)`, scaling the const on whichever side it
821
+ // sits fixes the bytes: `add` is commutative so the pointer may be either operand; `sub` is
822
+ // not, so only operand[0] (the minuend) may be the pointer.
823
+ const scale = (t: IrType | undefined, c: Extract<Expr, { k: 'const' }>): Expr => {
824
+ const es = t?.kind === 'ptr' ? ptrElemBytes(t.to) : 0;
825
+ return es > 1 && c.value % es === 0 ? { k: 'const', value: c.value / es } : c;
826
+ };
827
+ if ((d.opcode === 'add' || d.opcode === 'sub') && r.k === 'const') {
828
+ r = scale(ctype(l), r);
829
+ } else if (d.opcode === 'add' && d.operands.length === 2 && l.k === 'const') {
830
+ l = scale(ctype(r), l); // commuted `const + ptr`
831
+ }
832
+ // C rejects a pointer operand outright under the non-additive operators (& | ^ << >> * / %),
833
+ // under `ptr + ptr`, and as the subtrahend of `int - ptr` — the asm just does 32-bit integer
834
+ // math on the address, so the honest spelling is the value cast to its integer self. Only a
835
+ // DEFINITELY-pointer rendering is cast (same conservative direction as memAccess); the
836
+ // additive ops keep C's legal pointer arithmetic untouched.
837
+ const op = ARITH_TO_BIN[d.opcode];
838
+ const intify = (x: Expr): Expr => (ctype(x)?.kind === 'ptr' ? { k: 'cast', to: T.s(32), e: x } : x);
839
+ if (!['+', '-', '&&', '||'].includes(op)) {
840
+ l = intify(l);
841
+ r = intify(r);
842
+ } else if (op === '+' && ctype(l)?.kind === 'ptr' && ctype(r)?.kind === 'ptr') {
843
+ r = intify(r); // ptr + ptr is not C; ptr + (s32)ptr is, with the same bytes
844
+ } else if (op === '-' && ctype(l)?.kind !== 'ptr' && ctype(r)?.kind === 'ptr') {
845
+ r = intify(r); // int - ptr is not C
846
+ }
847
+ return { k: 'bin', op, l, r };
848
+ }
849
+ // `-`/`~` on a pointer rendering is equally not C — same honest integer cast as above.
850
+ if (d.opcode === 'rotr' || d.opcode === 'rotl') {
851
+ // The C rotate idiom — `x >> n | x << (32 - n)` (mirrored for rotl). Byte-exact round-trip
852
+ // on agbcc (thumb ror) and mwcc (rotlw/rotlwi), verified against both toolchains before the
853
+ // ops landed. `x` and `n` render twice — both pure by construction (SSA values; the rotate's
854
+ // operands are register reads), and recovery seeds the rotated value unsigned so `>>`
855
+ // spells the logical shift the idiom requires.
856
+ //
857
+ // (The PPC mirror fold — `rotl(x, 32 - m)` ⇒ rotr(x, m) — lives in the PATTERN layer,
858
+ // engine.ts ROTL_MIRROR: it is a compiler-spelling idiom, mwcc-gated there, not a
859
+ // structurer concern.)
860
+ const dir: 'rotr' | 'rotl' = d.opcode;
861
+ const n: Expr = d.operands.length === 2 ? e(d.operands[1]) : { k: 'const', value: d.attrs.imm as number };
862
+ const x = e(d.operands[0]);
863
+ // constant-amount edges: 0 and 32 are the IDENTITY (the C idiom would spell the UB
864
+ // shift-by-32); otherwise a constant folds the complement (`a0 >> 24`, not `a0 >> 32 - 8`).
865
+ if (n.k === 'const' && (n.value === 0 || n.value === 32)) {
866
+ return x;
867
+ }
868
+ const w: Expr =
869
+ n.k === 'const'
870
+ ? { k: 'const', value: 32 - n.value }
871
+ : { k: 'bin', op: '-', l: { k: 'const', value: 32 }, r: n };
872
+ const [near, far] = dir === 'rotr' ? (['>>', '<<'] as const) : (['<<', '>>'] as const);
873
+ return {
874
+ k: 'bin',
875
+ op: '|',
876
+ l: { k: 'bin', op: near, l: x, r: n },
877
+ r: { k: 'bin', op: far, l: x, r: w },
878
+ };
879
+ }
880
+ if (d.opcode === 'neg') {
881
+ const x = e(d.operands[0]);
882
+ return { k: 'un', op: '-', e: ctype(x)?.kind === 'ptr' ? { k: 'cast', to: T.s(32), e: x } : x };
883
+ }
884
+ if (d.opcode === 'not') {
885
+ const x = e(d.operands[0]);
886
+ return { k: 'un', op: '~', e: ctype(x)?.kind === 'ptr' ? { k: 'cast', to: T.s(32), e: x } : x };
887
+ }
888
+ // Width-narrowing casts: `zext`/`sext` widen a `width`-bit value back to 32 → C `(u8)e`/`(s8)e`.
889
+ if (d.opcode === 'zext') {
890
+ return { k: 'cast', to: T.int(d.attrs.width as number, false), e: e(d.operands[0]) };
891
+ }
892
+ if (d.opcode === 'sext') {
893
+ return { k: 'cast', to: T.int(d.attrs.width as number, true), e: e(d.operands[0]) };
894
+ }
895
+ if (d.opcode === 'call') {
896
+ return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
897
+ }
898
+ if (d.opcode === 'gaddr') {
899
+ return { k: 'addr', name: d.attrs.sym as string };
900
+ }
901
+ if (d.opcode === 'load') {
902
+ return memAccess(
903
+ d.operands[0],
904
+ e(d.operands[0]),
905
+ d.attrs.off as number,
906
+ d.attrs.width as number,
907
+ (d.attrs.signed as boolean) ?? false,
908
+ ctype,
909
+ scalarGlobals,
910
+ );
911
+ }
912
+ // aload carries a runtime index operand (variable-index array access) — `base[index]`, or
913
+ // `base[index].field_K` when it carries a `fieldOff` (array-of-STRUCT element access).
914
+ if (d.opcode === 'aload') {
915
+ return arrayAccess(
916
+ d.operands[0],
917
+ e(d.operands[0]),
918
+ e(d.operands[1]),
919
+ d.attrs.fieldOff as number | undefined,
920
+ d.attrs.elemSize as number,
921
+ (d.attrs.signed as boolean) ?? false,
922
+ ctype,
923
+ );
924
+ }
925
+ return d.opcode === 'opaque'
926
+ ? mkGap(`unmodelled instruction '${(d.attrs.mnemonic as string) ?? '?'}'`, d.operands.map(e))
927
+ : mkGap(`no lowering for op '${d.opcode}'`, d.operands.map(e));
928
+ };
929
+
930
+ const exprWith = (sub: Map<Value, string> | null) => {
931
+ const e = (v: Value): Expr => {
932
+ const subbed = sub?.get(v);
933
+ if (subbed) {
934
+ return { k: 'var', name: subbed };
935
+ }
936
+ if (varName.has(v)) {
937
+ return { k: 'var', name: varName.get(v)! };
938
+ }
939
+ const d = defs.get(v);
940
+ if (!d) {
941
+ return mkGap('value has no reaching definition (dropped def)', []);
942
+ }
943
+ return lowerDef(d, e);
944
+ };
945
+ return e;
946
+ };
947
+ // The loop-emission hazard checks (readsClobbered / loopEscapeHazard / loopUpdateHazard) —
948
+ // pure decline-or-emit predicates, extracted to hazards.ts behind the explicit-deps factory.
949
+ // `varName` is captured as a live reference: it is still being populated here in the naming
950
+ // pipeline, and each check reads the names that exist when EMISSION calls it.
951
+ const { loopUpdateHazard } = makeLoopHazards({ defs, varName, useSitesOf });
952
+
953
+ // A POST-LOOP substitution active while structuring a loop's exit region: a loop-carried value (a
954
+ // latch back-edge arg) is held in its loop-variable NAME after the loop, so any post-loop use must
955
+ // read the name, not re-inline the computation (which would double-count, e.g. `(u8)(v1+1)` instead
956
+ // of `v1`). `expr` consults it; `withSub` installs/merges it around the exit region. Null normally.
957
+ let activeSub: Map<Value, string> | null = null;
958
+ const expr = (v: Value): Expr => exprWith(activeSub)(v);
959
+ const withSub = <R>(sub: Map<Value, string>, run: () => R): R => {
960
+ const prev = activeSub;
961
+ activeSub = prev ? new Map([...prev, ...sub]) : sub;
962
+ try {
963
+ return run();
964
+ } finally {
965
+ activeSub = prev;
966
+ }
967
+ };
968
+
969
+ // Assignments a predecessor must perform when branching into `target`, as a PARALLEL
970
+ // copy (skip identities), then sequentialised so no assignment clobbers a still-needed
971
+ // value. `sub` (used for the emitWhile un-rotation's exit copies) substitutes back-edge args to
972
+ // their header-param NAMES — post-loop the params already hold their updated values, so a merged
973
+ // exit value is read as `v` not `v-1`.
974
+ const tempCounter = { n: 0 }; // per-function swap-cycle temp names (sequentialize)
975
+ // The copies for ONE specific successor record — the workhorse behind argAssigns, taken
976
+ // directly by the switch_br path, whose duplicate case targets successorTo cannot
977
+ // disambiguate.
978
+ const argAssignsFor = (
979
+ pred: Block,
980
+ succ: { block: Block; args: Value[] },
981
+ sub: Map<Value, string> | null = null,
982
+ ): Stmt[] => {
983
+ const target = succ.block;
984
+ const argExpr = sub ? exprWith(sub) : expr;
985
+ const copies: { name: string; value: Expr; arg: Value }[] = [];
986
+ target.params.forEach((p, i) => {
987
+ const name = varName.get(p)!;
988
+ const arg = succ.args[i];
989
+ if ((sub?.get(arg) ?? varName.get(arg)) === name) {
990
+ return;
991
+ } // identity copy — coalesced away
992
+ copies.push({ name, value: argExpr(arg), arg });
993
+ });
994
+ // Emit in the order the args are COMPUTED in `pred` — a compiler that lays the defining ops
995
+ // (and thus the copies that read them) out in that order matches with no spurious arg-swap.
996
+ // This is a per-compiler behavior (orderArgCopiesByComputation), not a universal: a compiler
997
+ // that emits copies in source/param order sets it false. Dependency ordering (sequentialize)
998
+ // still has the final say regardless.
999
+ if (orderArgCopiesByComputation) {
1000
+ // opIndex is only valid for a def IN this block; a def elsewhere keeps indexOf's -1 (sorts first).
1001
+ const pos = (v: Value) => {
1002
+ const d = defs.get(v);
1003
+ return d && opBlock.get(d) === pred ? opIndex.get(d)! : -1;
1004
+ };
1005
+ copies.sort((a, b) => pos(a.arg) - pos(b.arg));
1006
+ }
1007
+ return sequentialize(
1008
+ copies.map(({ name, value }) => ({ name, value })),
1009
+ varType,
1010
+ tempCounter,
1011
+ fn.name,
1012
+ );
1013
+ };
1014
+ const argAssigns = (pred: Block, target: Block, sub: Map<Value, string> | null = null): Stmt[] => {
1015
+ const succ = successorTo(pred, target);
1016
+ return succ ? argAssignsFor(pred, succ, sub) : [];
1017
+ };
1018
+
1019
+ // Side-effecting ops of a block, emitted as statements in program order: memory stores,
1020
+ // calls whose return value nothing consumes (a void/discarded call), and MATERIALIZED defs
1021
+ // — a call/load whose value cannot soundly render at its use is assigned to its named
1022
+ // temp here, at its own program position.
1023
+ const sideEffects = (b: Block): Stmt[] => {
1024
+ const out: Stmt[] = [];
1025
+ for (const op of b.ops) {
1026
+ if (op.opcode === 'store') {
1027
+ // A store whose lvalue is a bare global (`gSym = v`, from an `&gSym` base at off 0) emits
1028
+ // as an ASSIGN, not a store — memAccess returns a `var` node for that case.
1029
+ const width = op.attrs.width as number;
1030
+ const lval0 = memAccess(
1031
+ op.operands[0],
1032
+ expr(op.operands[0]),
1033
+ op.attrs.off as number,
1034
+ width,
1035
+ width === 4,
1036
+ ctype,
1037
+ scalarGlobals,
1038
+ );
1039
+ if (lval0.k === 'var') {
1040
+ globalNames.add(lval0.name);
1041
+ out.push({ k: 'assign', name: lval0.name, value: expr(op.operands[1]) });
1042
+ continue;
1043
+ }
1044
+ // signedness mirrors recoverTypes' store seed (word ⇒ signed, narrow ⇒ unsigned), so an
1045
+ // inserted cast declares the same scalar the recovered pointee would have.
1046
+ out.push({ k: 'store', lval: lval0, value: expr(op.operands[1]) });
1047
+ } else if (op.opcode === 'astore') {
1048
+ const elemSize = op.attrs.elemSize as number;
1049
+ out.push({
1050
+ k: 'store',
1051
+ lval: arrayAccess(
1052
+ op.operands[0],
1053
+ expr(op.operands[0]),
1054
+ expr(op.operands[1]),
1055
+ op.attrs.fieldOff as number | undefined,
1056
+ elemSize,
1057
+ elemSize === 4,
1058
+ ctype,
1059
+ ),
1060
+ value: expr(op.operands[2]),
1061
+ });
1062
+ } else if (op.opcode === 'call' && op.results.length && !useSitesOf.has(op.results[0])) {
1063
+ out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1064
+ } else if (materialize.has(op)) {
1065
+ out.push({ k: 'assign', name: varName.get(op.results[0])!, value: lowerDef(op, expr) });
1066
+ }
1067
+ }
1068
+ return out;
1069
+ };
1070
+
1071
+ // Blocks currently on the recursion stack. A well-formed reducible CFG structures each
1072
+ // block at most once per active path, so re-entering a block already on the stack means an
1073
+ // unrecovered back-edge (a cycle loop-recovery didn't lower) — which would recurse forever.
1074
+ // Bail explicitly instead. This also bounds recursion depth by the block count.
1075
+ const onStack = new Set<Block>();
1076
+ // do-while headers currently being emitted — so structuring the do-while's own body (which re-enters
1077
+ // the header block to structure its ops up to the latch) does not re-trigger the do-while hook.
1078
+ const dwActive = new Set<Block>();
1079
+ // The innermost loop whose BODY is currently being structured. A body cond_br with one edge back
1080
+ // to `header` is a conditional continue; its other edge, when it leaves the loop, is an early exit
1081
+ // (a `break` to `exit`, or an early `return` through a trampoline). Null outside any loop body —
1082
+ // the early-exit branch in structureBlock is inert there.
1083
+ type LoopFrame = { header: Block; exit: Block; body: Set<Block> };
1084
+ let loopCtx: LoopFrame | null = null;
1085
+ const withLoop = <R>(frame: LoopFrame, run: () => R): R => {
1086
+ const prev = loopCtx;
1087
+ loopCtx = frame;
1088
+ try {
1089
+ return run();
1090
+ } finally {
1091
+ loopCtx = prev;
1092
+ }
1093
+ };
1094
+
1095
+ const structureRegion = (b: Block, stop: Block | null): Stmt[] => {
1096
+ if (b === stop) {
1097
+ return [];
1098
+ }
1099
+ if (onStack.has(b)) {
1100
+ throw new StructureError(
1101
+ `cannot structure '${fn.name}': unrecovered back-edge into block #${fn.blocks.indexOf(b)} ` +
1102
+ `(loop-recovery declined this shape: multi-latch, irreducible/overlapping loops, ` +
1103
+ `a conditional continue, or an unsafe break)`,
1104
+ );
1105
+ }
1106
+ onStack.add(b);
1107
+ try {
1108
+ return structureBlock(b, stop);
1109
+ } finally {
1110
+ onStack.delete(b);
1111
+ }
1112
+ };
1113
+
1114
+ // ── Regime-A switch recovery (structure/switch-recover.ts): the recognizer's case bodies call
1115
+ // back into structureRegion, and Regime B (switch_br, below) shares its fall-through predicate.
1116
+ const { recognizeSwitch, caseRegionReachesSibling } = makeSwitchRecovery({
1117
+ fn,
1118
+ defs,
1119
+ dom,
1120
+ ipdom,
1121
+ opBlock,
1122
+ isNamed: (v) => varName.has(v),
1123
+ isCmpOpcode: (opcode) => !!CMP_TO_BIN[opcode],
1124
+ switchAllowsNeqCase,
1125
+ expr: (v) => expr(v),
1126
+ structureRegion: (b, stop) => structureRegion(b, stop),
1127
+ });
1128
+
1129
+ const structureBlock = (b: Block, stop: Block | null): Stmt[] => {
1130
+ // Bottom-test `do-while`: this block is a do-while header (the body-first loop entry). Emit the
1131
+ // do-while — its body includes `b`'s own ops (structured via structureRegion with the hook masked),
1132
+ // so do NOT emit sideEffects(b) here. The init was already emitted by the predecessor's argAssigns.
1133
+ const dw = doWhileLoops.get(b);
1134
+ if (dw && !dwActive.has(b)) {
1135
+ return emitDoWhile(dw, stop);
1136
+ }
1137
+
1138
+ const out: Stmt[] = [...sideEffects(b)];
1139
+ const term = b.ops[b.ops.length - 1];
1140
+ if (term.opcode === 'ret') {
1141
+ // A void function's `bx lr` leaves whatever in r0; suppress that phantom return value.
1142
+ out.push({ k: 'return', value: returnsVoid || !term.operands.length ? undefined : expr(term.operands[0]) });
1143
+ return out;
1144
+ }
1145
+ if (term.opcode === 'br') {
1146
+ const target = term.successors[0].block;
1147
+ out.push(...argAssignsFor(b, term.successors[0]));
1148
+ out.push(...structureRegion(target, stop));
1149
+ return out;
1150
+ }
1151
+ // Regime B: a `switch_br` (jump-table dispatch) lowers directly to the `switch` node — scrutinee,
1152
+ // per-successor case value, last successor = default. Case bodies delegate to structureRegion (as in
1153
+ // Regime A). Fall-through between jump-table cases is not yet handled: if a case body reaches another
1154
+ // case/default block inside the region, fail LOUD rather than duplicate it.
1155
+ if (term.opcode === 'switch_br') {
1156
+ const merge = ipdom.get(b) ?? stop;
1157
+ const succ = term.successors;
1158
+ const caseVals = term.attrs.cases as number[];
1159
+ const targets = new Set<Block>(succ.map((s) => s.block));
1160
+ if (caseRegionReachesSibling(targets, b, merge)) {
1161
+ throw new StructureError(
1162
+ `cannot structure '${fn.name}': fall-through between jump-table cases is not yet supported`,
1163
+ );
1164
+ }
1165
+ // Switch edges CARRY phi args (frontend/ssa.ts appends them terminator-generically) — each
1166
+ // case/default body must open with its edge's copies, exactly as cond_br edges do; dropping
1167
+ // them leaves the target's params uninitialized on the switch path. Two case values sharing
1168
+ // a target must agree on their args (else the copies are ambiguous → loud decline); the
1169
+ // shared body is then structured per case entry.
1170
+ const argsSeen = new Map<Block, Value[]>();
1171
+ for (const s of succ) {
1172
+ const prev = argsSeen.get(s.block);
1173
+ if (prev && (prev.length !== s.args.length || prev.some((v, i) => v !== s.args[i]))) {
1174
+ throw new StructureError(
1175
+ `cannot structure '${fn.name}': jump-table cases share a target block with differing phi args`,
1176
+ );
1177
+ }
1178
+ argsSeen.set(s.block, s.args as Value[]);
1179
+ }
1180
+ const outCases: SwitchCase[] = succ.slice(0, -1).map((s, i) => ({
1181
+ values: [caseVals[i]],
1182
+ body: [...argAssignsFor(b, s), ...structureRegion(s.block, merge)],
1183
+ fallsThrough: false,
1184
+ }));
1185
+ const sw: Stmt = {
1186
+ k: 'switch',
1187
+ scrutinee: expr(term.operands[0]),
1188
+ cases: outCases,
1189
+ default: [...argAssignsFor(b, succ[succ.length - 1]), ...structureRegion(succ[succ.length - 1].block, merge)],
1190
+ };
1191
+ out.push(sw);
1192
+ if (merge && merge !== stop) {
1193
+ out.push(...structureRegion(merge, stop));
1194
+ }
1195
+ return out;
1196
+ }
1197
+ // Everything below assumes a 2-way `cond_br`. Any OTHER terminator (a malformed op) must fail LOUD
1198
+ // here — otherwise it is silently read as a `cond_br` and every successor past the second is dropped,
1199
+ // a silent control-flow miscompile at the structuring seam.
1200
+ if (term.opcode !== 'cond_br') {
1201
+ throw new StructureError(
1202
+ `cannot structure '${fn.name}': unsupported terminator '${term.opcode}' in block #${fn.blocks.indexOf(b)} ` +
1203
+ `(only ret / br / cond_br / switch_br are structured today)`,
1204
+ );
1205
+ }
1206
+ // cond_br: successors = [taken, fallthrough]
1207
+ const takenB = term.successors[0].block;
1208
+ const fallB = term.successors[1].block;
1209
+
1210
+ // Test-at-top `while`: this block IS a loop header whose pure test decides body-vs-exit. The
1211
+ // header test is the loop condition (read on entry values); the body is a region that stops at the
1212
+ // header (= continue). The init was already emitted by the predecessor's argAssigns into `b`.
1213
+ const wl = whileLoops.get(b);
1214
+ if (wl) {
1215
+ out.push(...emitTestAtTopWhile(wl, stop));
1216
+ return out;
1217
+ }
1218
+
1219
+ // guard-fused loop: this cond_br decides "enter loop header h vs its exit". Emit the
1220
+ // inits unconditionally, then a while whose own test subsumes this guard. Never fuse when `b`
1221
+ // is itself the header (a guard-LESS single-block do-while would emit the update once before a
1222
+ // wrongly-`while` loop) — require a DISTINCT dominating guard block.
1223
+ for (const h of [takenB, fallB]) {
1224
+ const li = loops.get(h);
1225
+ if (li && h !== b && (takenB === li.exit || fallB === li.exit)) {
1226
+ // A MATERIALIZED header op's temp is assigned only inside the body (sideEffects), but the
1227
+ // un-rotated `while` condition renders BEFORE the body ever ran — reading the temp
1228
+ // uninitialized on the first test. Mirror of the headerPure gate: decline loud.
1229
+ if (li.header.ops.some((o) => materialize.has(o))) {
1230
+ throw new StructureError(
1231
+ `cannot structure '${fn.name}': loop header holds a materialized def its condition would read uninitialized`,
1232
+ );
1233
+ }
1234
+ // Self-loop emitter hazards: the while condition, the header→exit args, and every
1235
+ // post-loop use of a header-computed value render under the un-rotation sub — sound only
1236
+ // when their loop-variable reads go through sub-mapped back-edge args (post-update). A
1237
+ // direct read of an updated variable is a PRE-update value the emitted name no longer
1238
+ // holds → decline LOUD, never emit wrong code.
1239
+ const sub = loopSub(li);
1240
+ const updates = argAssigns(li.header, li.header);
1241
+ const updateWrites = updateWriteSet(updates);
1242
+ const hterm = li.header.ops[li.header.ops.length - 1];
1243
+ const hexitArgs = (successorTo(li.header, li.exit)?.args ?? []) as Value[];
1244
+ if (
1245
+ loopUpdateHazard(
1246
+ hterm.operands[0],
1247
+ hexitArgs,
1248
+ new Set([li.header]),
1249
+ sub,
1250
+ updateWrites,
1251
+ null,
1252
+ new Set(li.header.params),
1253
+ )
1254
+ ) {
1255
+ throw new StructureError(
1256
+ `cannot structure '${fn.name}': loop condition or a post-loop value reads a pre-update loop variable`,
1257
+ );
1258
+ }
1259
+ out.push(...argAssigns(b, h)); // loop-variable initialisation
1260
+ out.push(emitWhile(li, updates));
1261
+ // The header→exit edge may carry non-identity phi args (the exit param merges the guard-false
1262
+ // value with the loop's final value). Emit those copies after the loop — dropping them returns
1263
+ // a stale value. Read under the un-rotation substitution (post-loop the params hold their
1264
+ // updated values), and structure the exit region under the same substitution so a post-loop
1265
+ // use of a loop value reads its name.
1266
+ out.push(...withSub(sub, () => [...argAssigns(li.header, li.exit, sub), ...structureRegion(li.exit, stop)]));
1267
+ return out;
1268
+ }
1269
+ }
1270
+
1271
+ // Conditional latch / in-body early exit: one edge of this cond_br is the loop back-edge (a
1272
+ // continue to `loopCtx.header`); the other LEAVES the loop. When the leaving edge lands on the
1273
+ // loop's own exit block it is a `break`; when it trampolines to a `return` it is an early `return`.
1274
+ // Emit the loop update (the back-edge args, RAW) then a single `if (leaveCond) { <exit arm> }` — the
1275
+ // back-edge arm is the implicit continue (control falls to the loop bottom). Guarded to leaving
1276
+ // edges only (`!body.has(exitB)` AND a break/return target); an in-body conditional continue still
1277
+ // declines (falls through → the header re-entry trips `onStack`, an honest loud fail).
1278
+ if (loopCtx && (takenB === loopCtx.header || fallB === loopCtx.header)) {
1279
+ const contIsTaken = takenB === loopCtx.header;
1280
+ const exitB = contIsTaken ? fallB : takenB;
1281
+ const isBreak = exitB === loopCtx.exit;
1282
+ // SOUNDNESS (break-clobber): a structured `break` jumps to AFTER the loop, where the header→exit
1283
+ // phi copies are emitted (emitTestAtTopWhile / emitDoWhile). If those copies are NON-identity, the
1284
+ // break path would fall through them and CLOBBER the value the break carried into the exit param.
1285
+ // Only emit `break` for a WHILE header whose header→exit copy is empty (the exit param already
1286
+ // coalesces across both edges); otherwise decline (fall through → honest loud fail). A do-while
1287
+ // `break` declines here (its exit copies live post-loop too, but the check differs) — the
1288
+ // return-trampoline path below still serves both. Trampolines are immune (the `return` terminates
1289
+ // the arm, so nothing falls through).
1290
+ const breakSafe = whileLoops.has(loopCtx.header) && argAssigns(loopCtx.header, loopCtx.exit).length === 0;
1291
+ // The back-edge substitution: each header param's back-edge arg → the param's name, so a test that
1292
+ // reads a POST-update value (the value carried to the header) shows the header var name.
1293
+ const sub = subFor(loopCtx.header.params, successorTo(b, loopCtx.header)!.args);
1294
+ // SOUNDNESS (pre-update-read hazard): the loop update (`argAssigns(b, header)`) is emitted
1295
+ // BEFORE the exit test/args. If the test or an exit arg reads a PRE-update value of an
1296
+ // induction variable (a body/header param, NOT a back-edge arg) whose coalesced name the
1297
+ // update overwrites, the emitted C would read the post-update value → a silent miscompile
1298
+ // (break/return fires on the wrong value; e.g. a test on `v0` where the update did
1299
+ // `v0 = v0 - 1`). Back-edge args (in `sub`) are the INTENDED post-update reads and are safe.
1300
+ // `readsClobbered` distinguishes the two at the VALUE level; on a hazard, decline (fall
1301
+ // through → honest loud fail) rather than emit wrong code.
1302
+ const updateCopies = argAssigns(b, loopCtx.header);
1303
+ const updateWrites = updateWriteSet(updateCopies);
1304
+ const exitArgs = (successorTo(b, exitB)?.args ?? []) as Value[];
1305
+ // The exit ARM may also read loop-body-computed values directly (an exitB dominated by `b`
1306
+ // — e.g. its `ret` operand), not just through edge args: apply the same escape test to the
1307
+ // arm's region (blocks reachable from exitB outside the loop body).
1308
+ const exitRegion = new Set([exitB, ...reachFrom(exitB)].filter((x) => !loopCtx!.body.has(x)));
1309
+ const hazard = loopUpdateHazard(
1310
+ term.operands[0],
1311
+ exitArgs,
1312
+ loopCtx.body,
1313
+ sub,
1314
+ updateWrites,
1315
+ exitRegion,
1316
+ new Set(loopCtx.header.params),
1317
+ );
1318
+ if (
1319
+ !hazard &&
1320
+ !loopCtx.body.has(exitB) &&
1321
+ ((isBreak && breakSafe) || (!isBreak && leadsToReturnOnly(exitB, loopCtx.body)))
1322
+ ) {
1323
+ out.push(...updateCopies); // the loop update, RAW (i++, p>>=1, …)
1324
+ let leaveCond = exprWith(sub)(term.operands[0]);
1325
+ if (contIsTaken) {
1326
+ leaveCond = negate(leaveCond);
1327
+ } // continue is `taken` → leave when NOT it
1328
+ const exitArm = isBreak
1329
+ ? [...argAssigns(b, loopCtx.exit, sub), { k: 'break' } as Stmt] // break to the loop exit
1330
+ : withSub(sub, () => [...argAssigns(b, exitB, sub), ...structureRegion(exitB, stop)]); // early return
1331
+ out.push(mkIf(leaveCond, exitArm, []));
1332
+ return out;
1333
+ }
1334
+ }
1335
+
1336
+ // Regime-A switch: if this cond_br roots a comparison tree over a single scrutinee, emit a
1337
+ // `switch`. A pre-check here — mirroring the guard-fused-loop check above — so it sees the raw
1338
+ // tree before if-recovery claims the diamonds. Declines (null) fall through to plain if-recovery.
1339
+ const asSwitch = recognizeSwitch(b, stop);
1340
+ if (asSwitch) {
1341
+ out.push(...asSwitch);
1342
+ return out;
1343
+ }
1344
+
1345
+ const cond = expr(term.operands[0]);
1346
+ const ipd = ipdom.get(b) ?? null; // null ⇒ the arms diverge (both reach EXIT), no join
1347
+ const merge = ipd ?? stop;
1348
+ // Per-successor records, NOT successorTo(b, block): a cond_br whose two edges reach the SAME
1349
+ // block with different args would otherwise give both arms the first edge's copies.
1350
+ const thenS = [...argAssignsFor(b, term.successors[0]), ...structureRegion(takenB, merge)];
1351
+ const elseS = [...argAssignsFor(b, term.successors[1]), ...structureRegion(fallB, merge)];
1352
+ if (ipd === null && thenS.length && elseS.length && preserveDivergentBranchSense) {
1353
+ // Divergent arms (both terminate — no reconvergence). The asm branched forward to the
1354
+ // `taken` block and fell through to `fall`; a compiler that PRESERVES source branch direction
1355
+ // re-emits that as a forward branch on the NEGATED condition to the else-arm, so putting the
1356
+ // taken arm as `else` (and negating) reproduces the original branch sense. Byte-exact on
1357
+ // IDO/MIPS; agbcc/GCC canonicalise either way, so it is safe there too. A compiler that
1358
+ // inverts branch canonicalization sets preserveDivergentBranchSense false and falls through
1359
+ // to the positive form below.
1360
+ out.push({ k: 'if', cond: negate(cond), then: elseS, else: thenS });
1361
+ return out;
1362
+ }
1363
+ out.push(mkIf(cond, thenS, elseS));
1364
+ if (merge && merge !== stop) {
1365
+ out.push(...structureRegion(merge, stop));
1366
+ }
1367
+ return out;
1368
+ };
1369
+
1370
+ // The un-rotation / back-edge substitution: each header param's back-edge arg → the param's
1371
+ // name, so a latch test (and exit copies) reads the post-update value under the param's name.
1372
+ // ONE builder for all three loop emitters (self-loop, do-while, early-exit).
1373
+ const subFor = (params: Value[], backArgs: Value[]): Map<Value, string> => {
1374
+ const sub = new Map<Value, string>();
1375
+ params.forEach((p, i) => sub.set(backArgs[i], varName.get(p)!));
1376
+ return sub;
1377
+ };
1378
+ const loopSub = (li: LoopInfo): Map<Value, string> => subFor(li.header.params, li.backArgOfParam);
1379
+
1380
+ // Un-rotate a header's do-while latch into a `while`: the test reads the header's own
1381
+ // params (back-edge args substituted back), and the body is the header's SIDE EFFECTS in
1382
+ // program order followed by its parallel update. The side effects are required — a copies-only
1383
+ // body would silently delete every store/discarded call in the header. Effect order is right by
1384
+ // construction: statements read pre-update names, the updates land after.
1385
+ const emitWhile = (li: LoopInfo, updates?: Stmt[]): Stmt => {
1386
+ const term = li.header.ops[li.header.ops.length - 1];
1387
+ let cond = exprWith(loopSub(li))(term.operands[0]);
1388
+ if (term.successors[0].block !== li.header) {
1389
+ cond = negate(cond);
1390
+ } // loop-continue must be `taken`
1391
+ const body = [...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
1392
+ return { k: 'while', cond, body };
1393
+ };
1394
+
1395
+ // Test-at-top `while`: the header's cond_br is the loop condition. The body is a region that stops
1396
+ // at the header (the back-edge = end-of-iteration; the latch's argAssigns emit the loop update where
1397
+ // it structurally lands). Polarity: the continue edge is the body-entry; negate iff body-entry
1398
+ // is the FALL-THROUGH (successors[1]) — NOT the self-loop-relative test emitWhile uses.
1399
+ const emitTestAtTopWhile = (wl: WhileLoopInfo, stop: Block | null): Stmt[] => {
1400
+ const term = wl.header.ops[wl.header.ops.length - 1];
1401
+ let cond = expr(term.operands[0]);
1402
+ if (term.successors[1].block === wl.bodyEntry) {
1403
+ cond = negate(cond);
1404
+ }
1405
+ // The header→bodyEntry edge may carry non-identity phi args (a value the header COMPUTED and passes
1406
+ // into the body). Those copies must open the body — dropping them reads an uninitialised local.
1407
+ // Mirror the br/cond_br cases: argAssigns then structureRegion. Structure the body under this
1408
+ // loop's frame so an in-body conditional exit (break / early return) is recognised instead of
1409
+ // tripping the header-re-entry `onStack` guard.
1410
+ const body = withLoop({ header: wl.header, exit: wl.exit, body: wl.body }, () => [
1411
+ ...argAssigns(wl.header, wl.bodyEntry),
1412
+ ...structureRegion(wl.bodyEntry, wl.header),
1413
+ ]);
1414
+ const out: Stmt[] = [{ k: 'while', cond, body }];
1415
+ out.push(...argAssigns(wl.header, wl.exit)); // phi args carried on the header→exit edge, if any
1416
+ out.push(...structureRegion(wl.exit, stop));
1417
+ return out;
1418
+ };
1419
+
1420
+ // The latch back-edge substitution (do-while) — subFor over the latch's back-edge args.
1421
+ const latchSub = (dw: DoWhileInfo): Map<Value, string> =>
1422
+ subFor(dw.header.params, successorTo(dw.latch, dw.header)!.args);
1423
+
1424
+ // Bottom-test `do-while`: the body runs header..latch (structured, with `b`'s do-while hook masked
1425
+ // via dwActive), then the latch's own side-effects + the loop update; the latch's cond_br test is the
1426
+ // do-while condition, read under `latchSub` (post-update the params hold their next value). Polarity:
1427
+ // the loop-CONTINUE edge is the back-edge to the header; negate iff that is the FALL-THROUGH.
1428
+ const emitDoWhile = (dw: DoWhileInfo, stop: Block | null): Stmt[] => {
1429
+ // The bottom test and everything post-loop render under `latchSub` — the update copies have
1430
+ // ALREADY run by then, so a condition/exit-arg/escaped-value read of an updated loop variable
1431
+ // that does NOT go through a sub-mapped back-edge arg means the PRE-update value (the
1432
+ // `i++ < n` shape: `icmp %i, %n` reads the pre-increment %i) and would render as the
1433
+ // post-update name — one iteration off, silently. Same readsClobbered guard the early-exit
1434
+ // path applies; on a hazard, decline LOUD.
1435
+ const sub = latchSub(dw);
1436
+ const updates = argAssigns(dw.latch, dw.header);
1437
+ const updateWrites = updateWriteSet(updates);
1438
+ const lterm = dw.latch.ops[dw.latch.ops.length - 1];
1439
+ const exitArgs = (successorTo(dw.latch, dw.exit)?.args ?? []) as Value[];
1440
+ if (loopUpdateHazard(lterm.operands[0], exitArgs, dw.body, sub, updateWrites, null, new Set(dw.header.params))) {
1441
+ throw new StructureError(
1442
+ `cannot structure '${fn.name}': do-while condition or a post-loop value reads a pre-update loop variable`,
1443
+ );
1444
+ }
1445
+ dwActive.add(dw.header);
1446
+ // structure the header's own block up to the latch. Call structureBlock DIRECTLY (not
1447
+ // structureRegion): the header is already on `onStack` from the caller's structureRegion, so
1448
+ // re-entering it via structureRegion would trip the back-edge guard. dwActive masks the do-while
1449
+ // hook so this pass structures `header` as an ordinary block (its ifs reconverge at the latch=stop).
1450
+ // Structure the header..latch body under this loop's frame so an in-body conditional exit
1451
+ // (break / early return before the bottom test) is recognised rather than declining.
1452
+ const inner =
1453
+ dw.header === dw.latch
1454
+ ? [] // single-block self-loop: the header IS the latch — its ops render via sideEffects below
1455
+ : withLoop({ header: dw.header, exit: dw.exit, body: dw.body }, () => structureBlock(dw.header, dw.latch)); // header..latch (exclusive of latch)
1456
+ dwActive.delete(dw.header);
1457
+ // The UPDATE is RAW (`v = v - 1`) — it IS the decrement; applying `sub` would make it look like the
1458
+ // identity `v = v` and drop it. Only the CONDITION and EXIT copies use `sub` (post-update the param
1459
+ // already holds the next value, so the latch-computed test reads `v`, not `v - 1`). `updates`
1460
+ // reuses the hazard check's computation — a second argAssigns call would burn a spurious
1461
+ // swap-cycle temp number.
1462
+ const body = [...inner, ...sideEffects(dw.latch), ...updates];
1463
+ let cond = exprWith(sub)(lterm.operands[0]);
1464
+ if (lterm.successors[1].block === dw.header) {
1465
+ cond = negate(cond);
1466
+ } // continue edge must be `taken`
1467
+ const out: Stmt[] = [{ k: 'dowhile', cond, body }];
1468
+ // The exit region reads latch back-edge values under `sub` (post-loop they live in the loop vars).
1469
+ out.push(...withSub(sub, () => [...argAssigns(dw.latch, dw.exit, sub), ...structureRegion(dw.exit, stop)]));
1470
+ return out;
1471
+ };
1472
+
1473
+ const body = recognizeForLoops(structureRegion(entry, null));
1474
+ // v* = coalesced/materialized locals; t* = sequentialize's swap-cycle temps (varType-only —
1475
+ // they have no Value, so they are collected from varType, not varName).
1476
+ const localNames = [...new Set([...varName.values(), ...[...varType.keys()].filter((n) => /^t\d+$/.test(n))])].filter(
1477
+ (n) => /^[vt]\d+$/.test(n) && !globalNames.has(n),
1478
+ );
1479
+ const structs = collectStructs(fn);
1480
+ return {
1481
+ name: fn.name,
1482
+ params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
1483
+ locals: localNames.map((n) => ({ name: n, type: varType.get(n)! })),
1484
+ retType: returnsVoid ? T.void() : returnType(fn),
1485
+ body,
1486
+ ...(structs.length ? { structs } : {}),
1487
+ };
1488
+ }
1489
+
1490
+ // Does any statement CONTINUE this loop (vs. a nested one)? A `continue` inside a nested while/dowhile/
1491
+ // for targets THAT loop, so we do not descend into them; but `if`/`switch` do not capture `continue`,
1492
+ // so we scan through those. DEFENSIVE: the structurer does not currently emit an explicit `continue`
1493
+ // node (the in-body early-exit is an IMPLICIT continue — a fall-through to the loop bottom — and a
1494
+ // conditional continue DECLINES), so this never fires today; it guards the one case where the while→for
1495
+ // re-bracketing would change semantics (a `continue` runs `inc` under `for`, skips it under `while`).
1496
+ function hasEnclosingContinue(stmts: Stmt[]): boolean {
1497
+ const scan = (s: Stmt): boolean => {
1498
+ switch (s.k) {
1499
+ case 'continue':
1500
+ return true;
1501
+ case 'if':
1502
+ return s.then.some(scan) || s.else.some(scan);
1503
+ case 'switch':
1504
+ return s.cases.some((c) => c.body.some(scan)) || (s.default ?? []).some(scan);
1505
+ case 'while':
1506
+ case 'dowhile':
1507
+ case 'for':
1508
+ return false; // nested loop captures its own continue
1509
+ default:
1510
+ return false;
1511
+ }
1512
+ };
1513
+ return stmts.some(scan);
1514
+ }
1515
+
1516
+ // Re-spell an eligible test-at-top `while` as a `for` (quality only). PURELY cosmetic — the C
1517
+ // desugaring `for(init;cond;inc){body}` compiles identically to `init; while(cond){body; inc}`, so it
1518
+ // NEVER changes a byte-exact match. Conservative preconditions (a Ghidra `findLoopVariable`-style
1519
+ // recognition that moves NO op — the init already precedes the loop, the increment is already the
1520
+ // body's last statement):
1521
+ // • the `while` is IMMEDIATELY preceded by `assign(iv, e0)` (the init literally precedes it);
1522
+ // • the `while` cond references `iv`;
1523
+ // • the body's LAST statement is `assign(iv, e1)` with `e1` referencing `iv` (a genuine self-update —
1524
+ // the increment is literally the body's last op, not an unrelated trailing assign);
1525
+ // • the body EXCLUDING that increment has no `continue` targeting THIS loop — a `continue` RUNS `inc`
1526
+ // under `for` but SKIPS it under `while`, so folding the increment into the header would change
1527
+ // semantics. This is the one hazard of the transform; all others are pure re-bracketing.
1528
+ // Any precondition failing leaves the `while` untouched. Runs bottom-up so inner loops convert first.
1529
+ function recognizeForLoops(stmts: Stmt[]): Stmt[] {
1530
+ const out: Stmt[] = [];
1531
+ for (const s0 of stmts) {
1532
+ const s: Stmt =
1533
+ s0.k === 'if'
1534
+ ? { ...s0, then: recognizeForLoops(s0.then), else: recognizeForLoops(s0.else) }
1535
+ : s0.k === 'while' || s0.k === 'dowhile'
1536
+ ? { ...s0, body: recognizeForLoops(s0.body) }
1537
+ : s0.k === 'for'
1538
+ ? { ...s0, body: recognizeForLoops(s0.body) }
1539
+ : s0.k === 'switch'
1540
+ ? {
1541
+ ...s0,
1542
+ cases: s0.cases.map((c) => ({ ...c, body: recognizeForLoops(c.body) })),
1543
+ ...(s0.default ? { default: recognizeForLoops(s0.default) } : {}),
1544
+ }
1545
+ : s0;
1546
+
1547
+ const prev = out[out.length - 1];
1548
+ if (s.k === 'while' && s.body.length >= 1 && prev && prev.k === 'assign') {
1549
+ const iv = prev.name;
1550
+ const inc = s.body[s.body.length - 1];
1551
+ if (
1552
+ inc.k === 'assign' &&
1553
+ inc.name === iv &&
1554
+ exprVars(inc.value).has(iv) &&
1555
+ exprVars(s.cond).has(iv) &&
1556
+ !hasEnclosingContinue(s.body.slice(0, -1))
1557
+ ) {
1558
+ out.pop(); // the init assign moves into the for-header
1559
+ out.push({ k: 'for', init: prev, cond: s.cond, inc, body: s.body.slice(0, -1) });
1560
+ continue;
1561
+ }
1562
+ }
1563
+ out.push(s);
1564
+ }
1565
+ return out;
1566
+ }
1567
+
1568
+ // Sequentialise a parallel copy: order the assignments so none writes a variable that a
1569
+ // still-pending assignment reads; break a cycle by spilling one destination to a temp.
1570
+ // `tmp` is the per-FUNCTION temp counter (threaded from structure()) so two independent
1571
+ // parallel copies never reuse a temp name against conflicting types.
1572
+ function sequentialize(
1573
+ copies: { name: string; value: Expr }[],
1574
+ varType: Map<string, IrType>,
1575
+ tmp: { n: number },
1576
+ fnName: string,
1577
+ ): Stmt[] {
1578
+ // Two writes to one destination have no correct order — that is two phi params coalesced onto
1579
+ // one name, which canTakeName prevents upstream. Fail loud, never pick one.
1580
+ const dests = new Set<string>();
1581
+ for (const c of copies) {
1582
+ if (dests.has(c.name)) {
1583
+ throw new StructureError(`cannot structure '${fnName}': parallel copy writes '${c.name}' twice (coalescing bug)`);
1584
+ }
1585
+ dests.add(c.name);
1586
+ }
1587
+ const pending = copies.map((c) => ({ ...c, reads: exprVars(c.value) }));
1588
+ const out: Stmt[] = [];
1589
+ while (pending.length) {
1590
+ const i = pending.findIndex((a) => !pending.some((b) => b !== a && b.reads.has(a.name)));
1591
+ if (i >= 0) {
1592
+ const a = pending.splice(i, 1)[0];
1593
+ out.push({ k: 'assign', name: a.name, value: a.value });
1594
+ continue;
1595
+ }
1596
+ // All remaining form a cycle: spill one destination into a temp, rewrite its readers, and
1597
+ // RECOMPUTE their read-sets — with stale sets the spilled copy never becomes emittable and
1598
+ // the loop mints fresh temps forever.
1599
+ const a = pending[0];
1600
+ const t = `t${tmp.n++}`;
1601
+ varType.set(t, varType.get(a.name)!);
1602
+ out.push({ k: 'assign', name: t, value: { k: 'var', name: a.name } });
1603
+ for (const b of pending) {
1604
+ if (b !== a) {
1605
+ b.value = substVar(b.value, a.name, t);
1606
+ b.reads = exprVars(b.value);
1607
+ }
1608
+ }
1609
+ }
1610
+ return out;
1611
+ }
1612
+ // Read-set / rewrite walkers over the FULL Expr union — a walker that misses a node kind (e.g.
1613
+ // call/index/field reads) sequentializes a copy keyed by an array index in the wrong order.
1614
+ function exprVars(e: Expr, acc: Set<string> = new Set()): Set<string> {
1615
+ if (e.k === 'var') {
1616
+ acc.add(e.name);
1617
+ }
1618
+ for (const c of exprChildren(e)) {
1619
+ exprVars(c, acc);
1620
+ }
1621
+ return acc;
1622
+ }
1623
+ function substVar(e: Expr, from: string, to: string): Expr {
1624
+ if (e.k === 'var') {
1625
+ return e.name === from ? { k: 'var', name: to } : e;
1626
+ }
1627
+ return mapExprChildren(e, (c) => substVar(c, from, to));
1628
+ }
1629
+
1630
+ // empty-then peephole: `if (c) {} else { S }` → `if (!c) { S }`
1631
+ function mkIf(cond: Expr, thenS: Stmt[], elseS: Stmt[]): Stmt {
1632
+ if (thenS.length === 0 && elseS.length > 0) {
1633
+ return { k: 'if', cond: negate(cond), then: elseS, else: [] };
1634
+ }
1635
+ return { k: 'if', cond, then: thenS, else: elseS };
1636
+ }
1637
+ function negate(e: Expr): Expr {
1638
+ if (e.k === 'bin' && NEGATE[e.op]) {
1639
+ return { ...e, op: NEGATE[e.op] };
1640
+ }
1641
+ return { k: 'un', op: '!', e };
1642
+ }
1643
+
1644
+ // --- CFG utilities ---
1645
+ function predecessorBlocks(fn: Fn): Map<Block, Block[]> {
1646
+ const m = new Map<Block, Block[]>();
1647
+ for (const b of fn.blocks) {
1648
+ m.set(b, []);
1649
+ }
1650
+ for (const b of fn.blocks) {
1651
+ for (const s of successorsOf(b)) {
1652
+ m.get(s)!.push(b);
1653
+ }
1654
+ }
1655
+ return m;
1656
+ }
1657
+ function successorTo(pred: Block, target: Block) {
1658
+ const term = pred.ops[pred.ops.length - 1];
1659
+ return term.successors.find((s) => s.block === target);
1660
+ }
1661
+
1662
+ // Immediate post-dominators. EXIT is represented as `null`; ret-blocks post-lead to it.
1663
+ function postDominators(fn: Fn): Map<Block, Block | null> {
1664
+ const nodes: (Block | null)[] = [null, ...fn.blocks];
1665
+ const succ = (b: Block): (Block | null)[] => {
1666
+ const term = b.ops[b.ops.length - 1];
1667
+ return term.opcode === 'ret' ? [null] : successorsOf(b);
1668
+ };
1669
+ const pdom = new Map<Block | null, Set<Block | null>>();
1670
+ pdom.set(null, new Set([null]));
1671
+ for (const b of fn.blocks) {
1672
+ pdom.set(b, new Set(nodes));
1673
+ }
1674
+ let changed = true;
1675
+ while (changed) {
1676
+ changed = false;
1677
+ for (const b of fn.blocks) {
1678
+ const ss = succ(b);
1679
+ let inter: Set<Block | null> | null = null;
1680
+ for (const s of ss) {
1681
+ const ps = pdom.get(s)!;
1682
+ if (inter === null) {
1683
+ inter = new Set(ps);
1684
+ continue;
1685
+ }
1686
+ for (const x of inter) {
1687
+ if (!ps.has(x)) {
1688
+ inter.delete(x);
1689
+ }
1690
+ } // intersect in place (spec-safe delete-in-iter)
1691
+ }
1692
+ const next = new Set<Block | null>(inter ?? []);
1693
+ next.add(b);
1694
+ if (!setEq(next, pdom.get(b)!)) {
1695
+ pdom.set(b, next);
1696
+ changed = true;
1697
+ }
1698
+ }
1699
+ }
1700
+ // ipdom(b) = the strict post-dom c with (strictPostDoms(b) \ {c}) ⊆ pdom(c)
1701
+ const ipdom = new Map<Block, Block | null>();
1702
+ for (const b of fn.blocks) {
1703
+ const strict = [...pdom.get(b)!].filter((c) => c !== b);
1704
+ let chosen: Block | null = null;
1705
+ for (const c of strict) {
1706
+ const others = strict.filter((x) => x !== c);
1707
+ if (others.every((x) => pdom.get(c)!.has(x))) {
1708
+ chosen = c;
1709
+ break;
1710
+ }
1711
+ }
1712
+ ipdom.set(b, chosen);
1713
+ }
1714
+ return ipdom;
1715
+ }
1716
+ function setEq<X>(a: Set<X>, b: Set<X>): boolean {
1717
+ if (a.size !== b.size) {
1718
+ return false;
1719
+ }
1720
+ for (const x of a) {
1721
+ if (!b.has(x)) {
1722
+ return false;
1723
+ }
1724
+ }
1725
+ return true;
1726
+ }