@asmlift/core 0.4.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.
@@ -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, negateCond } 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';
@@ -585,6 +587,13 @@ interface WhileLoopInfo {
585
587
  body: Set<Block>; // the pure natural-loop body (for in-body vs exit classification)
586
588
  }
587
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
+
588
597
  // A bottom-tested `do { body } while(cond)`. The header is the body entry (entered before any
589
598
  // test); the LATCH holds the loop condition and the single exit. Body = header..latch structured, then
590
599
  // the latch's own ops + the loop-update; the latch test is the do-while condition. The condition is
@@ -629,6 +638,11 @@ export interface StructureOptions {
629
638
  // read recompiles at the DECLARATION's access width — where that diverges from the asm's load
630
639
  // width the honest shift spelling is the one that matches, and the differ referees.
631
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;
632
646
  // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
633
647
  // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
634
648
  // the PROCESS);
@@ -652,6 +666,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
652
666
  anchorConstCopies = false,
653
667
  littleEndian = true,
654
668
  spellBitfieldMembers = true,
669
+ rereadGlobals = false,
655
670
  onGap = 'strict',
656
671
  symbols,
657
672
  } = opts;
@@ -664,6 +679,15 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
664
679
  const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween } = analyze(
665
680
  fn,
666
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
+ },
667
691
  );
668
692
 
669
693
  // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
@@ -676,13 +700,68 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
676
700
  // widths is a union/type-pun, which the downstream struct-layout recovery rejects LOUD
677
701
  // ("overlapping fields ... unions not modelled") before this classification is consumed — so a
678
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
+
679
748
  const scalarGlobals = new Set<string>();
