@asmlift/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
@@ -36,9 +36,11 @@
36
36
  // code): multi-latch headers, irreducible/overlapping loops, conditional `continue`, a `break`
37
37
  // whose exit copies would clobber, switch fall-through, and mixed-entry self-loops (a guarded
38
38
  // header also entered by a plain br).
39
+ import { type GlobalCell, globalCellOf, mayWriteGlobal } from '../ir/alias';
39
40
  import { Block, Fn, Op, Value, defOpMap, successorsOf } from '../ir/core';
41
+ import { EFFECTFUL_OPS } from '../ir/opcodes';
40
42
  import { type IrType, T, scalarTypeForAccess, typeEquals } from '../ir/types';
41
- import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren } from '../l3/ast';
43
+ import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, gapReasonFor, mapExprChildren, negateCond } from '../l3/ast';
42
44
  import { exprCType, ptrElemBytes } from '../l3/typing';
43
45
  import { returnType } from '../raise/recover';
44
46
  import { collectStructs } from '../raise/structs';
@@ -46,9 +48,13 @@ import {
46
48
  type DeclaredField,
47
49
  type SymbolInfo,
48
50
  type SymbolStructField,
51
+ arrayInnerExtents,
49
52
  declaredFields,
50
53
  isArrayField,
54
+ isBitfieldField,
55
+ isScalarCellSize,
51
56
  pointeeFields,
57
+ scalarCellType,
52
58
  } from '../symbols';
53
59
  import { analyze } from './analysis';
54
60
  import { makeLoopHazards, updateWriteSet } from './hazards';
@@ -108,6 +114,32 @@ function globalOf(e: Expr, width: number): { name: string; idx: Expr } | null {
108
114
  return null;
109
115
  }
110
116
 
117
+ // THE one gate on the BARE-NAME array-global spelling (`gSym[i]` rather than `((T *)&gSym)[i]`),
118
+ // shared by the constant-offset and variable-index access paths so the two cannot disagree.
119
+ // Returns the `index` node's `lead` fragment when the bare form is spellable, or null to fall
120
+ // through to the always-valid `&gSym` cast form.
121
+ //
122
+ // Two facts are required, not one. The element WIDTH must match, as it always has. And the RANK
123
+ // must be SPELLABLE, because one subscript reaches an element only on a rank-1 array: on `u16
124
+ // g[4][0x400]`, `g[i]` is a ROW. Against the project's own header that is usually a type error,
125
+ // but where the row address flows into an integer context it is merely a warning and the emitted C
126
+ // then addresses a different object than the asm did — silently.
127
+ //
128
+ // A rank > 1 pins the leading dimensions at 0 and puts the whole flat element index in the last
129
+ // subscript (`g[0][i]`) — the same address arithmetic, and the idiom decomp sources themselves use
130
+ // when the split is not observable in the asm either (`gBgTilemapBufs[0][…]` in kleod,
131
+ // `gNatureStatTable[nature][…]` in pokeemerald). A rank the map states but cannot spell (an unknown
132
+ // inner extent) gets no bare form at all; `((T *)&gSym)[i]` is byte-identical and valid under ANY
133
+ // declaration, which is why it is the safe fallback. See symbols.ts arrayInnerExtents for why an
134
+ // ABSENT rank is read as 1 rather than as unknown.
135
+ function bareArrayLead(si: SymbolInfo, width: number): { lead?: number[] } | null {
136
+ if (si.shape !== 'array' || si.elemSize !== width) {
137
+ return null;
138
+ }
139
+ const inner = arrayInnerExtents(si);
140
+ return inner === null ? null : inner.length === 0 ? {} : { lead: new Array<number>(inner.length).fill(0) };
141
+ }
142
+
111
143
  // A BYTE residual read as an ELEMENT index of `elemSize`-wide elements, or null when it is not one
112
144
  // — the residual then addresses mid-element and no whole-element spelling can express it, so the
113
145
  // caller falls through to the honest cast forms. THE one copy of the rule, indexing the
@@ -305,8 +337,10 @@ function pointeeAccess(
305
337
  // Constant offset: the member must match EXACTLY — offset, read width, and the SPELLED type
306
338
  // (spellsAccessType). An ARRAY member is excluded whatever its size: `u8 x[1]` would match a
307
339
  // byte access by (offset, size) and spell `->x`, which is not an lvalue of that width at all.
340
+ // A BITFIELD member likewise: its `size` is the byte span its bits touch, so a 7-bit field
341
+ // would match a plain u16 read and spell a 7-bit lvalue for a 16-bit access.
308
342
  const p = spellablePointee(pg.name, sym);
309
- const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m));
343
+ const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m) && !isBitfieldField(m));
310
344
  return p && f && spellsAccessType(f.signed, width, signed) && memberQualsAllow(f, p.const, isStore)
311
345
  ? { k: 'field', base: { k: 'var', name: pg.name }, name: f.name }
312
346
  : null;
@@ -363,8 +397,12 @@ function memAccess(
363
397
  // declaration on: a layout it declines whole is a layout with no nameable members, and a
364
398
  // union alias it drops for the first view at that offset is a name no declaration carries.
365
399
  // An ARRAY member is excluded for the same reason as in pointeeAccess: `u8 x[1]` would match
366
- // a byte access by (offset, size) and spell `.x`, which is not an lvalue of that width.
367
- const fld = declaredFields(si.layout)?.find((f) => f.offset === gb.byte && f.size === width && !isArrayField(f));
400
+ // a byte access by (offset, size) and spell `.x`, which is not an lvalue of that width. A
401
+ // BITFIELD member likewise a plain read of its bytes is not a read of its bits (the named
402
+ // bitfield spelling has its own recognizer, on the extract shape: see lowerDef).
403
+ const fld = declaredFields(si.layout)?.find(
404
+ (f) => f.offset === gb.byte && f.size === width && !isArrayField(f) && !isBitfieldField(f),
405
+ );
368
406
  if (fld && memberQualsAllow(fld, si.const, isStore)) {
369
407
  return { k: 'field', base: { k: 'var', name: gb.name }, name: fld.name, dot: true };
370
408
  }
@@ -402,9 +440,10 @@ function memAccess(
402
440
  // dogfood proved agbcc needs for ROM tables — with the element type registered in the env
403
441
  // so the stride check passes and no cast is added. Element-width match only.
404
442
  const siArr = sym?.info(g.name);
405
- if (siArr?.shape === 'array' && siArr.elemSize === width) {
406
- sym!.noteGlobal(g.name, T.ptr(T.int(width * 8, siArr.elemSigned ?? false)));
407
- return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed };
443
+ const lead = siArr === undefined ? null : bareArrayLead(siArr, width);
444
+ if (lead !== null) {
445
+ sym!.noteGlobal(g.name, T.ptr(T.int(width * 8, siArr!.elemSigned ?? false)));
446
+ return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed, ...lead };
408
447
  }
409
448
  return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
410
449
  }
