@asmlift/core 0.5.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 (86) 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 -167
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +194 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +54 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +337 -2
  16. package/src/ir/opcodes.ts +140 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/argbase.ts +2 -1
  24. package/src/l3/ast.ts +464 -57
  25. package/src/l3/basecse.ts +664 -76
  26. package/src/l3/coalesce.ts +429 -43
  27. package/src/l3/dce.ts +31 -9
  28. package/src/l3/gates.ts +21 -0
  29. package/src/l3/hoist.ts +293 -14
  30. package/src/l3/homesplit.ts +285 -0
  31. package/src/l3/initfirst.ts +301 -0
  32. package/src/l3/inlinebase.ts +193 -0
  33. package/src/l3/mentions.ts +113 -0
  34. package/src/l3/mulfirst.ts +42 -0
  35. package/src/l3/nearbase.ts +152 -0
  36. package/src/l3/offmember.ts +371 -0
  37. package/src/l3/parkfirst.ts +96 -0
  38. package/src/l3/pollguard.ts +154 -0
  39. package/src/l3/ptrfield.ts +227 -0
  40. package/src/l3/regspell.ts +110 -85
  41. package/src/l3/reindex.ts +715 -78
  42. package/src/l3/scopebase.ts +644 -218
  43. package/src/l3/sinkinit.ts +40 -0
  44. package/src/l3/slotorder.ts +123 -0
  45. package/src/l3/storage.ts +48 -0
  46. package/src/l3/symbol-refs.ts +41 -8
  47. package/src/l3/tailmerge.ts +15 -0
  48. package/src/l3/typing.ts +198 -9
  49. package/src/l3/unmerge.ts +263 -0
  50. package/src/l3/unreduce.ts +971 -0
  51. package/src/l3/volatileptr.ts +207 -0
  52. package/src/l3/volatileval.ts +130 -0
  53. package/src/l3/volstore.ts +229 -0
  54. package/src/l3/zerosub.ts +62 -0
  55. package/src/pattern/engine.ts +236 -13
  56. package/src/pipeline.ts +157 -56
  57. package/src/proto.ts +112 -14
  58. package/src/raise/arrays.ts +6 -1
  59. package/src/raise/divpow2.ts +2 -2
  60. package/src/raise/globalshape.ts +1038 -0
  61. package/src/raise/gvn.ts +33 -18
  62. package/src/raise/latch.ts +126 -0
  63. package/src/raise/memberarrays.ts +594 -0
  64. package/src/raise/narrow.ts +124 -0
  65. package/src/raise/narrowlocal.ts +556 -0
  66. package/src/raise/paramwidth.ts +179 -0
  67. package/src/raise/pre-recovery.ts +97 -14
  68. package/src/raise/recover.ts +56 -23
  69. package/src/raise/retsink.ts +210 -10
  70. package/src/raise/shortcircuit.ts +474 -74
  71. package/src/raise/struct-arrays.ts +19 -2
  72. package/src/raise/structs.ts +33 -3
  73. package/src/rank-axes.ts +630 -0
  74. package/src/rank-declare.ts +256 -0
  75. package/src/rank.ts +1723 -272
  76. package/src/structure/analysis.ts +1392 -141
  77. package/src/structure/bitfields.ts +332 -0
  78. package/src/structure/globalaccess.ts +274 -0
  79. package/src/structure/hazards.ts +411 -20
  80. package/src/structure/loops.ts +2 -49
  81. package/src/structure/namecoalesce.ts +435 -0
  82. package/src/structure/structure.ts +2678 -526
  83. package/src/structure/switch-recover.ts +616 -144
  84. package/src/symbols.ts +62 -1
  85. package/src/target.ts +367 -24
  86. package/src/trace.ts +111 -32
@@ -15,8 +15,8 @@
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, Op, 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
21
  import { FrontendUnsupportedError } from './errors';
22
22
 
@@ -48,13 +48,14 @@ export interface SsaBuilder {
48
48
  /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic). */
49
49
  hasReachingDef(reg: string, b: number, seen?: Set<number>): boolean;
50
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 before writing
52
- * the call's own result. */
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. */
53
54
  noteCall(b: number): void;
54
55
  /** Register a `call` op whose arity was GUESSED (no prototype), so `finish` can cut it back to the
55
56
  * argument registers that were actually set up on every path (see {@link trimClobberedCallArgs}).
56
- * `argRegs` is the target's argument-register order. */
57
- recordGuessedCall(op: Op, b: number, argRegs: string[]): void;
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;
58
59
  /** Remove trivial phis and enforce the frontend's postconditions; call once every block is
59
60
  * filled. Throws FrontendUnsupportedError if a stack slot escaped as an entry parameter. */