680
749
  {
681
750
  const offsets = new Map<string, Set<number>>();
682
751
  const bumpAgg = (sym: string) => offsets.set(sym, new Set([-1])); // -1 marks "variable index"
683
752
  for (const b of fn.blocks) {
684
753
  for (const op of b.ops) {
685
- 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
+ };
686
765
  if (op.opcode === 'load' || op.opcode === 'store') {
687
766
  const s = gaddrSym(op.operands[0]);
688
767
  if (s) {
@@ -963,8 +1042,8 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
963
1042
  * computes the right address anyway. That leniency is not something to rely on: the Klonoa
964
1043
  * project's own build template treats these as fatal, so the row's emitted C does not build
965
1044
  * where its author would put it. The cast is the always-valid spelling — the same fallback
966
- * `bareArrayLead` documents for the indexed form — and it is byte-identical (measured on
967
- * kleod:UpdateHUDCounterDisplay: 81 with and without).
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.
968
1047
  *
969
1048
  * The test is whether `&gSym`'s rendered type PROVABLY equals the destination's, not whether the
970
1049
  * symbol looks like an aggregate. A shape enumeration got this wrong three ways, each a real
@@ -1351,43 +1430,14 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1351
1430
  const absorbedLoads = new Set<Op>();
1352
1431
  if (symCtx && littleEndian && spellBitfieldMembers) {
1353
1432
  // the (name, byte) of a load's address when it resolves through defs alone — `gaddr` or
1354
- // `add(gaddr, const)`; anything else (a materialized base, a variable index) declines
1355
- const loadTargets = new Map<Op, { name: string; byte: number }>();
1356
- const addrOf = (v: Value, off: number): { name: string; byte: number } | null => {
1357
- const d0 = defs.get(v);
1358
- if (d0?.opcode === 'gaddr') {
1359
- return { name: d0.attrs.sym as string, byte: off };
1360
- }
1361
- if (d0?.opcode === 'add' && d0.operands.length === 2) {
1362
- for (const [x, y] of [
1363
- [d0.operands[0], d0.operands[1]],
1364
- [d0.operands[1], d0.operands[0]],
1365
- ] as const) {
1366
- const g0 = defs.get(x);
1367
- const c0 = defs.get(y);
1368
- if (g0?.opcode === 'gaddr' && c0?.opcode === 'const') {
1369
- return { name: g0.attrs.sym as string, byte: (c0.attrs.value as number) + off };
1370
- }
1371
- }
1372
- }
1373
- return null;
1374
- };
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);
1375
1438
  // A write for the fold's purposes: calls and opaques always; a store/astore unless its base
1376
- // resolves to a global PROVABLY different from the folded one. (Name comparison suffices:
1377
- // the pool promotion picks one canonical name per address, so one cell cannot appear under
1378
- // two names within a function.)
1379
- const mayWrite =
1380
- (sym: string) =>
1381
- (x: Op): boolean => {
1382
- if (x.opcode === 'call' || x.opcode === 'opaque') {
1383
- return true;
1384
- }
1385
- if (x.opcode !== 'store' && x.opcode !== 'astore') {
1386
- return false;
1387
- }
1388
- const t = addrOf(x.operands[0], 0);
1389
- return !(t && t.name !== sym);
1390
- };
1439
+ // resolves to a global PROVABLY different from the folded one.
1440
+ const mayWrite = (sym: string) => mayWriteGlobal(defs, sym);
1391
1441
  for (const blk of fn.blocks) {
1392
1442
  for (const op of blk.ops) {
1393
1443
  if ((op.opcode !== 'shr_u' && op.opcode !== 'shr_s') || op.operands.length !== 1) {
@@ -1648,6 +1698,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1648
1698
  if (d.opcode === 'call') {
1649
1699
  return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
1650
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
+ }
1651
1707
  if (d.opcode === 'gaddr') {
1652
1708
  // A promoted CODE symbol (frontend `code: true`) is a function pointer stored as an
1653
1709
  // integer: spelled `(u32)Name` — the source idiom — never `&Name` (defect G of the
@@ -1684,7 +1740,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1684
1740
  );
1685
1741
  }
1686
1742
  return d.opcode === 'opaque'
1687
- ? mkGap(`unmodelled instruction '${(d.attrs.mnemonic as string) ?? '?'}'`, d.operands.map(e))
1743
+ ? mkGap(gapReasonFor(d.attrs.mnemonic), d.operands.map(e))
1688
1744
  : mkGap(`no lowering for op '${d.opcode}'`, d.operands.map(e));
1689
1745
  };
1690
1746
 
@@ -1781,10 +1837,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1781
1837
  return succ ? argAssignsFor(pred, succ, sub) : [];
1782
1838
  };
1783
1839
 
1784
- // Side-effecting ops of a block, emitted as statements in program order: memory stores,
1785
- // calls whose return value nothing consumes (a void/discarded call), and MATERIALIZED defs
1786
- // a call/load whose value cannot soundly render at its use is assigned to its named
1787
- // temp here, at its own program position.
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.
1788
1844
  const sideEffects = (b: Block): Stmt[] => {
1789
1845
  const out: Stmt[] = [];
1790
1846
  for (const op of b.ops) {
@@ -1827,7 +1883,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1827
1883
  ),
1828
1884
  value: expr(op.operands[2]),
1829
1885
  });
1830
- } 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.
1831
1897
  out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1832
1898
  } else if (materialize.has(op) && !absorbedLoads.has(op)) {
1833
1899
  // (an absorbed load's every consumer spells a named bitfield read — emitting its temp
@@ -1888,7 +1954,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1888
1954
 
1889
1955
  // ── Regime-A switch recovery (structure/switch-recover.ts): the recognizer's case bodies call
1890
1956
  // back into structureRegion, and Regime B (switch_br, below) shares its fall-through predicate.
1891
- const { recognizeSwitch, caseRegionReachesSibling } = makeSwitchRecovery({
1957
+ const { recognizeSwitch, analyzeArmExit } = makeSwitchRecovery({
1892
1958
  fn,
1893
1959
  defs,
1894
1960
  dom,
@@ -1926,23 +1992,18 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1926
1992
  }
1927
1993
  // Regime B: a `switch_br` (jump-table dispatch) lowers directly to the `switch` node — scrutinee,
1928
1994
  // per-successor case value, last successor = default. Case bodies delegate to structureRegion (as in
1929
- // Regime A). Fall-through between jump-table cases is not yet handled: if a case body reaches another
1930
- // case/default block inside the region, fail LOUD rather than duplicate it.
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.
1931
1998
  if (term.opcode === 'switch_br') {
1932
1999
  const merge = ipdom.get(b) ?? stop;
1933
2000
  const succ = term.successors;
1934
2001
  const caseVals = term.attrs.cases as number[];
1935
- const targets = new Set<Block>(succ.map((s) => s.block));
1936
- if (caseRegionReachesSibling(targets, b, merge)) {
1937
- throw new StructureError(
1938
- `cannot structure '${fn.name}': fall-through between jump-table cases is not yet supported`,
1939
- );
1940
- }
1941
2002
  // Switch edges CARRY phi args (frontend/ssa.ts appends them terminator-generically) — each
1942
2003
  // case/default body must open with its edge's copies, exactly as cond_br edges do; dropping
1943
2004
  // them leaves the target's params uninitialized on the switch path. Two case values sharing
1944
2005
  // a target must agree on their args (else the copies are ambiguous → loud decline); the
1945
- // shared body is then structured per case entry.
2006
+ // shared body is then emitted ONCE under both labels.
1946
2007
  const argsSeen = new Map<Block, Value[]>();
1947
2008
  for (const s of succ) {
1948
2009
  const prev = argsSeen.get(s.block);
@@ -1953,16 +2014,79 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1953
2014
  }
1954
2015
  argsSeen.set(s.block, s.args as Value[]);
1955
2016
  }
1956
- const outCases: SwitchCase[] = succ.slice(0, -1).map((s, i) => ({
1957
- values: [caseVals[i]],
1958
- body: [...argAssignsFor(b, s), ...structureRegion(s.block, merge)],
1959
- fallsThrough: false,
1960
- }));
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;
1961
2085
  const sw: Stmt = {
1962
2086
  k: 'switch',
1963
2087
  scrutinee: expr(term.operands[0]),
1964
2088
  cases: outCases,
1965
- default: [...argAssignsFor(b, succ[succ.length - 1]), ...structureRegion(succ[succ.length - 1].block, merge)],
2089
+ ...(defBody.length ? { default: defBody } : {}),
1966
2090
  };
1967
2091
  out.push(sw);
1968
2092
  if (merge && merge !== stop) {
@@ -2218,6 +2342,26 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2218
2342
  `cannot structure '${fn.name}': do-while condition or a post-loop value reads a pre-update loop variable`,
2219
2343
  );
2220
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
+ }
2221
2365
  dwActive.add(dw.header);
2222
2366
  // structure the header's own block up to the latch. Call structureBlock DIRECTLY (not
2223
2367
  // structureRegion): the header is already on `onStack` from the caller's structureRegion, so
@@ -2264,7 +2408,28 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
2264
2408
  return {
2265
2409
  name: fn.name,
2266
2410
  params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
2267
- 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
+ ],
2268
2433
  ...(shapedGlobalTypes.size
2269
2434
  ? {
2270
2435
  globals: [...shapedGlobalTypes]
@@ -28,10 +28,23 @@ export interface SwitchRecoverDeps {
28
28
  structureRegion: (b: Block, stop: Block | null) => Stmt[];
29
29
  }
30
30
 
31
+ /** Where ONE switch arm's region leaves it — the fact that decides whether the arm can be spelled
32
+ * as C at all, and with or without a `break`.
33
+ *
34
+ * - `break` every path out of the arm reaches the switch's merge (or returns / loops
35
+ * inside the arm). The ordinary closed arm.
36
+ * - `fallthrough` every path out leaves into exactly ONE sibling arm's entry: C's fall-through.
37
+ * Only spellable when that sibling is the arm emitted NEXT (the caller checks
38
+ * emission adjacency — see the l3/ast.ts non-neutrality note).
39
+ * - `unstructurable` anything else: two different siblings, or a mix of "into a sibling" and
40
+ * "out to the merge". C needs a `goto` for those, so callers decline LOUD. */
41
+ export type ArmExit = { kind: 'break' } | { kind: 'fallthrough'; to: Block } | { kind: 'unstructurable'; why: string };
42
+
31
43
  export interface SwitchRecovery {
32
44
  recognizeSwitch: (b: Block, stop: Block | null) => Stmt[] | null;
33
- /** shared with the Regime-B (`switch_br`) path in structure.ts, which throws where A declines */
34
- caseRegionReachesSibling: (targets: Set<Block>, b: Block, merge: Block | null) => boolean;
45
+ /** shared with the Regime-B (`switch_br`) path in structure.ts, which recovers the fall-through
46
+ * this returns; Regime A only accepts `break` arms and otherwise declines to if-recovery. */
47
+ analyzeArmExit: (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>) => ArmExit;
35
48
  }
36
49
 
37
50
  export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
@@ -198,31 +211,83 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
198
211
  }
199
212
  };
200
213
 
201
- // Can any case/default entry's region reach a SIBLING entry (switch fall-through)? Region =
202
- // blocks strictly dominated by `b`, short of `merge`. Shared by Regime A (declines to
203
- // if-recovery) and Regime B (throwsa jump-table has no fallback).
204
- const caseRegionReachesSibling = (targets: Set<Block>, b: Block, merge: Block | null): boolean => {
205
- const inRegion = (blk: Block) => blk !== merge && dom.get(blk)!.has(b);
206
- for (const entry of targets) {
207
- const rseen = new Set<Block>([entry]);
208
- const q = [entry];
209
- while (q.length) {
210
- const cur = q.pop()!;
211
- for (const s of successorsOf(cur)) {
212
- if (s === entry) {
214
+ // Where does one arm's region LEAVE? Walk it from `entry`, never stepping THROUGH the merge or a
215
+ // sibling arm's entry, and classify what it steps INTO. `siblings` is every OTHER arm entry the
216
+ // caller can emit a `case`/`default` label for the merge is deliberately not among them, so a
217
+ // switch whose default block IS the merge (agbcc's usual "the default just leaves") reads as an
218
+ // ordinary `break`, not as falling into the default.
219
+ //
220
+ // Region membership is `dom(blk) ∋ b` as before: a block NOT dominated by the switch is outside
221
+ // this switch's region and is not walked. It IS recorded as an escape, because an arm that can
222
+ // leave sideways does not fall into the next case — but only the fall-through verdict consults
223
+ // that, so no arm that used to be accepted as closed becomes a decline.
224
+ //
225
+ // A CONSEQUENCE, not a hole: a sibling reachable only THROUGH such a block is never seen, so the
226
+ // arm reads as closed and `structureRegion` walks into the sibling's blocks and emits them again
227
+ // under this arm. That is duplication, not a wrong dispatch — the same duplication the structurer
228
+ // already does for any tail two arms share, and how the case bodies agbcc tail-merged are put
229
+ // back. Costly for matching, correct to run.
230
+ const analyzeArmExit = (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>): ArmExit => {
231
+ if (entry === merge) {
232
+ return { kind: 'break' }; // an empty arm (a table slot pointing straight at the switch's end)
233
+ }
234
+ const into = new Set<Block>(); // sibling entries this arm flows into
235
+ let toMerge = false,
236
+ escapes = false;
237
+ const seen = new Set<Block>([entry]);
238
+ const q = [entry];
239
+ while (q.length) {
240
+ const cur = q.pop()!;
241
+ for (const s of successorsOf(cur)) {
242
+ if (s === merge) {
243
+ toMerge = true;
244
+ } else if (s !== entry && siblings.has(s)) {
245
+ into.add(s);
246
+ } else if (s !== entry && !seen.has(s)) {
247
+ if (!dom.get(s)!.has(b)) {
248
+ escapes = true;
213
249
  continue;
214
250
  }
215
- if (targets.has(s)) {
216
- return true;
217
- }
218
- if (inRegion(s) && !rseen.has(s)) {
219
- rseen.add(s);
220
- q.push(s);
221
- }
251
+ seen.add(s);
252
+ q.push(s);
222
253
  }
223
254
  }
224
255
  }
225
- return false;
256
+ if (into.size === 0) {
257
+ return { kind: 'break' };
258
+ }
259
+ if (into.size === 1 && !toMerge && !escapes) {
260
+ return { kind: 'fallthrough', to: [...into][0] };
261
+ }
262
+ // Name what is actually missing. These three are different facts, and only the first is a shape
263
+ // C has no spelling for — the other two are asmlift's own limits, so say so rather than blame C.
264
+ const names = () => [...into].map((x) => `#${fn.blocks.indexOf(x)}`).join(', ');
265
+ if (into.size > 1) {
266
+ return {
267
+ kind: 'unstructurable',
268
+ why: `a case body reaches several sibling cases (${names()}) — C fall-through reaches only one, so this needs a goto`,
269
+ };
270
+ }
271
+ if (escapes) {
272
+ return {
273
+ kind: 'unstructurable',
274
+ why: `a case body reaches sibling case ${names()} on one path and, on another, a block the switch does not dominate`,
275
+ };
276
+ }
277
+ return {
278
+ kind: 'unstructurable',
279
+ // `case 0: if (c) { …; break; } /* fall through */ case 1:` is the C for this, and the reason
280
+ // asmlift cannot write it is its own: `{k:'break'}` is emitted only for the innermost LOOP
281
+ // (l3/ast.ts), never switch-scoped. That is the capability this shape is waiting on.
282
+ why: `a case body reaches sibling case ${names()} on one path and the end of the switch on another — a switch-scoped \`break\` inside a case body is not emitted yet`,
283
+ };
284
+ };
285
+
286
+ /** Every arm closed (`break`)? The precondition Regime A needs — it has a behaviourally identical
287
+ * fallback (if-recovery), so it declines on anything else instead of recovering fall-through. */
288
+ const allArmsClosed = (targets: Set<Block>, b: Block, merge: Block | null): boolean => {
289
+ const siblings = new Set([...targets].filter((t) => t !== merge));
290
+ return [...siblings].every((t) => analyzeArmExit(t, b, merge, siblings).kind === 'break');
226
291
  };
227
292
 
228
293
  const recognizeSwitch = (b: Block, stop: Block | null): Stmt[] | null => {
@@ -385,11 +450,12 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
385
450
  }
386
451
 
387
452
  // PRE2 (fall-through): only NON-fall-through switches are handled — decline if any case body
388
- // can reach ANOTHER case body (or the default) while staying inside the region. (The SAME
389
- // predicate serves the Regime-B path, which throws instead.)
453
+ // can reach ANOTHER case body (or a default that has its own block) while staying inside the
454
+ // region. (The SAME analysis serves the Regime-B path, which RECOVERS the adjacent-sibling
455
+ // case as C fall-through instead of declining; A has if-recovery to fall back on, B does not.)
390
456
  const merge = ipdom.get(b) ?? stop;
391
457
  const targets = new Set<Block>([...caseBlocks, ...(defaultBlk ? [defaultBlk] : [])]);
392
- if (caseRegionReachesSibling(targets, b, merge)) {
458
+ if (!allArmsClosed(targets, b, merge)) {
393
459
  return null;
394
460
  }
395
461
 
@@ -412,11 +478,14 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
412
478
  body: structureRegion(blk, merge),
413
479
  fallsThrough: false,
414
480
  }));
481
+ // An empty default arm is not a default (see the Regime-B note in structure.ts): the label
482
+ // would carry no statement, which says nothing and is not valid C89.
483
+ const defBody = defaultBlk ? structureRegion(defaultBlk, merge) : [];
415
484
  const sw: Stmt = {
416
485
  k: 'switch',
417
486
  scrutinee: scrutExpr,
418
487
  cases: outCases,
419
- ...(defaultBlk ? { default: structureRegion(defaultBlk, merge) } : {}),
488
+ ...(defBody.length ? { default: defBody } : {}),
420
489
  };
421
490
  const out: Stmt[] = [sw];
422
491
  if (merge && merge !== stop) {
@@ -424,5 +493,5 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
424
493
  }
425
494
  return out;
426
495
  };
427
- return { recognizeSwitch, caseRegionReachesSibling };
496
+ return { recognizeSwitch, analyzeArmExit };
428
497
  }