@@ -447,9 +486,10 @@ function arrayAccess(
447
486
  if (baseExpr.k === 'addr' && fieldOff === undefined) {
448
487
  // ARRAY-declared global (symbol map): the bare-name spelling, same rule as memAccess.
449
488
  const si = sym?.info(baseExpr.name);
450
- if (si?.shape === 'array' && si.elemSize === elemSize) {
451
- sym!.noteGlobal(baseExpr.name, T.ptr(T.int(elemSize * 8, si.elemSigned ?? false)));
452
- return { k: 'index', base: { k: 'var', name: baseExpr.name }, idx: idxExpr, width: elemSize, signed };
489
+ const lead = si === undefined ? null : bareArrayLead(si, elemSize);
490
+ if (lead !== null) {
491
+ sym!.noteGlobal(baseExpr.name, T.ptr(T.int(elemSize * 8, si!.elemSigned ?? false)));
492
+ return { k: 'index', base: { k: 'var', name: baseExpr.name }, idx: idxExpr, width: elemSize, signed, ...lead };
453
493
  }
454
494
  return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
455
495
  }
@@ -520,12 +560,11 @@ const ARITH_TO_BIN: Record<string, BinOp> = {
520
560
  and: '&',
521
561
  xor: '^',
522
562
  shl: '<<',
523
- shr_u: '>>',
563
+ shr_u: '>>>', // the LOGICAL right shift; the C backend spells it `>>` over an unsigned operand
524
564
  shr_s: '>>',
525
565
  logic_and: '&&',
526
566
  logic_or: '||', // short-circuit connectives (raise/shortcircuit.ts)
527
567
  };
528
- const NEGATE: Record<string, BinOp> = { '<': '>=', '>=': '<', '>': '<=', '<=': '>', '==': '!=', '!=': '==' };
529
568
 
530
569
  // Recovered info for a self-loop header: its exit block and the per-parameter back-edge
531
570
  // arg it feeds (the value on the header→header edge). The back-edge arg is the "next"
@@ -548,6 +587,13 @@ interface WhileLoopInfo {
548
587
  body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
549
588
  }
550
589
 
590
+ // Opcodes whose NUMBER OF EXECUTIONS is observable. Moving one of these out of a loop changes what
591
+ // the program does — a call that ran per iteration would run once. A `load`/`aload` is deliberately
592
+ // NOT here: it is a pure read, so running it once instead of per-iteration is unobservable as long
593
+ // as it reads the same memory, which is exactly what the existing def→render barrier scan
594
+ // (structure/analysis.ts) already proves before it lets one inline at all.
595
+ const REPEATED_EFFECT = new Set(['call', 'opaque']);
596
+
551
597
  // A bottom-tested `do { body } while(cond)`. The header is the body entry (entered before any
552
598
  // test); the LATCH holds the loop condition and the single exit. Body = header..latch structured, then
553
599
  // the latch's own ops + the loop-update; the latch test is the do-while condition. The condition is
@@ -577,6 +623,26 @@ export interface StructureOptions {
577
623
  // body). GCC freely uses `!=`; IDO prefers `==`/`<`. A per-compiler DATA lever, not an `arch ==`
578
624
  // branch — default true (permissive; the decline path keeps it sound either way).
579
625
  switchAllowsNeqCase?: boolean;
626
+ // Anchor a constant merge copy at its const op's ORIGINAL position instead of at the CFG edge:
627
+ // `movs r9, #0` at entry ahead of a single-armed overwrite emits as a pre-initialization above
628
+ // the `if`, not as its else-arm. A differ-refereed candidate axis (rank.ts `/defsite`), never a
629
+ // default — see the refusal conditions where it is computed.
630
+ anchorConstCopies?: boolean;
631
+ // HARDWARE fact from TargetDescription.capabilities.endianness, threaded by structureOptionsFor:
632
+ // the bitfield extract recognizer solves an LSB-first equation, so it only runs on little-endian
633
+ // data. The provider already refuses to EMIT bitfield facts for a big-endian ELF; this is the
634
+ // same boundary enforced on core's side, against a hand-built map that never went through it.
635
+ littleEndian?: boolean;
636
+ // Spell `(x << a) >> b` extracts of a struct global as the map's named bitfield member. On by
637
+ // default; rank.ts enumerates the OFF spelling as the `/no-bitfield` axis, because the named
638
+ // read recompiles at the DECLARATION's access width — where that diverges from the asm's load
639
+ // width the honest shift spelling is the one that matches, and the differ referees.
640
+ spellBitfieldMembers?: boolean;
641
+ // Let a read of a named global render at its use across writes that PROVABLY cannot reach it
642
+ // (a store to a different named global), instead of caching it in a local. Off by default;
643
+ // rank.ts enumerates the ON spelling as the `/reread-globals` axis — see analysis.ts
644
+ // AnalyzeOptions for why this is a differ-refereed lever and not a fix.
645
+ rereadGlobals?: boolean;
580
646
  // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
581
647
  // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
582
648
  // the PROCESS);
@@ -597,6 +663,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
597
663
  preserveDivergentBranchSense = true,
598
664
  orderArgCopiesByComputation = true,
599
665
  switchAllowsNeqCase = true,
666
+ anchorConstCopies = false,
667
+ littleEndian = true,
668
+ spellBitfieldMembers = true,
669
+ rereadGlobals = false,
600
670
  onGap = 'strict',
601
671
  symbols,
602
672
  } = opts;
@@ -606,7 +676,19 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
606
676
  const dom = dominators(fn);
607
677
 
608
678
  // ── analysis phase (structure/analysis.ts): use registry, liveness, materialization ──
609
- const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom } = analyze(fn, returnsVoid);
679
+ const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween } = analyze(
680
+ fn,
681
+ returnsVoid,
682
+ {
683
+ defs,
684
+ rereadGlobals,
685
+ // the map's own declaration truth: a volatile object's read may not be duplicated or moved
686
+ volatileGlobal: (n) => {
687
+ const si = symbols?.get(n);
688
+ return si?.volatile === true || (si?.layout ?? []).some((f) => f.volatile === true);
689
+ },
690
+ },
691
+ );
610
692
 
611
693
  // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
612
694
  // global → the bare name `gSym` (byte-exact, matches the source). A symbol accessed at any
@@ -618,13 +700,68 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
618
700
  // widths is a union/type-pun, which the downstream struct-layout recovery rejects LOUD
619
701
  // ("overlapping fields ... unions not modelled") before this classification is consumed — so a
620
702
  // width collision at off-0 declines honestly rather than reaching a wrong bare-`gSym` emission.
703
+ // FRAME-LOCAL OBJECT NAMES (laddr). Minted HERE, not in the frontend, because identifiers live
704
+ // in this layer's namespace: params, locals, every gaddr symbol, and the project's symbol map —
705
+ // none of which the frontend can see. A frontend-chosen `sp0` silently shadowed a project global
706
+ // of the same name. `sp<off>` uniquified with underscores until free; one name per offset.
707
+ const laddrName = (() => {
708
+ const taken = new Set<string>();
709
+ if (symbols) {
710
+ for (const [n] of symbols) {
711
+ taken.add(n);
712
+ }
713
+ }
714
+ for (const b of fn.blocks) {
715
+ for (const op of b.ops) {
716
+ if (op.opcode === 'gaddr') {
717
+ taken.add(op.attrs.sym as string);
718
+ } else if (op.opcode === 'call') {
719
+ // a CALLEE's name is in this namespace too: a function really named sp0 would be
720
+ // shadowed by the minted local, and `sp0()` on a u16 object is a compile error
721
+ taken.add(op.attrs.target as string);
722
+ }
723
+ }
724
+ }
725
+ const byOff = new Map<number, string>();
726
+ const names = new Map<Op, string>();
727
+ for (const b of fn.blocks) {
728
+ for (const op of b.ops) {
729
+ if (op.opcode !== 'laddr') {
730
+ continue;
731
+ }
732
+ const off = op.attrs.off as number;
733
+ let n = byOff.get(off);
734
+ if (n === undefined) {
735
+ n = `sp${off}`;
736
+ while (taken.has(n)) {
737
+ n += '_';
738
+ }
739
+ taken.add(n);
740
+ byOff.set(off, n);
741
+ }
742
+ names.set(op, n);
743
+ }
744
+ }
745
+ return names;
746
+ })();
747
+
621
748
  const scalarGlobals = new Set<string>();
