@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -15,9 +15,10 @@
15
15
  // computation via read/writeVar, push its terminator op last (successors referencing
16
16
  // `irBlocks`, args left empty — phi wiring appends them), then call `markFilled(b)`. When all
17
17
  // blocks are filled, call `finish()` to remove trivial phis.
18
- import { Block, Fn, Value, mkValue } from '../ir/core';
19
- import { simplifyTrivialPhis } from '../ir/simplify';
18
+ import { Block, Fn, Op, type SlotHomes, Value, type WriteOrder, mkOp, mkValue } from '../ir/core';
19
+ import { pruneDeadParams, simplifyTrivialPhis } from '../ir/simplify';
20
20
  import { T } from '../ir/types';
21
+ import { FrontendUnsupportedError } from './errors';
21
22
 
22
23
  export interface SsaBuilder {
23
24
  fn: Fn;
@@ -28,36 +29,301 @@ export interface SsaBuilder {
28
29
  writeVar(reg: string, b: number, v: Value): void;
29
30
  /** Mark block `b` fully emitted (terminator pushed); seals any now-ready successors. */
30
31
  markFilled(b: number): void;
31
- /** Live-in parameter value → the ABI register it arrived on (for calling-convention order). */
32
+ /** Live-in parameter value → the key it arrived on (for calling-convention order). Usually an
33
+ * ABI register name, but a frontend's virtual key (see the module header) ranks here too. */
32
34
  paramReg: Map<Value, string>;
35
+ /** Assert that block `b` takes a parameter for `key`, whether or not anything reads it.
36
+ *
37
+ * `readVar` cannot express this. It asks "what value does `key` hold here?", so a key the block
38
+ * DEFINES before any read answers with that local definition and no parameter is created — to it
39
+ * "never read" and "written before first read" are the same thing. When a calling convention
40
+ * proves an argument exists, that is an obligation on the SIGNATURE, independent of whether the
41
+ * body happens to use it, so it needs its own verb.
42
+ *
43
+ * Never touches the block's definitions: the parameter is added and left unused, so any local
44
+ * value already flowing keeps flowing. Only meaningful on a block with no predecessors —
45
+ * elsewhere a parameter is a phi whose position is aligned with its predecessors' terminator
46
+ * args, and appending an unpaired one would corrupt that. */
47
+ ensureParam(key: string, b: number): void;
33
48
  /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic). */
34
49
  hasReachingDef(reg: string, b: number, seen?: Set<number>): boolean;
35
- /** Remove trivial phis; call once every block is filled. */
50
+ /** Record that block `b` makes a call HERE: the ABI's caller-saved registers stop being ones the
51
+ * caller set up. Call it AFTER `recordGuessedCall` for the same instruction, and after writing
52
+ * the call's own result — the result is the CALLEE's, so it must not count as caller-side
53
+ * argument setup for whatever call comes next. */
54
+ noteCall(b: number): void;
55
+ /** Register a `call` op whose arity was GUESSED (no prototype), so `finish` can cut it back to the
56
+ * argument registers that were actually set up on every path (see {@link trimClobberedCallArgs}).
57
+ * `abi` is the target's argument-register order and its return register. */
58
+ recordGuessedCall(op: Op, b: number, abi: { argRegs: string[]; returnReg: string }): void;
59
+ /** Remove trivial phis and enforce the frontend's postconditions; call once every block is
60
+ * filled. Throws FrontendUnsupportedError if a stack slot escaped as an entry parameter. */
36
61
  finish(): void;
37
62
  }
38
63
 
39
64
  /** `preds` is per-EDGE (see the module header): one entry per CFG edge into each block. */
