@asmlift/core 0.8.1 → 0.9.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 (105) hide show
  1. package/README.md +34 -30
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +248 -0
  4. package/src/backend/c.ts +5 -2
  5. package/src/backend/cfamily.ts +236 -37
  6. package/src/backend/cpp.ts +114 -9
  7. package/src/backend/pascal.ts +39 -0
  8. package/src/cdecl/constant.ts +59 -0
  9. package/src/cdecl/lex.ts +209 -0
  10. package/src/cdecl/parse.ts +975 -0
  11. package/src/cdecl/spell.ts +69 -0
  12. package/src/codegen-flags.ts +38 -6
  13. package/src/compiler-diagnostics.ts +194 -0
  14. package/src/contracts.ts +615 -79
  15. package/src/declare.ts +75 -5
  16. package/src/frontend/asmdata.ts +19 -5
  17. package/src/frontend/call-plan.ts +686 -0
  18. package/src/frontend/device-pins.ts +277 -0
  19. package/src/frontend/errors.ts +5 -0
  20. package/src/frontend/flags-edge.ts +101 -0
  21. package/src/frontend/fpu.ts +167 -0
  22. package/src/frontend/frame-objects.ts +2131 -0
  23. package/src/frontend/high-half.ts +2 -1
  24. package/src/frontend/local-object.ts +490 -0
  25. package/src/frontend/mips.ts +363 -40
  26. package/src/frontend/opaque.ts +97 -18
  27. package/src/frontend/ppc.ts +520 -129
  28. package/src/frontend/reloc-symbol.ts +94 -13
  29. package/src/frontend/splat.ts +108 -5
  30. package/src/frontend/ssa.ts +669 -76
  31. package/src/frontend/stackargs.ts +120 -39
  32. package/src/frontend/thumb.ts +2764 -2018
  33. package/src/ir/alias.ts +29 -0
  34. package/src/ir/core.ts +101 -3
  35. package/src/ir/discipline.ts +248 -0
  36. package/src/ir/float-bits.ts +47 -0
  37. package/src/ir/opcodes.ts +84 -112
  38. package/src/ir/parse.ts +1 -0
  39. package/src/ir/types.ts +118 -2
  40. package/src/ir/verify.ts +150 -3
  41. package/src/l3/address.ts +5 -0
  42. package/src/l3/argcopy.ts +38 -6
  43. package/src/l3/ast.ts +186 -14
  44. package/src/l3/coalesce.ts +21 -13
  45. package/src/l3/dce.ts +32 -10
  46. package/src/l3/gates.ts +9 -3
  47. package/src/l3/hoist.ts +8 -7
  48. package/src/l3/initfirst.ts +27 -9
  49. package/src/l3/intcell.ts +97 -0
  50. package/src/l3/mentions.ts +10 -4
  51. package/src/l3/narrowdecl.ts +218 -0
  52. package/src/l3/nearbase.ts +10 -5
  53. package/src/l3/parkfirst.ts +8 -5
  54. package/src/l3/pollguard.ts +3 -2
  55. package/src/l3/ptrcell.ts +357 -0
  56. package/src/l3/regspell.ts +17 -3
  57. package/src/l3/reindex.ts +10 -7
  58. package/src/l3/symbol-refs.ts +104 -23
  59. package/src/l3/tailmerge.ts +6 -19
  60. package/src/l3/typing.ts +137 -25
  61. package/src/l3/unreduce.ts +4 -4
  62. package/src/l3/volstore.ts +96 -7
  63. package/src/mangle.ts +11 -1
  64. package/src/pattern/engine.ts +19 -16
  65. package/src/pipeline.ts +57 -12
  66. package/src/proto-context.ts +788 -0
  67. package/src/proto.ts +689 -50
  68. package/src/raise/arrays.ts +4 -3
  69. package/src/raise/declared-volatile.ts +131 -0
  70. package/src/raise/divpow2.ts +2 -2
  71. package/src/raise/errors.ts +17 -0
  72. package/src/raise/floathelpers.ts +243 -0
  73. package/src/raise/globalshape.ts +11 -5
  74. package/src/raise/gvn.ts +26 -5
  75. package/src/raise/narrow.ts +29 -2
  76. package/src/raise/narrowlocal.ts +11 -6
  77. package/src/raise/offsetnames.ts +510 -0
  78. package/src/raise/pairparams.ts +81 -0
  79. package/src/raise/paramwidth.ts +7 -0
  80. package/src/raise/pre-recovery.ts +78 -4
  81. package/src/raise/recover.ts +14 -10
  82. package/src/raise/retsink.ts +1 -1
  83. package/src/raise/shortcircuit.ts +41 -19
  84. package/src/raise/softdiv.ts +21 -23
  85. package/src/raise/struct-arrays.ts +5 -4
  86. package/src/raise/structs.ts +311 -98
  87. package/src/raise/truncload.ts +366 -0
  88. package/src/raise/widehelpers.ts +106 -0
  89. package/src/rank-declare.ts +30 -5
  90. package/src/rank-variations.ts +36 -8
  91. package/src/rank.ts +267 -79
  92. package/src/runtime-helpers.ts +287 -0
  93. package/src/stillborn.ts +289 -0
  94. package/src/structure/analysis.ts +1285 -448
  95. package/src/structure/hazards.ts +633 -58
  96. package/src/structure/local-statics.ts +381 -0
  97. package/src/structure/namecoalesce.ts +1 -1
  98. package/src/structure/redundant-test.ts +99 -0
  99. package/src/structure/structure.ts +1772 -395
  100. package/src/structure/switch-recover.ts +159 -78
  101. package/src/symbols.ts +38 -3
  102. package/src/target.ts +662 -59
  103. package/src/trace.ts +44 -24
  104. package/src/variation-definitions.ts +119 -1
  105. package/src/variation-tokens.ts +20 -6
@@ -23,19 +23,46 @@ import {
23
23
  type SlotHomes,
24
24
  Value,
25
25
  type WriteOrder,
26
+ callArgs,
26
27
  defOpMap,
27
28
  mkOp,
28
29
  mkValue,
30
+ terminator,
31
+ truncateCallArgs,
29
32
  } from '../ir/core';
30
33
  import { pruneDeadParams, simplifyTrivialPhis } from '../ir/simplify';
31
- import { T } from '../ir/types';
32
- import { FrontendUnsupportedError } from './errors';
34
+ import { type IrType, T } from '../ir/types';
35
+ import { type FrontendRefusal, FrontendUnsupportedError } from './errors';
33
36
 
