@asmlift/core 0.7.0 → 0.8.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 (44) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/pascal.ts +2 -2
  4. package/src/codegen-flags.ts +640 -0
  5. package/src/frontend/disasm.ts +141 -11
  6. package/src/frontend/high-half.ts +149 -0
  7. package/src/frontend/mips.ts +458 -209
  8. package/src/frontend/ppc.ts +332 -67
  9. package/src/frontend/reloc-symbol.ts +109 -0
  10. package/src/frontend/splat.ts +56 -18
  11. package/src/frontend/ssa.ts +126 -29
  12. package/src/frontend/stackargs.ts +420 -0
  13. package/src/frontend/thumb.ts +207 -230
  14. package/src/ir/core.ts +62 -3
  15. package/src/ir/opcodes.ts +9 -0
  16. package/src/ir/parse.ts +7 -1
  17. package/src/l3/advance.ts +2 -2
  18. package/src/l3/argbase.ts +2 -2
  19. package/src/l3/argcopy.ts +269 -0
  20. package/src/l3/ast.ts +45 -1
  21. package/src/l3/basecse.ts +2 -2
  22. package/src/l3/coalesce.ts +109 -52
  23. package/src/l3/scopebase.ts +4 -4
  24. package/src/l3/tailret.ts +70 -0
  25. package/src/l3/unmerge.ts +2 -2
  26. package/src/l3/unreduce.ts +2 -1
  27. package/src/mangle.ts +49 -0
  28. package/src/pattern/engine.ts +128 -13
  29. package/src/pipeline.ts +22 -11
  30. package/src/raise/extscale.ts +5 -2
  31. package/src/raise/paramwidth.ts +111 -3
  32. package/src/raise/pre-recovery.ts +11 -1
  33. package/src/raise/retsink.ts +8 -4
  34. package/src/raise/tailsink.ts +17 -2
  35. package/src/rank-declare.ts +17 -9
  36. package/src/rank.ts +45 -19
  37. package/src/structure/retspell.ts +95 -0
  38. package/src/structure/structure.ts +12 -3
  39. package/src/structure/switch-recover.ts +1 -1
  40. package/src/target.ts +224 -14
  41. package/src/trace.ts +27 -18
  42. package/src/variation-definitions.ts +52 -2
  43. package/src/variation-gates.ts +3 -0
  44. package/src/variation-tokens.ts +1 -0
package/src/l3/advance.ts CHANGED
@@ -81,7 +81,7 @@
81
81
  // the promoted form.
82
82
  import { type IrType, scalarTypeForAccess } from '../ir/types';
83
83
  import { cellAddress } from './address';