60
61
  finish(): void;
@@ -67,9 +68,135 @@ export interface SsaBuilder {
67
68
  // A virtual key must be outside its ISA's register grammar so it cannot collide with a real one, and
68
69
  // a key read with no reaching def becomes a function PARAMETER by the live-in path below — which is
69
70
  // how both of those capabilities get their parameters without a new opcode or pass.
70
- export function makeSsaBuilder(name: string, blockCount: number, preds: number[][]): SsaBuilder {
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;
71
197
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
72
- 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 };
73
200
 
74
201
  const defs: Array<Map<string, Value>> = irBlocks.map(() => new Map());
75
202
  const sealed: boolean[] = irBlocks.map(() => false);
@@ -100,11 +227,91 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
100
227
  const writtenSinceCall: Array<Set<string>> = irBlocks.map(() => new Set());
101
228
  const callsIn = new Set<number>();
102
229
  const guessedCalls: GuessedCallSite[] = [];
103
- let argRegsSeen: string[] = [];
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
+ };
104
309
 
105
310
  const writeVar = (reg: string, b: number, v: Value) => {
311
+ noteSlotHome(reg, v);
106
312
  writtenSinceCall[b].add(reg);
107
313
  defs[b].set(reg, v);
314
+ lastWriteAt[b].set(reg, writeCount[b]++);
108
315
  };
109
316
  const readVar = (reg: string, b: number): Value => defs[b].get(reg) ?? readRecursive(reg, b);
110
317
 
@@ -113,6 +320,10 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
113
320
  irBlocks[b].params.push(phi);
114
321
  phiBlock.set(phi, b);
115
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);
116
327
  defs[b].set(reg, phi); // set before wiring operands to break cycles
117
328
  return phi;
118
329
  };
@@ -128,7 +339,29 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
128
339
  // manufacture a join (and a phi) where there is none.
129
340
  const ps = distinctPreds(b);
130
341
  if (ps.length === 0) {
131
- // 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.
132
365
  // If one was already asserted for this key (ensureParam), adopt it — minting a second
133
366
  // parameter for the same key would put the key in the signature twice.
134
367
  const obliged = obligedParams[b].get(reg);
@@ -150,12 +383,21 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
150
383
  }
151
384
  // sealed join: create the phi and wire every predecessor's terminator arg now.
152
385
  const phi = newPhi(reg, b);
153
- addPhiOperands(reg, b);
386
+ addPhiOperands(reg, b, phi);
154
387
  return phi;
155
388
  };
156
- 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) => {
157
393
  for (const p of distinctPreds(b)) {
158
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
+ }
159
401
  }
160
402
  };
161
403
  // Append `arg` to EVERY successor edge of predecessor p that targets block b.
@@ -179,8 +421,8 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
179
421
  return;
180
422
  }
181
423
  sealed[b] = true; // set first: addPhiOperands may recurse back here
182
- for (const reg of incompletePhis[b].keys()) {
183
- addPhiOperands(reg, b);
424
+ for (const [reg, phi] of incompletePhis[b]) {
425
+ addPhiOperands(reg, b, phi);
184
426
  }
185
427
  incompletePhis[b].clear();
186
428
  };
@@ -233,10 +475,12 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
233
475
  hasReachingDef,
234
476
  noteCall: (b: number) => {
235
477
  callsIn.add(b);
236
- writtenSinceCall[b] = new Set(); // the callee clobbers the caller-saved registers
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();
237
481
  },