34
37
  export interface SsaBuilder {
35
38
  fn: Fn;
36
39
  irBlocks: Block[];
37
40
  /** Current SSA value of `reg` on entry to block `b` (creating phis/params as needed). */
38
41
  readVar(reg: string, b: number): Value;
42
+ /** Read `reg` as an argument register of a call whose arity was GUESSED — otherwise identical to
43
+ * {@link readVar}, and used INSTEAD of it so the read carries no claim.
44
+ *
45
+ * `fallbackArgc` counts argument registers by reaching definition, so a read here is a QUESTION
46
+ * ("did the caller set this up?") rather than an assertion, and {@link trimClobberedCallArgs} is
47
+ * what answers it. That answer already covers a register a call destroyed, and it covers it
48
+ * EXHAUSTIVELY: destroyed on any path means not written-since-the-call on that path, so the
49
+ * must-analysis drops it from the run and the operand goes — bar an argument register the ABI
50
+ * ALIASES onto the return register, which a call that writes its result there does not list as
51
+ * destroyed; one that writes none there does, and the trim reads that too. So none of these
52
+ * reads can leave a destroyed value in the graph, and refusing the function over one would cost
53
+ * a row the trim has already made correct.
54
+ *
55
+ * THAT ALIASING IS A PER-TARGET FACT AND TWO TARGETS HERE DO NOT HAVE IT. ARM and PowerPC both
56
+ * pass argument 0 in the return register (r0/r0, r3/r3), so on them the exemption is the whole
57
+ * of the gap. MIPS returns in `v0` and passes in `a0`, so `clobberedByCall` lists `a0` for both
58
+ * MIPS targets and there is no exemption to reason about — which is the sound direction, not a
59
+ * hole.
60
+ *
61
+ * A DECLARED arity uses `readVar`, and must: there the callee says the argument exists, so
62
+ * reading a destroyed register for it is a wrong value with nothing to retract it.
63
+ *
64
+ * One of the three call verbs `CallDeclarations.lower` owns ({@link noteCall}). */
65
+ readGuessedArg(reg: string, b: number): Value;
39
66
  /** Record that `reg` now holds `v` within block `b`. */
40
67
  writeVar(reg: string, b: number, v: Value): void;
41
68
  /** Mark block `b` fully emitted (terminator pushed); seals any now-ready successors. */
@@ -63,15 +90,47 @@ export interface SsaBuilder {
63
90
  * "a def reaches here" and "a value reaches here" are the same question only when every def is
64
91
  * a value. */
65
92
  hasReachingDef(reg: string, b: number, accept?: (v: Value) => boolean): boolean;
93
+ /** {@link hasReachingDef}, with a path ending where a call destroyed `reg` (`noteCall`): whether a
94
+ * value this function named reaches it here on some path. A path that brings the destroyed bytes
95
+ * and one that brings a value still answer yes, and the read that follows refuses at `finish`. */
96
+ holdsValue(reg: string, b: number, accept?: (v: Value) => boolean): boolean;
97
+ /** The entry parameter `reg` still holds at this point of block `b`, taken WITHOUT a read. A read
98
+ * leaves a definition behind, and `hasReachingDef` counts it as argument setup, so a frontend that
99
+ * only moves the argument somewhere (a frame store) would raise the guessed arity of every later
100
+ * prototype-less call. `undefined` where "still the entry value" is not shown: a definition or a
101
+ * call reaches from some path, or a block on the way is not filled yet. */
102
+ entryValue(reg: string, b: number): Value | undefined;
103
+ /** Whether `reg` still holds its entry value at this point of block `b`, in {@link entryValue}'s
104
+ * sense, answered without the entry parameter that one hands back. A callee-saved register stored
105
+ * to its save slot holds the caller's value, which is no parameter, so asking must not mint one. */
106
+ holdsEntryValue(reg: string, b: number): boolean;
66
107
  /** Record that block `b` makes a call HERE: the ABI's caller-saved registers stop being ones the
67
- * caller set up. Call it AFTER `recordGuessedCall` for the same instruction, and after writing
68
- * the call's own result — the result is the CALLEE's, so it must not count as caller-side
69
- * argument setup for whatever call comes next. */
70
- noteCall(b: number): void;
108
+ * caller set up.
109
+ *
110
+ * THE CALL VERBS ARE `CallDeclarations.lower`'s (frontend/call-plan.ts): a frontend lowers a
111
+ * call through it, and it alone calls this, {@link recordGuessedCall} and {@link readGuessedArg},
112
+ * in one order — `recordGuessedCall`, then the call's own result, then this. The result is the
113
+ * CALLEE's, so it must not count as caller-side argument setup for whatever call comes next.
114
+ *
115
+ * `clobbers` are the registers the callee DESTROYS and leaves holding nothing this function can
116
+ * name — {@link clobberedByCall} spells it, and it is the ABI's caller-saved set minus the
117
+ * return register precisely because of the ordering above: the callee's own result is already
118
+ * written there, so that one register does have a name. A callee that hands a STRUCT back in
119
+ * that register writes no value a caller may read as a word, so none is written and the
120
+ * register is listed here too. Required rather than optional: a call lowered without it would
121
+ * keep resolving a destroyed register to its pre-call value, silently. */
122
+ noteCall(b: number, clobbers: readonly string[]): void;
71
123
  /** Register a `call` op whose arity was GUESSED (no prototype), so `finish` can cut it back to the
72
124
  * argument registers that were actually set up on every path (see {@link trimClobberedCallArgs}).
73
- * `abi` is the target's argument-register order and its return register. */
74
- recordGuessedCall(op: Op, b: number, abi: { argRegs: string[]; returnReg: string }): void;
125
+ * `abi` is the target's argument-register order and its return register. It snapshots what the
126
+ * caller wrote, so it comes before the call's result is written ({@link noteCall}).
127
+ * `passesCalleeResult`: see {@link GuessedCallSite}. */
128
+ recordGuessedCall(
129
+ op: Op,
130
+ b: number,
131
+ abi: { argRegs: string[]; returnReg: string },
132
+ passesCalleeResult?: (result: Value) => boolean,
133
+ ): void;
75
134
  /** Remove trivial phis and enforce the frontend's postconditions; call once every block is
76
135
  * filled. Throws FrontendUnsupportedError if a stack slot escaped as an entry parameter. */
77
136
  finish(): void;
@@ -95,7 +154,7 @@ export interface LiveInModel {
95
154
  *
96
155
  * Asserts more than ownership: that this function's own stores are the ONLY writer. An address
97
156
  * into the frame that escapes to anything which could write it stops that holding, and the
98
- * retraction is the frontend's obligation (frontend/thumb.ts, after the frame-object audit). */
157
+ * retraction is the frontend's obligation (the frame-object audit, frontend/frame-objects.ts). */
99
158
  ownedLocals?: { from: number; to: number };
100
159
  /** Storage this function DECLARES as locals ⇒ a `[sp,#k]` spill here is a DECLARATION RANK
101
160
  * (`ir/core.ts` `SlotHomes`, ordered by `l3/slotorder.ts`). `[from, to)`.
@@ -194,15 +253,56 @@ function checkedLiveInModel(fnName: string, m: LiveInModel): LiveInModel {
194
253
  return m;
195
254
  }
196
255
 
256
+ /** A read whose register may be holding a callee's leftover — see `refuseStaleCallerSavedReads`. */
257
+ interface StaleRead {
258
+ reg: string;
259
+ value: Value;
260
+ block: number;
261
+ /** the block's op count when the read happened. The uses this read answers for start here: an op
262
+ * before it consumed a value read while the register was still this function's own, and the same
263
+ * register really can be read on both sides of a call without its value changing. */
264
+ fromOp: number;
265
+ /** a call in this very block destroyed it, so the predecessors have nothing to add. */
266
+ local: boolean;
267
+ }
268
+
269
+ /** The registers a call leaves holding nothing the CALLER can name — what {@link SsaBuilder.noteCall}
270
+ * wants. The ABI's caller-saved set minus the return register: `CallDeclarations.lower` writes the
271
+ * callee's own result there before recording the clobber, so that one register does have a name
272
+ * and the rest do not.
273
+ *
274
+ * CHECKED rather than trusted, the way `checkedLiveInModel` checks the register partition: a
275
+ * register the caller passes arguments in is by construction one the callee may destroy, so
276
+ * `argRegs` must be a subset of `callerSaved`. A target spelling one of them differently in the two
277
+ * lists would silently stop refusing reads of it. */
278
+ export function clobberedByCall(target: {
279
+ callerSaved: readonly string[];
280
+ argRegs: readonly string[];
281
+ returnReg: string;
282
+ }): readonly string[] {
283
+ const missing = target.argRegs.filter((r) => !target.callerSaved.includes(r));
284
+ if (missing.length > 0) {
285
+ throw new Error(
286
+ `this target passes arguments in ${missing.join(', ')} but does not list ${missing.length > 1 ? 'them' : 'it'} ` +
287
+ `as caller-saved, so a read of one past a call cannot be refused`,
288
+ );
289
+ }
290
+ return target.callerSaved.filter((r) => r !== target.returnReg);
291
+ }
292
+
197
293
  export function makeSsaBuilder(
198
294
  name: string,
199
295
  blockCount: number,
200
296
  preds: number[][],
201
- /** A supplier because half the partition is MEASURED rather than declared: Thumb's local area
202
- * comes from a prologue walk that runs after this call. Evaluated once, on first use. Omitted ⇒
297
+ /** Evaluated once, at its first use, which is where `checkedLiveInModel` checks it. Omitted ⇒
203
298
  * no partition is claimed, so every slot refuses and every register is a parameter. */
204
299
  liveInOf: () => LiveInModel = () => ({}),
300
+ /** The type of a value that stands for `key` before any instruction defines it — a parameter, a
301
+ * phi. A register FILE, not a register: the FPU's keys are floats wherever they arrive, and every
302
+ * other key is `unknown` for type recovery to settle. */
303
+ keyType: (key: string) => IrType | undefined = () => undefined,
205
304
  ): SsaBuilder {
305
+ const unset = (key: string): IrType => keyType(key) ?? T.unk(32);
206
306
  let modelMemo: LiveInModel | null = null;
207
307
  // Checked ONCE, where the model is materialised — every function with a parameter reads a
208
308
  // register def-lessly, so this runs on effectively every lift rather than only on the rare
@@ -213,7 +313,14 @@ export function makeSsaBuilder(
213
313
  const inRange = (off: number, r?: { from: number; to: number }) => r !== undefined && off >= r.from && off < r.to;
214
314
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
215
315
  // `writeOrder` and `slotHomes` are filled in below, where the builder's counters live.
216
- const fn: Fn = { name, blocks: irBlocks, writeOrder: undefined, slotHomes: undefined, paramEvidence: undefined };
316
+ const fn: Fn = {
317
+ name,
318
+ blocks: irBlocks,
319
+ writeOrder: undefined,
320
+ slotHomes: undefined,
321
+ paramEvidence: undefined,
322
+ localObjects: undefined,
323
+ };
217
324
 
218
325
  const defs: Array<Map<string, Value>> = irBlocks.map(() => new Map());
219
326
  const sealed: boolean[] = irBlocks.map(() => false);
@@ -243,6 +350,25 @@ export function makeSsaBuilder(
243
350
  // wrapper, and a MISSED write under-counts an arity — which drops a real argument silently.
244
351
  const writtenSinceCall: Array<Set<string>> = irBlocks.map(() => new Set());
245
352
  const callsIn = new Set<number>();
353
+ // WHAT A CALL DESTROYED, which is a DIFFERENT question from the one above and not its complement.
354
+ // `writtenSinceCall` asks "did the CALLER set this register up" — a MUST question, whose answer
355
+ // for the return register is deliberately no, because `CallDeclarations.lower` writes the
356
+ // callee's result there before recording the clobber. This asks "does this register still hold a
357
+ // value anyone can name" — a MAY question, whose answer for that same register is yes. One
358
+ // analysis serving both would have to be wrong about one of them, so they are two, and each names
359
+ // the other.
360
+ //
361
+ // `clobberedLocal[b]`: destroyed by a call in `b` with nothing written since. `decidedLocal[b]`:
362
+ // registers `b` has settled either way, so a register in neither inherits its fate from the
363
+ // predecessors.
364
+ const clobberedLocal: Array<Set<string>> = irBlocks.map(() => new Set());
365
+ const decidedLocal: Array<Set<string>> = irBlocks.map(() => new Set());
366
+ const staleCandidates: StaleRead[] = [];
367
+ // `readVar` runs once per operand, so a memo per (register, verdict) keeps the record list
368
+ // proportional to what a block READS rather than to how many times it reads it. The value a key
369
+ // holds only changes at a write, and the verdict only flips at a call, so a run of identical
370
+ // reads collapses to the earliest — which is the one whose uses cover the rest.
371
+ const staleSeen: Array<Map<string, Value>> = irBlocks.map(() => new Map());
246
372
  const guessedCalls: GuessedCallSite[] = [];
247
373
  let abiSeen: { argRegs: string[]; returnReg: string } = { argRegs: [], returnReg: '' };
248
374
 
@@ -256,10 +382,11 @@ export function makeSsaBuilder(
256
382
  fn.writeOrder = writeOrder;
257
383
 
258
384
  // SLOT HOMES (ir/core.ts `SlotHomes`). Measured HERE, in the shared builder, for the same
259
- // reason the clobber set and the write order are: BOTH frontends already spell a word spill as
260
- // a write to the key `sp@k` (`stackSlotKey`, below), so the frontend supplies the coordinate
261
- // and one rule applies it — a per-frontend stamp would be right only while each remembered to
262
- // route every slot write past a wrapper, and a missed write is a local with no frame order.
385
+ // reason the clobber set and the write order are: every slot-modelling frontend (MIPS, Thumb,
386
+ // PowerPC) spells a word spill as a write to the key `sp@k` (`stackSlotKey`, below), so the
387
+ // frontend supplies the coordinate and one rule applies it — a per-frontend stamp would be right
388
+ // only while each remembered to route every slot write past a wrapper, and a missed write is a
389
+ // local with no frame order.
263
390
  // Empty rather than absent on a function that spills nothing: this builder measured it.
264
391
  const slotHomes: SlotHomes = new Map();
265
392
  fn.slotHomes = slotHomes;
@@ -270,7 +397,7 @@ export function makeSsaBuilder(
270
397
  }
271
398
  // THE KEY SPELLING CANNOT DECIDE THIS, exactly as `readRecursive` says below of a def-less
272
399
  // read: `sp@40` is a local on one ABI and the caller's fifth argument on another. So the stamp
273
- // asks the frontend for a partition and refuses where no answer exists. The two frontends
400
+ // asks the frontend for a partition and refuses where no answer exists. The frontends
274
401
  // differ here and the refusal is what makes that safe: Thumb declares a range; MIPS declares NO
275
402
  // partition (frontend/mips.ts: `addiu sp,sp,±N` is transparent, so its slot keys span O32's
276
403
  // caller-owned register-parameter home area `[0,16)` and the incoming stack arguments above
@@ -306,6 +433,16 @@ export function makeSsaBuilder(
306
433
  // separately declared SCALARS; intra-aggregate offsets are fixed by the aggregate's layout at
307
434
  // expand time.
308
435
  //
436
+ // A FORM OF THAT RECOVERY EXISTS, and what keeps the condition unmet is two guards, this
437
+ // function's and one in another file. The frame-object audit declares an untyped frame object
438
+ // as `u8 name[N]` over the DECLARED range (`notTheWholeArea`, frontend/frame-objects.ts) and
439
+ // refuses it where the slot model keys any byte of that range (`overSlot`, refused by
440
+ // `failIfSlotKeysIt`). The slots it does share a frame with lie below `declaredLocals` — the
441
+ // licensed outgoing argument words — and the `declaredLocals` test above stamps none of them.
442
+ // So no slot home sits beside one of those arrays and nothing here is ever asked to order the
443
+ // two. Letting a keyed slot into the declared range beside the array is what meets the
444
+ // condition, and it has to bring `l3/slotorder.ts` with it.
445
+ //
309
446
  // UNION, not a choice (ir/core.ts `SlotHomes`): whether the earlier declaration rank is the
310
447
  // lower or the higher offset is a per-COMPILER fact, and this builder is handed a name, a
311
448
  // block count, a predecessor list and a live-in model — no target. `l3/slotorder.ts` reduces.
@@ -320,7 +457,7 @@ export function makeSsaBuilder(
320
457
  };
321
458
  // PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`), measured HERE for the same reason the clobber
322
459
  // set, the write order and the slot homes are: a slot write is a `writeVar` and a slot read is a
323
- // `readVar` in BOTH slot-modelling frontends, so one rule covers them and no frontend can forget
460
+ // `readVar` in every slot-modelling frontend, so one rule covers them and no frontend can forget
324
461
  // to route a store past a wrapper. The two directions are NOT symmetric: raise/paramwidth.ts reads
325
462
  // an absent observation as proof the declaration was wide, so a missed one costs a narrowing while
326
463
  // a spurious one retypes a parameter the machine never declared narrow.
@@ -369,16 +506,42 @@ export function makeSsaBuilder(
369
506
  firstEntryWrite.set(reg, v);
370
507
  }
371
508
  writtenSinceCall[b].add(reg);
509
+ clobberedLocal[b].delete(reg);
510
+ decidedLocal[b].add(reg);
372
511
  defs[b].set(reg, v);
373
512
  lastWriteAt[b].set(reg, writeCount[b]++);
374
513
  };
375
514
  const readVar = (reg: string, b: number): Value => {
515
+ const v = readAny(reg, b);
516
+ noteStaleCandidate(reg, b, v);
517
+ return v;
518
+ };
519
+ /** The lookup, with no claim attached. The FRONTEND's read is what asserts "this register holds
520
+ * this value here"; the lookups this construction makes on its own — a single predecessor's
521
+ * definition, a phi's operand at each in-edge — are how that one read is answered, not further
522
+ * reads to be judged. Recording them would blame a use in one block on a read in another. */
523
+ const readAny = (reg: string, b: number): Value => {
376
524
  noteSlotTraffic(reg, null);
377
525
  return defs[b].get(reg) ?? readRecursive(reg, b);
378
526
  };
527
+ /** Record a read whose register MIGHT be holding a callee's leftover, for `finish` to judge. A
528
+ * register this block has already settled answers here and needs no record; one it has not takes
529
+ * its answer from the predecessors, which are not all filled yet. */
530
+ const noteStaleCandidate = (reg: string, b: number, v: Value) => {
531
+ const local = clobberedLocal[b].has(reg);
532
+ if (decidedLocal[b].has(reg) && !local) {
533
+ return; // this function wrote it after the last call in this block: it is its own value
534
+ }
535
+ const key = `${reg}|${local ? 1 : 0}`;
536
+ if (staleSeen[b].get(key) === v) {
537
+ return;
538
+ }
539
+ staleSeen[b].set(key, v);
540
+ staleCandidates.push({ reg, value: v, block: b, fromOp: irBlocks[b].ops.length, local });
541
+ };
379
542
 
380
543
  const newPhi = (reg: string, b: number): Value => {
381
- const phi = mkValue(T.unk(32));
544
+ const phi = mkValue(unset(reg));
382
545
  irBlocks[b].params.push(phi);
383
546
  phiBlock.set(phi, b);
384
547
  phiKey.set(phi, reg);
@@ -389,6 +552,9 @@ export function makeSsaBuilder(
389
552
  defs[b].set(reg, phi); // set before wiring operands to break cycles
390
553
  return phi;
391
554
  };
555
+ // The single-predecessor blocks the current read is walking up through. A join ends the walk:
556
+ // its phi is in `defs` before its operands are read, so a cycle through it is already cut there.
557
+ let singlePredWalk = new Set<string>();
392
558
  const readRecursive = (reg: string, b: number): Value => {
393
559
  if (!sealed[b]) {
394
560
  // predecessors not all filled yet (e.g. a loop back-edge): defer operand wiring.
@@ -432,16 +598,38 @@ export function makeSsaBuilder(
432
598
  defs[b].set(reg, obliged);
433
599
  return obliged;
434
600
  }
435
- const p = mkValue(T.unk(32));
601
+ const p = mkValue(unset(reg));
436
602
  irBlocks[b].params.push(p);
437
603
  defs[b].set(reg, p);
438
604
  paramReg.set(p, reg);
439
605
  return p;
440
606
  }
441
607
  if (ps.length === 1) {
442
- const v = readVar(reg, ps[0]);
443
- defs[b].set(reg, v);
444
- return v;
608
+ // A walk up single predecessors that comes back to where it started has gone round a cycle
609
+ // no edge enters from outside, so no path from the entry reaches it — unless the entry is on
610
+ // it. agbcc leaves such a cycle behind a loop it proved never runs. No execution reads the
611
+ // key there, which is the uninitialised read's answer. Through the entry, the value is a
612
+ // live-in joined with the cycle's, which a single-predecessor block has no phi for.
613
+ const at = `${b}|${reg}`;
614
+ if (singlePredWalk.has(at)) {
615
+ if (singlePredWalk.has(`0|${reg}`)) {
616
+ throw new FrontendUnsupportedError(
617
+ `cannot lift '${name}': ${reg} is read in a cycle through the entry block with no join to meet at`,
618
+ );
619
+ }
620
+ const op = mkOp('undef', { results: [mkValue(T.unk(32))], attrs: { key: reg } });
621
+ irBlocks[b].ops.unshift(op);
622
+ defs[b].set(reg, op.results[0]);
623
+ return op.results[0];
624
+ }
625
+ singlePredWalk.add(at);
626
+ try {
627
+ const v = readAny(reg, ps[0]);
628
+ defs[b].set(reg, v);
629
+ return v;
630
+ } finally {
631
+ singlePredWalk.delete(at);
632
+ }
445
633
  }
446
634
  // sealed join: create the phi and wire every predecessor's terminator arg now.
447
635
  const phi = newPhi(reg, b);
@@ -452,8 +640,17 @@ export function makeSsaBuilder(
452
640
  // write-order record is keyed by. `phi` is passed rather than looked up: by the time a deferred
453
641
  // phi is wired the block may have written its key again, so `defs[b]` no longer names it.
454
642
  const addPhiOperands = (reg: string, b: number, phi: Value) => {
643
+ const walk = singlePredWalk;
644
+ singlePredWalk = new Set();
645
+ try {
646
+ addEachPhiOperand(reg, b, phi);
647
+ } finally {
648
+ singlePredWalk = walk;
649
+ }
650
+ };
651
+ const addEachPhiOperand = (reg: string, b: number, phi: Value) => {
455
652
  for (const p of distinctPreds(b)) {
456
- appendSuccessorArg(p, b, readVar(reg, p));
653
+ appendSuccessorArg(p, b, readAny(reg, p));
457
654
  const at = lastWriteAt[p].get(reg);
458
655
  if (at !== undefined) {
459
656
  const rec = writeOrder.lastWrite.get(irBlocks[p]) ?? new Map<Value, number>();
@@ -510,14 +707,17 @@ export function makeSsaBuilder(
510
707
  return; // already a parameter, however it got there
511
708
  }
512
709
  }
513
- const p = mkValue(T.unk(32));
710
+ const p = mkValue(unset(key));
514
711
  irBlocks[b].params.push(p);
515
712
  paramReg.set(p, key); // ranked by the ABI sort like any other parameter
516
713
  obligedParams[b].set(key, p);
517
714
  };
518
715
 
519
- const hasReachingDef = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean => {
716
+ const reaches = (reg: string, b: number, accept: (v: Value) => boolean, throughCalls: boolean): boolean => {
520
717
  const walk = (at: number, seen: Set<number>): boolean => {
718
+ if (!throughCalls && clobberedLocal[at].has(reg)) {
719
+ return false;
720
+ }
521
721
  const own = defs[at].get(reg);
522
722
  // A def `accept` rejects does not fall through to the predecessors: it is still a def, and
523
723
  // nothing older than it reaches past it.
@@ -532,28 +732,153 @@ export function makeSsaBuilder(
532
732
  };
533
733
  return walk(b, new Set<number>());
534
734
  };
735
+ const hasReachingDef = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean =>
736
+ reaches(reg, b, accept, true);
737
+ const holdsValue = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean =>
738
+ reaches(reg, b, accept, false);
739
+
740
+ const holdsEntryValue = (reg: string, b: number): boolean => {
741
+ // A predecessor is known only once filled, and `b` itself only up to here — so a path that comes
742
+ // back round to `b` carries writes this walk has not seen.
743
+ const untouched = (at: number, seen: Set<number>): boolean => {
744
+ if (at === b || !filled[at]) {
745
+ return false;
746
+ }
747
+ if (seen.has(at)) {
748
+ return true;
749
+ }
750
+ seen.add(at);
751
+ return clean(at, seen);
752
+ };
753
+ // `decidedLocal` holds a register a call destroyed as well as one this block wrote.
754
+ const clean = (at: number, seen: Set<number>): boolean => {
755
+ if (defs[at].has(reg) || decidedLocal[at].has(reg)) {
756
+ return false;
757
+ }
758
+ const ps = distinctPreds(at);
759
+ return ps.length === 0 ? at === 0 : ps.every((p) => untouched(p, seen));
760
+ };
761
+ return clean(b, new Set());
762
+ };
763
+ const entryValue = (reg: string, b: number): Value | undefined => {
764
+ if (!holdsEntryValue(reg, b)) {
765
+ return undefined;
766
+ }
767
+ ensureParam(reg, 0);
768
+ return irBlocks[0].params.find((p) => paramReg.get(p) === reg);
769
+ };
770
+
771
+ /** REFUSE a value the ABI destroyed. The builder is RIGHT that a caller-saved register has a
772
+ * reaching definition after a call; what it cannot see is that the call destroyed the bytes, so
773
+ * that definition names something the callee overwrote. Resolving the read to it is a silently
774
+ * wrong VALUE at exit 0 — `mov r3,#0x2a; bl f; add r4,r3,#0` reading as `return 42` — which is
775
+ * the one failure a frontend may not produce.
776
+ *
777
+ * It fires on a USE rather than on the read, and after two things in `finish` that legitimately
778
+ * retract one: `trimClobberedCallArgs`, which drops a read that fed a guessed call's argument
779
+ * list — the sound answer for those, and not a reason to refuse the function as well — and
780
+ * `pruneDeadParams`, which drops one that fed a join nothing reads. A destroyed value nothing is
781
+ * left holding is a dead register, not a wrong answer.
782
+ *
783
+ * TWO-SIDED, so both sides are stated. TOO STRICT costs a row and nothing else: a register the
784
+ * compiler rematerialised in a way no `writeVar` saw reads as destroyed and the function
785
+ * declines. TOO LOOSE is the wrong value, and the MAY direction below is what rules it out — a
786
+ * register destroyed on ANY path into a block is destroyed there, so a join cannot launder one. */
787
+ /** Per block, the registers a call destroyed on SOME path into it with nothing written since:
788
+ * the UNION, against `trimClobberedCallArgs`'s intersection, and for the opposite reason. That
789
+ * one proves the caller set a register up, which needs every path to agree; this one proves
790
+ * nobody can name it, which one path is enough for. Meaningful once every block is filled. */
791
+ const destroyedOnEntry = (): Array<Set<string>> => {
792
+ const destroyedIn: Array<Set<string>> = irBlocks.map(() => new Set());
793
+ const outOf = (b: number): Set<string> => {
794
+ const out = new Set(clobberedLocal[b]);
795
+ for (const r of destroyedIn[b]) {
796
+ if (!decidedLocal[b].has(r)) {
797
+ out.add(r);
798
+ }
799
+ }
800
+ return out;
801
+ };
802
+ for (let changed = true; changed;) {
803
+ changed = false;
804
+ for (let b = 0; b < irBlocks.length; b++) {
805
+ for (const p of distinctPreds(b)) {
806
+ for (const r of outOf(p)) {
807
+ if (!destroyedIn[b].has(r)) {
808
+ destroyedIn[b].add(r);
809
+ changed = true;
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+ return destroyedIn;
816
+ };
817
+ const refuseStaleCallerSavedReads = () => {
818
+ if (staleCandidates.length === 0) {
819
+ return;
820
+ }
821
+ const destroyedIn = destroyedOnEntry();
822
+ for (const c of staleCandidates) {
823
+ if (!c.local && !destroyedIn[c.block].has(c.reg)) {
824
+ continue;
825
+ }
826
+ const ops = irBlocks[c.block].ops;
827
+ if (ops.slice(c.fromOp).some((op) => op.operands.includes(c.value))) {
828
+ throw new FrontendUnsupportedError(
829
+ `cannot lift '${name}': ${c.reg} is read on a path where a call has destroyed it, so what ` +
830
+ `it holds is the callee's and not a value this function named — not modelled`,
831
+ );
832
+ }
833
+ }
834
+ };
835
+
836
+ const dropPhiRecord = (p: Value) => {
837
+ phiBlock.delete(p);
838
+ phiKey.delete(p);
839
+ forgetOrder(p);
840
+ };
535
841
 
536
842
  return {
537
843
  fn,
538
844
  irBlocks,
539
845
  readVar,
846
+ readGuessedArg: readAny, // see the interface: the trim is this read's answer, not a refusal
540
847
  writeVar,
541
848
  paramReg,
542
849
  ensureParam,
543
850
  hasReachingDef,
544
- noteCall: (b: number) => {
851
+ holdsValue,
852
+ entryValue,
853
+ holdsEntryValue,
854
+ noteCall: (b: number, clobbers: readonly string[]) => {
545
855
  callsIn.add(b);
546
856
  // the callee clobbers the caller-saved registers, its own result register included — see
547
857
  // the ordering contract on the interface
548
858
  writtenSinceCall[b] = new Set();
859
+ for (const r of clobbers) {
860
+ clobberedLocal[b].add(r);
861
+ decidedLocal[b].add(r);
862
+ }
549
863
  },
550
- recordGuessedCall: (op: Op, b: number, abi: { argRegs: string[]; returnReg: string }) => {
864
+ recordGuessedCall: (
865
+ op: Op,
866
+ b: number,
867
+ abi: { argRegs: string[]; returnReg: string },
868
+ passesCalleeResult?: (result: Value) => boolean,
869
+ ) => {
551
870
  abiSeen = abi;
552
871
  guessedCalls.push({
553
872
  block: b,
554
873
  op,
874
+ passesCalleeResult,
555
875
  freshBefore: new Set(writtenSinceCall[b]),
556
876
  afterCallInBlock: callsIn.has(b), // `noteCall` runs after this, so this means an EARLIER call
877
+ returnRegBefore: clobberedLocal[b].has(abi.returnReg)
878
+ ? 'destroyed'
879
+ : decidedLocal[b].has(abi.returnReg)
880
+ ? 'written'
881
+ : 'inherited',
557
882
  });
558
883
  },
559
884
  markFilled: (b: number) => {
@@ -575,31 +900,35 @@ export function makeSsaBuilder(
575
900
  }
576
901
  }
577
902
  trimClobberedCallArgs({
903
+ name,
578
904
  argRegs: abiSeen.argRegs,
579
905
  returnReg: abiSeen.returnReg,
580
906
  calleeResults,
907
+ destroyedIn: destroyedOnEntry(),
581
908
  preds,
909
+ blocks: irBlocks,
582
910
  freshAtEnd: writtenSinceCall,
583
911
  callsIn,
584
912
  sites: guessedCalls,
585
913
  });
586
914
  }
587
915
  irBlocks.forEach((blk, i) => writeOrder.writes.set(blk, writeCount[i]));
588
- simplifyTrivialPhis(fn, (p) => {
589
- phiBlock.delete(p);
590
- phiKey.delete(p);
591
- forgetOrder(p);
592
- });
593
- // Then the phis nothing reads at all — a register two paths leave holding different junk
594
- // (a loop counter after its last use, a scratch the epilogue overwrites) still joins as a
595
- // phi, and a dead phi is not junk downstream: its edge args become post-loop copies in the
596
- // emitted C and block gates keyed on "this exit carries nothing". Order matters only for
597
- // economy: trivial-phi removal can orphan a phi's last reader, never the reverse.
598
- pruneDeadParams(fn, (p) => {
599
- phiBlock.delete(p);
600
- phiKey.delete(p);
601
- forgetOrder(p);
602
- });
916
+ // The phis nothing reads at all — a register two paths leave holding different junk (a loop
917
+ // counter after its last use, a scratch the epilogue overwrites) still joins as a phi, and a
918
+ // dead phi is not junk downstream: its edge args become post-loop copies in the emitted C and
919
+ // block gates keyed on "this exit carries nothing".
920
+ //
921
+ // DEAD-PHI PRUNING RUNS FIRST, and `refuseStaleCallerSavedReads` is why. That check asks
922
+ // whether a destroyed value still reaches a use, and a dead phi's edge arg is not one. The
923
+ // trivial-phi
924
+ // pass must not run before it for the opposite reason: that one REPLACES the phi's uses with
925
+ // the arg and splices the arg away, which takes the last visible use of a destroyed value out
926
+ // of sight. Running it twice is what keeps both true — trivial-phi removal can orphan a phi's
927
+ // last reader, never the reverse, so the second sweep is the one that pays for the first.
928
+ pruneDeadParams(fn, dropPhiRecord);
929
+ refuseStaleCallerSavedReads();
930
+ simplifyTrivialPhis(fn, dropPhiRecord);
931
+ pruneDeadParams(fn, dropPhiRecord);
603
932
  // SEAL THE PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`). Here and not at the store or the
604
933
  // write, because `pruneDeadParams` above is the last thing that can retire an entry
605
934
  // parameter, and an observation about a value no longer in the signature is one the reader
@@ -667,21 +996,58 @@ export function makeSsaBuilder(
667
996
  };
668
997
  }
669
998
 
670
- // ── shared frontend tail helpers ──
999
+ // ── a guessed call arity: counted by `CallDeclarations.lower`, trimmed by `finish` ──
671
1000
 
672
1001
  /** Best-effort call arity when a callee has no prototype: the count of contiguous argument
673
1002
  * registers with a value reaching the call's block. Correct when the arguments are set up in
674
1003
  * the calling block; it can under-count pass-through parameters — which is why a prototype's
675
- * declared `params` is authoritative when available. */
1004
+ * declared `params` is authoritative when available.
1005
+ *
1006
+ * `accept` says which definitions are values (`SsaBuilder.hasReachingDef`). A pending high half
1007
+ * is a def but not a value, and counted it raises the arity: a `lis` hoisted into the prologue
1008
+ * leaves its half in r4 across an intervening call and makes `strlen(s)` into
1009
+ * `strlen(s, <half>)`, which then refuses at the read.
1010
+ *
1011
+ * `gap` MAKES A GAP REFUSE. The count is contiguous, so an argument register with nothing reaching
1012
+ * it ends it — and one is empty for two opposite reasons. Either the call really takes that few
1013
+ * arguments, or the register still holds this function's own untouched incoming argument, a value
1014
+ * the machine passes on that SSA has no definition for because nothing ever wrote it. When a
1015
+ * LATER argument register does hold a value the second reading is the only one left, and taking
1016
+ * the first drops that argument and every one after it: `ac-decomp:evw_anime_colreg_manual` sets
1017
+ * up seven registers for `evw_color_set` and, with r4 at its incoming value, lifts to
1018
+ * `evw_color_set(a0);` — its divide, its multiply and five arguments gone. Which reading it is
1019
+ * cannot be decided here — the function's own arity is exactly what is missing — so this refuses
1020
+ * and names the gap rather than guessing. A declaration answers it, and this scan is never
1021
+ * weighed against one: it is a guess, and a guess that cannot fail cannot witness a width a
1022
+ * declaration left open. */
676
1023
  export function fallbackArgc(
677
- ssa: { hasReachingDef(reg: string, b: number): boolean },
678
- argRegs: string[],
1024
+ ssa: { hasReachingDef(reg: string, b: number, accept?: (v: Value) => boolean): boolean },
1025
+ argRegs: readonly string[],
679
1026
  bi: number,
1027
+ opts: {
1028
+ accept?: (v: Value) => boolean;
1029
+ /** the call's function and address, and the frontend's own refusal */
1030
+ gap?: { name: string; at: number; fail: FrontendRefusal };
1031
+ } = {},
680
1032
  ): number {
1033
+ // whether the caller set argument register k up: a definition reaches it, through a call too
1034
+ const setUp = (k: number) => ssa.hasReachingDef(argRegs[k], bi, opts.accept);
681
1035
  let n = 0;
682
- while (n < argRegs.length && ssa.hasReachingDef(argRegs[n], bi)) {
1036
+ while (n < argRegs.length && setUp(n)) {
683
1037
  n++;
684
1038
  }
1039
+ if (opts.gap !== undefined) {
1040
+ const { name, at, fail } = opts.gap;
1041
+ for (let k = n + 1; k < argRegs.length; k++) {
1042
+ if (setUp(k)) {
1043
+ fail(
1044
+ `cannot lift '${name}': the call at 0x${at.toString(16)} has no prototype, ${argRegs[k]} holds a ` +
1045
+ `value and ${argRegs[n]} holds none — an argument register left at its incoming value and one ` +
1046
+ `the call does not pass look the same here, so the argument count is not decidable`,
1047
+ );
1048
+ }
1049
+ }
1050
+ }
685
1051
  return n;
686
1052
  }
687
1053
 
@@ -689,15 +1055,24 @@ export function fallbackArgc(
689
1055
  * of its own block up to that instruction. */
690
1056
  export interface GuessedCallSite {
691
1057
  block: number;
692
- /** the `call` op — its operands are the guessed arguments, in argument-register order */
1058
+ /** the `call` op — its arguments (`callArgs`) are the guessed ones, in argument-register order */
693
1059
  op: Op;
694
1060
  /** argument registers written between the last call in this block (or the block's start) and here */
695
1061
  freshBefore: Set<string>;
696
1062
  /** did this block already make a call before this one? */
697
1063
  afterCallInBlock: boolean;
1064
+ /** what this block did to the return register before this call: a call here left it holding
1065
+ * nothing (`destroyed`), the block wrote it since (`written`), or neither, so the predecessors
1066
+ * decide (`inherited`) */
1067
+ returnRegBefore: 'destroyed' | 'written' | 'inherited';
1068
+ /** which earlier callee's result in the return register is an argument here, not a leftover:
1069
+ * the site's lowering says so ({@link SsaBuilder.recordGuessedCall}). Absent where none is. */
1070
+ passesCalleeResult?: (result: Value) => boolean;
698
1071
  }
699
1072
 
700
1073
  export interface CallArgTrim {
1074
+ /** the function being lifted, for a refusal */
1075
+ name: string;
701
1076
  argRegs: string[];
702
1077
  /** the ABI return register. Load-bearing only where it IS `argRegs[0]` (ARM r0, PPC r3) — that
703
1078
  * aliasing is what makes a callee's result indistinguishable from caller-side argument setup. */
@@ -705,8 +1080,14 @@ export interface CallArgTrim {
705
1080
  /** every value a `call` op produced. Tells a callee's own return apart from a join that merely
706
1081
  * PASSES THROUGH one, which the register file cannot: both leave argument 0 unfresh. */
707
1082
  calleeResults: ReadonlySet<Value>;
1083
+ /** per block, the registers a call destroyed on SOME path into it with nothing written since. A
1084
+ * call lists the return register there only when it leaves it holding nothing the caller can
1085
+ * name — a struct handed back in it ({@link SsaBuilder.noteCall}). */
1086
+ destroyedIn: ReadonlyArray<ReadonlySet<string>>;
708
1087
  /** one entry per CFG edge, as passed to {@link makeSsaBuilder} */
709
1088
  preds: number[][];
1089
+ /** the blocks `preds` indexes, terminators included — the edges a guard proves a value on */
1090
+ blocks: readonly Block[];
710
1091
  /** per block: the keys written since its LAST call (since its start if it makes none). Indexed by
711
1092
  * block, and it holds every key the builder saw, not only argument registers. */
712
1093
  freshAtEnd: Array<Set<string>>;
@@ -736,12 +1117,13 @@ export interface CallArgTrim {
736
1117
  * real argument CAN go with them: a fresh register above a hole stops the run (a 64-bit return
737
1118
  * occupies two registers and the frontend cannot express one, so the caller's r2 goes with the
738
1119
  * unfillable r1), and a callee's return read as the callee's own drops an argument a `g(f())`
739
- * source did pass. A declared prototype is what closes either.
1120
+ * source did pass. A declared prototype is what closes either for a named callee; a call through a
1121
+ * register has none, so it keeps that return instead (`GuessedCallSite.passesCalleeResult`).
740
1122
  *
741
1123
  * Frontend-agnostic: the caller supplies what its own lifting scan observed, so nothing here
742
1124
  * re-derives which instruction writes which register. */
743
1125
  export function trimClobberedCallArgs(inp: CallArgTrim): void {
744
- const { argRegs, returnReg, calleeResults, preds, freshAtEnd, callsIn, sites } = inp;
1126
+ const { argRegs, returnReg, calleeResults, destroyedIn, preds, blocks, freshAtEnd, callsIn, sites } = inp;
745
1127
  const blockCount = freshAtEnd.length;
746
1128
  const all = () => new Set(argRegs);
747
1129
  const localEnd = (b: number) => freshAtEnd[b] ?? new Set<string>();
@@ -792,7 +1174,7 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
792
1174
  // (`bl __mulsf3; add r1,r4,#0; bl __addsf3` is `__addsf3(__mulsf3(a, b), c)`).
793
1175
  const setsUpLater = (fresh: Set<string>): boolean => argRegs.some((r, i) => i > 0 && fresh.has(r));
794
1176
  // THE RETURN REGISTER IS NOT ARGUMENT SETUP. Where the ABI aliases it onto argument 0, the
795
- // frontends record a call's clobber AFTER its own result, so the result leaves the register
1177
+ // call lowering records a call's clobber AFTER its own result, so the result leaves the register
796
1178
  // UNfresh here. That disproves caller setup only where the callee's return is BOTH what the
797
1179
  // register still holds and all the site has to go on: with a later register set up (above), or
798
1180
  // with a value no call produced — a join of one path's return with another path's caller-computed
@@ -804,20 +1186,136 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
804
1186
  // `f` to return a value and `g` to accept one — a spelling the project's own header rejects
805
1187
  // outright when it does not. A declared prototype never reaches here, and stays the way `g(f())`
806
1188
  // is recovered.
807
- const argcAt = (fresh: Set<string>, op: Op): number => {
808
- if (argRegs[0] !== returnReg || fresh.has(argRegs[0])) {
1189
+ //
1190
+ // A SITE THAT PASSES THE RESULT ON keeps it (`GuessedCallSite.passesCalleeResult`).
1191
+ //
1192
+ // THE EXEMPTION NEEDS A RESULT IN THE REGISTER. A call that hands a struct back in it (`struct W1
1193
+ // mkw(s32)`) writes no value there — its bytes are a struct, not a word — so the register still
1194
+ // names what it held BEFORE that call, and neither clause above can tell: that value is no callee
1195
+ // result, which is the join clause's sign of a real argument. Destroyed on any path is no setup on
1196
+ // that path, so the register is an ordinary unfresh one there, and the run stops below it —
1197
+ // unless a later register is set up, which puts argument 0 below a proven one: that argument is
1198
+ // then the struct itself (`other(mv(a), b)`), a value no register here names, and it declines.
1199
+ //
1200
+ // WHETHER AN EDGE PROVES `v` CONSTANT EVERYWHERE `b` IS REACHED: up the chain of blocks with one
1201
+ // predecessor, a conditional branch on `v == K` arriving by its true edge, or on `v != K` by its
1202
+ // false one. K is any value arithmetic builds out of literals: agbcc compares with 1000 as
1203
+ // `mov r1,#250; lsl r1,#2; cmp r0,r1`. `v` holds every name the value goes by: compared past a join
1204
+ // the trivial-phi pass has yet to fold, a callee's result is that join's parameter.
1205
+ let defs: Map<Value, Op> | undefined;
1206
+ const defOf = (v: Value): Op | undefined => {
1207
+ defs ??= new Map(blocks.flatMap((blk) => blk.ops.flatMap((op) => op.results.map((r) => [r, op] as const))));
1208
+ return defs.get(v);
1209
+ };
1210
+ const isLiteral = (v: Value): boolean => {
1211
+ const d = defOf(v);
1212
+ return (
1213
+ d?.opcode === 'const' ||
1214
+ (d !== undefined && LITERAL_ARITHMETIC.has(d.opcode) && d.operands.length > 0 && d.operands.every(isLiteral))
1215
+ );
1216
+ };
1217
+ const provenConstant = (v: ReadonlySet<Value>, b: number): boolean => {
1218
+ for (let at = b, seen = new Set<number>(); !seen.has(at);) {
1219
+ seen.add(at);
1220
+ const ps = [...new Set(preds[at] ?? [])];
1221
+ if (ps.length !== 1) {
1222
+ return false;
1223
+ }
1224
+ const term = terminator(blocks[ps[0]]);
1225
+ const arrives = term?.successors.map((e) => e.block === blocks[at]) ?? [];
1226
+ const cmp = term?.opcode === 'cond_br' && arrives[0] !== arrives[1] ? defOf(term.operands[0]) : undefined;
1227
+ const equalEdge = cmp?.opcode === 'icmp_eq' ? 0 : cmp?.opcode === 'icmp_ne' ? 1 : -1;
1228
+ if (
1229
+ equalEdge >= 0 &&
1230
+ arrives[equalEdge] &&
1231
+ cmp!.operands.some((o) => v.has(o)) &&
1232
+ cmp!.operands.some((o) => !v.has(o) && isLiteral(o))
1233
+ ) {
1234
+ return true;
1235
+ }
1236
+ at = ps[0];
1237
+ }
1238
+ return false;
1239
+ };
1240
+ // THE RESULT A SITE PASSES ON is looked for through the joins the trivial-phi pass has yet to
1241
+ // fold: this trim runs before it, so a result r0 carries untouched across a loop reaches the call
1242
+ // as the loop header's parameter, not as the callee's own value. The site passes one where every
1243
+ // path brings it an earlier callee's result it passes (`GuessedCallSite.passesCalleeResult`) —
1244
+ // that value, or the join itself where the paths bring different ones (`v = x ? g() : h(); p(v);`).
1245
+ let joinArgs: Map<Value, Value[]> | undefined;
1246
+ const joinedInto = (v: Value): Value[] | undefined => {
1247
+ if (joinArgs === undefined) {
1248
+ joinArgs = new Map();
1249
+ for (const blk of blocks) {
1250
+ for (const op of blk.ops) {
1251
+ for (const e of op.successors) {
1252
+ // the entry block's parameters are the function's own, whatever a back edge brings
1253
+ if (e.block === blocks[0]) {
1254
+ continue;
1255
+ }
1256
+ e.block.params.forEach((param, i) => {
1257
+ const into = joinArgs!.get(param);
1258
+ if (into) {
1259
+ into.push(e.args[i]);
1260
+ } else {
1261
+ joinArgs!.set(param, [e.args[i]]);
1262
+ }
1263
+ });
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+ return joinArgs.get(v);
1269
+ };
1270
+ const passedResult = (s: GuessedCallSite): Value | undefined => {
1271
+ const arg = callArgs(s.op)[0];
1272
+ const passes = s.passesCalleeResult;
1273
+ if (arg === undefined || passes === undefined) {
1274
+ return undefined;
1275
+ }
1276
+ const results = new Set<Value>();
1277
+ const seen = new Set<Value>();
1278
+ for (const work = [arg]; work.length > 0;) {
1279
+ const v = work.pop()!;
1280
+ if (seen.has(v)) {
1281
+ continue;
1282
+ }
1283
+ seen.add(v);
1284
+ const into = joinedInto(v);
1285
+ if (into !== undefined) {
1286
+ work.push(...into);
1287
+ } else if (calleeResults.has(v) && passes(v)) {
1288
+ results.add(v);
1289
+ } else {
1290
+ return undefined;
1291
+ }
1292
+ }
1293
+ return results.size === 0 ? undefined : results.size === 1 ? [...results][0] : arg;
1294
+ };
1295
+ const argcAt = (s: GuessedCallSite, fresh: Set<string>): number => {
1296
+ const destroyed =
1297
+ s.returnRegBefore === 'destroyed' || (s.returnRegBefore === 'inherited' && destroyedIn[s.block].has(returnReg));
1298
+ if (argRegs[0] === returnReg && !fresh.has(argRegs[0]) && destroyed && setsUpLater(fresh)) {
1299
+ const callee = s.op.attrs.target;
1300
+ throw new FrontendUnsupportedError(
1301
+ `cannot lift '${inp.name}': argument 1 of the call to ${typeof callee === 'string' ? `'${callee}'` : 'a callee'} ` +
1302
+ `is a struct an earlier call handed back in ${returnReg}, and nothing here passes one on as an argument — not modelled`,
1303
+ );
1304
+ }
1305
+ if (argRegs[0] !== returnReg || fresh.has(argRegs[0]) || destroyed) {
809
1306
  return runOfFresh(fresh, 0);
810
1307
  }
811
- if (setsUpLater(fresh) || !calleeResults.has(op.operands[0])) {
1308
+ const op = s.op;
1309
+ if (setsUpLater(fresh) || !calleeResults.has(callArgs(op)[0]) || passedResult(s) !== undefined) {
812
1310
  return runOfFresh(new Set([argRegs[0], ...fresh]), 0);
813
1311
  }
814
1312
  return 0;
815
1313
  };
816
1314
  for (const s of sites) {
817
1315
  const fresh = s.afterCallInBlock ? s.freshBefore : new Set([...freshIn[s.block], ...s.freshBefore]);
818
- const n = argcAt(fresh, s.op);
819
- if (n < s.op.operands.length) {
820
- s.op.operands.length = n;
1316
+ const n = argcAt(s, fresh);
1317
+ if (n < callArgs(s.op).length) {
1318
+ truncateCallArgs(s.op, n);
821
1319
  }
822
1320
  // The SHORTER arity the same evidence also allows, recorded for {@link narrowToSetupArgs}: the
823
1321
  // run over what THIS BLOCK wrote, dropping the registers that are fresh only because no call
@@ -825,20 +1323,60 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
825
1323
  // is recorded rather than applied. A survivor is what it drops, so the join clause above has no
826
1324
  // place here — but `setsUpLater` still does: a register this block set up two instructions
827
1325
  // before the call is not something the narrower reading may call dead.
828
- const localFresh = setsUpLater(s.freshBefore) ? new Set([argRegs[0], ...s.freshBefore]) : s.freshBefore;
829
- const local = Math.min(runOfFresh(localFresh, 0), s.op.operands.length);
830
- if (local < s.op.operands.length) {
1326
+ //
1327
+ // A SITE THAT PASSES AN EARLIER CALLEE'S RESULT ON keeps it in this reading too, except where an
1328
+ // edge proves that result equal to a constant on the way here (`if (g() == 0) p();`): a source
1329
+ // that passed it there makes the compiler load the constant it proved (`mov r0,#0`), so an asm
1330
+ // with no such load passed nothing. Per site, because this reading cuts every site at once, and a
1331
+ // drop that one site needs would otherwise drop a value another site passes.
1332
+ const result = n > 0 ? passedResult(s) : undefined;
1333
+ const passed = result !== undefined;
1334
+ const dropsPassed = passed && provenConstant(new Set([result, callArgs(s.op)[0]]), s.block);
1335
+ const localFresh =
1336
+ setsUpLater(s.freshBefore) || (passed && !dropsPassed) ? new Set([argRegs[0], ...s.freshBefore]) : s.freshBefore;
1337
+ const local = Math.min(runOfFresh(localFresh, 0), callArgs(s.op).length);
1338
+ if (local < callArgs(s.op).length) {
831
1339
  setupArgc.set(s.op, local);
1340
+ if (local === 0 && dropsPassed) {
1341
+ discardsPassedResult.add(s.op);
1342
+ }
832
1343
  }
833
1344
  }
834
1345
  }
835
1346
 
1347
+ /** The opcodes whose result is a literal when every operand is one ({@link trimClobberedCallArgs}). */
1348
+ const LITERAL_ARITHMETIC: ReadonlySet<string> = new Set([
1349
+ 'add',
1350
+ 'sub',
1351
+ 'mul',
1352
+ 'and',
1353
+ 'or',
1354
+ 'xor',
1355
+ 'shl',
1356
+ 'shr_u',
1357
+ 'shr_s',
1358
+ 'not',
1359
+ 'neg',
1360
+ ]);
1361
+
836
1362
  /** The narrower arity {@link narrowToSetupArgs} would cut each guessed call to. A SIDE table and
837
1363
  * not an attr: this is a fact about one LIFT, not part of the IR the rest of the pipeline compares
838
1364
  * and prints — `structure/hazards.ts` decides two ops equal by comparing their attrs verbatim, so
839
1365
  * an attr only one of an otherwise-matching pair carries would cost a recovery. */
840
1366
  const setupArgc = new WeakMap<Op, number>();
841
1367
 
1368
+ /** The calls whose narrower arity drops an earlier callee's result that the site passes on
1369
+ * (`GuessedCallSite.passesCalleeResult`): only where an edge proves that result constant on the way
1370
+ * to the call ({@link trimClobberedCallArgs}). Read by {@link setupArgsDiscardsPassedResult}. */
1371
+ const discardsPassedResult = new WeakSet<Op>();
1372
+
1373
+ /** Whether {@link narrowToSetupArgs} drops, somewhere in `fn`, an earlier callee's result that a
1374
+ * call through a register is passed — the fact that makes the narrowed lift's candidates lose a
1375
+ * score tie (`Candidate.discardsPassedResult`). */
1376
+ export function setupArgsDiscardsPassedResult(fn: Fn): boolean {
1377
+ return fn.blocks.some((b) => b.ops.some((op) => discardsPassedResult.has(op)));
1378
+ }
1379
+
842
1380
  /** Whether anything in `fn` HAS the narrower reading — the variation's gate, so the ~99% of functions
843
1381
  * with no narrowable call cost no re-lift. Read it off the lift itself: a later pipeline stage may
844
1382
  * replace a `call` op (softdiv rewrites one to a division), and the table is keyed by op. */
@@ -864,8 +1402,8 @@ export function narrowToSetupArgs(fn: Fn): boolean {
864
1402
  for (const b of fn.blocks) {
865
1403
  for (const op of b.ops) {
866
1404
  const setup = setupArgc.get(op);
867
- if (setup !== undefined && setup < op.operands.length) {
868
- op.operands.length = setup;
1405
+ if (setup !== undefined && setup < callArgs(op).length) {
1406
+ truncateCallArgs(op, setup);
869
1407
  changed = true;
870
1408
  }
871
1409
  }
@@ -879,9 +1417,11 @@ export function narrowToSetupArgs(fn: Fn): boolean {
879
1417
  return changed;
880
1418
  }
881
1419
 
882
- /** The stack-slot key both the MIPS and Thumb frontends use for a word-sized local in the
883
- * function's own frame. Shared so the two spell it identically and the frame-partition rule can
884
- * recognise either frontend's slots. See the virtual-key note in the module header. */
1420
+ // ── shared frontend tail helpers ──
1421
+
1422
+ /** The stack-slot key the MIPS, Thumb and PowerPC frontends use for a word-sized local in the
1423
+ * function's own frame. Shared so they spell it identically and the frame-partition rule can
1424
+ * recognise any frontend's slots. See the virtual-key note in the module header. */
885
1425
  const SLOT_PREFIX = 'sp@';
886
1426
  export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
887
1427
  /** The byte offset a slot key names, or null if `key` is not a slot key at all (an ordinary
@@ -890,20 +1430,73 @@ export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
890
1430
  export const slotKeyOffset = (key: string): number | null =>
891
1431
  key.startsWith(SLOT_PREFIX) ? Number(key.slice(SLOT_PREFIX.length)) : null;
892
1432
 
893
- /** Order the TRUE entry block's parameters by ABI argument register, so downstream naming
894
- * (`a0`, `a1`, …) matches the calling convention, not first-read order (a callee-saved copy can
895
- * read a later argument register first). No-op when the entry has predecessors — a loop
896
- * header's params are phis position-aligned with predecessor terminator args and must not be
897
- * reordered. `rank` is per-ISA: the tie-break for a non-ABI live-in deliberately differs
898
- * (Thumb sorts it LAST via 99, MIPS/PPC FIRST via indexOf's -1) to keep each frontend's
899
- * output byte-exact. */
1433
+ /** Where a live-in of the entry block sits in the calling convention: `slotOf` gives its ABI
1434
+ * argument rank (a register or an incoming stack word), or null for a live-in no argument arrives
1435
+ * in — an uninitialised register the body reads; `holes` gives the keys of the argument slots the
1436
+ * convention proves precede the ones read (`readKeys`), whether or not the body reads them. One per
1437
+ * frontend, read by both halves of the entry-parameter rule: {@link mintArgSlotHoles} and
1438
+ * {@link abiSortEntryParams}.
1439
+ *
1440
+ * `holes` is a function of everything read, not a slot-to-key table, because where two register
1441
+ * files share one slot sequence (MIPS o32's floats) the file a hole comes from depends on what the
1442
+ * function reads — `frontend/fpu.ts` `fpuArgSlots`.
1443
+ *
1444
+ * Both halves need an entry block with NO predecessors, whose parameters are the function's own.
1445
+ * A loop header's are phis, index-aligned with its predecessors' edge arguments, and neither
1446
+ * minting one nor reordering them is legal there — so a frontend whose first block is a branch
1447
+ * target inserts an empty preheader ahead of it (each frontend's `preheader` note). */
1448
+ export interface ArgSlots {
1449
+ slotOf(key: string): number | null;
1450
+ holes(readKeys: readonly string[]): string[];
1451
+ }
1452
+
1453
+ /** The slots of a convention that passes arguments in `argRegs` only: every register below the
1454
+ * highest one read. */
1455
+ export const registerArgSlots = (argRegs: readonly string[]): ArgSlots => ({
1456
+ slotOf: (key) => (argRegs.includes(key) ? argRegs.indexOf(key) : null),
1457
+ holes: (readKeys) => argRegs.slice(0, Math.max(0, ...readKeys.map((k) => argRegs.indexOf(k)))),
1458
+ });
1459
+
1460
+ /** A rank past every argument slot, for a live-in that is not one. */
1461
+ const NOT_AN_ARGUMENT = Number.MAX_SAFE_INTEGER;
1462
+
1463
+ /** Give the entry block a parameter for every argument slot below the highest one it takes. Naming
1464
+ * is POSITIONAL (`a0`, `a1`, … in {@link abiSortEntryParams} order), so a slot the body never reads
1465
+ * drops out of the signature and binds every later argument one slot low, silently: `int f(int a,
1466
+ * int b, int c) { return a + c; }` reads r3 and r5, and lifted as `f(a0, a1)` with r5 in r4's slot.
1467
+ * Arguments take their slots in order, registers first and then the stack words, so reading slot
1468
+ * k proves slots 0..k-1 precede it — an obligation on the signature, which is what `ensureParam`
1469
+ * is for. A live-in that is no slot proves nothing about that sequence. Call it before `finish()`,
1470
+ * which records evidence for every entry parameter. */
1471
+ export function mintArgSlotHoles(
1472
+ ssa: Pick<SsaBuilder, 'irBlocks' | 'paramReg' | 'ensureParam'>,
1473
+ entryHasPreds: boolean,
1474
+ slots: ArgSlots,
1475
+ ): void {
1476
+ assertTrueEntry(entryHasPreds);
1477
+ const read = ssa.irBlocks[0].params.map((p) => ssa.paramReg.get(p)).filter((k): k is string => k !== undefined);
1478
+ for (const k of slots.holes(read)) {
1479
+ ssa.ensureParam(k, 0);
1480
+ }
1481
+ }
1482
+
1483
+ /** Order the entry block's parameters by ABI argument slot, so downstream naming (`a0`, `a1`, …)
1484
+ * matches the calling convention, not first-read order (a callee-saved copy can read a later
1485
+ * argument register first). A live-in that is no slot goes after every one that is: ranked
1486
+ * first, it takes `a0` and binds every real argument one slot high. */
900
1487
  export function abiSortEntryParams(
901
1488
  entry: { params: Value[] },
902
1489
  entryHasPreds: boolean,
903
- rank: (v: Value) => number,
1490
+ paramReg: ReadonlyMap<Value, string>,
1491
+ slots: ArgSlots,
904
1492
  ): void {
1493
+ assertTrueEntry(entryHasPreds);
1494
+ const rank = (v: Value): number => slots.slotOf(paramReg.get(v) ?? '') ?? NOT_AN_ARGUMENT;
1495
+ entry.params.sort((x, y) => rank(x) - rank(y));
1496
+ }
1497
+
1498
+ function assertTrueEntry(entryHasPreds: boolean): void {
905
1499
  if (entryHasPreds) {
906
- return;
1500
+ throw new Error('internal: the entry-parameter rule needs an entry block with no predecessors');
907
1501
  }
908
- entry.params.sort((x, y) => rank(x) - rank(y));
909
1502
  }