622
749
  {
623
750
  const offsets = new Map<string, Set<number>>();
624
751
  const bumpAgg = (sym: string) => offsets.set(sym, new Set([-1])); // -1 marks "variable index"
625
752
  for (const b of fn.blocks) {
626
753
  for (const op of b.ops) {
627
- const gaddrSym = (v: Value) => (defs.get(v)?.opcode === 'gaddr' ? (defs.get(v)!.attrs.sym as string) : null);
754
+ const gaddrSym = (v: Value) => {
755
+ const dv = defs.get(v);
756
+ // laddr participates identically: `sp0` is scalar-spelled at off 0 and cast-spelled
757
+ // anywhere else, exactly as a global of its shape would be — by its MINTED name
758
+ // (laddrName): the op carries no name attr, that namespace is this layer's
759
+ return dv?.opcode === 'gaddr'
760
+ ? (dv.attrs.sym as string)
761
+ : dv?.opcode === 'laddr'
762
+ ? (laddrName.get(dv) ?? null)
763
+ : null;
764
+ };
628
765
  if (op.opcode === 'load' || op.opcode === 'store') {
629
766
  const s = gaddrSym(op.operands[0]);
630
767
  if (s) {
@@ -895,6 +1032,50 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
895
1032
  // The C static type of a rendered expression, over the declared variable types — what decides
896
1033
  // whether a memory access's base may be dereferenced as spelled (memAccess/arrayAccess).
897
1034
  const ctype = (e0: Expr): IrType | undefined => exprCType(e0, (n) => varType.get(n));
1035
+
1036
+ /** `&gSym` assigned to a `T *` local: the address of an AGGREGATE is not a pointer to its
1037
+ * element. `&gArr` is `T (*)[n]`, `&gStruct` is `struct S *`, and neither is assignable to
1038
+ * `T *` — yet the IR's `gaddr` value legitimately has type `T *`, because that is what the asm
1039
+ * loaded. The bare spelling therefore states a type the project's own header contradicts.
1040
+ *
1041
+ * It survived because agbcc only WARNS ("assignment from incompatible pointer type") and
1042
+ * computes the right address anyway. That leniency is not something to rely on: the Klonoa
1043
+ * project's own build template treats these as fatal, so the row's emitted C does not build
1044
+ * where its author would put it. The cast is the always-valid spelling — the same fallback
1045
+ * `bareArrayLead` documents for the indexed form — and it is byte-identical, so no benchmark row
1046
+ * moves either way and the rule that decides it is pinned in test/deref-typing.test.ts instead.
1047
+ *
1048
+ * The test is whether `&gSym`'s rendered type PROVABLY equals the destination's, not whether the
1049
+ * symbol looks like an aggregate. A shape enumeration got this wrong three ways, each a real
1050
+ * miss: `shape:'pointer'` declares a pointer cell (`void *gSym`, or `struct Tag *gSym` when the
1051
+ * pointee has a declarable layout), so `&gSym` is a pointer-to-pointer either way; a `shape:'scalar'`
1052
+ * whose width differs from the destination's pointee gives `s32 *` for a `u16 *` slot; and a
1053
+ * NAME-ONLY symbol is synthesized as `extern u32 gSym;` (declare.ts), which is `u32 *` — not the
1054
+ * `T *` the older comment here claimed. So the default is to CAST, and the cast is omitted only
1055
+ * where the declared cell type is known and matches exactly. Byte-identical either way, so the
1056
+ * cost of casting one time too many is a redundant `(T *)`, never a wrong address. */
1057
+ const castAggregateAddr = (name: string, value: Expr): Expr => {
1058
+ const t = varType.get(name);
1059
+ if (t?.kind !== 'ptr' || value.k !== 'addr') {
1060
+ return value;
1061
+ }
1062
+ // The only provably-redundant case: a NON-VOLATILE scalar cell whose DECLARED type is the
1063
+ // destination's pointee, where `&gSym` already denotes exactly `T *`.
1064
+ //
1065
+ // `scalarCellType` and not `scalarTypeForAccess`: the latter answers what an ACCESS of that
1066
+ // width reads and collapses every 4-byte access to `s32`, so it called a `u32` cell equal to an
1067
+ // `s32 *` destination and let the incompatible assignment through. And a `volatile` cell makes
1068
+ // `&gSym` a `volatile T *`, so omitting the cast would DISCARD the qualifier — the same class of
1069
+ // fatal-under-a-strict-build defect this rule exists to remove.
1070
+ const si = symCtx?.info(value.name);
1071
+ if (si?.shape === 'scalar' && !si.volatile && isScalarCellSize(si.size)) {
1072
+ if (typeEquals(scalarCellType(si.size, si.signed), t.to)) {
1073
+ return value;
1074
+ }
1075
+ }
1076
+ return { k: 'cast', to: t, e: value };
1077
+ };
1078
+
898
1079
  let fresh = 0;
899
1080
  // Materialized defs are named FIRST: the temp is the register the compiler held the
900
1081
  // value in, so downstream coalescing (loop inits, merge params) may adopt it — subject to the
@@ -1101,6 +1282,227 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1101
1282
  }
1102
1283
  }
1103
1284
 
1285
+ // ── def-site anchoring of constant merge copies (anchorConstCopies) ──────────────────────────
1286
+ // An edge copy `v = K` places the constant where the EDGE is, but the asm often materialized K
1287
+ // earlier: `movs r9, #0` at entry ahead of a single-armed overwrite, `movs r5, #1` at the top
1288
+ // of an arm ahead of a nested if. Anchoring the copy at the const op's own program position
1289
+ // reproduces that placement — the write is emitted as a statement there (sideEffects reads
1290
+ // `anchoredAt`) and the edge copies it replaces are suppressed (argAssignsFor reads
1291
+ // `suppressedArgs`). Where the surviving arm then empties, mkIf's empty-then peephole yields
1292
+ // the single-armed positive `if` the source wrote.
1293
+ //
1294
+ // REFUSAL CONDITIONS — each keeps the edge placement, never producing a different write:
1295
+ // - the arg is not an UNNAMED `const` op (only a rematerializable constant carries
1296
+ // unambiguous placement evidence; a named value's position is its materialized def's);
1297
+ // - the merge is a loop header (loop copies have their own placement discipline);
1298
+ // - the const's block does not dominate every edge source passing it (the anchored write
1299
+ // must precede the edge on every path);
1300
+ // - the const's block or any edge source sits inside ANY loop. Block-level dominance does
1301
+ // not give per-ITERATION precedence — a path may pass the def in iteration 1 and take the
1302
+ // suppressed edge in iteration 2 with the variable overwritten in between, the /preinit
1303
+ // sticky-arm failure class (PR #13) — so in-loop shapes are declined outright;
1304
+ // - the merge variable names any OTHER SSA value (a shared name has readers and writers
1305
+ // between the def site and the edge that edge placement respects and anchoring would not);
1306
+ // - another anchored const of the same variable lies on a path from this one to this one's
1307
+ // edge (the later write would clobber this arg's value; both stay at their edges instead).
1308
+ const anchoredAt = new Map<Op, { name: string; arg: Value }[]>();
1309
+ const suppressedArgs = new Map<object, Set<number>>();
1310
+ if (anchorConstCopies) {
1311
+ const nameCount = new Map<string, number>();
1312
+ for (const n of varName.values()) {
1313
+ nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
1314
+ }
1315
+ const inLoop = (b: Block): boolean => {
1316
+ for (const nl of forest.byHeader.values()) {
1317
+ if (nl.body.has(b)) {
1318
+ return true;
1319
+ }
1320
+ }
1321
+ return false;
1322
+ };
1323
+ // conservative "a write in `a` may execute between one in `b` and `b`'s terminator": same
1324
+ // block counts (op order refined by the caller where it matters), else CFG reachability
1325
+ const mayFollow = (a: Block, b: Block): boolean => a === b || reachFrom(a).has(b);
1326
+ for (const M of fn.blocks) {
1327
+ if (M === entry || M.params.length === 0 || forest.byHeader.has(M)) {
1328
+ continue;
1329
+ }
1330
+ M.params.forEach((p, i) => {
1331
+ const name = varName.get(p)!;
1332
+ if (nameCount.get(name) !== 1) {
1333
+ return;
1334
+ }
1335
+ // every in-edge record into M, grouped by the SSA value it passes for param i
1336
+ const groups = new Map<Value, { rec: { block: Block; args: Value[] }; src: Block }[]>();
1337
+ for (const pr of new Set(preds.get(M) ?? [])) {
1338
+ for (const s of pr.ops[pr.ops.length - 1].successors) {
1339
+ if (s.block === M) {
1340
+ const g = groups.get(s.args[i]);
1341
+ if (g) {
1342
+ g.push({ rec: s, src: pr });
1343
+ } else {
1344
+ groups.set(s.args[i], [{ rec: s, src: pr }]);
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ const candidates: { arg: Value; def: Op; defBlock: Block; edges: { rec: object; src: Block }[] }[] = [];
1350
+ for (const [arg, edges] of groups) {
1351
+ const def = defs.get(arg);
1352
+ if (!def || def.opcode !== 'const' || varName.has(arg)) {
1353
+ continue;
1354
+ }
1355
+ const defBlock = opBlock.get(def)!;
1356
+ if (inLoop(defBlock) || edges.some(({ src }) => inLoop(src))) {
1357
+ continue;
1358
+ }
1359
+ if (edges.some(({ src }) => !dom.get(src)!.has(defBlock))) {
1360
+ continue;
1361
+ }
1362
+ candidates.push({ arg, def, defBlock, edges });
1363
+ }
1364
+ // pairwise clobber check: candidate `c` is unsafe when another candidate's write can lie
1365
+ // between c's def and one of c's edges (def_c → def_o → edge_c); both then keep their edges
1366
+ const safe = candidates.filter((c) =>
1367
+ candidates.every((o) => {
1368
+ if (o === c) {
1369
+ return true;
1370
+ }
1371
+ const oAfterC =
1372
+ c.defBlock === o.defBlock ? opIndex.get(o.def)! > opIndex.get(c.def)! : mayFollow(c.defBlock, o.defBlock);
1373
+ return !(oAfterC && c.edges.some(({ src }) => mayFollow(o.defBlock, src)));
1374
+ }),
1375
+ );
1376
+ for (const c of safe) {
1377
+ const at = anchoredAt.get(c.def);
1378
+ if (at) {
1379
+ at.push({ name, arg: c.arg });
1380
+ } else {
1381
+ anchoredAt.set(c.def, [{ name, arg: c.arg }]);
1382
+ }
1383
+ for (const { rec } of c.edges) {
1384
+ const sup = suppressedArgs.get(rec);
1385
+ if (sup) {
1386
+ sup.add(i);
1387
+ } else {
1388
+ suppressedArgs.set(rec, new Set([i]));
1389
+ }
1390
+ }
1391
+ }
1392
+ });
1393
+ }
1394
+ }
1395
+
1396
+ // ── BITFIELD member reads (symbol map) ──────────────────────────────────────────────────────
1397
+ // The `(x << a) >> b` extract of a struct global's loaded bytes IS a bitfield access when the
1398
+ // map declares a bitfield at exactly those bits: spelled `gSym.field`, the source form, whose
1399
+ // declared `u32 field : n` then makes C's own integer promotion reproduce the signedness every
1400
+ // downstream operator compiled with (a 7-bit unsigned field promotes to signed int — sdiv
1401
+ // renders `/` and recompiles to __divsi3, where the raw-shift spelling stays u32).
1402
+ //
1403
+ // Semantically EXACT, never approximate: the window must lie inside the loaded bytes (so the
1404
+ // load's extension bits cannot reach it), the field's position, width and signedness must all
1405
+ // match the extract (a logical shift is an unsigned read, an arithmetic one a signed read —
1406
+ // a signless field never matches), and the member must be nameable at all (memberQualsAllow;
1407
+ // the map only carries bitfield facts for little-endian ELFs — see SymbolStructField). Any
1408
+ // mismatch keeps the honest shift spelling.
1409
+ //
1410
+ // Precomputed over the ops (not folded during rendering) for the load's sake: a load whose
1411
+ // EVERY use is a spelled extract chain must not also emit its materialized `v = *(u16 *)&g;`
1412
+ // temp — the compiler CSEs the repeated member reads back to one load, but the leftover temp
1413
+ // would be a second one. A VOLATILE container refuses the whole fold: N member reads are N
1414
+ // volatile accesses where the asm did one load. (Byte-level residual, differ-refereed: a load
1415
+ // only PARTIALLY absorbed — one extract spelled, another use kept — emits both the temp and
1416
+ // the named reads, one load more than the asm; semantics hold, the score decides.)
1417
+ //
1418
+ // ORDERING GATE (adversarial round, CRITICAL 1 — twice): the named spelling replaces a
1419
+ // REGISTER value — the bits captured at the load's program position — with a fresh memory
1420
+ // read at each render position. Every other memory read in this file goes through the
1421
+ // materialization model (analysis.ts) for exactly that hazard, so the fold clears the SAME
1422
+ // bar with the SAME machinery: `emitPos` resolves where each extract actually renders
1423
+ // (transitively through its inlining consumers — an unresolvable position refuses), and
1424
+ // `memWriteBetween` walks every def-avoiding load→render path for a call, an opaque, or a
1425
+ // store not provably to a DIFFERENT named global. Path-based on purpose: the second audit
1426
+ // pass broke the first fix's linear-position scan with a block laid out AFTER the render in
1427
+ // address order but executing between load and render on the taken path — fn.blocks order is
1428
+ // address order, not topological order.
1429
+ const bitfieldSpelling = new Map<Op, { global: string; field: string }>();
1430
+ const absorbedLoads = new Set<Op>();
1431
+ if (symCtx && littleEndian && spellBitfieldMembers) {
1432
+ // the (name, byte) of a load's address when it resolves through defs alone — `gaddr` or
1433
+ // `add(gaddr, const)`; anything else (a materialized base, a variable index) declines. THE
1434
+ // shared L2 disjointness query (ir/alias.ts), which the materialization model consults with
1435
+ // the same rule, so the fold and the model cannot disagree about what a store can reach.
1436
+ const loadTargets = new Map<Op, GlobalCell>();
1437
+ const addrOf = (v: Value, off: number): GlobalCell | null => globalCellOf(defs, v, off);
1438
+ // A write for the fold's purposes: calls and opaques always; a store/astore unless its base
1439
+ // resolves to a global PROVABLY different from the folded one.
1440
+ const mayWrite = (sym: string) => mayWriteGlobal(defs, sym);
1441
+ for (const blk of fn.blocks) {
1442
+ for (const op of blk.ops) {
1443
+ if ((op.opcode !== 'shr_u' && op.opcode !== 'shr_s') || op.operands.length !== 1) {
1444
+ continue;
1445
+ }
1446
+ const b = op.attrs.imm as number | undefined;
1447
+ const inner = defs.get(op.operands[0]);
1448
+ if (typeof b !== 'number' || b <= 0 || b >= 32 || inner?.opcode !== 'shl' || inner.operands.length !== 1) {
1449
+ continue;
1450
+ }
1451
+ const a = inner.attrs.imm as number | undefined;
1452
+ if (typeof a !== 'number' || a < 0 || b < a) {
1453
+ continue;
1454
+ }
1455
+ const w = 32 - b; // extract width
1456
+ const lo = b - a; // low bit within the loaded value
1457
+ const load = defs.get(inner.operands[0]);
1458
+ if (load?.opcode !== 'load' || lo + w > (load.attrs.width as number) * 8) {
1459
+ continue;
1460
+ }
1461
+ // a materialized shl would still emit its `v = x << a` temp reading the load — the fold
1462
+ // would then ADD member reads on top of it; rare, refuse
1463
+ if (materialize.has(inner)) {
1464
+ continue;
1465
+ }
1466
+ const gb = addrOf(load.operands[0], load.attrs.off as number);
1467
+ const si = gb ? symCtx.info(gb.name) : undefined;
1468
+ if (!gb || si?.shape !== 'struct' || si.volatile) {
1469
+ continue;
1470
+ }
1471
+ // where does the member read RENDER? at the extract's own position when materialized,
1472
+ // else wherever each of its consumers ultimately renders (emitPos, transitively —
1473
+ // unresolvable refuses); every load→render path must be write-free
1474
+ const renders = materialize.has(op)
1475
+ ? [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }]
1476
+ : [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))].map((c) => emitPos(c));
1477
+ const writes = mayWrite(gb.name);
1478
+ if (renders.some((r) => r === null) || renders.some((r) => memWriteBetween(load, r!, writes))) {
1479
+ continue;
1480
+ }
1481
+ const signedRead = op.opcode === 'shr_s';
1482
+ const fld = declaredFields(si.layout)?.find(
1483
+ (f) => f.bitWidth === w && f.offset * 8 + f.bitOffset! === gb.byte * 8 + lo && f.signed === signedRead,
1484
+ );
1485
+ if (fld && memberQualsAllow(fld, si.const, false)) {
1486
+ bitfieldSpelling.set(op, { global: gb.name, field: fld.name });
1487
+ loadTargets.set(load, gb);
1488
+ }
1489
+ }
1490
+ }
1491
+ // a load is ABSORBED when every use is an shl whose every use is a spelled extract
1492
+ for (const load of loadTargets.keys()) {
1493
+ const shls = useSitesOf.get(load.results[0]) ?? [];
1494
+ const absorbed =
1495
+ shls.length > 0 &&
1496
+ shls.every(
1497
+ (u) =>
1498
+ u.op.opcode === 'shl' && (useSitesOf.get(u.op.results[0]) ?? []).every((v) => bitfieldSpelling.has(v.op)),
1499
+ );
1500
+ if (absorbed) {
1501
+ absorbedLoads.add(load);
1502
+ }
1503
+ }
1504
+ }
1505
+
1104
1506
  // An unresolvable value: strict mode keeps the `"?"` sentinel AND records the reason — the
1105
1507
  // decline thrown below names the actual gaps ("unmodelled instruction 'adde'"), the same
1106
1508
  // reasons annotate mode's markers carry, instead of the anonymous `?` that assertResolved
@@ -1123,6 +1525,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1123
1525
  if (d.opcode === 'const') {
1124
1526
  return { k: 'const', value: d.attrs.value as number };
1125
1527
  }
1528
+ // a bitfield extract recognized over the ops (see the precompute above): the member read,
1529
+ // not the shift pair
1530
+ const bf = bitfieldSpelling.get(d);
1531
+ if (bf) {
1532
+ return { k: 'field', base: { k: 'var', name: bf.global }, name: bf.field, dot: true };
1533
+ }
1126
1534
  if (CMP_TO_BIN[d.opcode]) {
1127
1535
  // A bare global address `&gSym` as a COMPARISON operand is the same unspelled escape as the
1128
1536
  // arithmetic case below (see intifyAddr): its C type comes from the PROJECT's own
@@ -1231,6 +1639,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1231
1639
  l = isPtrGlobal(l) ? intifyPtrGlobal(l) : l;
1232
1640
  r = isPtrGlobal(r) ? intifyPtrGlobal(r) : r;
1233
1641
  }
1642
+ // (The two right shifts stay DISTINCT ops here — `>>>` logical, `>>` arithmetic. Which token
1643
+ // a language spells each with, and what cast pins the choice, is a BACKEND decision; see
1644
+ // l3/ast.ts BinOp and backend/cfamily.ts's shift rule.)
1234
1645
  // SCOPE: this and intifyAddr cover the ARITHMETIC escapes. A pointer global under a
1235
1646
  // COMPARISON (`gPtr < K` — C compares unsigned whatever the asm's icmp_s* said) is the same
1236
1647
  // class as intifyAddrCmp's `addr` rule and is deliberately left alone here: it is valid C
@@ -1242,8 +1653,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1242
1653
  // The C rotate idiom — `x >> n | x << (32 - n)` (mirrored for rotl). Byte-exact round-trip
1243
1654
  // on agbcc (thumb ror) and mwcc (rotlw/rotlwi), verified against both toolchains before the
1244
1655
  // ops landed. `x` and `n` render twice — both pure by construction (SSA values; the rotate's
1245
- // operands are register reads), and recovery seeds the rotated value unsigned so `>>`
1246
- // spells the logical shift the idiom requires.
1656
+ // operands are register reads). The right half is the LOGICAL shift `>>>` the idiom is
1657
+ // wrong with an arithmetic one — stated on the node rather than left to the rotated value's
1658
+ // recovered unsignedness, which is a property of recovery rather than of the idiom.
1247
1659
  //
1248
1660
  // (The PPC mirror fold — `rotl(x, 32 - m)` ⇒ rotr(x, m) — lives in the PATTERN layer,
1249
1661
  // engine.ts ROTL_MIRROR: it is a compiler-spelling idiom, mwcc-gated there, not a
@@ -1260,7 +1672,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1260
1672
  n.k === 'const'
1261
1673
  ? { k: 'const', value: 32 - n.value }
1262
1674
  : { k: 'bin', op: '-', l: { k: 'const', value: 32 }, r: n };
1263
- const [near, far] = dir === 'rotr' ? (['>>', '<<'] as const) : (['<<', '>>'] as const);
1675
+ const [near, far] = dir === 'rotr' ? (['>>>', '<<'] as const) : (['<<', '>>>'] as const);
1264
1676
  return {
1265
1677
  k: 'bin',
1266
1678
  op: '|',
@@ -1286,6 +1698,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1286
1698
  if (d.opcode === 'call') {
1287
1699
  return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
1288
1700
  }
1701
+ if (d.opcode === 'laddr') {
1702
+ // gaddr's local twin: the address of the frame-local object the Thumb frontend PROVED
1703
+ // (frame-object audit — width/signed are stamped machine facts). The NAME is this layer's:
1704
+ // see laddrName. Renders `&sp0`; the object itself is declared in `locals`.
1705
+ return { k: 'addr', name: laddrName.get(d)! };
1706
+ }
1289
1707
  if (d.opcode === 'gaddr') {
1290
1708
  // A promoted CODE symbol (frontend `code: true`) is a function pointer stored as an
1291
1709
  // integer: spelled `(u32)Name` — the source idiom — never `&Name` (defect G of the
@@ -1322,7 +1740,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1322
1740
  );
1323
1741
  }
1324
1742
  return d.opcode === 'opaque'
1325
- ? mkGap(`unmodelled instruction '${(d.attrs.mnemonic as string) ?? '?'}'`, d.operands.map(e))
1743
+ ? mkGap(gapReasonFor(d.attrs.mnemonic), d.operands.map(e))
1326
1744
  : mkGap(`no lowering for op '${d.opcode}'`, d.operands.map(e));
1327
1745
  };
1328
1746
 
@@ -1382,13 +1800,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1382
1800
  const target = succ.block;
1383
1801
  const argExpr = sub ? exprWith(sub) : expr;
1384
1802
  const copies: { name: string; value: Expr; arg: Value }[] = [];
1803
+ const suppressed = suppressedArgs.get(succ);
1385
1804
  target.params.forEach((p, i) => {
1805
+ if (suppressed?.has(i)) {
1806
+ return;
1807
+ } // anchored at its const's def site — the write already ran before this edge
1386
1808
  const name = varName.get(p)!;
1387
1809
  const arg = succ.args[i];
1388
1810
  if ((sub?.get(arg) ?? varName.get(arg)) === name) {
1389
1811
  return;
1390
1812
  } // identity copy — coalesced away
1391
- copies.push({ name, value: argExpr(arg), arg });
1813
+ copies.push({ name, value: castAggregateAddr(name, argExpr(arg)), arg });
1392
1814
  });
1393
1815
  // Emit in the order the args are COMPUTED in `pred` — a compiler that lays the defining ops
1394
1816
  // (and thus the copies that read them) out in that order matches with no spurious arg-swap.
@@ -1415,10 +1837,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1415
1837
  return succ ? argAssignsFor(pred, succ, sub) : [];
1416
1838
  };
1417
1839
 
1418
- // Side-effecting ops of a block, emitted as statements in program order: memory stores,
1419
- // calls whose return value nothing consumes (a void/discarded call), and MATERIALIZED defs
1420
- // a call/load whose value cannot soundly render at its use is assigned to its named
1421
- // temp here, at its own program position.
1840
+ // Side-effecting ops of a block, emitted as statements in program order: memory stores; any
1841
+ // EFFECTFUL op whose result nothing consumes (a void/discarded call, and an `opaque` standing for
1842
+ // an instruction asmlift could not model); and MATERIALIZED defs a call/load whose value cannot
1843
+ // soundly render at its use is assigned to its named temp here, at its own program position.
1422
1844
  const sideEffects = (b: Block): Stmt[] => {
1423
1845
  const out: Stmt[] = [];
1424
1846
  for (const op of b.ops) {
@@ -1461,10 +1883,27 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1461
1883
  ),
1462
1884
  value: expr(op.operands[2]),
1463
1885
  });
1464
- } else if (op.opcode === 'call' && op.results.length && !useSitesOf.has(op.results[0])) {
1886
+ } else if (EFFECTFUL_OPS.has(op.opcode) && op.results.length && !useSitesOf.has(op.results[0])) {
1887
+ // An effectful op whose result nobody reads is still an execution. `store`/`astore` have no
1888
+ // result and were handled above, so what reaches here is `call` and `opaque` — and an
1889
+ // `opaque` missing from this walk is an instruction the frontend could not model
1890
+ // disappearing with no diagnostic, which is the one thing this project refuses to do.
1891
+ //
1892
+ // Keyed on EFFECTFUL_OPS rather than the two opcode names: the deciding property is "has an
1893
+ // effect the result does not account for", which is what the flag already means, so the next
1894
+ // op to acquire it needs no edit here. Statement, not expression — `expr` on the result
1895
+ // routes through `lowerDef`, already where `opaque` becomes the gap, so this reuses the SAME
1896
+ // degradation a live opaque gets rather than inventing a second way to be loud.
1465
1897
  out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1466
- } else if (materialize.has(op)) {
1467
- out.push({ k: 'assign', name: varName.get(op.results[0])!, value: lowerDef(op, expr) });
1898
+ } else if (materialize.has(op) && !absorbedLoads.has(op)) {
1899
+ // (an absorbed load's every consumer spells a named bitfield read — emitting its temp
1900
+ // here would recompile to a second load the asm does not have)
1901
+ const nm = varName.get(op.results[0])!;
1902
+ out.push({ k: 'assign', name: nm, value: castAggregateAddr(nm, lowerDef(op, expr)) });
1903
+ }
1904
+ // a merge copy anchored at this const's original position (anchorConstCopies, above)
1905
+ for (const a of anchoredAt.get(op) ?? []) {
1906
+ out.push({ k: 'assign', name: a.name, value: expr(a.arg) });
1468
1907
  }