40
- export function makeSsaBuilder(name: string, blockCount: number, preds: number[][]): SsaBuilder {
65
+ // VARIABLE NAMES ARE NOT ALWAYS MACHINE REGISTERS. `readVar`/`writeVar` key on an arbitrary string,
66
+ // and frontends mint VIRTUAL keys for storage the ISA has no register for — MIPS `sp@<off>` for a
67
+ // stack slot (frontend/mips.ts), Thumb `@sarg<k>` for an incoming stack argument (frontend/thumb.ts).
68
+ // A virtual key must be outside its ISA's register grammar so it cannot collide with a real one, and
69
+ // a key read with no reaching def becomes a function PARAMETER by the live-in path below — which is
70
+ // how both of those capabilities get their parameters without a new opcode or pass.
71
+ /** What a def-less live-in MEANS here, in two coordinate systems — the frame in slot-key offsets,
72
+ * the register file by key. RANGES and LISTS rather than a verdict, so the classification below is
73
+ * checkable here: a frontend that is wrong about its own frame gets refused instead of believed,
74
+ * and a range that collapses to empty (an unmeasurable frame) stops claiming anything on its own.
75
+ * Ghidra carries the same partition as compiler-spec data (`<localrange>`, stack `<pentry>`) read
76
+ * by architecture-neutral code. */
77
+ export interface LiveInModel {
78
+ /** Storage this function owns as LOCALS ⇒ a def-less read is an uninitialised local. `[from, to)`.
79
+ *
80
+ * Asserts more than ownership: that this function's own stores are the ONLY writer. An address
81
+ * into the frame that escapes to anything which could write it stops that holding, and the
82
+ * retraction is the frontend's obligation (frontend/thumb.ts, after the frame-object audit). */
83
+ ownedLocals?: { from: number; to: number };
84
+ /** Storage this function DECLARES as locals ⇒ a `[sp,#k]` spill here is a DECLARATION RANK
85
+ * (`ir/core.ts` `SlotHomes`, ordered by `l3/slotorder.ts`). `[from, to)`.
86
+ *
87
+ * WHY THIS IS NOT `ownedLocals`, and the distinction is the whole point of the field. Owning
88
+ * storage and declaring it are different claims, and agbcc's frame contains storage it owns
89
+ * and does not declare: `ACCUMULATE_OUTGOING_ARGS` puts the OUTGOING STACK-ARGUMENT area at
90
+ * the BOTTOM of `localArea` (frontend/thumb.ts says so at its own decline), so `[0, localArea)`
91
+ * admits argument slots. A def-less read of one is still an uninitialised local — `ownedLocals`
92
+ * is right for that question — but its offset is an ABI position, not an `expand_decl` rank,
93
+ * and ranking a declaration list by it would be wrong with no diagnostic.
94
+ *
95
+ * TODAY THE TWO RANGES COINCIDE UNDER THUMB, AND THAT IS DELEGATED, NOT PROVED. What keeps
96
+ * argument slots out of `SlotHomes` is `prefixStored` (frontend/thumb.ts): a function whose
97
+ * frame has an outgoing area DECLINES before it reaches here, so every function that does
98
+ * reach here has none. That guard's own comment says "Neither is sound alone and the pair is
99
+ * not either", and lifting it is a named next step — so this field exists to make the
100
+ * dependency TYPED and LOCAL rather than implicit and cross-module. Whoever lifts that decline
101
+ * must narrow this range above the argument block; leaving it equal to `ownedLocals` would
102
+ * start minting declaration ranks out of argument slots silently.
103
+ *
104
+ * The class is populated, not hypothetical. Over a sweep of every sa3 and klonoa listing, of
105
+ * 2,001 lifted real agbcc functions 27 carry any L1 slot home, 12 of those also CALL, and 11 of
106
+ * those carry a home at offset 0 — the exact offset `prefixStored` encodes as where an argument
107
+ * block starts (`PackSaveSector` homes [0,4,…,72], `modf` [0,4,…,36], `RenderDialogSprites`
108
+ * [0,4,…,36], and eight more). None reaches the ordering today, for an unrelated reason
109
+ * (`l3/slotorder.ts`'s REACH note), so nothing downstream is guarding this.
110
+ *
111
+ * ABSENT ⇒ NO STAMP. MIPS and PPC declare no frame partition at all, so they stamp nothing,
112
+ * which is the refusing direction. */
113
+ declaredLocals?: { from: number; to: number };
114
+ /** Storage the CALLER wrote — incoming stack arguments ⇒ a def-less read is a parameter.
115
+ * `[from, to)`. O32's register-parameter home area belongs to NEITHER range: caller-owned, but
116
+ * not an argument. */
117
+ callerParams?: { from: number; to: number };
118
+ /** Registers a def-less read of which is an uninitialised local the compiler put in a register.
119
+ * TWO facts, and the frontend owes both: the ABI passes no argument there (`target.nonArgRegs`,
120
+ * so no caller could have handed a value over, however early the read happens) AND this function
121
+ * saved the register (so it is one the compiler was free to home a local in). The ABI half alone
122
+ * describes the CALLER, and asm that follows no ABI — hand-written, or a mid-function fragment,
123
+ * which klonoa's `bl`-as-a-long-branch splits produce for real — is genuinely handed live values
124
+ * in registers it never saved. Passing the ABI list unfiltered cost the MP2K engine's
125
+ * `ChnVolSetAsm` its two-pointer signature and left it storing through `uninit_r4`, silently.
126
+ *
127
+ * The save is a MEASUREMENT, like the frame's, and belongs to whoever can make it — Thumb reads
128
+ * the leading push run (`savedRegs`); a frontend that cannot measure it passes nothing here and
129
+ * keeps the parameter it would have got anyway.
130
+ *
131
+ * The frame's sole-writer obligation has no counterpart here and needs none: a register has no
132
+ * address, so nothing outside this function can name it and there is no escape to retract.
133
+ *
134
+ * LISTED, not derived as "everything outside argRegs", because the complement contains the
135
+ * VIRTUAL keys too (`@sarg<k>` — an incoming stack argument, which really is a parameter), and a
136
+ * rule that had to exclude them would be reading a grammar this module does not own. A register
137
+ * spelling nobody listed keeps its existing treatment, so the list is safe to grow.
138
+ *
139
+ * Declaring this obliges `argRegs` below, and the two must be DISJOINT. */
140
+ uninitRegs?: readonly string[];
141
+ /** Registers the ABI DOES pass arguments in — the other side of the register partition, and the
142
+ * only reason the side above is checkable rather than believed. The frame coordinate declares
143
+ * both of its sides and refuses an offset in neither; this one declares both and refuses a key
144
+ * in BOTH, which is the same move.
145
+ *
146
+ * Without it the whole contract rests on one hand-written list in target.ts being right, in a
147
+ * file whose idiom is "a compiler fact is one field": spelling `r1` where `r11` was meant
148
+ * deletes a parameter and emits `s32 uninit_r1;` in its place, with no diagnostic anywhere.
149
+ * `readRecursive` cannot catch that on its own — it never sees the argument registers, because
150
+ * a read of one takes the parameter path by falling through every other case. */
151
+ argRegs?: readonly string[];
152
+ }
153
+
154
+ /** The register partition's postcondition, checked at the point of USE. `uninitRegs` asserts "no
155
+ * caller could have handed a value over in these"; `argRegs` is the set of registers a caller
156
+ * hands values over in. A key in both is a target that contradicts itself, and a `uninitRegs` with
157
+ * no `argRegs` beside it is one whose assertion nothing can check — both refuse rather than
158
+ * silently reclassify an argument as an uninitialised local. */
159
+ function checkedLiveInModel(fnName: string, m: LiveInModel): LiveInModel {
160
+ if (m.uninitRegs === undefined) {
161
+ return m;
162
+ }
163
+ const args = m.argRegs;
164
+ if (args === undefined) {
165
+ throw new Error(
166
+ `lifting '${fnName}': the live-in model lists registers the ABI does not pass arguments in ` +
167
+ `but not the ones it does, so nothing can check the two agree`,
168
+ );
169
+ }
170
+ const both = m.uninitRegs.filter((r) => args.includes(r));
171
+ if (both.length > 0) {
172
+ throw new Error(
173
+ `lifting '${fnName}': the live-in model lists ${both.join(', ')} as BOTH an argument register ` +
174
+ `and one the ABI does not pass arguments in`,
175
+ );
176
+ }
177
+ return m;
178
+ }
179
+
180
+ export function makeSsaBuilder(
181
+ name: string,
182
+ blockCount: number,
183
+ preds: number[][],
184
+ /** A supplier because half the partition is MEASURED rather than declared: Thumb's local area
185
+ * comes from a prologue walk that runs after this call. Evaluated once, on first use. Omitted ⇒
186
+ * no partition is claimed, so every slot refuses and every register is a parameter. */
187
+ liveInOf: () => LiveInModel = () => ({}),
188
+ ): SsaBuilder {
189
+ let modelMemo: LiveInModel | null = null;
190
+ // Checked ONCE, where the model is materialised — every function with a parameter reads a
191
+ // register def-lessly, so this runs on effectively every lift rather than only on the rare
192
+ // function that reads the mis-listed register. A contradictory or half-declared partition is a
193
+ // bug in the TARGET, not an unliftable function, so it throws a plain Error: a decline would
194
+ // report the target's typo as a property of the input, once per function, forever.
195
+ const model = (): LiveInModel => (modelMemo ??= checkedLiveInModel(name, liveInOf()));
196
+ const inRange = (off: number, r?: { from: number; to: number }) => r !== undefined && off >= r.from && off < r.to;
41
197
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
42
- const fn: Fn = { name, blocks: irBlocks };
198
+ // `writeOrder` and `slotHomes` are filled in below, where the builder's counters live.
199
+ const fn: Fn = { name, blocks: irBlocks, writeOrder: undefined, slotHomes: undefined };
43
200
 
44
201
  const defs: Array<Map<string, Value>> = irBlocks.map(() => new Map());
45
202
  const sealed: boolean[] = irBlocks.map(() => false);
46
203
  const filled: boolean[] = irBlocks.map(() => false);
47
204
  const incompletePhis: Array<Map<string, Value>> = irBlocks.map(() => new Map());
48
205
  const phiBlock = new Map<Value, number>();
206
+ // The key each phi stands for. `paramReg` covers live-ins only, so without this a slot that
207
+ // arrives as a PHI — which is what happens when the entry block is itself a loop header — is
208
+ // invisible to the escape check below. Braun's construction gives no other way to tell.
209
+ const phiKey = new Map<Value, string>();
49
210
  const paramReg = new Map<Value, string>();
211
+ // Parameters created by ensureParam that nothing has read yet. They are deliberately NOT in
212
+ // `defs`: a parameter asserted because a calling convention proves it exists is not evidence that
213
+ // a VALUE reaches anything, and writing one into `defs` would say it does. That distinction is
214
+ // load-bearing — `hasReachingDef` feeds `fallbackArgc`, so a def here silently raises the guessed
215
+ // arity of every prototype-less call in the function, making it pass registers the calling block
216
+ // never set up (`unknown(1)` became `unknown(1, a1, a2, a3)`). The first read adopts the value
217
+ // from here instead of minting a second parameter for the same key.
218
+ const obligedParams: Array<Map<string, Value>> = irBlocks.map(() => new Map());
50
219
 
51
220
  // `preds` lists an entry per CFG EDGE; these are the distinct predecessor BLOCKS.
52
221
  const distinctPreds = (b: number): number[] => [...new Set(preds[b])];
53
222
 
54
- const writeVar = (reg: string, b: number, v: Value) => defs[b].set(reg, v);
223
+ // CALLER-SAVED CLOBBER, for guessed call arities (see trimClobberedCallArgs). Tracked HERE
224
+ // because every register write in every frontend already goes through `writeVar`: a frontend
225
+ // that gathered this itself would be sound only while it remembered to route each write past a
226
+ // wrapper, and a MISSED write under-counts an arity — which drops a real argument silently.
227
+ const writtenSinceCall: Array<Set<string>> = irBlocks.map(() => new Set());
228
+ const callsIn = new Set<number>();
229
+ const guessedCalls: GuessedCallSite[] = [];
230
+ let abiSeen: { argRegs: string[]; returnReg: string } = { argRegs: [], returnReg: '' };
231
+
232
+ // WRITE ORDER (ir/core.ts `WriteOrder`). Measured here for the same reason the clobber set is:
233
+ // every register write in every frontend already goes through `writeVar`, and a delay-slot write
234
+ // is decoded before its branch is emitted, so the ordinal is the machine's own program order on
235
+ // every ISA with no frontend code. Per block, because the consumer asks a per-EDGE question.
236
+ const writeCount: number[] = irBlocks.map(() => 0);
237
+ const lastWriteAt: Array<Map<string, number>> = irBlocks.map(() => new Map());
238
+ const writeOrder: WriteOrder = { lastWrite: new Map(), writes: new Map() };
239
+ fn.writeOrder = writeOrder;
240
+
241
+ // SLOT HOMES (ir/core.ts `SlotHomes`). Measured HERE, in the shared builder, for the same
242
+ // reason the clobber set and the write order are: BOTH frontends already spell a word spill as
243
+ // a write to the key `sp@k` (`stackSlotKey`, below), so the frontend supplies the coordinate
244
+ // and one rule applies it — a per-frontend stamp would be right only while each remembered to
245
+ // route every slot write past a wrapper, and a missed write is a local with no frame order.
246
+ // Empty rather than absent on a function that spills nothing: this builder measured it.
247
+ const slotHomes: SlotHomes = new Map();
248
+ fn.slotHomes = slotHomes;
249
+ const noteSlotHome = (key: string, v: Value) => {
250
+ const off = slotKeyOffset(key);
251
+ if (off === null) {
252
+ return; // an ordinary register: no frame coordinate exists
253
+ }
254
+ // THE KEY SPELLING CANNOT DECIDE THIS, exactly as `readRecursive` says below of a def-less
255
+ // read: `sp@40` is a local on one ABI and the caller's fifth argument on another. So the stamp
256
+ // asks the frontend for a partition and refuses where no answer exists. The two frontends
257
+ // differ here and the refusal is what makes that safe: Thumb declares a range; MIPS declares NO
258
+ // partition (frontend/mips.ts: `addiu sp,sp,±N` is transparent, so its slot keys span O32's
259
+ // caller-owned register-parameter home area `[0,16)` and the incoming stack arguments above
260
+ // it), and PPC declares none either — so both stamp nothing rather than reporting the caller's
261
+ // frame as this function's declaration ranks.
262
+ //
263
+ // AND IT ASKS `declaredLocals`, NOT `ownedLocals`, which is a different question with a
264
+ // different answer under agbcc — the outgoing stack-argument area is storage the function owns
265
+ // and does not declare. The two ranges are equal under Thumb today only because `prefixStored`
266
+ // declines every function with an outgoing area; see `declaredLocals`' own doc for the
267
+ // measurement and for what lifting that decline obliges.
268
+ if (!inRange(off, model().declaredLocals)) {
269
+ return;
270
+ }
271
+ // ONE CLASS INSIDE THE PARTITION IS STILL NOT A DECLARATION RANK, and NOTHING HERE REFUSES IT.
272
+ // A stack AGGREGATE the frontend decomposed into per-word keys yields several `sp@k`s that are
273
+ // fields of ONE declared object, not several declared scalars — and it reaches the stamp
274
+ // because a non-address-taken array never mints an `laddr`, which is the only aggregate the
275
+ // structurer's `frame` refusal catches. It is the only class the ordering meets in the wild:
276
+ // over 2,463 real agbcc functions (158 sa3 + klonoa listings) exactly one carries two
277
+ // slot-carrying locals, sa3 `sub_80617E0`, whose [sp,#0]..[sp,#0xc] are the four words of
278
+ // `Vec2_32 sp00[2]` (its own preprocessed source declares it), with the only genuine reload
279
+ // spill at [sp,#0x10].
280
+ //
281
+ // That one is declined downstream — its words land under two names at offset 12, and
282
+ // `l3/slotorder.ts`'s injectivity refusal reads a duplicate as evidence reload did not produce
283
+ // — but the class is NOT covered by that refusal: an aggregate whose words reach L3 under
284
+ // distinct names at distinct offsets is injective and would be ordered. FLIP CONDITION: once
285
+ // stack-array recovery declares `sp00[2]`, ordering an aggregate against a reload spill by the
286
+ // minimum of its element offsets is WRONG — `assign_stack_local` runs before reload and puts
287
+ // every array below every spill slot regardless of declaration rank. The licence this
288
+ // capability rests on (reload hands a spilled pseudo its slot by `expand_decl` rank) is about
289
+ // separately declared SCALARS; intra-aggregate offsets are fixed by the aggregate's layout at
290
+ // expand time.
291
+ //
292
+ // UNION, not a choice (ir/core.ts `SlotHomes`): whether the earlier declaration rank is the
293
+ // lower or the higher offset is a per-COMPILER fact, and this builder is handed a name, a
294
+ // block count, a predecessor list and a live-in model — no target. `l3/slotorder.ts` reduces.
295
+ // A GUARD WITH NO CORPUS INHABITANT: over both benchmark tiers no value is ever written to two
296
+ // DIFFERENT slots, so the `else` below has never produced a set of size two on a real input.
297
+ const prev = slotHomes.get(v);
298
+ if (prev === undefined) {
299
+ slotHomes.set(v, new Set([off]));
300
+ } else {
301
+ prev.add(off);
302
+ }
303
+ };
304
+ const forgetOrder = (p: Value) => {
305
+ for (const m of writeOrder.lastWrite.values()) {
306
+ m.delete(p);
307
+ }
308
+ };
309
+
310
+ const writeVar = (reg: string, b: number, v: Value) => {
311
+ noteSlotHome(reg, v);
312
+ writtenSinceCall[b].add(reg);
313
+ defs[b].set(reg, v);
314
+ lastWriteAt[b].set(reg, writeCount[b]++);
315
+ };
55
316
  const readVar = (reg: string, b: number): Value => defs[b].get(reg) ?? readRecursive(reg, b);
56
317
 
57
318
  const newPhi = (reg: string, b: number): Value => {
58
319
  const phi = mkValue(T.unk(32));
59
320
  irBlocks[b].params.push(phi);
60
321
  phiBlock.set(phi, b);
322
+ phiKey.set(phi, reg);
323
+ // A slot that arrives as a PHI — a loop header reading back what an earlier iteration spilled
324
+ // — is the same frame coordinate under a block param, and the structurer names it like any
325
+ // other value, so it carries the home too.
326
+ noteSlotHome(reg, phi);
61
327
  defs[b].set(reg, phi); // set before wiring operands to break cycles
62
328
  return phi;
63
329
  };
@@ -73,7 +339,37 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
73
339
  // manufacture a join (and a phi) where there is none.
74
340
  const ps = distinctPreds(b);
75
341
  if (ps.length === 0) {
76
- // live-in with no predecessor: an incoming argument register → function parameter.
342
+ // A live-in with no predecessor is a value this function never produced: an incoming argument,
343
+ // or storage it allocated and never wrote. WHICH ONE is the partition's answer, in whichever
344
+ // coordinate the key names. The key spelling cannot decide a slot on its own — `sp@40` is a
345
+ // local on one ABI and the caller's fifth argument on another — so a slot in neither range is
346
+ // refused rather than guessed. A register is decided by the calling convention instead of by
347
+ // a measurement: a caller cannot pass a value in a register the ABI does not pass arguments
348
+ // in, so a read of one before any write is an uninitialised local.
349
+ const off = slotKeyOffset(reg);
350
+ if (off !== null && !inRange(off, model().ownedLocals) && !inRange(off, model().callerParams)) {
351
+ throw new FrontendUnsupportedError(
352
+ `cannot lift '${name}': ${reg} is read on a path that never stores it, and lies outside ` +
353
+ `this function's frame partition (uninitialised local, or storage it does not own) — not modelled`,
354
+ );
355
+ }
356
+ const uninitialised =
357
+ off !== null ? inRange(off, model().ownedLocals) : (model().uninitRegs?.includes(reg) ?? false);
358
+ if (uninitialised) {
359
+ const op = mkOp('undef', { results: [mkValue(T.unk(32))], attrs: { key: reg } });
360
+ irBlocks[b].ops.unshift(op); // ahead of everything in a block that nothing precedes
361
+ defs[b].set(reg, op.results[0]);
362
+ return op.results[0];
363
+ }
364
+ // an incoming argument register → function parameter.
365
+ // If one was already asserted for this key (ensureParam), adopt it — minting a second
366
+ // parameter for the same key would put the key in the signature twice.
367
+ const obliged = obligedParams[b].get(reg);
368
+ if (obliged !== undefined) {
369
+ obligedParams[b].delete(reg);
370
+ defs[b].set(reg, obliged);
371
+ return obliged;
372
+ }
77
373
  const p = mkValue(T.unk(32));
78
374
  irBlocks[b].params.push(p);
79
375
  defs[b].set(reg, p);
@@ -87,12 +383,21 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
87
383
  }
88
384
  // sealed join: create the phi and wire every predecessor's terminator arg now.
89
385
  const phi = newPhi(reg, b);
90
- addPhiOperands(reg, b);
386
+ addPhiOperands(reg, b, phi);
91
387
  return phi;
92
388
  };
93
- const addPhiOperands = (reg: string, b: number) => {
389
+ // The ONE point that knows the phi, its key and each predecessor together, which is what the
390
+ // write-order record is keyed by. `phi` is passed rather than looked up: by the time a deferred
391
+ // phi is wired the block may have written its key again, so `defs[b]` no longer names it.
392
+ const addPhiOperands = (reg: string, b: number, phi: Value) => {
94
393
  for (const p of distinctPreds(b)) {
95
394
  appendSuccessorArg(p, b, readVar(reg, p));
395
+ const at = lastWriteAt[p].get(reg);
396
+ if (at !== undefined) {
397
+ const rec = writeOrder.lastWrite.get(irBlocks[p]) ?? new Map<Value, number>();
398
+ rec.set(phi, at);
399
+ writeOrder.lastWrite.set(irBlocks[p], rec);
400
+ }
96
401
  }
97
402
  };
98
403
  // Append `arg` to EVERY successor edge of predecessor p that targets block b.
@@ -116,8 +421,8 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
116
421
  return;
117
422
  }
118
423
  sealed[b] = true; // set first: addPhiOperands may recurse back here
119
- for (const reg of incompletePhis[b].keys()) {
120
- addPhiOperands(reg, b);
424
+ for (const [reg, phi] of incompletePhis[b]) {
425
+ addPhiOperands(reg, b, phi);
121
426
  }
122
427
  incompletePhis[b].clear();
123
428
  };
@@ -130,6 +435,25 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
130
435
  };