238
- recordGuessedCall: (op: Op, b: number, argRegs: string[]) => {
239
- argRegsSeen = argRegs;
482
+ recordGuessedCall: (op: Op, b: number, abi: { argRegs: string[]; returnReg: string }) => {
483
+ abiSeen = abi;
240
484
  guessedCalls.push({
241
485
  block: b,
242
486
  op,
@@ -252,17 +496,41 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
252
496
  // Guessed arities counted argument registers by reaching definition alone; now that every
253
497
  // block's calls are known, drop the ones an intervening call had already clobbered.
254
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
+ }
255
509
  trimClobberedCallArgs({
256
- argRegs: argRegsSeen,
510
+ argRegs: abiSeen.argRegs,
511
+ returnReg: abiSeen.returnReg,
512
+ calleeResults,
257
513
  preds,
258
514
  freshAtEnd: writtenSinceCall,
259
515
  callsIn,
260
516
  sites: guessedCalls,
261
517
  });
262
518
  }
519
+ irBlocks.forEach((blk, i) => writeOrder.writes.set(blk, writeCount[i]));
263
520
  simplifyTrivialPhis(fn, (p) => {
264
521
  phiBlock.delete(p);
265
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);
266
534
  });
267
535
  // A STACK SLOT MAY NEVER LEAVE AS AN ENTRY PARAMETER. A slot is memory the function itself
268
536
  // allocated, so its value can only come from a store the function made; arriving as a live-in
@@ -286,10 +554,15 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
286
554
  // semantic postcondition, and a postcondition enforced by convention is not enforced.
287
555
  for (const p of irBlocks[0].params) {
288
556
  const key = paramReg.get(p) ?? phiKey.get(p);
289
- if (key?.startsWith(SLOT_PREFIX)) {
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)) {
290
563
  throw new FrontendUnsupportedError(
291
- `cannot lift '${name}': stack slot ${key} is read on a path that never stores it ` +
292
- `(partially-initialised local) — not modelled`,
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`,
293
566
  );
294
567
  }
295
568
  }
@@ -329,6 +602,12 @@ export interface GuessedCallSite {
329
602
 
330
603
  export interface CallArgTrim {
331
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>;
332
611
  /** one entry per CFG edge, as passed to {@link makeSsaBuilder} */
333
612
  preds: number[][];
334
613
  /** per block: the keys written since its LAST call (since its start if it makes none). Indexed by
@@ -351,17 +630,21 @@ export interface CallArgTrim {
351
630
  * SCOPE: this closes the arguments an intervening CALL disproves, which is the common case in real
352
631
  * code. It does not close the rest — a dead value the compiler happened to leave in the next
353
632
  * argument register with no call in between still reads as an argument, and nothing about the
354
- * register file can say otherwise. Only a declared prototype closes those.
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}).
355
635
  *
356
636
  * A must-analysis: a register is FRESH at a point iff on EVERY path reaching it, it was written
357
637
  * after the last call. The entry block starts all-fresh (those are the caller's own arguments).
358
- * The result only ever SHRINKS an arity a register the analysis cannot prove clobbered stays an
359
- * argument so no real argument can be dropped by it.
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.
360
643
  *
361
644
  * Frontend-agnostic: the caller supplies what its own lifting scan observed, so nothing here
362
645
  * re-derives which instruction writes which register. */
363
646
  export function trimClobberedCallArgs(inp: CallArgTrim): void {
364
- const { argRegs, preds, freshAtEnd, callsIn, sites } = inp;
647
+ const { argRegs, returnReg, calleeResults, preds, freshAtEnd, callsIn, sites } = inp;
365
648
  const blockCount = freshAtEnd.length;
366
649
  const all = () => new Set(argRegs);
367
650
  const localEnd = (b: number) => freshAtEnd[b] ?? new Set<string>();
@@ -400,23 +683,115 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
400
683
  freshOut[b] = fout;
401
684
  }
402
685
  }
403
- for (const s of sites) {
404
- const fresh = s.afterCallInBlock ? s.freshBefore : new Set([...freshIn[s.block], ...s.freshBefore]);
405
- let n = 0;
686
+ const runOfFresh = (fresh: Set<string>, from: number): number => {
687
+ let n = from;
406
688
  while (n < argRegs.length && fresh.has(argRegs[n])) {
407
689
  n++;
408
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);
409
722
  if (n < s.op.operands.length) {
410
723
  s.op.operands.length = n;
411
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);
412
781
  }
782
+ return changed;
413
783
  }
414
784
 
415
785
  /** The stack-slot key both the MIPS and Thumb frontends use for a word-sized local in the
416
- * function's own frame. Shared so the two spell it identically and `assertNoSlotEscaped` can
786
+ * function's own frame. Shared so the two spell it identically and the frame-partition rule can
417
787
  * recognise either frontend's slots. See the virtual-key note in the module header. */
418
788
  const SLOT_PREFIX = 'sp@';
419
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;
420
795
 
421
796
  /** Order the TRUE entry block's parameters by ABI argument register, so downstream naming
422
797
  * (`a0`, `a1`, …) matches the calling convention, not first-read order (a callee-saved copy can