1469
1908
  }
1470
1909
  return out;
@@ -1515,7 +1954,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1515
1954
 
1516
1955
  // ── Regime-A switch recovery (structure/switch-recover.ts): the recognizer's case bodies call
1517
1956
  // back into structureRegion, and Regime B (switch_br, below) shares its fall-through predicate.
1518
- const { recognizeSwitch, caseRegionReachesSibling } = makeSwitchRecovery({
1957
+ const { recognizeSwitch, analyzeArmExit } = makeSwitchRecovery({
1519
1958
  fn,
1520
1959
  defs,
1521
1960
  dom,
@@ -1524,6 +1963,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1524
1963
  isNamed: (v) => varName.has(v),
1525
1964
  isCmpOpcode: (opcode) => !!CMP_TO_BIN[opcode],
1526
1965
  switchAllowsNeqCase,
1966
+ emitsAnchoredWrite: (blk) => blk.ops.some((o) => anchoredAt.has(o)),
1527
1967
  expr: (v) => expr(v),
1528
1968
  structureRegion: (b, stop) => structureRegion(b, stop),
1529
1969
  });
@@ -1552,23 +1992,18 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1552
1992
  }
1553
1993
  // Regime B: a `switch_br` (jump-table dispatch) lowers directly to the `switch` node — scrutinee,
1554
1994
  // per-successor case value, last successor = default. Case bodies delegate to structureRegion (as in
1555
- // Regime A). Fall-through between jump-table cases is not yet handled: if a case body reaches another
1556
- // case/default block inside the region, fail LOUD rather than duplicate it.
1995
+ // Regime A). Two table slots naming ONE block are one arm carrying both `case` labels; an arm whose
1996
+ // region flows into the NEXT arm is C fall-through (no `break`). Any other shape needs a `goto` and
1997
+ // fails LOUD rather than being duplicated or silently closed.
1557
1998
  if (term.opcode === 'switch_br') {
1558
1999
  const merge = ipdom.get(b) ?? stop;
1559
2000
  const succ = term.successors;
1560
2001
  const caseVals = term.attrs.cases as number[];
1561
- const targets = new Set<Block>(succ.map((s) => s.block));
1562
- if (caseRegionReachesSibling(targets, b, merge)) {
1563
- throw new StructureError(
1564
- `cannot structure '${fn.name}': fall-through between jump-table cases is not yet supported`,
1565
- );
1566
- }
1567
2002
  // Switch edges CARRY phi args (frontend/ssa.ts appends them terminator-generically) — each
1568
2003
  // case/default body must open with its edge's copies, exactly as cond_br edges do; dropping
1569
2004
  // them leaves the target's params uninitialized on the switch path. Two case values sharing
1570
2005
  // a target must agree on their args (else the copies are ambiguous → loud decline); the
1571
- // shared body is then structured per case entry.
2006
+ // shared body is then emitted ONCE under both labels.
1572
2007
  const argsSeen = new Map<Block, Value[]>();
1573
2008
  for (const s of succ) {
1574
2009
  const prev = argsSeen.get(s.block);
@@ -1579,16 +2014,79 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1579
2014
  }
1580
2015
  argsSeen.set(s.block, s.args as Value[]);
1581
2016
  }
1582
- const outCases: SwitchCase[] = succ.slice(0, -1).map((s, i) => ({
1583
- values: [caseVals[i]],
1584
- body: [...argAssignsFor(b, s), ...structureRegion(s.block, merge)],
1585
- fallsThrough: false,
1586
- }));
2017
+ // Group the case slots by target block, in TABLE order — `case 5: case 6:` is one arm with two
2018
+ // labels, not two copies of one body. Emission order is the array order (load-bearing: see the
2019
+ // l3/ast.ts fall-through note), so the adjacency check below is against this same order.
2020
+ const defEdge = succ[succ.length - 1];
2021
+ const arms: { entry: Block; edge: (typeof succ)[number]; values: number[] }[] = [];
2022
+ const armOf = new Map<Block, (typeof arms)[number]>();
2023
+ succ.slice(0, -1).forEach((s, i) => {
2024
+ let a = armOf.get(s.block);
2025
+ if (!a) {
2026
+ a = { entry: s.block, edge: s, values: [] };
2027
+ armOf.set(s.block, a);
2028
+ arms.push(a);
2029
+ }
2030
+ a.values.push(caseVals[i]);
2031
+ });
2032
+ // The blocks an arm could fall INTO. The default block counts only when it is a block of its
2033
+ // own: when it IS the merge, "the default" is just where the switch ends, and an arm reaching
2034
+ // it is a plain `break`.
2035
+ const siblings = new Set<Block>(arms.map((a) => a.entry));
2036
+ if (defEdge.block !== merge) {
2037
+ siblings.add(defEdge.block);
2038
+ }
2039
+ // ONE emission order for the whole statement: the case arms in table order, then the default —
2040
+ // which is exactly where C puts it. Adjacency is read off this array, so "falls into the next
2041
+ // arm" needs no separate rule for a case that falls into the default (it is the arm after the
2042
+ // last case, and legal C).
2043
+ const emitOrder = [...arms, { entry: defEdge.block, edge: defEdge, values: null as number[] | null }];
2044
+ // Each arm's switch-edge copies, computed ONCE and in emission order: `argAssignsFor` mints
2045
+ // swap-cycle temp names, so calling it twice for one edge burns a temp number and changes the
2046
+ // output (the same reason emitDoWhile reuses its `updates`).
2047
+ const edgeCopies = emitOrder.map((a) => argAssignsFor(b, a.edge));
2048
+ const bodies = emitOrder.map((a, i) => {
2049
+ const exit = analyzeArmExit(a.entry, b, merge, siblings);
2050
+ if (exit.kind === 'unstructurable') {
2051
+ throw new StructureError(`cannot structure '${fn.name}': ${exit.why}`);
2052
+ }
2053
+ const ft = exit.kind === 'fallthrough';
2054
+ const next = emitOrder[i + 1];
2055
+ if (ft && next?.entry !== exit.to) {
2056
+ throw new StructureError(
2057
+ `cannot structure '${fn.name}': ${a.values ? `case ${a.values.join('/')}` : 'the default arm'} falls ` +
2058
+ `through into an arm that is not the next one emitted — C fall-through only reaches the arm below`,
2059
+ );
2060
+ }
2061
+ // The arm fallen INTO opens with its own switch-edge copies, which are how the dispatch hands
2062
+ // it its block parameters. On the fall-through path those copies would RE-RUN and overwrite
2063
+ // what the falling arm just computed (`case 0: v=7; case 1: v=5;` — the case-0 path calling
2064
+ // with 5). They cannot simply be dropped either: entering that arm by its own case value
2065
+ // needs them. Hoisting them above the switch is possible but not always safe (another arm may
2066
+ // read the same name first), so this shape declines LOUD; recovering it is future work.
2067
+ if (ft && edgeCopies[i + 1].length) {
2068
+ throw new StructureError(
2069
+ `cannot structure '${fn.name}': the case fallen into takes a value from the switch edge, ` +
2070
+ `which the fall-through path would re-run`,
2071
+ );
2072
+ }
2073
+ return {
2074
+ // A falling-through arm stops AT its successor arm, which then emits that body once under
2075
+ // its own labels; a closed arm runs to the merge as before.
2076
+ body: [...edgeCopies[i], ...structureRegion(a.entry, ft ? exit.to : merge)],
2077
+ fallsThrough: ft,
2078
+ };
2079
+ });
2080
+ const outCases: SwitchCase[] = arms.map((a, i) => ({ values: a.values, ...bodies[i] }));
2081
+ // An EMPTY default arm is not a default at all: it is where the switch ends, which is where
2082
+ // an unmatched scrutinee goes anyway. Emitting the label with nothing under it says nothing
2083
+ // and is not even valid C89 (a label needs a statement).
2084
+ const defBody = bodies[bodies.length - 1].body;
1587
2085
  const sw: Stmt = {
1588
2086
  k: 'switch',
1589
2087
  scrutinee: expr(term.operands[0]),
1590
2088
  cases: outCases,
1591
- default: [...argAssignsFor(b, succ[succ.length - 1]), ...structureRegion(succ[succ.length - 1].block, merge)],
2089
+ ...(defBody.length ? { default: defBody } : {}),
1592
2090
  };
1593
2091
  out.push(sw);
1594
2092
  if (merge && merge !== stop) {
@@ -1725,7 +2223,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1725
2223
  out.push(...updateCopies); // the loop update, RAW (i++, p>>=1, …)
1726
2224
  let leaveCond = exprWith(sub)(term.operands[0]);
1727
2225
  if (contIsTaken) {
1728
- leaveCond = negate(leaveCond);
2226
+ leaveCond = negateCond(leaveCond);
1729
2227
  } // continue is `taken` → leave when NOT it
1730
2228
  const exitArm = isBreak
1731
2229
  ? [...argAssigns(b, loopCtx.exit, sub), { k: 'break' } as Stmt] // break to the loop exit
@@ -1759,7 +2257,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1759
2257
  // IDO/MIPS; agbcc/GCC canonicalise either way, so it is safe there too. A compiler that
1760
2258
  // inverts branch canonicalization sets preserveDivergentBranchSense false and falls through
1761
2259
  // to the positive form below.
1762
- out.push({ k: 'if', cond: negate(cond), then: elseS, else: thenS });
2260
+ out.push({ k: 'if', cond: negateCond(cond), then: elseS, else: thenS });
1763
2261
  return out;
1764
2262
  }
1765
2263
  out.push(mkIf(cond, thenS, elseS));
@@ -1788,7 +2286,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1788
2286
  const term = li.header.ops[li.header.ops.length - 1];
1789
2287
  let cond = exprWith(loopSub(li))(term.operands[0]);
1790
2288
  if (term.successors[0].block !== li.header) {
1791
- cond = negate(cond);
2289
+ cond = negateCond(cond);
1792
2290
  } // loop-continue must be `taken`
1793
2291
  const body = [...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
1794
2292
  return { k: 'while', cond, body };
@@ -1802,7 +2300,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1802
2300
  const term = wl.header.ops[wl.header.ops.length - 1];
1803
2301
  let cond = expr(term.operands[0]);
1804
2302
  if (term.successors[1].block === wl.bodyEntry) {
1805
- cond = negate(cond);
2303
+ cond = negateCond(cond);
1806
2304
  }
1807
2305
  // The header→bodyEntry edge may carry non-identity phi args (a value the header COMPUTED and passes
1808
2306
  // into the body). Those copies must open the body — dropping them reads an uninitialised local.
@@ -1844,6 +2342,26 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1844
2342
  `cannot structure '${fn.name}': do-while condition or a post-loop value reads a pre-update loop variable`,
1845
2343
  );
1846
2344
  }