131
436
  sealReadyBlocks(); // seals the entry (no predecessors) up front
132
437
 
438
+ // See the interface docs. Two cases, and the split is the whole point: when nothing defines the
439
+ // key, the ordinary live-in path already does exactly the right thing; when something does, a
440
+ // parameter still has to exist for the signature, and it must be added WITHOUT redirecting the
441
+ // dataflow to it.
442
+ const ensureParam = (key: string, b: number): void => {
443
+ if (preds[b].length > 0) {
444
+ return; // a parameter here is a phi; see the precondition on the interface
445
+ }
446
+ for (const p of irBlocks[b].params) {
447
+ if (paramReg.get(p) === key) {
448
+ return; // already a parameter, however it got there
449
+ }
450
+ }
451
+ const p = mkValue(T.unk(32));
452
+ irBlocks[b].params.push(p);
453
+ paramReg.set(p, key); // ranked by the ABI sort like any other parameter
454
+ obligedParams[b].set(key, p);
455
+ };
456
+
133
457
  const hasReachingDef = (reg: string, b: number, seen = new Set<number>()): boolean => {
134
458
  if (defs[b].has(reg)) {
135
459
  return true;
@@ -147,12 +471,102 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
147
471
  readVar,
148
472
  writeVar,
149
473
  paramReg,
474
+ ensureParam,
150
475
  hasReachingDef,
476
+ noteCall: (b: number) => {
477
+ callsIn.add(b);
478
+ // the callee clobbers the caller-saved registers, its own result register included — see
479
+ // the ordering contract on the interface
480
+ writtenSinceCall[b] = new Set();
481
+ },
482
+ recordGuessedCall: (op: Op, b: number, abi: { argRegs: string[]; returnReg: string }) => {
483
+ abiSeen = abi;
484
+ guessedCalls.push({
485
+ block: b,
486
+ op,
487
+ freshBefore: new Set(writtenSinceCall[b]),
488
+ afterCallInBlock: callsIn.has(b), // `noteCall` runs after this, so this means an EARLIER call
489
+ });
490
+ },
151
491
  markFilled: (b: number) => {
152
492
  filled[b] = true;
153
493
  sealReadyBlocks();
154
494
  },
155
- finish: () => simplifyTrivialPhis(fn, (p) => phiBlock.delete(p)),
495
+ finish: () => {
496
+ // Guessed arities counted argument registers by reaching definition alone; now that every
497
+ // block's calls are known, drop the ones an intervening call had already clobbered.
498
+ if (guessedCalls.length) {
499
+ const calleeResults = new Set<Value>();
500
+ for (const b of irBlocks) {
501
+ for (const op of b.ops) {
502
+ if (op.opcode === 'call') {
503
+ for (const r of op.results) {
504
+ calleeResults.add(r);
505
+ }
506
+ }
507
+ }
508
+ }
509
+ trimClobberedCallArgs({
510
+ argRegs: abiSeen.argRegs,
511
+ returnReg: abiSeen.returnReg,
512
+ calleeResults,
513
+ preds,
514
+ freshAtEnd: writtenSinceCall,
515
+ callsIn,
516
+ sites: guessedCalls,
517
+ });
518
+ }
519
+ irBlocks.forEach((blk, i) => writeOrder.writes.set(blk, writeCount[i]));
520
+ simplifyTrivialPhis(fn, (p) => {
521
+ phiBlock.delete(p);
522
+ phiKey.delete(p);
523
+ forgetOrder(p);
524
+ });
525
+ // Then the phis nothing reads at all — a register two paths leave holding different junk
526
+ // (a loop counter after its last use, a scratch the epilogue overwrites) still joins as a
527
+ // phi, and a dead phi is not junk downstream: its edge args become post-loop copies in the
528
+ // emitted C and block gates keyed on "this exit carries nothing". Order matters only for
529
+ // economy: trivial-phi removal can orphan a phi's last reader, never the reverse.
530
+ pruneDeadParams(fn, (p) => {
531
+ phiBlock.delete(p);
532
+ phiKey.delete(p);
533
+ forgetOrder(p);
534
+ });
535
+ // A STACK SLOT MAY NEVER LEAVE AS AN ENTRY PARAMETER. A slot is memory the function itself
536
+ // allocated, so its value can only come from a store the function made; arriving as a live-in
537
+ // instead means it was read on a path that never stored it, and the signature has grown an
538
+ // argument the function does not take, standing in for uninitialised stack.
539
+ //
540
+ // Checked here, of the FINISHED function, rather than as a precondition at each read. The
541
+ // per-read test available during construction (`hasReachingDef`) asks whether a store reaches
542
+ // on SOME path, which a diamond defeats; strengthening it to "every path" is not answerable
543
+ // mid-fill, because a loop's back-edge predecessor is not filled yet and the query would
544
+ // report "unassigned" for a slot initialised before the loop — the commonest real shape.
545
+ // Asking about the symptom instead costs one pass and cannot be defeated by fill order.
546
+ //
547
+ // It is total because in Braun's construction a value undefined on some path can surface only
548
+ // as a live-in of a block with no predecessors — and BOTH spellings of that are checked:
549
+ // `paramReg` for the live-in path, `phiKey` for the case where the entry block is itself a
550
+ // loop header and the fabricated value arrives as a phi instead. Missing the second is what
551
+ // let this survive on MIPS.
552
+ //
553
+ // In `finish()` and not a helper each frontend remembers to call: this is the frontend's only
554
+ // semantic postcondition, and a postcondition enforced by convention is not enforced.
555
+ for (const p of irBlocks[0].params) {
556
+ const key = paramReg.get(p) ?? phiKey.get(p);
557
+ // The SAME rule the mint site used, over the same ranges, so the two cannot disagree. A
558
+ // slot that reached the signature is either owned storage (which should have become an
559
+ // `undef`) or unclassified — both are bugs, and this is where a per-read test cannot be
560
+ // total, so it is asserted over the finished function.
561
+ const koff = key === undefined ? null : slotKeyOffset(key);
562
+ if (koff !== null && !inRange(koff, model().callerParams)) {
563
+ throw new FrontendUnsupportedError(
564
+ `cannot lift '${name}': ${key} is read on a path that never stores it ` +
565
+ `(partially-initialised local, or storage this function does not own) — not modelled`,
566
+ );
567
+ }
568
+ }
569
+ },
156
570
  };
157
571
  }