84
- import { type Expr, type SFn, type Stmt, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
84
+ import { type Expr, type SFn, type Stmt, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
85
85
  import { type Gate, firstRejection } from './gates';
86
86
  import type { BaseInit } from './hoist';
87
87
  import { nameAllocator, placeBaseLocals } from './hoist';
@@ -230,7 +230,7 @@ function collectSites(body: readonly Stmt[]): { sites: Site[]; nestedAddrs: Set<
230
230
  };
231
231
  const walk = (stmts: readonly Stmt[], stmt: number, nested: boolean): void => {
232
232
  for (const s of stmts) {
233
- const repeats = s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
233
+ const repeats = isLoop(s);
234
234
  for (const e of stmtExprs(s)) {
235
235
  visit(e, stmt, nested || repeats);
236
236
  }
package/src/l3/argbase.ts CHANGED
@@ -39,7 +39,7 @@
39
39
  // leaves the diff at 2; both together take it to 0.)
40
40
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
41
41
  import type { Expr, SFn, Stmt } from './ast';
42
- import { mapExprChildren, stmtExprs } from './ast';
42
+ import { isLoop, mapExprChildren, stmtExprs } from './ast';
43
43
  import { nameAllocator } from './hoist';
44
44
  import { declaredGlobals } from './storage';
45
45
 
@@ -133,7 +133,7 @@ export function materializeArgBases(sfn: SFn): SFn | null {
133
133
  // had. That is the register-pressure failure basecse.ts's `inLoop` gate exists to refuse, and
134
134
  // it would contradict this pass's own placement rule two comments down. So a loop's condition
135
135
  // is left alone; only its body (via the recursion) is eligible.
136
- const ownExprs = s.k === 'while' || s.k === 'dowhile' || s.k === 'for' ? [] : stmtExprs(s);
136
+ const ownExprs = isLoop(s) ? [] : stmtExprs(s);
137
137
  for (const e of ownExprs) {
138
138
  const scan = (x: Expr): void => {
139
139
  if (x.k === 'call') {
@@ -0,0 +1,269 @@
1
+ // L3 respell variation: copy a pointer PARAMETER into a local for ONE REGION.
2
+ //
3
+ // A pointer parameter that a whole function reads pins its incoming register for the whole body.
4
+ // A source that instead copies it into a local — `u8 *b = a0;` at the head of the block that uses
5
+ // it — hands the allocator a SECOND name for the same address, which it may home in a different
6
+ // register, freeing the parameter's for something else. On
7
+ // pokeemerald:SetMauvilleOldManLanguage:agbcc that something else is the arm's loop counter: the
8
+ // target copies the base with `adds r6, r5, #0` and then counts in r5.
9
+ //
10
+ // WHY THIS IS NOT ANY OF ITS NEIGHBOURS. `l3/scopebase.ts` hoists one base per region too, but the
11
+ // value it hoists is the ADDRESS OF A GLOBAL (`(T *)&gSym`) and its `shadowed-or-nonarray-base`
12
+ // rule refuses a `var` base precisely because `&local` names a different object; a parameter
13
+ // already HOLDS the pointer, so nothing is addressed and that rule's argument does not reach here.
14
+ // `l3/argbase.ts` names a call argument's fixed addresses, `l3/inlinebase.ts` DELETES a
15
+ // constant-address local, and `l3/parkfirst.ts` only reorders a copy the tree already has. None of
16
+ // them mints `b = a0`.
17
+ //
18
+ // THE COPY IS A PLAIN LOCAL, NOT A SCOPED DECLARATION. Both spellings — a braced region
19
+ // declaration, and a function-top local assigned at the head of the region — were taken through
20
+ // this row's own agbcc and produce the same bytes, so this pass emits the one L3 already spells
21
+ // and adds no block-scope representation to carry a distinction no object shows.
22
+ //
23
+ // WHAT IT GIVES UP, NAMED. A pointer parameter read only inside a loop that sits at FUNCTION TOP
24
+ // LEVEL gets no candidate at all: `regions()` does not offer the function's own list, so the only
25
+ // region over those reads is the loop body, and `loop-region` refuses it. That population — a base
26
+ // live across a top-level loop — is `/livebase` and `/hipress`'s, and the decline is deliberate.
27
+ // The spelling that shape wants is a copy placed BEFORE the loop with only the loop's reads
28
+ // repointed: a copy SITE separate from its region, which nothing here represents. Relaxing
29
+ // `loop-region` does not reach it — it mints the per-iteration copy that rule exists to refuse.
30
+ //
31
+ // WHAT IS ENUMERATED, AND WHY. Which region the source copied in is not derivable from the tree,
32
+ // so every legal region is offered as its own candidate and the differ referees — the `/regcopy`
33
+ // idiom this file shares with `l3/coalesce.ts`. Uses OUTSIDE the chosen region keep naming the
34
+ // parameter, which is the point: the copy is what makes the two live ranges separable.
35
+ //
36
+ // IT KEEPS ITS OWN REGION WALK, AND THAT IS A SECOND REGION MODEL. `l3/scopebase.ts` ships one
37
+ // already, and this is a narrower duplicate; merging them is the right end state. What blocks that
38
+ // is the SAFETY OBLIGATION, not the region question. scopebase hoists a pure ADDRESS and
39
+ // discharges its obligation by PLACEMENT — `assertHoistsDominate` re-walks the emitted tree to
40
+ // check the hoist dominates every use it repointed. This pass hoists a VALUE the caller passed:
41
+ // its obligation is a whole-function invariance fact (`assigned`, `addressed`) that no placement
42
+ // check can see, and it needs no dominance check at all, because uses outside the region are not
43
+ // repointed. One table over both would need scopebase's `collect()` to admit a plain `var` read
44
+ // as a site and `AccessCtx`/`keyOf` to key a parameter — a widening of a file four shipped
45
+ // variations rest on. Until then every walk HERE goes through `ast.ts`'s shared ones, so the two
46
+ // models at least cannot disagree about what a statement contains.
47
+ import type { Expr, SFn, Stmt } from './ast';
48
+ import { isLoop, mapExprChildren, mapStmtExprs, mapStmtLists, stmtChildren, stmtLists, walkExprs } from './ast';
49
+ import { type Gate, firstRejection } from './gates';
50
+ import { nameAllocator } from './hoist';
51
+
52
+ /** One candidate copy, as the gates see it. */
53
+ export interface ArgCopyCtx {
54
+ /** the parameter being copied */
55
+ readonly param: string;
56
+ /** the parameter's declared type is a pointer — the register this variation is about is a base */
57
+ readonly isPointer: boolean;
58
+ /** the function assigns the parameter somewhere, so a copy taken earlier can go stale */
59
+ readonly assigned: boolean;
60
+ /** `&param` occurs, so the copy is a DIFFERENT object and any write through the address misses it */
61
+ readonly addressed: boolean;
62
+ }
63
+
64
+ /** The admission rules. The two SOUND ones are the whole soundness argument: with the parameter
65
+ * never assigned and never addressed, the copy holds the parameter's value at every point the
66
+ * region can reach, so repointing the region's reads at it renames a value rather than changing
67
+ * one. Drop either and the rewrite names different memory — C that compiles and scores.
68
+ *
69
+ * Both are decided over the WHOLE tree — see `countReads` on why every walk in this file goes
70
+ * through `stmtChildren`/`walkExprs`. A gate that called itself sound while judging a subset of the
71
+ * statements `repoint` rewrites would be sound about a function nobody compiles. */
72
+ export const ARGCOPY_GATES: readonly Gate<ArgCopyCtx>[] = [
73
+ {
74
+ id: 'non-pointer',
75
+ why: 'the freed register is a base register, and a scalar parameter does not hold one',
76
+ sound: false,
77
+ rejects: (c) => !c.isPointer,
78
+ },
79
+ {
80
+ id: 'assigned',
81
+ why: 'the function assigns the parameter, so the copy would hold a value the parameter no longer has',
82
+ sound: true,
83
+ guardedBy: 'argcopy.test.ts: a parameter a `for` header ADVANCES is never copied',
84
+ rejects: (c) => c.assigned,
85
+ },
86
+ {
87
+ id: 'addressed',
88
+ why: 'taking the parameter’s address names its own cell, and the copy is a different cell',
89
+ sound: true,
90
+ guardedBy: 'argcopy.test.ts: a parameter whose ADDRESS is taken in a `for` header is never copied',
91
+ rejects: (c) => c.addressed,
92
+ },
93
+ ];
94
+
95
+ /** One candidate REGION, as the region rules see it. Separate from {@link ARGCOPY_GATES} for the
96
+ * reason `scopebase` keeps two tables: those rules judge the PARAMETER once, these judge each
97
+ * place it could be copied, and a refusal tally that mixed the two would count a parameter's
98
+ * single verdict once per region. */
99
+ export interface ArgCopyRegionCtx {
100
+ /** reads of the parameter inside this region */
101
+ readonly reads: number;
102
+ /** a loop encloses the region — its own body, or any list nested below one */
103
+ readonly underLoop: boolean;
104
+ }
105
+
106
+ /** Which regions are worth offering. NEITHER is sound — each refuses a spelling that is correct
107
+ * but models nothing, and what they buy is the candidate count: every one of these is a COMPILE,
108
+ * and this variation multiplies with `/coalesce`. */
109
+ export const ARGCOPY_REGION_GATES: readonly Gate<ArgCopyRegionCtx>[] = [
110
+ {
111
+ id: 'single-read',
112
+ why: 'a region reading the parameter once gives the allocator no live range to shorten, so the copy buys nothing',
113
+ sound: false,
114
+ rejects: (c) => c.reads < 2,
115
+ },
116
+ {
117
+ id: 'loop-region',
118
+ why: 'a copy anywhere inside a loop re-runs every iteration, so it belongs to a region that holds the loop instead — and no region does when the loop is at function top level, where this variation offers nothing at all',
119
+ sound: false,
120
+ // the second clause has its own test, which `guardedBy` cannot also name (one title per gate):
121
+ // `argcopy.test.ts: a TOP-LEVEL loop is offered NOTHING`
122
+ guardedBy: 'argcopy.test.ts: an arm NESTED inside a loop body is refused too',
123
+ rejects: (c) => c.underLoop,
124
+ },
125
+ ];
126
+
127
+ /** How many `var n` leaves the region holds, nested statements included — the region rules'
128
+ * yardstick for whether a copy has a range to shorten.
129
+ *
130
+ * `walkExprs` (ast.ts) is the whole-tree walk, which descends through `stmtChildren` and so sees a
131
+ * `for`'s `init` and `inc`. That is the walk this file must use everywhere: `repoint` rewrites
132
+ * those two statements (`mapStmtExprs` recurses into them), so a count taken with `stmtLists` —
133
+ * which deliberately omits them, being a walk over the SCOPES a statement opens — would report
134
+ * fewer reads than the rewrite touches. */
135
+ function countReads(list: Stmt[], n: string): number {
136
+ let reads = 0;
137
+ for (const e of walkExprs(list)) {
138
+ if (e.k === 'var' && e.name === n) {
139
+ reads++;
140
+ }
141
+ }
142
+ return reads;
143
+ }
144
+
145
+ /** Every nested statement list in `body`, each with the path that reaches it — a REGION is any
146
+ * list a statement contains, which is what a `case` body, an `if` arm and a loop body all are.
147
+ * The function's own top-level list is NOT among them: repointing it renames the parameter over
148
+ * the whole function, which is a PARK — the shape `l3/parkfirst.ts` reorders — and not the two
149
+ * separable live ranges this variation is about. */
150
+ function regions(body: Stmt[]): { at: number[]; list: Stmt[]; underLoop: boolean }[] {
151
+ const out: { at: number[]; list: Stmt[]; underLoop: boolean }[] = [];
152
+ // carried DOWN rather than read off the immediate parent: an `if` arm two levels inside a loop
153
+ // body re-runs every iteration exactly as the body does, which is what `loop-region` refuses
154
+ const walk = (list: Stmt[], path: number[], underLoop: boolean): void => {
155
+ list.forEach((st, i) => {
156
+ const inside = underLoop || isLoop(st);
157
+ stmtLists(st).forEach((inner, j) => {
158
+ const here = [...path, i, j];
159
+ out.push({ at: here, list: inner, underLoop: inside });
160
+ walk(inner, here, inside);
161
+ });
162
+ });
163
+ };
164
+ walk(body, [], false);
165
+ return out;
166
+ }
167
+
168
+ /** `list` with every `var n` leaf rewritten to `var to`, nested statements included. Both kind
169
+ * switches are ast.ts's, so a new statement or expression kind is that file's problem, not this
170
+ * one's.
171
+ *
172
+ * ONE walk: `mapStmtExprs` already recurses into every nested statement — a `for`'s `init`/`inc`
173
+ * included, which is what makes this symmetric with `countReads` — so a single `map` over the list
174
+ * reaches the whole subtree. Driving it through `mapStmtLists` as well would visit a statement at
175
+ * depth d once per level above it: 40 expression visits against 13 on a five-level tree (switch >
176
+ * for > do-while > if > store). This runs once per region per parameter per candidate, and each
177
+ * candidate is a compile. */
178
+ function repoint(list: Stmt[], n: string, to: string): Stmt[] {
179
+ const inExpr = (e: Expr): Expr => (e.k === 'var' && e.name === n ? { ...e, name: to } : mapExprChildren(e, inExpr));
180
+ return list.map((s) => mapStmtExprs(s, inExpr));
181
+ }
182
+
183
+ /** Replace the region at `at` with `next`. */
184
+ function replaceRegion(body: Stmt[], at: number[], next: Stmt[]): Stmt[] {
185
+ const [i, j, ...rest] = at;
186
+ return body.map((st, k) => {
187
+ if (k !== i) {
188
+ return st;
189
+ }
190
+ let seen = -1;
191
+ return mapStmtLists(st, (inner) => {
192
+ seen++;
193
+ if (seen !== j) {
194
+ return inner;
195
+ }
196
+ return rest.length ? replaceRegion(inner, rest, next) : next;
197
+ });
198
+ });
199
+ }
200
+
201
+ /** `argCopyCandidates` with the gate table supplied plus which gate refused each parameter — the
202
+ * same ablation-as-a-value seam the coalescer provides. */
203
+ export function argCopyUnder(
204
+ gates: readonly Gate<ArgCopyCtx>[],
205
+ sfn: SFn,
206
+ regionGates: readonly Gate<ArgCopyRegionCtx>[] = ARGCOPY_REGION_GATES,
207
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
208
+ const refusals = new Map<string, number>();
209
+ const out: { merged: string; sfn: SFn }[] = [];
210
+ /** `&n` anywhere in the function — every expression node, `for` header included. */
211
+ const addressed = (n: string): boolean => {
212
+ for (const e of walkExprs(sfn.body)) {
213
+ if (e.k === 'addr' && e.name === n) {
214
+ return true;
215
+ }
216
+ }
217
+ return false;
218
+ };
219
+ /** The function assigns `n` anywhere. `stmtChildren`, never `stmtLists`: a `for`'s `init` and
220
+ * `inc` are STATEMENTS rather than lists, and an induction step that advances the parameter is
221
+ * exactly one of them — the shape `structure/structure.ts`'s `recognizeForLoops` mints. */
222
+ const assignsTo = (n: string, list: Stmt[] = sfn.body): boolean =>
223
+ list.some((st) => (st.k === 'assign' && st.name === n) || assignsTo(n, stmtChildren(st)));
224
+ for (const p of sfn.params) {
225
+ const refused = firstRejection(gates, {
226
+ param: p.name,
227
+ isPointer: p.type.kind === 'ptr',
228
+ assigned: assignsTo(p.name),
229
+ addressed: addressed(p.name),
230
+ });
231
+ if (refused !== null) {
232
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
233
+ continue;
234
+ }
235
+ for (const r of regions(sfn.body)) {
236
+ // not a gate: a region with no read of the parameter has nothing to repoint
237
+ const reads = countReads(r.list, p.name);
238
+ if (reads === 0) {
239
+ continue;
240
+ }
241
+ const regionRefused = firstRejection(regionGates, { reads, underLoop: r.underLoop });
242
+ if (regionRefused !== null) {
243
+ refusals.set(regionRefused, (refusals.get(regionRefused) ?? 0) + 1);
244
+ continue;
245
+ }
246
+ // allocated against the ORIGINAL function, so every candidate names its copy identically —
247
+ // each is a separate tree and none of them ever meets another
248
+ const name = nameAllocator(sfn)();
249
+ const copied = [
250
+ { k: 'assign', name, value: { k: 'var', name: p.name } } as Stmt,
251
+ ...repoint(r.list, p.name, name),
252
+ ];
253
+ out.push({
254
+ merged: `${p.name}@${r.at.join('.')}`,
255
+ sfn: {
256
+ ...sfn,
257
+ body: replaceRegion(sfn.body, r.at, copied),
258
+ locals: [...sfn.locals, { name, type: p.type }],
259
+ },
260
+ });
261
+ }
262
+ }
263
+ return { candidates: out, refusals };
264
+ }
265
+
266
+ /** Every legal region copy, each as its own tree. */
267
+ export function argCopyCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
268
+ return argCopyUnder(ARGCOPY_GATES, sfn).candidates;
269
+ }
package/src/l3/ast.ts CHANGED
@@ -266,7 +266,13 @@ export type Stmt =
266
266
  // by instrumenting the printer. Both regimes produce them: the jump table spells `case 4:` of
267
267
  // `kleod:UpdateWorldMapNodeAnim`, the comparison tree `synthetic:sw_fallmem:agbcc`.
268
268
  | { k: 'switch'; scrutinee: Expr; cases: SwitchCase[]; default?: Stmt[]; defaultAt?: number }
269
- | { k: 'return'; value?: Expr };
269
+ // `unspelled` the assembly shows no `return` STATEMENT behind this node: the machine reached
270
+ // the epilogue here by falling into it, or on a conditional branch's own edge, never by the
271
+ // `b <epilogue>` a source `return;` compiles to (structure.ts sets it; the reading is stated
272
+ // there). A void return is then a no-op wherever nothing follows it, and `l3/tailret.ts` drops
273
+ // it — which is not cosmetic: unoptimised code gives every source `return;` a branch of its own,
274
+ // so the redundant spelling costs bytes. Advice about SPELLING; nothing may read it as semantics.
275
+ | { k: 'return'; value?: Expr; unspelled?: true };
270
276
 
271
277
  /** One arm of a `switch`. `values` stacks multiple `case K:` labels onto one body (`case 1: case 2:`).
272
278
  * `fallsThrough` true ⇒ the body flows into the NEXT arm (no `break;`); see the non-neutrality note. */
@@ -725,6 +731,44 @@ export function stmtChildren(s: Stmt): Stmt[] {
725
731
  }
726
732
  }
727
733
 
734
+ /** A statement that REPEATS what it contains — what {@link isLoop} narrows to.
735
+ *
736
+ * Derived STRUCTURALLY, not from a second list of kinds: a repeating statement is exactly one that
737
+ * carries both a `cond` and a `body`, and no other `Stmt` carries both (`if` has `cond` without
738
+ * `body`, `switch` has neither). A new repeating kind therefore joins this type by construction,
739
+ * where a hand-written kind list falls out of step SILENTLY — TypeScript does not check a
740
+ * predicate's body against the type it asserts. */
741
+ export type Loop = Extract<Stmt, { cond: Expr; body: Stmt[] }>;
742
+
743
+ /** Does this statement REPEAT what it contains? Its body and its own condition run once per
744
+ * iteration, which is the fact half of `l3/` asks about: a hoist out of one is loop-invariant
745
+ * code motion, a copy inside one re-runs, and a live range that crosses one is under pressure.
746
+ *
747
+ * It lives here, exhaustive and with no `default`, for the reason {@link stmtLists} does: a new
748
+ * `Stmt` kind that repeats is then a compile error at ONE site rather than a silent `false` in
749
+ * each caller's own hand-spelled kind test. It NARROWS to {@link Loop} rather than returning
750
+ * `boolean`, because a caller that reaches `body` afterwards cannot use a `boolean` one and spells
751
+ * the kind test out again instead. `backend/pascal.ts` is where that costs correctness: its
752
+ * `hasReturn` walk decides a LOUD failure — Pascal's `case-of` cannot spell an early `return` — so
753
+ * a repeating kind read as a non-loop turns the refusal into an emitted miscompile. */
754
+ export function isLoop(s: Stmt): s is Loop {
755
+ switch (s.k) {
756
+ case 'while':
757
+ case 'dowhile':
758
+ case 'for':
759
+ return true;
760
+ case 'if':
761
+ case 'switch':
762
+ case 'assign':
763
+ case 'store':
764
+ case 'exprstmt':
765
+ case 'return':
766
+ case 'break':
767
+ case 'continue':
768
+ return false;
769
+ }
770
+ }
771
+
728
772
  /** The nested statement LISTS of a statement — the SCOPES it opens.
729
773
  *
730
774
  * Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
package/src/l3/basecse.ts CHANGED
@@ -108,7 +108,7 @@
108
108
  import { assertHoistsDominate } from '../contracts';
109
109
  import { type IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
110
110
  import type { Expr, SFn, Stmt } from './ast';
111
- import { exprChildren, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
111
+ import { exprChildren, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
112
112
  import { type Gate, ablateHeuristic, firstRejection } from './gates';
113
113
  import { type BaseInit, type HoistPlacement, nameAllocator, placeBaseLocals } from './hoist';
114
114
 
@@ -240,7 +240,7 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
240
240
  for (const s of stmts) {
241
241
  // A loop's OWN condition (`stmtExprs` of a while/do-while/for) runs every iteration, so a base
242
242
  // there is loop-invariant just like a body use — visit it with `nested`, not the outer flag.
243
- const nested = loop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
243
+ const nested = loop || isLoop(s);
244
244
  for (const e of stmtExprs(s)) {
245
245
  visitExpr(e, nested);
246
246
  }
@@ -6,18 +6,15 @@
6
6
  // TWO admission paths live here, each with its own gate table and its own reading of loops. The
7
7
  // SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
8
8
  // loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
9
- // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one `if` picks
10
- // between them, so it asks only whether ANY loop encloses that `if` — a second entry breaks the
11
- // argument however the arms' own loops relate. `coalesceCandidates` offers both.
9
+ // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one BRANCH picks
10
+ // between them an `if`'s two arms or a `switch`'s case bodies alike so it asks only whether ANY
11
+ // loop encloses that branch (a second entry breaks the argument however the arms' own loops relate)
12
+ // and whether fall-through joins the two arms onto one path. `coalesceCandidates` offers both.
12
13
  import { typeToString } from '../ir/types';
13
14
  import type { Expr, SFn, Stmt } from './ast';
14
- import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
15
+ import { exprChildren, isLoop, mapExprChildren, stmtChildren, stmtExprs } from './ast';
15
16
  import { type Gate, firstRejection } from './gates';
16
17
 
17
- /** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
18
- * walk and the arm path's `visit`. */
19
- const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
20
-
21
18
  function namesIn(e: Expr, out: Set<string>): void {
22
19
  // `addr` names a GLOBAL (`&gSym`) or a LOCAL — the structurer renders an `laddr` frame object
23
20
  // as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
@@ -325,8 +322,11 @@ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SF
325
322
  export interface ArmPair {
326
323
  a: string;
327
324
  b: string;
328
- /** the confining `if` has a loop ancestor, so it can run more than once */
325
+ /** the confining branch has a loop ancestor, so it can run more than once */
329
326
  ifInLoop: boolean;
327
+ /** control can run from one of the two arms INTO the other — `switch` fall-through, the one
328
+ * way two arms of a branch land on a single path. Always false for an `if`. */
329
+ armsJoined: boolean;
330
330
  sameType: boolean;
331
331
  /** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
332
332
  eitherIsVolatile: boolean;
@@ -341,12 +341,13 @@ export interface ArmPair {
341
341
  * different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
342
342
  * so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
343
343
  * there. Two locals confined to
344
- * OPPOSITE arms of one `if` never coexist at runtime: the `if` picks one arm, so no read of either
345
- * can observe the other's write — no liveness reasoning needed. That argument is exactly what the
346
- * `loop` gate here protects: a loop ancestor re-enters the `if`, later entries can take the other
347
- * arm, and a value written on one visit becomes readable on the next. Note this gate wants ANY
348
- * enclosing loop, not the span model's shared-loop rule: never-coexisting is a claim about one
349
- * entry, so a second entry breaks it however the two arms' loops relate. */
344
+ * DIFFERENT arms of one branch never coexist at runtime: the branch picks one arm, so no read of
345
+ * either can observe the other's write — no liveness reasoning needed. `branchArms` says what an
346
+ * arm is, and the argument holds for a `switch`'s case bodies exactly as it does for an `if`'s two.
347
+ *
348
+ * Two gates protect it, one per way it fails. `loop` covers a SECOND ENTRY, which is why it wants
349
+ * ANY enclosing loop rather than the span model's shared-loop rule: never-coexisting is a claim
350
+ * about one entry. `fall-through` covers the failure WITHIN one entry. */
350
351
  export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
351
352
  {
352
353
  id: 'type',
@@ -363,11 +364,18 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
363
364
  },
364
365
  {
365
366
  id: 'loop',
366
- why: 'a loop ancestor re-enters the if, so opposite arms both run and a value could cross',
367
+ why: 'a loop ancestor re-enters the branch, so different arms both run and a value could cross',
367
368
  sound: true,
368
- guardedBy: 'coalesce.test.ts: an in-loop if never admits its arm pair',
369
+ guardedBy: 'coalesce.test.ts: never admits its arm pair',
369
370
  rejects: (c) => c.ifInLoop,
370
371
  },
372
+ {
373
+ id: 'fall-through',
374
+ why: 'a case that runs on into the other arm puts both locals on one path, so they coexist',
375
+ sound: true,
376
+ guardedBy: 'coalesce.test.ts: two arms joined by fall-through never merge',
377
+ rejects: (c) => c.armsJoined,
378
+ },
371
379
  {
372
380
  id: 'arm-init',
373
381
  why: 'a local its arm does not first set to a constant is one the compiler had a reason to keep apart',
@@ -477,6 +485,44 @@ function mentionIndex(): {
477
485
  return { mentionsOf, mentionsUnder, firstMention };
478
486
  }
479
487
 
488
+ /** The MUTUALLY EXCLUSIVE arms of a branching statement, and which pairs of them control can
489
+ * nevertheless run through together — the one shape both admission sites read, so `if` and
490
+ * `switch` are one rule here rather than two walkers.
491
+ *
492
+ * An `if` has exactly two arms and no way to reach one from the other. A `switch`'s arms are its
493
+ * `case` bodies, and FALL-THROUGH is the one way two of them land on a single path: arm `i` runs
494
+ * into arm `j` exactly when every arm from `i` up to `j` falls through, so the reach is the
495
+ * TRANSITIVE chain and not merely the adjacent pair.
496
+ *
497
+ * ITS REACH, MEASURED, because a soundness argument with no corpus witness should say so. Counted
498
+ * 2026-09-19 with a throwaway counter in this walk, over `pnpm bench sweep --fan --map-modes
499
+ * harness,nomap` — 2,396 records over the 1,198-row corpus: the switch half ADMITS 192 pairs, all
500
+ * on `pokeemerald:SetMauvilleOldManLanguage:agbcc` and none on any other function. Across all five
501
+ * gates, `arm-init` refuses 4,024 and `type` 1,346, while `volatile`, `loop` and `fall-through`
502
+ * refuse **zero** — so what bounds this extension corpus-wide is a COST gate, while both SOUND
503
+ * rules are witnessed only by the fixtures in `coalesce.test.ts`. Re-take the count rather than
504
+ * quoting it: a corpus that grows falsifies the number, not the argument.
505
+ *
506
+ * A `switch`'s `default` body is deliberately NOT an arm. `defaultAt` may place the label between
507
+ * case labels, where the body is reachable both by dispatch and by running on into the arm below
508
+ * it — a path `fallsThrough` does not describe, because the flag indexes the `cases` array the
509
+ * label does not sit in. Rather than reason about a position this file cannot see, no pair
510
+ * involving the default is offered: a merge withheld is a candidate missed, a merge admitted on a
511
+ * path that exists is a wrong answer. */
512
+ const branchArms = (st: Stmt): { arms: Stmt[][]; joined: (i: number, j: number) => boolean } | null => {
513
+ if (st.k === 'if' && st.then.length && st.else.length) {
514
+ return { arms: [st.then, st.else], joined: () => false };
515
+ }
516
+ if (st.k === 'switch') {
517
+ const { cases } = st;
518
+ return {
519
+ arms: cases.map((c) => c.body),
520
+ joined: (i, j) => cases.slice(Math.min(i, j), Math.max(i, j)).every((c) => c.fallsThrough),
521
+ };
522
+ }
523
+ return null;
524
+ };
525
+
480
526
  /** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
481
527
  * same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
482
528
  export function armDisjointUnder(
@@ -498,44 +544,55 @@ export function armDisjointUnder(
498
544
  const l = locals.get(n);
499
545
  return l !== undefined && isVolatileLocal(l);
500
546
  };
547
+ // locals only, and never a name that is ALSO a param — the span path holds the same belief as a
548
+ // gate, and a local shadowing a param would let rename() rewrite the param's own mentions
549
+ const confined = (m: Map<string, number>): string[] =>
550
+ [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
501
551
  const visit = (stmts: Stmt[], inLoop: boolean): void => {
502
552
  for (const st of stmts) {
503
- if (st.k === 'if' && st.then.length && st.else.length) {
504
- const thenM = mentionsOf(st.then);
505
- const elseM = mentionsOf(st.else);
506
- // locals only, and never a name that is ALSO a param — the span path holds the same
507
- // belief as a gate, and a local shadowing a param would let rename() rewrite the param's
508
- // own mentions
509
- const confined = (m: Map<string, number>): string[] =>
510
- [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
511
- for (const a of confined(thenM)) {
512
- for (const b of confined(elseM)) {
513
- // the survivor is the earlier declaration, matching how a shared source local reads.
514
- //
515
- // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into the
516
- // target's frame order at EMIT time (l3/slotorder.ts), after this pass, so `declIdx`
517
- // is the naming walk's order and the choice means "the earlier declaration in the
518
- // source asmlift recovered". Ordering the list any earlier would silently change which
519
- // local survives every arm-disjoint merge on a function whose frame order disagrees
520
- // with its declaration order — exactly the population the ordering exists for.
521
- const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
522
- const refused = firstRejection(gates, {
523
- a: gone,
524
- b: kept,
525
- ifInLoop: inLoop,
526
- sameType: typeOf.get(a) === typeOf.get(b),
527
- eitherIsVolatile: isVolatile(a) || isVolatile(b),
528
- bothArmConstInit:
529
- firstMention(st.then, a) === 'const-write' && firstMention(st.else, b) === 'const-write',
530
- });
531
- if (refused !== null) {
532
- refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
533
- continue;
553
+ const branch = branchArms(st);
554
+ if (branch !== null) {
555
+ const { arms, joined } = branch;
556
+ const confinedIn = arms.map((body) => confined(mentionsOf(body)));
557
+ for (let i = 0; i < arms.length; i++) {
558
+ for (let j = i + 1; j < arms.length; j++) {
559
+ for (const a of confinedIn[i]) {
560
+ for (const b of confinedIn[j]) {
561
+ // the survivor is the earlier declaration, matching how a shared source local
562
+ // reads.
563
+ //
564
+ // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into
565
+ // the target's frame order at EMIT time (l3/slotorder.ts), after this pass, so
566
+ // `declIdx` is the naming walk's order and the choice means "the earlier
567
+ // declaration in the source asmlift recovered". Ordering the list any earlier
568
+ // would silently change which local survives every arm-disjoint merge on a
569
+ // function whose frame order disagrees with its declaration order exactly the
570
+ // population the ordering exists for.
571
+ const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
572
+ const refused = firstRejection(gates, {
573
+ a: gone,
574
+ b: kept,
575
+ ifInLoop: inLoop,
576
+ armsJoined: joined(i, j),
577
+ sameType: typeOf.get(a) === typeOf.get(b),
578
+ eitherIsVolatile: isVolatile(a) || isVolatile(b),
579
+ bothArmConstInit:
580
+ firstMention(arms[i], a) === 'const-write' && firstMention(arms[j], b) === 'const-write',
581
+ });
582
+ if (refused !== null) {
583
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
584
+ continue;
585
+ }
586
+ out.push({
587
+ merged: `${gone}-${kept}`,
588
+ sfn: {
589
+ ...sfn,
590
+ body: rename(sfn.body, gone, kept),
591
+ locals: localsAfterMerge(sfn.locals, gone, kept),
592
+ },
593
+ });
594
+ }
534
595
  }
535
- out.push({
536
- merged: `${gone}-${kept}`,
537
- sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
538
- });
539
596
  }
540
597
  }
541
598
  }
@@ -69,7 +69,7 @@
69
69
  import { assertHoistsDominate } from '../contracts';
70
70
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
71
71
  import type { Expr, SFn, Stmt } from './ast';
72
- import { mapExprChildren, stmtExprs, stmtLists } from './ast';
72
+ import { isLoop, mapExprChildren, stmtExprs, stmtLists } from './ast';
73
73
  import { type Gate, ablateHeuristic, firstRejection } from './gates';
74
74
  import { nameAllocator, takenNames } from './hoist';
75
75
  import { addressableGlobals } from './storage';
@@ -227,7 +227,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
227
227
  };
228
228
  for (const [i, s] of body.entries()) {
229
229
  at = i;
230
- const isLoop = s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
230
+ const repeats = isLoop(s);
231
231
  // A loop's OWN condition runs every iteration — a base there is loop-invariant exactly as a
232
232
  // body use is, and it lives at THIS list, which does not. basecse.ts and argbase.ts treat the
233
233
  // CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
@@ -235,7 +235,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
235
235
  // it at the enclosing cadence, which is the truthful reading — it runs once. Recorded because
236
236
  // the divergence is real and an extraction has to pick one; both readings are pinned in
237
237
  // test/addr-placement.test.ts so the pick is deliberate rather than whichever survives.
238
- stmtExprs(s).forEach((e) => visit(e, isLoop));
238
+ stmtExprs(s).forEach((e) => visit(e, repeats));
239
239
  if (s.k === 'for') {
240
240
  // `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
241
241
  // reaches only its own expressions while `rewriteStmt` descends into any nested list — the
@@ -257,7 +257,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
257
257
  stmtExprs(s.inc).forEach((e) => visit(e, true));
258
258
  }
259
259
  for (const child of stmtLists(s)) {
260
- collect(child, [...path, child], [...loop, isLoop], [...idxPath, i], st);
260
+ collect(child, [...path, child], [...loop, repeats], [...idxPath, i], st);
261
261
  }
262
262
  }
263
263
  }