2345
+ // The exit copies render AFTER the `dowhile` statement, but the analysis judged where each
2346
+ // value they carry may inline as if the copies sat on the latch's terminator — INSIDE the loop.
2347
+ // For a pure def that is only a naming question (the pre-update guard above covers the rest);
2348
+ // for an EFFECTFUL one it moves the effect out of the loop: a call that ran once per iteration
2349
+ // would render once, after it. Nothing can re-place it here — `materialize` was decided before
2350
+ // emission — so decline LOUD rather than emit a plausible loop that calls the wrong number of
2351
+ // times.
2352
+ const movedEffect = exitArgs.find((v) => {
2353
+ const d = defs.get(v);
2354
+ return (
2355
+ d && REPEATED_EFFECT.has(d.opcode) && dw.body.has(opBlock.get(d)!) && !materialize.has(d) && !varName.has(v)
2356
+ );
2357
+ });
2358
+ if (movedEffect) {
2359
+ const d = defs.get(movedEffect)!;
2360
+ throw new StructureError(
2361
+ `cannot structure '${fn.name}': a post-loop value inlines a '${d.opcode}' from inside the loop, ` +
2362
+ `which would move that effect out of it`,
2363
+ );
2364
+ }
1847
2365
  dwActive.add(dw.header);
1848
2366
  // structure the header's own block up to the latch. Call structureBlock DIRECTLY (not