158
572
 
@@ -174,6 +588,211 @@ export function fallbackArgc(
174
588
  return n;
175
589
  }
176
590
 
591
+ /** One call site whose arity was GUESSED by {@link fallbackArgc}, with what the lifting scan saw
592
+ * of its own block up to that instruction. */
593
+ export interface GuessedCallSite {
594
+ block: number;
595
+ /** the `call` op — its operands are the guessed arguments, in argument-register order */
596
+ op: Op;
597
+ /** argument registers written between the last call in this block (or the block's start) and here */
598
+ freshBefore: Set<string>;
599
+ /** did this block already make a call before this one? */
600
+ afterCallInBlock: boolean;
601
+ }
602
+
603
+ export interface CallArgTrim {
604
+ argRegs: string[];
605
+ /** the ABI return register. Load-bearing only where it IS `argRegs[0]` (ARM r0, PPC r3) — that
606
+ * aliasing is what makes a callee's result indistinguishable from caller-side argument setup. */
607
+ returnReg: string;
608
+ /** every value a `call` op produced. Tells a callee's own return apart from a join that merely
609
+ * PASSES THROUGH one, which the register file cannot: both leave argument 0 unfresh. */
610
+ calleeResults: ReadonlySet<Value>;
611
+ /** one entry per CFG edge, as passed to {@link makeSsaBuilder} */
612
+ preds: number[][];
613
+ /** per block: the keys written since its LAST call (since its start if it makes none). Indexed by
614
+ * block, and it holds every key the builder saw, not only argument registers. */
615
+ freshAtEnd: Array<Set<string>>;
616
+ /** blocks that make at least one call */
617
+ callsIn: Set<number>;
618
+ sites: GuessedCallSite[];
619
+ }
620
+
621
+ /** Cut a GUESSED call arity down by the ABI's caller-saved clobber.
622
+ *
623
+ * `fallbackArgc` counts argument registers that merely have a reaching definition. A call clobbers
624
+ * r0..r3, so a definition the call sits between cannot be an argument the caller set up — correct
625
+ * compiled code would have re-materialized it. Counting it anyway INVENTS arguments
626
+ * (`m4aSongNumStart(0x89, 30, x, &g)` for a one-argument callee) — a hard compile error where the
627
+ * project's own header is in scope, and silently wrong code where C89's implicit declaration
628
+ * covers for it.
629
+ *
630
+ * SCOPE: this closes the arguments an intervening CALL disproves, which is the common case in real
631
+ * code. It does not close the rest — a dead value the compiler happened to leave in the next
632
+ * argument register with no call in between still reads as an argument, and nothing about the
633
+ * register file can say otherwise. A declared prototype closes those outright; short of one, the
634
+ * narrower reading is recorded here and offered as a ranked candidate ({@link narrowToSetupArgs}).
635
+ *
636
+ * A must-analysis: a register is FRESH at a point iff on EVERY path reaching it, it was written
637
+ * after the last call. The entry block starts all-fresh (those are the caller's own arguments).
638
+ * The result only ever SHRINKS an arity, but two of the shrinks are REFUSALS and not proofs, so a
639
+ * real argument CAN go with them: a fresh register above a hole stops the run (a 64-bit return
640
+ * occupies two registers and the frontend cannot express one, so the caller's r2 goes with the
641
+ * unfillable r1), and a callee's return read as the callee's own drops an argument a `g(f())`
642
+ * source did pass. A declared prototype is what closes either.
643
+ *
644
+ * Frontend-agnostic: the caller supplies what its own lifting scan observed, so nothing here
645
+ * re-derives which instruction writes which register. */
646
+ export function trimClobberedCallArgs(inp: CallArgTrim): void {
647
+ const { argRegs, returnReg, calleeResults, preds, freshAtEnd, callsIn, sites } = inp;
648
+ const blockCount = freshAtEnd.length;
649
+ const all = () => new Set(argRegs);
650
+ const localEnd = (b: number) => freshAtEnd[b] ?? new Set<string>();
651
+ // freshOut[b]: registers fresh where b ends. A block that calls forgets everything before its
652
+ // last call; one that does not passes its input through, plus what it wrote.
653
+ const freshOut: Set<string>[] = Array.from({ length: blockCount }, () => all());
654
+ const freshIn: Set<string>[] = Array.from({ length: blockCount }, () => all());
655
+ const inOf = (b: number): Set<string> => {
656
+ // A block with NO predecessors is the function entry (or unreachable): its argument registers
657
+ // are the ones the caller set up. An entry that DOES have predecessors — an entry that is also
658
+ // a loop header — gets the ordinary intersection instead, because on the back edge the caller's
659
+ // setup is long gone and an intervening call may have clobbered it.
660
+ const ps = [...new Set(preds[b] ?? [])];
661
+ if (ps.length === 0) {
662
+ return all();
663
+ }
664
+ const acc = new Set(freshOut[ps[0]]);
665
+ for (const p of ps.slice(1)) {
666
+ for (const r of [...acc]) {
667
+ if (!freshOut[p].has(r)) {
668
+ acc.delete(r);
669
+ }
670
+ }
671
+ }
672
+ return acc;
673
+ };
674
+ for (let changed = true; changed;) {
675
+ changed = false;
676
+ for (let b = 0; b < blockCount; b++) {
677
+ const fin = inOf(b);
678
+ const fout = callsIn.has(b) ? localEnd(b) : new Set([...fin, ...localEnd(b)]);
679
+ if (fout.size !== freshOut[b].size || [...fout].some((r) => !freshOut[b].has(r))) {
680
+ changed = true;
681
+ }
682
+ freshIn[b] = fin;
683
+ freshOut[b] = fout;
684
+ }
685
+ }
686
+ const runOfFresh = (fresh: Set<string>, from: number): number => {
687
+ let n = from;
688
+ while (n < argRegs.length && fresh.has(argRegs[n])) {
689
+ n++;
690
+ }
691
+ return n;
692
+ };
693
+ // A LATER argument register this caller set up proves the call takes arguments at all, and
694
+ // argument 0 sits below one that is proven — so it is being passed too, whatever put it there
695
+ // (`bl __mulsf3; add r1,r4,#0; bl __addsf3` is `__addsf3(__mulsf3(a, b), c)`).
696
+ const setsUpLater = (fresh: Set<string>): boolean => argRegs.some((r, i) => i > 0 && fresh.has(r));
697
+ // THE RETURN REGISTER IS NOT ARGUMENT SETUP. Where the ABI aliases it onto argument 0, the
698
+ // frontends record a call's clobber AFTER its own result, so the result leaves the register
699
+ // UNfresh here. That disproves caller setup only where the callee's return is BOTH what the
700
+ // register still holds and all the site has to go on: with a later register set up (above), or
701
+ // with a value no call produced — a join of one path's return with another path's caller-computed
702
+ // value — argument 0 is a real argument, and dropping the second kind would delete the
703
+ // instructions that computed it.
704
+ //
705
+ // With neither, the site carries no argument evidence at all: `bl f; bl g` is `f(); g();` as
706
+ // readily as `g(f())`, the two spell the same bytes on this ABI, and only the nested one needs
707
+ // `f` to return a value and `g` to accept one — a spelling the project's own header rejects
708
+ // outright when it does not. A declared prototype never reaches here, and stays the way `g(f())`
709
+ // is recovered.
710
+ const argcAt = (fresh: Set<string>, op: Op): number => {
711
+ if (argRegs[0] !== returnReg || fresh.has(argRegs[0])) {
712
+ return runOfFresh(fresh, 0);
713
+ }
714
+ if (setsUpLater(fresh) || !calleeResults.has(op.operands[0])) {
715
+ return runOfFresh(new Set([argRegs[0], ...fresh]), 0);
716
+ }
717
+ return 0;
718
+ };
719
+ for (const s of sites) {
720
+ const fresh = s.afterCallInBlock ? s.freshBefore : new Set([...freshIn[s.block], ...s.freshBefore]);
721
+ const n = argcAt(fresh, s.op);
722
+ if (n < s.op.operands.length) {
723
+ s.op.operands.length = n;
724
+ }
725
+ // The SHORTER arity the same evidence also allows, recorded for {@link narrowToSetupArgs}: the
726
+ // run over what THIS BLOCK wrote, dropping the registers that are fresh only because no call
727
+ // stands between here and wherever they were last written. Both readings stay live, so this one
728
+ // is recorded rather than applied. A survivor is what it drops, so the join clause above has no
729
+ // place here — but `setsUpLater` still does: a register this block set up two instructions
730
+ // before the call is not something the narrower reading may call dead.
731
+ const localFresh = setsUpLater(s.freshBefore) ? new Set([argRegs[0], ...s.freshBefore]) : s.freshBefore;
732
+ const local = Math.min(runOfFresh(localFresh, 0), s.op.operands.length);
733
+ if (local < s.op.operands.length) {
734
+ setupArgc.set(s.op, local);
735
+ }
736
+ }
737
+ }
738
+
739
+ /** The narrower arity {@link narrowToSetupArgs} would cut each guessed call to. A SIDE table and
740
+ * not an attr: this is a fact about one LIFT, not part of the IR the rest of the pipeline compares
741
+ * and prints — `structure/hazards.ts` decides two ops equal by comparing their attrs verbatim, so
742
+ * an attr only one of an otherwise-matching pair carries would cost a recovery. */
743
+ const setupArgc = new WeakMap<Op, number>();
744
+
745
+ /** Whether anything in `fn` HAS the narrower reading — the lever's gate, so the ~99% of functions
746
+ * with no narrowable call cost no re-lift. Read it off the lift itself: a later pipeline stage may
747
+ * replace a `call` op (softdiv rewrites one to a division), and the table is keyed by op. */
748
+ export function hasSetupArgsNarrowing(fn: Fn): boolean {
749
+ return fn.blocks.some((b) => b.ops.some((op) => setupArgc.has(op)));
750
+ }
751
+
752
+ /** Cut every guessed call to the arity its OWN BLOCK set up, and report whether anything moved.
753
+ *
754
+ * `trimClobberedCallArgs` keeps an argument register whose value merely survives from an earlier
755
+ * block, because compiled code really does pass one that way: agbcc leaves a value already in r0
756
+ * where it is and branches to the call (`if (x) f(x);` is `cmp r0,#0; beq; bl f`, no setup at
757
+ * all). Those are also the bytes `if (x) f();` compiles to, so usually neither reading is
758
+ * refutable — but where the guard is an EQUALITY the compiler proves the argument constant and
759
+ * has to materialize it (`if (x == 0) f(x);` opens the arm with `mov r0,#0`), and the absence of
760
+ * that instruction rules the wider reading out. Which case a function is in is not knowable from
761
+ * the register file, and is exactly what a differ decides. Hence a ranked candidate rather than a
762
+ * default: the arm that passes only what the calling block itself put there.
763
+ *
764
+ * Applies only to arities that were GUESSED — a declared prototype never recorded the fact. */
765
+ export function narrowToSetupArgs(fn: Fn): boolean {
766
+ let changed = false;
767
+ for (const b of fn.blocks) {
768
+ for (const op of b.ops) {
769
+ const setup = setupArgc.get(op);
770
+ if (setup !== undefined && setup < op.operands.length) {
771
+ op.operands.length = setup;
772
+ changed = true;
773
+ }
774
+ }
775
+ }
776
+ if (changed) {
777
+ // A dropped argument can be a join's last reader, and `finish()` pruned the dead phis before
778
+ // this reading existed. Left in, the phi's edge args render as assignments to a local nothing
779
+ // reads (`v0 = UpdateWorldMapCursor();`) — see the note on the prune in `finish`.
780
+ pruneDeadParams(fn);
781
+ }
782
+ return changed;
783
+ }
784
+
785
+ /** The stack-slot key both the MIPS and Thumb frontends use for a word-sized local in the
786
+ * function's own frame. Shared so the two spell it identically and the frame-partition rule can
787
+ * recognise either frontend's slots. See the virtual-key note in the module header. */
788
+ const SLOT_PREFIX = 'sp@';
789
+ export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
790
+ /** The byte offset a slot key names, or null if `key` is not a slot key at all (an ordinary
791
+ * register). The grammar stays owned by this module — {@link LiveInModel} is expressed in the same
792
+ * coordinate, so the classification rule can be generic. */
793
+ export const slotKeyOffset = (key: string): number | null =>
794
+ key.startsWith(SLOT_PREFIX) ? Number(key.slice(SLOT_PREFIX.length)) : null;
795
+
177
796
  /** Order the TRUE entry block's parameters by ABI argument register, so downstream naming
178
797
  * (`a0`, `a1`, …) matches the calling convention, not first-read order (a callee-saved copy can
179
798
  * read a later argument register first). No-op when the entry has predecessors — a loop