1849
2367
  // structureRegion): the header is already on `onStack` from the caller's structureRegion, so
@@ -1864,7 +2382,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1864
2382
  const body = [...inner, ...sideEffects(dw.latch), ...updates];
1865
2383
  let cond = exprWith(sub)(lterm.operands[0]);
1866
2384
  if (lterm.successors[1].block === dw.header) {
1867
- cond = negate(cond);
2385
+ cond = negateCond(cond);
1868
2386
  } // continue edge must be `taken`
1869
2387
  const out: Stmt[] = [{ k: 'dowhile', cond, body }];
1870
2388
  // The exit region reads latch back-edge values under `sub` (post-loop they live in the loop vars).
@@ -1890,7 +2408,28 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1890
2408
  return {
1891
2409
  name: fn.name,
1892
2410
  params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
1893
- locals: localNames.map((n) => ({ name: n, type: varType.get(n)! })),
2411
+ locals: [
2412
+ ...localNames.map((n) => ({ name: n, type: varType.get(n)! })),
2413
+ // frame-local objects (laddr): declared with EXACTLY the access type the machine used —
2414
+ // the frontend's frame-object audit proved all accesses agree, so this is a fact, not a guess
2415
+ ...[
2416
+ ...new Map(
2417
+ fn.blocks
2418
+ .flatMap((b) => b.ops)
2419
+ .filter((op) => op.opcode === 'laddr')
2420
+ .map((op) => [
2421
+ laddrName.get(op)!,
2422
+ {
2423
+ name: laddrName.get(op)!,
2424
+ type: T.int((op.attrs.width as number) * 8, op.attrs.signed as boolean),
2425
+ // an ESCAPED address makes every store observable (the DMA hardware reads it);
2426
+ // without volatile, gcc-2.9 deletes a store to a local nothing in-function reads
2427
+ ...(op.attrs.volatile === true ? { volatile: true as const } : {}),
2428
+ },
2429
+ ]),
2430
+ ).values(),
2431
+ ],
2432
+ ],
1894
2433
  ...(shapedGlobalTypes.size
1895
2434
  ? {
1896
2435
  globals: [...shapedGlobalTypes]
@@ -2047,16 +2586,10 @@ function substVar(e: Expr, from: string, to: string): Expr {
2047
2586
  // empty-then peephole: `if (c) {} else { S }` → `if (!c) { S }`
2048
2587
  function mkIf(cond: Expr, thenS: Stmt[], elseS: Stmt[]): Stmt {
2049
2588
  if (thenS.length === 0 && elseS.length > 0) {
2050
- return { k: 'if', cond: negate(cond), then: elseS, else: [] };
2589
+ return { k: 'if', cond: negateCond(cond), then: elseS, else: [] };
2051
2590
  }
2052
2591
  return { k: 'if', cond, then: thenS, else: elseS };
2053
2592
  }
2054
- function negate(e: Expr): Expr {
2055
- if (e.k === 'bin' && NEGATE[e.op]) {
2056
- return { ...e, op: NEGATE[e.op] };
2057
- }
2058
- return { k: 'un', op: '!', e };
2059
- }
2060
2593
 
2061
2594
  // --- CFG utilities ---
2062
2595
  function predecessorBlocks(fn: Fn): Map<Block, Block[]> {