@asmlift/core 0.8.1 → 0.10.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 (130) hide show
  1. package/README.md +36 -31
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +397 -0
  4. package/src/backend/c.ts +6 -2
  5. package/src/backend/cfamily.ts +370 -56
  6. package/src/backend/cpp.ts +116 -10
  7. package/src/backend/pascal.ts +69 -11
  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 +777 -84
  15. package/src/declare.ts +132 -13
  16. package/src/frontend/asmdata.ts +26 -6
  17. package/src/frontend/call-plan.ts +1590 -0
  18. package/src/frontend/dead-reads.ts +628 -0
  19. package/src/frontend/device-pins.ts +296 -0
  20. package/src/frontend/emit.ts +4 -3
  21. package/src/frontend/errors.ts +5 -0
  22. package/src/frontend/flags-edge.ts +101 -0
  23. package/src/frontend/fpu.ts +374 -0
  24. package/src/frontend/frame-objects.ts +3171 -0
  25. package/src/frontend/frontend.ts +64 -3
  26. package/src/frontend/high-half.ts +55 -5
  27. package/src/frontend/jump-table.ts +530 -0
  28. package/src/frontend/local-object.ts +616 -0
  29. package/src/frontend/mips.ts +2392 -208
  30. package/src/frontend/opaque.ts +97 -18
  31. package/src/frontend/own-struct-return.ts +500 -0
  32. package/src/frontend/ppc.ts +3137 -267
  33. package/src/frontend/register-struct-call.ts +484 -0
  34. package/src/frontend/reloc-symbol.ts +109 -13
  35. package/src/frontend/splat.ts +108 -5
  36. package/src/frontend/ssa.ts +2098 -89
  37. package/src/frontend/stackargs.ts +172 -42
  38. package/src/frontend/thumb.ts +4563 -2091
  39. package/src/ir/alias.ts +71 -0
  40. package/src/ir/bits.ts +681 -1
  41. package/src/ir/core.ts +199 -6
  42. package/src/ir/discipline.ts +313 -0
  43. package/src/ir/entry.ts +159 -0
  44. package/src/ir/float-bits.ts +47 -0
  45. package/src/{structure → ir}/loops.ts +6 -2
  46. package/src/ir/opcodes.ts +193 -121
  47. package/src/ir/parse.ts +1 -0
  48. package/src/ir/types.ts +202 -2
  49. package/src/ir/verify.ts +219 -3
  50. package/src/l3/address.ts +5 -0
  51. package/src/l3/argcopy.ts +38 -6
  52. package/src/l3/ast.ts +467 -23
  53. package/src/l3/basecse.ts +33 -5
  54. package/src/l3/coalesce.ts +21 -13
  55. package/src/l3/constinit.ts +382 -0
  56. package/src/l3/dce.ts +34 -12
  57. package/src/l3/gates.ts +9 -3
  58. package/src/l3/hoist.ts +8 -7
  59. package/src/l3/initfirst.ts +68 -19
  60. package/src/l3/intcell.ts +98 -0
  61. package/src/l3/mentions.ts +10 -4
  62. package/src/l3/narrowdecl.ts +259 -0
  63. package/src/l3/nearbase.ts +10 -5
  64. package/src/l3/parkfirst.ts +8 -5
  65. package/src/l3/pollguard.ts +11 -4
  66. package/src/l3/ptrcell.ts +354 -0
  67. package/src/l3/ptrfield.ts +123 -8
  68. package/src/l3/regspell.ts +26 -7
  69. package/src/l3/reindex.ts +293 -103
  70. package/src/l3/storage.ts +98 -1
  71. package/src/l3/symbol-refs.ts +182 -23
  72. package/src/l3/tailmerge.ts +9 -20
  73. package/src/l3/typing.ts +237 -32
  74. package/src/l3/unreduce.ts +10 -9
  75. package/src/l3/volatileptr.ts +17 -54
  76. package/src/l3/volstore.ts +96 -7
  77. package/src/mangle.ts +41 -1
  78. package/src/pattern/engine.ts +373 -82
  79. package/src/pipeline.ts +589 -27
  80. package/src/proto-context.ts +1648 -0
  81. package/src/proto.ts +1203 -49
  82. package/src/raise/aggmembers.ts +290 -0
  83. package/src/raise/arrays.ts +8 -3
  84. package/src/raise/declared-volatile.ts +307 -0
  85. package/src/raise/divpow2.ts +2 -2
  86. package/src/raise/errors.ts +17 -0
  87. package/src/raise/floathelpers.ts +464 -0
  88. package/src/raise/globalshape.ts +11 -5
  89. package/src/raise/gvn.ts +29 -8
  90. package/src/raise/latch.ts +162 -6
  91. package/src/raise/memberarrays.ts +10 -4
  92. package/src/raise/narrow.ts +29 -2
  93. package/src/raise/narrowlocal.ts +11 -6
  94. package/src/raise/offsetnames.ts +494 -0
  95. package/src/raise/pairparams.ts +114 -0
  96. package/src/raise/paramwidth.ts +203 -6
  97. package/src/raise/pre-recovery.ts +174 -17
  98. package/src/raise/pre-structure.ts +54 -0
  99. package/src/raise/recover.ts +26 -15
  100. package/src/raise/reroll.ts +694 -0
  101. package/src/raise/retsink.ts +1 -1
  102. package/src/raise/shortcircuit.ts +66 -30
  103. package/src/raise/softdiv.ts +21 -23
  104. package/src/raise/storesign.ts +153 -0
  105. package/src/raise/struct-arrays.ts +34 -14
  106. package/src/raise/structs.ts +674 -98
  107. package/src/raise/truncload.ts +367 -0
  108. package/src/raise/unthread.ts +400 -0
  109. package/src/raise/widehelpers.ts +106 -0
  110. package/src/rank-declare.ts +143 -12
  111. package/src/rank-variations.ts +117 -31
  112. package/src/rank.ts +563 -122
  113. package/src/runtime-helpers.ts +405 -0
  114. package/src/stillborn.ts +289 -0
  115. package/src/structure/analysis.ts +1449 -449
  116. package/src/structure/arith-ops.ts +23 -0
  117. package/src/structure/globalaccess.ts +111 -9
  118. package/src/structure/hazards.ts +641 -114
  119. package/src/structure/local-statics.ts +517 -0
  120. package/src/structure/namecoalesce.ts +2 -2
  121. package/src/structure/pointer-spelling.ts +1106 -0
  122. package/src/structure/redundant-test.ts +99 -0
  123. package/src/structure/structure.ts +3478 -975
  124. package/src/structure/switch-recover.ts +403 -178
  125. package/src/symbols.ts +44 -3
  126. package/src/target.ts +1387 -71
  127. package/src/trace.ts +66 -28
  128. package/src/variation-definitions.ts +243 -13
  129. package/src/variation-gates.ts +2 -0
  130. package/src/variation-tokens.ts +25 -7
@@ -23,21 +23,58 @@ import {
23
23
  type SlotHomes,
24
24
  Value,
25
25
  type WriteOrder,
26
+ callArgs,
26
27
  defOpMap,
27
28
  mkOp,
28
29
  mkValue,
30
+ replaceAllUsesWith,
31
+ terminator,
32
+ truncateCallArgs,
29
33
  } from '../ir/core';
30
34
  import { pruneDeadParams, simplifyTrivialPhis } from '../ir/simplify';
31
- import { T } from '../ir/types';
32
- import { FrontendUnsupportedError } from './errors';
35
+ import { type IrType, T } from '../ir/types';
36
+ import { globalRegisterSymbol } from '../target';
37
+ import { type FrontendRefusal, FrontendUnsupportedError } from './errors';
33
38
 
34
39
  export interface SsaBuilder {
35
40
  fn: Fn;
36
41
  irBlocks: Block[];
37
42
  /** Current SSA value of `reg` on entry to block `b` (creating phis/params as needed). */
38
43
  readVar(reg: string, b: number): Value;
44
+ /** Read `reg` as an argument register of a call whose arity was GUESSED — otherwise identical to
45
+ * {@link readVar}, and used INSTEAD of it so the read carries no claim.
46
+ *
47
+ * `fallbackArgc` counts argument registers by reaching definition, so a read here is a QUESTION
48
+ * ("did the caller set this up?") rather than an assertion, and {@link trimClobberedCallArgs} is
49
+ * what answers it. That answer already covers a register a call destroyed, and it covers it
50
+ * EXHAUSTIVELY: destroyed on any path means not written-since-the-call on that path, so the
51
+ * must-analysis drops it from the run and the operand goes — bar an argument register the ABI
52
+ * ALIASES onto the return register, which a call that writes its result there does not list as
53
+ * destroyed; one that writes none there does, and the trim reads that too. So none of these
54
+ * reads can leave a destroyed value in the graph, and refusing the function over one would cost
55
+ * a row the trim has already made correct.
56
+ *
57
+ * THAT ALIASING IS A PER-TARGET FACT AND TWO TARGETS HERE DO NOT HAVE IT. ARM and PowerPC both
58
+ * pass argument 0 in the return register (r0/r0, r3/r3), so on them the exemption is the whole
59
+ * of the gap. MIPS returns in `v0` and passes in `a0`, so `clobberedByCall` lists `a0` for both
60
+ * MIPS targets and there is no exemption to reason about — which is the sound direction, not a
61
+ * hole.
62
+ *
63
+ * A DECLARED arity uses `readVar`, and must: there the callee says the argument exists, so
64
+ * reading a destroyed register for it is a wrong value with nothing to retract it.
65
+ *
66
+ * One of the three call verbs `CallDeclarations.lower` owns ({@link noteCall}). */
67
+ readGuessedArg(reg: string, b: number): Value;
68
+ /** The value `reg` holds at the END of block `b` once `b` is filled, as the edges out of it carry
69
+ * it; `null` before. A lookup, not a read: it records no read for `finish` to judge. */
70
+ readAtEnd(reg: string, b: number): Value | null;
39
71
  /** Record that `reg` now holds `v` within block `b`. */
40
72
  writeVar(reg: string, b: number, v: Value): void;
73
+ /** Every value written to a frame-slot key ({@link stackSlotKey}) or an incoming stack-argument key
74
+ * ({@link stackArgKey}): a word the machine STORED to its stack. The slot is a variable here, so a
75
+ * store nothing reloads leaves no op behind, and the value it stored looks unused — the dead-read
76
+ * pin asks this (frontend/dead-reads.ts `spilled`). */
77
+ readonly frameWrites: ReadonlySet<Value>;
41
78
  /** Mark block `b` fully emitted (terminator pushed); seals any now-ready successors. */
42
79
  markFilled(b: number): void;
43
80
  /** Live-in parameter value → the key it arrived on (for calling-convention order). Usually an
@@ -54,7 +91,9 @@ export interface SsaBuilder {
54
91
  * Never touches the block's definitions: the parameter is added and left unused, so any local
55
92
  * value already flowing keeps flowing. Only meaningful on a block with no predecessors —
56
93
  * elsewhere a parameter is a phi whose position is aligned with its predecessors' terminator
57
- * args, and appending an unpaired one would corrupt that. */
94
+ * args, and appending an unpaired one would corrupt that. Nor on a register the live-in model
95
+ * calls an uninitialised local's home (`LiveInModel.uninitRegs`): no caller fills it, so no
96
+ * convention proves an argument there, and {@link entryValue} answers `undefined` for it. */
58
97
  ensureParam(key: string, b: number): void;
59
98
  /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic).
60
99
  *
@@ -63,15 +102,105 @@ export interface SsaBuilder {
63
102
  * "a def reaches here" and "a value reaches here" are the same question only when every def is
64
103
  * a value. */
65
104
  hasReachingDef(reg: string, b: number, accept?: (v: Value) => boolean): boolean;
105
+ /** {@link hasReachingDef}, with a path ending where a call destroyed `reg` (`noteCall`): whether a
106
+ * value this function named reaches it here on some path. A path that brings the destroyed bytes
107
+ * and one that brings a value still answer yes, and the read that follows refuses at `finish`. */
108
+ holdsValue(reg: string, b: number, accept?: (v: Value) => boolean): boolean;
109
+ /** The MUST question beside {@link hasReachingDef}'s MAY one: whether EVERY path into the end of
110
+ * block `b` brings a definition of `reg` that `accept` takes, with no call destroying it since
111
+ * (`noteCall`). A path from the entry that defines nothing answers no — the register holds the
112
+ * caller's value there. Asked once every block is filled: before that, a back edge from a block
113
+ * not yet filled carries definitions this walk cannot see, so it refuses to answer. */
114
+ everyReachingDef(reg: string, b: number, accept: (v: Value) => boolean): boolean;
115
+ /** The entry parameter `reg` still holds at this point of block `b`, taken WITHOUT a read. A read
116
+ * leaves a definition behind, and `hasReachingDef` counts it as argument setup, so a frontend that
117
+ * only moves the argument somewhere (a frame store) would raise the guessed arity of every later
118
+ * prototype-less call. `undefined` where "still the entry value" is not shown: a definition or a
119
+ * call reaches from some path, or a block on the way is not filled yet. */
120
+ entryValue(reg: string, b: number): Value | undefined;
121
+ /** Whether `reg` still holds its entry value at this point of block `b`, in {@link entryValue}'s
122
+ * sense, answered without the entry parameter that one hands back. A callee-saved register stored
123
+ * to its save slot holds the caller's value, which is no parameter, so asking must not mint one. */
124
+ holdsEntryValue(reg: string, b: number): boolean;
125
+ /** Whether block `b` WROTE `reg` since its last call so far (since its start if it made none).
126
+ * Only a write counts: a read leaves a definition behind but is not one (`writeVar`). */
127
+ writtenSinceCall(reg: string, b: number): boolean;
128
+ /** Whether EVERY path into this point of block `b` wrote `reg` (or had a call destroy it) after
129
+ * leaving the last block `stop` names: {@link writtenSinceCall} asked of `b` so far and, where `b`
130
+ * did not write it, of each predecessor in turn. A path reaching a `stop` block, the entry, or a
131
+ * block not filled yet answers no; a cycle adds no path the first visit did not judge. `visit`
132
+ * is told each block the walk passes through after `b`. */
133
+ writtenOnEveryPath(reg: string, b: number, stop: (at: number) => boolean, visit?: (at: number) => void): boolean;
134
+ /** Record that the frontend CONSUMED `v`, which it read from a register in block `b`, without
135
+ * placing it in any op: PowerPC's register save routine takes its frame address in r11 and is
136
+ * erased, so the address appears in no operand. The refusal of a read a call destroyed
137
+ * (`refuseStaleCallerSavedReads`) judges a read by its uses, and an op-less use is one too — left
138
+ * unrecorded, a frame address a call destroyed would still be consumed, silently. */
139
+ useOutsideOps(v: Value, b: number): void;
66
140
  /** 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;
141
+ * caller set up.
142
+ *
143
+ * THE CALL VERBS ARE `CallDeclarations.lower`'s (frontend/call-plan.ts): a frontend lowers a
144
+ * call through it, and it alone calls this, {@link recordGuessedCall} and {@link readGuessedArg},
145
+ * in one order — `recordGuessedCall`, then the call's own result, then this. The result is the
146
+ * CALLEE's, so it must not count as caller-side argument setup for whatever call comes next.
147
+ *
148
+ * `clobbers` are the registers the callee DESTROYS and leaves holding nothing this function can
149
+ * name — {@link clobberedByCall} spells it, and it is the ABI's caller-saved set minus the
150
+ * return register precisely because of the ordering above: the callee's own result is already
151
+ * written there, so that one register does have a name. A callee that hands a STRUCT back in
152
+ * that register writes no value a caller may read as a word, so none is written and the
153
+ * register is listed here too — save one whose bytes a local of the struct is read back from,
154
+ * which is written as the call's value and noted as an image ({@link noteStructImage}).
155
+ * Required rather than optional: a call lowered without it would keep resolving a destroyed
156
+ * register to its pre-call value, silently. */
157
+ noteCall(b: number, clobbers: readonly string[]): void;
158
+ /** The call {@link noteCall} just recorded in `b` left `reg` holding `image`, the bytes of a
159
+ * struct its callee is declared to return there (frontend/call-plan.ts `registerStructLocal`).
160
+ * A read takes it as the call's value, and the lift re-reads it off the struct's local; a GUESSED
161
+ * arity does not count it, as it counts no register a call destroyed: a call nothing declares
162
+ * may be handed the struct by value or a member of it, in the same register, and the bytes do
163
+ * not say which. So the trim treats `reg` as destroyed, and a guess that still passes the image
164
+ * refuses in `finish`. */
165
+ noteStructImage(b: number, reg: string, image: Value): void;
71
166
  /** Register a `call` op whose arity was GUESSED (no prototype), so `finish` can cut it back to the
72
167
  * 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;
168
+ * `abi` is the target's argument-register order and its return register. It snapshots what the
169
+ * caller wrote, so it comes before the call's result is written ({@link noteCall}).
170
+ * `passesCalleeResult`, `trimmed`, `withholdsSetupArgs`, `asksEveryCut` and `floor`: see
171
+ * {@link GuessedCallSite}. */
172
+ recordGuessedCall(
173
+ op: Op,
174
+ b: number,
175
+ abi: { argRegs: string[]; returnReg: string },
176
+ passesCalleeResult?: (result: Value) => boolean,
177
+ trimmed?: (cut: readonly string[], kept: readonly string[]) => void,
178
+ withholdsSetupArgs?: () => boolean,
179
+ asksEveryCut?: () => boolean,
180
+ floor?: number,
181
+ ): void;
182
+ /** Require that nothing but `op` reads `v` once the function is lifted — no other op, no join that
183
+ * survives, no frame store (`frameWrites`), no op-less use (`useOutsideOps`) — or call `refuse`.
184
+ * A read the lowering has yet to see may come after `op` through a copy in a register the call
185
+ * leaves alone, so the question is asked in `finish`, of the finished function. Its asker is the
186
+ * guessed-arity gap (frontend/call-plan.ts `decideGap`). */
187
+ requireSoleReader(v: Value, op: Op, refuse: () => void): void;
188
+ /** Require that something other than a `ret` reads `v` once the function is lifted — an op, a
189
+ * frame store or an op-less use, of `v` or of a join it reaches — or call `refuse`. Asked in
190
+ * `finish`, as `requireSoleReader` is, and by the same asker: a decided gap at the first argument
191
+ * register, where the call's result being read is what says the callee returns a word
192
+ * (frontend/call-plan.ts `decideGap`). A `ret` does not count, since a frontend returns the
193
+ * return register at every `ret` whatever the declared type. */
194
+ requireReader(v: Value, refuse: () => void): void;
195
+ /** Record that `v` is a value an instruction wrote as a SIDE EFFECT of its own job, which the
196
+ * compiler may leave in a register nobody reads: the base writeback of a Thumb `ldmia`/`stmia`,
197
+ * which has no form without one, and the base a PowerPC load or store with update advances. Such
198
+ * a value is no evidence that anything wanted it, so the guessed-arity gap (frontend/call-plan.ts
199
+ * `decideGap`) never takes one as an argument. agbcc copies a struct of 3+ words with
200
+ * `ldmia r3!, {r4, r5, r6}; stmia r2!, {r4, r5, r6}` and leaves both writebacks dead in r2/r3. */
201
+ noteIncidentalWrite(v: Value): void;
202
+ /** Whether {@link noteIncidentalWrite} recorded `v`. */
203
+ isIncidentalWrite(v: Value): boolean;
75
204
  /** Remove trivial phis and enforce the frontend's postconditions; call once every block is
76
205
  * filled. Throws FrontendUnsupportedError if a stack slot escaped as an entry parameter. */
77
206
  finish(): void;
@@ -80,7 +209,7 @@ export interface SsaBuilder {
80
209
  /** `preds` is per-EDGE (see the module header): one entry per CFG edge into each block. */
81
210
  // VARIABLE NAMES ARE NOT ALWAYS MACHINE REGISTERS. `readVar`/`writeVar` key on an arbitrary string,
82
211
  // and frontends mint VIRTUAL keys for storage the ISA has no register for — MIPS `sp@<off>` for a
83
- // stack slot (frontend/mips.ts), Thumb `@sarg<k>` for an incoming stack argument (frontend/thumb.ts).
212
+ // stack slot (frontend/mips.ts), `@sarg<k>` for an incoming stack argument (`stackArgKey`).
84
213
  // A virtual key must be outside its ISA's register grammar so it cannot collide with a real one, and
85
214
  // a key read with no reaching def becomes a function PARAMETER by the live-in path below — which is
86
215
  // how both of those capabilities get their parameters without a new opcode or pass.
@@ -95,7 +224,7 @@ export interface LiveInModel {
95
224
  *
96
225
  * Asserts more than ownership: that this function's own stores are the ONLY writer. An address
97
226
  * 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). */
227
+ * retraction is the frontend's obligation (the frame-object audit, frontend/frame-objects.ts). */
99
228
  ownedLocals?: { from: number; to: number };
100
229
  /** Storage this function DECLARES as locals ⇒ a `[sp,#k]` spill here is a DECLARATION RANK
101
230
  * (`ir/core.ts` `SlotHomes`, ordered by `l3/slotorder.ts`). `[from, to)`.
@@ -125,8 +254,8 @@ export interface LiveInModel {
125
254
  * eight more). None reaches the ordering today, for an unrelated reason (`l3/slotorder.ts`'s
126
255
  * REACH note), so nothing downstream is guarding this.
127
256
  *
128
- * ABSENT ⇒ NO STAMP. MIPS and PPC declare no frame partition at all, so they stamp nothing,
129
- * which is the refusing direction. */
257
+ * ABSENT ⇒ NO STAMP. MIPS and PPC declare none (MIPS claims `ownedLocals` alone), so they stamp
258
+ * nothing, which is the refusing direction. */
130
259
  declaredLocals?: { from: number; to: number };
131
260
  /** Storage the CALLER wrote — incoming stack arguments ⇒ a def-less read is a parameter.
132
261
  * `[from, to)`. O32's register-parameter home area belongs to NEITHER range: caller-owned, but
@@ -138,13 +267,23 @@ export interface LiveInModel {
138
267
  * saved the register (so it is one the compiler was free to home a local in). The ABI half alone
139
268
  * describes the CALLER, and asm that follows no ABI — hand-written, or a mid-function fragment,
140
269
  * which klonoa's `bl`-as-a-long-branch splits produce for real — is genuinely handed live values
141
- * in registers it never saved. Passing the ABI list unfiltered cost the MP2K engine's
142
- * `ChnVolSetAsm` its two-pointer signature and left it storing through `uninit_r4`, silently.
270
+ * in registers it never saved. Passing the ABI list unfiltered left the MP2K engine's
271
+ * `ChnVolSetAsm` storing through `uninit_r4`, silently; such a read is
272
+ * `unsavedCalleeSavedRegs`' instead.
143
273
  *
144
- * The save is a MEASUREMENT, like the frame's, and belongs to whoever can make it — Thumb reads
145
- * the leading push run (`savedRegs`); a frontend that cannot measure it passes nothing here and
274
+ * The save is a MEASUREMENT, like the frame's, and belongs to whoever can make it. Thumb reads
275
+ * its `push`es (`savedRegs`; past the prologue, `bodySavedRegs`). PPC reads every save in the
276
+ * entry block of a register nothing before it named (`prologueSavedRegs`), because mwcc
277
+ * interleaves its saves with the body (`stw r31; mr r31,r3; stw r30`); "nothing named it first"
278
+ * is the condition Thumb's leading run meets by construction; MIPS reads its entry block the same way
279
+ * (frontend/mips.ts `prologueSavedRegs`). A frontend that cannot measure it passes nothing here and
146
280
  * keeps the parameter it would have got anyway.
147
281
  *
282
+ * The one register class exempt from the save is the ABI's scratch set (`target.scratchRegs`):
283
+ * a callee may use one with no save at all, so demanding one would refuse a local the compiler
284
+ * homed there with no prologue (agbcc's `ip`, mwcc's `r0`). `registerLiveIns` composes both
285
+ * halves for every frontend, so this list is never assembled by hand.
286
+ *
148
287
  * The frame's sole-writer obligation has no counterpart here and needs none: a register has no
149
288
  * address, so nothing outside this function can name it and there is no escape to retract.
150
289
  *
@@ -155,6 +294,41 @@ export interface LiveInModel {
155
294
  *
156
295
  * Declaring this obliges `argRegs` below, and the two must be DISJOINT. */
157
296
  uninitRegs?: readonly string[];
297
+ /** Of `uninitRegs`, the ones something outside the C convention hands an input in
298
+ * (`target.hiddenInputRegs`). The entry value of one must reach A JOIN that also takes a
299
+ * definition the entry value does not feed — the shape of a local the compiler assigned on one
300
+ * arm, which may also be read before that assignment. A join alone is not enough: an input
301
+ * carried round a loop meets its own next value at the loop header. Meeting no such join, it is
302
+ * the input, and `finish` refuses rather than spell it `uninit_<reg>`. */
303
+ hiddenInputRegs?: readonly string[];
304
+ /** Registers the ABI requires a callee to PRESERVE (`target.nonArgRegs` less `target.scratchRegs`)
305
+ * that this function did NOT save — the third side of the register partition. No caller passes
306
+ * an argument in one, and the compiler homed no local of this function there, so a value it holds
307
+ * on entry is one the function shares with every other: a FILE-SCOPE GLOBAL REGISTER VARIABLE
308
+ * (`register int g asm("r8");`), which is exactly what agbcc, gcc 2.7.2, KMC and mwcc 2.4 compile
309
+ * `return g;` to (`mov r0, r8`, `move v0,s0`, `mr r3,r14`). gcc's `globalize_reg` (regclass.c)
310
+ * marks such a register fixed and call-used, so no prologue saves it. A def-less read of one
311
+ * refuses: naming it a parameter puts a value in the signature that a C caller would pass in an
312
+ * argument register. A WRITE to one is visible to the caller just the same. Both refuse in
313
+ * `finish`, of the finished function — unless the compiler can spell the global, which is
314
+ * `globalRegs`.
315
+ *
316
+ * Disjoint from `argRegs` and `uninitRegs`, checked with them. */
317
+ unsavedCalleeSavedRegs?: readonly string[];
318
+ /** Of `unsavedCalleeSavedRegs`, the ones the function does save or restore, but not around every
319
+ * use or not restored at every return (a `pop` with no `push` before it, a `push` past a read, a
320
+ * use past the restore). Refused like the rest, and named apart only so the refusal says what it
321
+ * saw. */
322
+ misSavedRegs?: readonly string[];
323
+ /** Where the frontend looks for the saves it measures, for those refusals to name: the partition
324
+ * is only as wide as that look, and a save it does not read is still a save. */
325
+ saveSite?: string;
326
+ /** The registers of `unsavedCalleeSavedRegs`' kind the target can declare a global register
327
+ * variable in (`target.globalRegisterVariables`), each to the name `__asm__` takes. Every read of
328
+ * one is a load of that global and every write a store to it, so a call between two reads may
329
+ * change it, as it may change any global. Disjoint from the other three lists, checked with
330
+ * them. */
331
+ globalRegs?: ReadonlyMap<string, string>;
158
332
  /** Registers the ABI DOES pass arguments in — the other side of the register partition, and the
159
333
  * only reason the side above is checkable rather than believed. The frame coordinate declares
160
334
  * both of its sides and refuses an offset in neither; this one declares both and refuses a key
@@ -166,6 +340,13 @@ export interface LiveInModel {
166
340
  * `readRecursive` cannot catch that on its own — it never sees the argument registers, because
167
341
  * a read of one takes the parameter path by falling through every other case. */
168
342
  argRegs?: readonly string[];
343
+ /** The argument words the function's own declaration fills (`proto.ts` `ownArgumentWords`), where
344
+ * it states them. A register past them is in `uninitRegs`; an INCOMING STACK ARGUMENT at or past
345
+ * them is refused, because no local lives in the caller's frame and the read says the
346
+ * declaration is wrong. Taken as a parameter it would be named by position, one slot below the
347
+ * registers the declaration left out: `int st2(int a,int b,int c,int d,int e){ return g(a)+e; }`
348
+ * declared with two parameters would lift `st2(a0, a1, a2)`, returning the fifth argument as `a2`. */
349
+ ownArgWords?: number;
169
350
  }
170
351
 
171
352
  /** The register partition's postcondition, checked at the point of USE. `uninitRegs` asserts "no
@@ -191,29 +372,305 @@ function checkedLiveInModel(fnName: string, m: LiveInModel): LiveInModel {
191
372
  `and one the ABI does not pass arguments in`,
192
373
  );
193
374
  }
375
+ const unsaved = [...(m.unsavedCalleeSavedRegs ?? []), ...(m.globalRegs?.keys() ?? [])];
376
+ const twice = unsaved.filter((r, i) => args.includes(r) || m.uninitRegs?.includes(r) || unsaved.indexOf(r) !== i);
377
+ if (twice.length > 0) {
378
+ throw new Error(
379
+ `lifting '${fnName}': the live-in model lists ${twice.join(', ')} as an unsaved callee-saved ` +
380
+ `register AND as an argument register or an uninitialised local's home`,
381
+ );
382
+ }
194
383
  return m;
195
384
  }
196
385
 
386
+ /** A `scratchRegs` entry outside `nonArgRegs` is inert, and inert is how a partition rots: the list
387
+ * would go on reading as if it exempted something. Refused as a target bug, like the
388
+ * argRegs/uninitRegs contradiction `checkedLiveInModel` refuses, rather than declined as a property
389
+ * of the input. */
390
+ export function assertScratchRegsPartitioned(target: {
391
+ id: string;
392
+ nonArgRegs?: readonly string[];
393
+ scratchRegs?: readonly string[];
394
+ }): ReadonlySet<string> {
395
+ const scratchRegs = new Set(target.scratchRegs ?? []);
396
+ for (const r of scratchRegs) {
397
+ if (!(target.nonArgRegs ?? []).includes(r)) {
398
+ throw new Error(`target '${target.id}': scratch register ${r} is not among the non-argument registers`);
399
+ }
400
+ }
401
+ return scratchRegs;
402
+ }
403
+
404
+ /** ONE SPELLING PER REGISTER, WHERE A FUNCTION WRITES ONE REGISTER TWO WAYS. The builder keys a
405
+ * register by its operand text, and so does every save measurement a frontend makes, so `sl` and
406
+ * `r10` (or O32's `s8` and `fp`) would be two variables and two partition entries for one register:
407
+ * `mov sl, r0; mov r0, r10` would return a second parameter where the machine returns the first,
408
+ * and a register saved as `s8` and written as `fp` would read as never saved, its writes as a
409
+ * global's. `pairs`
410
+ * is each register's two spellings, the one kept first. Only a function that names BOTH of a pair
411
+ * is rewritten — every other keeps the spelling it was written in, and with it the name of any local
412
+ * homed there — and an alias is rewritten wherever it stands as a whole word of an operand, so a
413
+ * memory operand's base (`8(fp)`) is too. */
414
+ export function unifyRegisterSpellings(
415
+ instrs: Iterable<{ ops: string[] }>,
416
+ pairs: readonly (readonly [keep: string, alias: string])[],
417
+ ): void {
418
+ const all = [...instrs];
419
+ const words = new Set(all.flatMap((ins) => ins.ops.flatMap((op) => op.match(/[A-Za-z_$.][\w$.]*/g) ?? [])));
420
+ for (const [keep, alias] of pairs) {
421
+ if (!words.has(keep) || !words.has(alias)) {
422
+ continue;
423
+ }
424
+ const re = new RegExp(`(?<![\\w$.])${alias}(?![\\w$.])`, 'g');
425
+ for (const ins of all) {
426
+ ins.ops = ins.ops.map((op) => op.replace(re, keep));
427
+ }
428
+ }
429
+ }
430
+
431
+ /** THE REGISTER HALF OF THE LIVE-IN MODEL (`LiveInModel.uninitRegs` and `argRegs`), composed here
432
+ * once for every frontend that claims it, so that no frontend can drop a half of the rule. A
433
+ * register is an uninitialised local's home when the ABI passes no argument in it
434
+ * (`target.nonArgRegs`) AND either the ABI lets a callee use it with no save (`target.scratchRegs`,
435
+ * checked against `nonArgRegs`) or this function saved it — `savedRegs`, the frontend's measurement
436
+ * of its saves. The rest — callee-saved, and not saved — is `globalRegs` where the target can
437
+ * declare a global register variable there and `unsavedCalleeSavedRegs` where it cannot, or where
438
+ * the function saves or restores it but not around every use of it (`misSaved`, a measurement only
439
+ * Thumb makes, whose `push` and `pop` are nothing but saves and restores): compiled code never saves
440
+ * a global register variable's register, and asm that saves one restores it too, so its writes are
441
+ * not the caller's and its entry value is no global's. A target stating no `nonArgRegs` claims no
442
+ * partition and gets an empty model, so every def-less read stays a parameter.
443
+ *
444
+ * THE FUNCTION'S OWN DECLARATION MOVES THE PARTITION'S EDGE. `ownWords` is how many argument
445
+ * words it can fill (`proto.ts` `ownArgumentWords`, an upper bound), and an argument register at
446
+ * or past it is one no caller fills for THIS function, so it moves from `argRegs` to `uninitRegs`.
447
+ * It needs no save: every argument register is one a callee may use unsaved (`clobberedByCall`
448
+ * checks they are caller-saved). KMC homes a local in `a2` past `uninit_spill(int k, int *p)`, which
449
+ * without the edge reads as a third parameter. Absent (no declaration, or one that bounds
450
+ * nothing), the ABI's whole list stays arguments. */
451
+ export function registerLiveIns(
452
+ target: {
453
+ id: string;
454
+ argRegs: readonly string[];
455
+ nonArgRegs?: readonly string[];
456
+ scratchRegs?: readonly string[];
457
+ hiddenInputRegs?: readonly string[];
458
+ globalRegisterVariables?: { regs: Readonly<Record<string, string>> };
459
+ },
460
+ savedRegs: ReadonlySet<string>,
461
+ ownWords?: number,
462
+ misSaved: ReadonlySet<string> = new Set(),
463
+ ): Pick<
464
+ LiveInModel,
465
+ | 'uninitRegs'
466
+ | 'argRegs'
467
+ | 'hiddenInputRegs'
468
+ | 'ownArgWords'
469
+ | 'unsavedCalleeSavedRegs'
470
+ | 'misSavedRegs'
471
+ | 'globalRegs'
472
+ > {
473
+ const scratchRegs = assertScratchRegsPartitioned(target);
474
+ for (const r of target.hiddenInputRegs ?? []) {
475
+ if (!scratchRegs.has(r)) {
476
+ throw new Error(`target '${target.id}': hidden-input register ${r} is not among the scratch registers`);
477
+ }
478
+ }
479
+ if (target.nonArgRegs === undefined) {
480
+ return {};
481
+ }
482
+ const filled = ownWords === undefined ? target.argRegs.length : Math.min(ownWords, target.argRegs.length);
483
+ const unsaved = target.nonArgRegs.filter((r) => !scratchRegs.has(r) && !savedRegs.has(r));
484
+ const spelled = target.globalRegisterVariables?.regs ?? {};
485
+ return {
486
+ uninitRegs: [
487
+ ...target.nonArgRegs.filter((r) => scratchRegs.has(r) || savedRegs.has(r)),
488
+ ...target.argRegs.slice(filled),
489
+ ],
490
+ argRegs: target.argRegs.slice(0, filled),
491
+ hiddenInputRegs: target.hiddenInputRegs,
492
+ unsavedCalleeSavedRegs: unsaved.filter((r) => !Object.hasOwn(spelled, r) || misSaved.has(r)),
493
+ misSavedRegs: unsaved.filter((r) => misSaved.has(r)),
494
+ globalRegs: new Map(
495
+ unsaved.filter((r) => Object.hasOwn(spelled, r) && !misSaved.has(r)).map((r) => [r, spelled[r]]),
496
+ ),
497
+ ...(ownWords === undefined ? {} : { ownArgWords: ownWords }),
498
+ };
499
+ }
500
+
501
+ /** REFUSE A WRITE TO A SAVED REGISTER THAT THE CALLER SEES. A register the frontend read as saved
502
+ * (its save store, `LiveInModel.uninitRegs`) is a local's home, and the lift drops what is left
503
+ * in it at a return — right only if every path restores it after its last write. A write past the
504
+ * restore (`lw s0,4(sp); move s0,a0; jr ra`), a reload from a word that is not its save, or a
505
+ * return its restore never reaches leaves the caller holding a value the C never assigns, so the
506
+ * function refuses here rather than drop it. MIPS and PowerPC read a save as an ordinary store in
507
+ * the entry block, so this is asked once the blocks are filled; Thumb asks the same of its
508
+ * `push`/`pop` pairs in `bodySavedRegs`.
509
+ *
510
+ * A forward may-dataflow over the asm: a register is DIRTY from a write (`writes`) until a restore
511
+ * of it (`restores`: the instructions the frontend lowered as reloading its save word), and dirty
512
+ * at the end of a block that returns is the refusal. Compiled code restores every saved register
513
+ * in its epilogue, after its last write, so it never fires there; a path that never writes the
514
+ * register needs no restore. */
515
+ export function refuseSavedRegisterLeaks<I extends { addr: number }>(
516
+ name: string,
517
+ blocks: readonly { instrs: readonly I[]; returns: boolean }[],
518
+ preds: readonly (readonly number[])[],
519
+ saved: ReadonlySet<string>,
520
+ writes: (ins: I, reg: string) => boolean,
521
+ restores: ReadonlyMap<I, ReadonlySet<string>>,
522
+ ): void {
523
+ if (saved.size === 0) {
524
+ return;
525
+ }
526
+ // register → the address of a write no restore has answered since, on some path
527
+ const outs: (Map<string, number> | null)[] = blocks.map(() => null);
528
+ const transfer = (b: number, dirty: Map<string, number>) => {
529
+ for (const ins of blocks[b].instrs) {
530
+ for (const r of saved) {
531
+ if (restores.get(ins)?.has(r)) {
532
+ dirty.delete(r);
533
+ } else if (writes(ins, r)) {
534
+ dirty.set(r, ins.addr);
535
+ }
536
+ }
537
+ }
538
+ return dirty;
539
+ };
540
+ const inOf = (b: number) => {
541
+ const dirty = new Map<string, number>();
542
+ for (const p of preds[b]) {
543
+ outs[p]?.forEach((at, r) => dirty.has(r) || dirty.set(r, at));
544
+ }
545
+ return dirty;
546
+ };
547
+ for (let changed = true; changed;) {
548
+ changed = false;
549
+ blocks.forEach((_, b) => {
550
+ if (b !== 0 && !preds[b].some((p) => outs[p] !== null)) {
551
+ return;
552
+ }
553
+ const out = transfer(b, inOf(b));
554
+ if (outs[b] === null || out.size !== outs[b]!.size) {
555
+ outs[b] = out;
556
+ changed = true;
557
+ }
558
+ });
559
+ }
560
+ blocks.forEach((blk, b) => {
561
+ const [leak] = blk.returns ? [...(outs[b] ?? [])] : [];
562
+ if (leak !== undefined) {
563
+ throw new FrontendUnsupportedError(
564
+ `cannot lift '${name}': the write to saved ${leak[0]} at 0x${leak[1].toString(16)} reaches a return ` +
565
+ `with no restore of ${leak[0]} after it, so the caller sees it — no C local spells a write ` +
566
+ 'that outlives the function',
567
+ );
568
+ }
569
+ });
570
+ }
571
+
572
+ /** A read whose register may be holding a callee's leftover — see `refuseStaleCallerSavedReads`. */
573
+ interface StaleRead {
574
+ reg: string;
575
+ value: Value;
576
+ block: number;
577
+ /** the block's op count when the read happened. The uses this read answers for start here: an op
578
+ * before it consumed a value read while the register was still this function's own, and the same
579
+ * register really can be read on both sides of a call without its value changing. */
580
+ fromOp: number;
581
+ /** a call in this very block destroyed it, so the predecessors have nothing to add. */
582
+ local: boolean;
583
+ /** The OTHER keys this block wrote the read's value into after it — a `move` or a slot store of
584
+ * the destroyed register, which carries the callee's leftover out of the block under a name no
585
+ * call destroyed. */
586
+ carriedBy: Set<string>;
587
+ }
588
+
589
+ /** The registers a call leaves holding nothing the CALLER can name — what {@link SsaBuilder.noteCall}
590
+ * wants. The ABI's caller-saved set minus the return register: `CallDeclarations.lower` writes the
591
+ * callee's own result there before recording the clobber, so that one register does have a name
592
+ * and the rest do not.
593
+ *
594
+ * CHECKED rather than trusted, the way `checkedLiveInModel` checks the register partition: a
595
+ * register the caller passes arguments in is by construction one the callee may destroy, so
596
+ * `argRegs` must be a subset of `callerSaved`. A target spelling one of them differently in the two
597
+ * lists would silently stop refusing reads of it. */
598
+ export function clobberedByCall(target: {
599
+ callerSaved: readonly string[];
600
+ argRegs: readonly string[];
601
+ returnReg: string;
602
+ }): readonly string[] {
603
+ const missing = target.argRegs.filter((r) => !target.callerSaved.includes(r));
604
+ if (missing.length > 0) {
605
+ throw new Error(
606
+ `this target passes arguments in ${missing.join(', ')} but does not list ${missing.length > 1 ? 'them' : 'it'} ` +
607
+ `as caller-saved, so a read of one past a call cannot be refused`,
608
+ );
609
+ }
610
+ return target.callerSaved.filter((r) => r !== target.returnReg);
611
+ }
612
+
197
613
  export function makeSsaBuilder(
198
614
  name: string,
199
615
  blockCount: number,
200
616
  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 ⇒
617
+ /** Evaluated once, at its first use, which is where `checkedLiveInModel` checks it. Omitted ⇒
203
618
  * no partition is claimed, so every slot refuses and every register is a parameter. */
204
619
  liveInOf: () => LiveInModel = () => ({}),
620
+ /** The type of a value that stands for `key` before any instruction defines it — a parameter, a
621
+ * phi. A register FILE, not a register: the FPU's keys are floats wherever they arrive, and every
622
+ * other key is `unknown` for type recovery to settle. */
623
+ keyType: (key: string) => IrType | undefined = () => undefined,
624
+ /** `LiveInModel.globalRegs`, which every read and write consults — so it is handed over when the
625
+ * builder is made, not when the model is first used. The frontend passes the same map in its
626
+ * model, where it is checked against the other lists. */
627
+ globalRegs: ReadonlyMap<string, string> = new Map(),
205
628
  ): SsaBuilder {
629
+ const unset = (key: string): IrType => keyType(key) ?? T.unk(32);
206
630
  let modelMemo: LiveInModel | null = null;
207
631
  // Checked ONCE, where the model is materialised — every function with a parameter reads a
208
632
  // register def-lessly, so this runs on effectively every lift rather than only on the rare
209
633
  // function that reads the mis-listed register. A contradictory or half-declared partition is a
210
634
  // bug in the TARGET, not an unliftable function, so it throws a plain Error: a decline would
211
635
  // report the target's typo as a property of the input, once per function, forever.
212
- const model = (): LiveInModel => (modelMemo ??= checkedLiveInModel(name, liveInOf()));
636
+ const model = (): LiveInModel => (modelMemo ??= sameGlobalRegs(checkedLiveInModel(name, liveInOf())));
637
+ // The model's `globalRegs` is the copy checked against the other lists, and the builder's own the
638
+ // one every read and write consults: two copies of one fact, which must be one.
639
+ const sameGlobalRegs = (m: LiveInModel): LiveInModel => {
640
+ const theirs = m.globalRegs ?? new Map<string, string>();
641
+ if (theirs.size !== globalRegs.size || [...theirs].some(([r, n]) => globalRegs.get(r) !== n)) {
642
+ throw new Error(`lifting '${name}': the live-in model's global registers are not the ones the builder was given`);
643
+ }
644
+ return m;
645
+ };
646
+ // What the refusals of unsaved callee-saved registers say of their saves (`misSavedRegs`).
647
+ const unsavedWhy = (regs: readonly string[]): string => {
648
+ const mis = regs.filter((r) => model().misSavedRegs?.includes(r));
649
+ const never = regs.filter((r) => !mis.includes(r));
650
+ return [
651
+ ...(mis.length
652
+ ? [
653
+ `saves or restores ${mis.join(', ')} but not around every use of it, restored at every return, on every path`,
654
+ ]
655
+ : []),
656
+ ...(never.length ? [`no ${model().saveSite ?? 'save this lift reads'} saves ${never.join(', ')}`] : []),
657
+ ].join(', and ');
658
+ };
659
+ // What compiled code means by such a register — but not by one the function saves and breaks the
660
+ // save of (`misSavedRegs`), which the measurement has shown is no global's.
661
+ const globalNote = (regs: readonly string[]): string =>
662
+ regs.some((r) => model().misSavedRegs?.includes(r)) ? '' : ' (a global register variable)';
213
663
  const inRange = (off: number, r?: { from: number; to: number }) => r !== undefined && off >= r.from && off < r.to;
214
664
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
215
665
  // `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 };
666
+ const fn: Fn = {
667
+ name,
668
+ blocks: irBlocks,
669
+ writeOrder: undefined,
670
+ slotHomes: undefined,
671
+ paramEvidence: undefined,
672
+ localObjects: undefined,
673
+ };
217
674
 
218
675
  const defs: Array<Map<string, Value>> = irBlocks.map(() => new Map());
219
676
  const sealed: boolean[] = irBlocks.map(() => false);
@@ -224,6 +681,9 @@ export function makeSsaBuilder(
224
681
  // arrives as a PHI — which is what happens when the entry block is itself a loop header — is
225
682
  // invisible to the escape check below. Braun's construction gives no other way to tell.
226
683
  const phiKey = new Map<Value, string>();
684
+ // The entry value of a register a runtime hands a hidden input in (`LiveInModel.hiddenInputRegs`),
685
+ // minted as an uninitialised local, for `finish` to check that a join takes it.
686
+ const hiddenEntryValues = new Map<Value, string>();
227
687
  const paramReg = new Map<Value, string>();
228
688
  // Parameters created by ensureParam that nothing has read yet. They are deliberately NOT in
229
689
  // `defs`: a parameter asserted because a calling convention proves it exists is not evidence that
@@ -243,6 +703,38 @@ export function makeSsaBuilder(
243
703
  // wrapper, and a MISSED write under-counts an arity — which drops a real argument silently.
244
704
  const writtenSinceCall: Array<Set<string>> = irBlocks.map(() => new Set());
245
705
  const callsIn = new Set<number>();
706
+ // WHAT A CALL DESTROYED, which is a DIFFERENT question from the one above and not its complement.
707
+ // `writtenSinceCall` asks "did the CALLER set this register up" — a MUST question, whose answer
708
+ // for the return register is deliberately no, because `CallDeclarations.lower` writes the
709
+ // callee's result there before recording the clobber. This asks "does this register still hold a
710
+ // value anyone can name" — a MAY question, whose answer for that same register is yes. One
711
+ // analysis serving both would have to be wrong about one of them, so they are two, and each names
712
+ // the other.
713
+ //
714
+ // `clobberedLocal[b]`: destroyed by a call in `b` with nothing written since. `decidedLocal[b]`:
715
+ // registers `b` has settled either way, so a register in neither inherits its fate from the
716
+ // predecessors.
717
+ const clobberedLocal: Array<Set<string>> = irBlocks.map(() => new Set());
718
+ const decidedLocal: Array<Set<string>> = irBlocks.map(() => new Set());
719
+ // `imageLocal[b]`: holding a struct's image a call in `b` handed back, with nothing written since
720
+ // (`noteStructImage`) — destroyed for a guessed arity alone
721
+ const imageLocal: Array<Set<string>> = irBlocks.map(() => new Set());
722
+ const structImages = new Set<Value>();
723
+ const staleCandidates: StaleRead[] = [];
724
+ const staleByValue = new Map<Value, StaleRead[]>();
725
+ /** The op-less uses `useOutsideOps` recorded, each at the op count of its block when it happened. */
726
+ const usesOutsideOps: Array<{ value: Value; block: number; atOp: number }> = [];
727
+ /** The sole-reader obligations `requireSoleReader` recorded, checked in `finish`. */
728
+ const soleReaders: Array<{ value: Value; op: Op; refuse: () => void }> = [];
729
+ /** The reader obligations `requireReader` recorded, checked in `finish`. */
730
+ const readObligations: Array<{ value: Value; refuse: () => void }> = [];
731
+ /** The side-effect values `noteIncidentalWrite` recorded. */
732
+ const incidentalWrites = new Set<Value>();
733
+ // `readVar` runs once per operand, so a memo per (register, verdict) keeps the record list
734
+ // proportional to what a block READS rather than to how many times it reads it. The value a key
735
+ // holds only changes at a write, and the verdict only flips at a call, so a run of identical
736
+ // reads collapses to the earliest — which is the one whose uses cover the rest.
737
+ const staleSeen: Array<Map<string, Value>> = irBlocks.map(() => new Map());
246
738
  const guessedCalls: GuessedCallSite[] = [];
247
739
  let abiSeen: { argRegs: string[]; returnReg: string } = { argRegs: [], returnReg: '' };
248
740
 
@@ -256,10 +748,11 @@ export function makeSsaBuilder(
256
748
  fn.writeOrder = writeOrder;
257
749
 
258
750
  // 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.
751
+ // reason the clobber set and the write order are: every slot-modelling frontend (MIPS, Thumb,
752
+ // PowerPC) spells a word spill as a write to the key `sp@k` (`stackSlotKey`, below), so the
753
+ // frontend supplies the coordinate and one rule applies it — a per-frontend stamp would be right
754
+ // only while each remembered to route every slot write past a wrapper, and a missed write is a
755
+ // local with no frame order.
263
756
  // Empty rather than absent on a function that spills nothing: this builder measured it.
264
757
  const slotHomes: SlotHomes = new Map();
265
758
  fn.slotHomes = slotHomes;
@@ -270,7 +763,7 @@ export function makeSsaBuilder(
270
763
  }
271
764
  // THE KEY SPELLING CANNOT DECIDE THIS, exactly as `readRecursive` says below of a def-less
272
765
  // 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
766
+ // asks the frontend for a partition and refuses where no answer exists. The frontends
274
767
  // differ here and the refusal is what makes that safe: Thumb declares a range; MIPS declares NO
275
768
  // partition (frontend/mips.ts: `addiu sp,sp,±N` is transparent, so its slot keys span O32's
276
769
  // caller-owned register-parameter home area `[0,16)` and the incoming stack arguments above
@@ -306,6 +799,16 @@ export function makeSsaBuilder(
306
799
  // separately declared SCALARS; intra-aggregate offsets are fixed by the aggregate's layout at
307
800
  // expand time.
308
801
  //
802
+ // A FORM OF THAT RECOVERY EXISTS, and what keeps the condition unmet is two guards, this
803
+ // function's and one in another file. The frame-object audit declares an untyped frame object
804
+ // as `u8 name[N]` over the DECLARED range (`notTheWholeArea`, frontend/frame-objects.ts) and
805
+ // refuses it where the slot model keys any byte of that range (`overSlot`, refused by
806
+ // `failIfSlotKeysIt`). The slots it does share a frame with lie below `declaredLocals` — the
807
+ // licensed outgoing argument words — and the `declaredLocals` test above stamps none of them.
808
+ // So no slot home sits beside one of those arrays and nothing here is ever asked to order the
809
+ // two. Letting a keyed slot into the declared range beside the array is what meets the
810
+ // condition, and it has to bring `l3/slotorder.ts` with it.
811
+ //
309
812
  // UNION, not a choice (ir/core.ts `SlotHomes`): whether the earlier declaration rank is the
310
813
  // lower or the higher offset is a per-COMPILER fact, and this builder is handed a name, a
311
814
  // block count, a predecessor list and a live-in model — no target. `l3/slotorder.ts` reduces.
@@ -320,7 +823,7 @@ export function makeSsaBuilder(
320
823
  };
321
824
  // PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`), measured HERE for the same reason the clobber
322
825
  // 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
826
+ // `readVar` in every slot-modelling frontend, so one rule covers them and no frontend can forget
324
827
  // to route a store past a wrapper. The two directions are NOT symmetric: raise/paramwidth.ts reads
325
828
  // an absent observation as proof the declaration was wide, so a missed one costs a narrowing while
326
829
  // a spurious one retypes a parameter the machine never declared narrow.
@@ -362,23 +865,119 @@ export function makeSsaBuilder(
362
865
  }
363
866
  };
364
867
 
868
+ const frameWrites = new Set<Value>();
869
+ // Every key some instruction wrote, for `finish` to find a write to an unsaved callee-saved
870
+ // register (`LiveInModel.unsavedCalleeSavedRegs`).
871
+ const writtenKeys = new Set<string>();
872
+ // A GLOBAL REGISTER VARIABLE (`LiveInModel.globalRegs`) is memory, not an SSA variable: each
873
+ // read loads the global and each write stores it, at the point the instruction runs, so nothing
874
+ // is forwarded across a call that may change it and the caller sees every write. Appended to the
875
+ // block being filled, which is where a frontend puts the op that reads or writes the register.
876
+ const globalLoads = new Set<Value>();
877
+ const globalCell = (reg: string, b: number): Value => {
878
+ if (filled[b]) {
879
+ throw new Error(`lifting '${name}': global register ${reg} accessed in block ${b} after it was filled`);
880
+ }
881
+ const asmName = globalRegs.get(reg)!;
882
+ const cell = mkValue(T.unk(32));
883
+ irBlocks[b].ops.push(
884
+ mkOp('gaddr', { results: [cell], attrs: { sym: globalRegisterSymbol(asmName), globalRegister: asmName } }),
885
+ );
886
+ return cell;
887
+ };
888
+ // A load of one nothing reads is a frontend reading the register for a question of its own (MIPS
889
+ // asks of an `addu` operand more than once), not an instruction's read: reading a register does
890
+ // nothing, so it goes, with its address.
891
+ const dropUnreadGlobalRegisterLoads = (): void => {
892
+ if (globalRegs.size === 0) {
893
+ return;
894
+ }
895
+ const read = new Set<Value>();
896
+ for (const blk of irBlocks) {
897
+ for (const op of blk.ops) {
898
+ op.operands.forEach((v) => read.add(v));
899
+ op.successors.forEach((s) => s.args.forEach((v) => read.add(v)));
900
+ }
901
+ }
902
+ for (const blk of irBlocks) {
903
+ const dead = new Set<Value>();
904
+ for (const op of blk.ops) {
905
+ if (op.opcode === 'load' && !read.has(op.results[0]) && globalLoads.has(op.results[0])) {
906
+ dead.add(op.results[0]);
907
+ dead.add(op.operands[0]);
908
+ }
909
+ }
910
+ blk.ops = blk.ops.filter((op) => !op.results.some((r) => dead.has(r)));
911
+ }
912
+ };
365
913
  const writeVar = (reg: string, b: number, v: Value) => {
914
+ if (globalRegs.has(reg)) {
915
+ irBlocks[b].ops.push(mkOp('store', { operands: [globalCell(reg, b), v], attrs: { off: 0, width: 4 } }));
916
+ return;
917
+ }
918
+ writtenKeys.add(reg);
919
+ if (slotKeyOffset(reg) !== null || reg.startsWith(STACK_ARG_PREFIX)) {
920
+ frameWrites.add(v);
921
+ }
366
922
  noteSlotHome(reg, v);
367
923
  noteSlotTraffic(reg, v);
924
+ for (const c of staleByValue.get(v) ?? []) {
925
+ if (c.block === b && c.reg !== reg) {
926
+ c.carriedBy.add(reg);
927
+ }
928
+ }
368
929
  if (b === 0 && !firstEntryWrite.has(reg)) {
369
930
  firstEntryWrite.set(reg, v);
370
931
  }
371
932
  writtenSinceCall[b].add(reg);
933
+ clobberedLocal[b].delete(reg);
934
+ imageLocal[b].delete(reg);
935
+ decidedLocal[b].add(reg);
372
936
  defs[b].set(reg, v);
373
937
  lastWriteAt[b].set(reg, writeCount[b]++);
374
938
  };
375
939
  const readVar = (reg: string, b: number): Value => {
940
+ if (globalRegs.has(reg)) {
941
+ const loaded = mkValue(T.unk(32));
942
+ globalLoads.add(loaded);
943
+ const cell = globalCell(reg, b);
944
+ irBlocks[b].ops.push(
945
+ mkOp('load', { operands: [cell], results: [loaded], attrs: { off: 0, width: 4, signed: true } }),
946
+ );
947
+ return loaded;
948
+ }
949
+ const v = readAny(reg, b);
950
+ noteStaleCandidate(reg, b, v);
951
+ return v;
952
+ };
953
+ /** The lookup, with no claim attached. The FRONTEND's read is what asserts "this register holds
954
+ * this value here"; the lookups this construction makes on its own — a single predecessor's
955
+ * definition, a phi's operand at each in-edge — are how that one read is answered, not further
956
+ * reads to be judged. Recording them would blame a use in one block on a read in another. */
957
+ const readAny = (reg: string, b: number): Value => {
376
958
  noteSlotTraffic(reg, null);
377
959
  return defs[b].get(reg) ?? readRecursive(reg, b);
378
960
  };
961
+ /** Record a read whose register MIGHT be holding a callee's leftover, for `finish` to judge. A
962
+ * register this block has already settled answers here and needs no record; one it has not takes
963
+ * its answer from the predecessors, which are not all filled yet. */
964
+ const noteStaleCandidate = (reg: string, b: number, v: Value) => {
965
+ const local = clobberedLocal[b].has(reg);
966
+ if (decidedLocal[b].has(reg) && !local) {
967
+ return; // this function wrote it after the last call in this block: it is its own value
968
+ }
969
+ const key = `${reg}|${local ? 1 : 0}`;
970
+ if (staleSeen[b].get(key) === v) {
971
+ return;
972
+ }
973
+ staleSeen[b].set(key, v);
974
+ const c: StaleRead = { reg, value: v, block: b, fromOp: irBlocks[b].ops.length, local, carriedBy: new Set() };
975
+ staleCandidates.push(c);
976
+ staleByValue.set(v, [...(staleByValue.get(v) ?? []), c]);
977
+ };
379
978
 
380
979
  const newPhi = (reg: string, b: number): Value => {
381
- const phi = mkValue(T.unk(32));
980
+ const phi = mkValue(unset(reg));
382
981
  irBlocks[b].params.push(phi);
383
982
  phiBlock.set(phi, b);
384
983
  phiKey.set(phi, reg);
@@ -389,6 +988,9 @@ export function makeSsaBuilder(
389
988
  defs[b].set(reg, phi); // set before wiring operands to break cycles
390
989
  return phi;
391
990
  };
991
+ // The single-predecessor blocks the current read is walking up through. A join ends the walk:
992
+ // its phi is in `defs` before its operands are read, so a cycle through it is already cut there.
993
+ let singlePredWalk = new Set<string>();
392
994
  const readRecursive = (reg: string, b: number): Value => {
393
995
  if (!sealed[b]) {
394
996
  // predecessors not all filled yet (e.g. a loop back-edge): defer operand wiring.
@@ -409,6 +1011,10 @@ export function makeSsaBuilder(
409
1011
  // a measurement: a caller cannot pass a value in a register the ABI does not pass arguments
410
1012
  // in, so a read of one before any write is an uninitialised local.
411
1013
  const off = slotKeyOffset(reg);
1014
+ if (globalRegs.has(reg)) {
1015
+ // only `readVar` reads one, and never through the SSA: a live-in here would be a parameter
1016
+ throw new Error(`lifting '${name}': global register ${reg} read as an SSA variable`);
1017
+ }
412
1018
  if (off !== null && !inRange(off, model().ownedLocals) && !inRange(off, model().callerParams)) {
413
1019
  throw new FrontendUnsupportedError(
414
1020
  `cannot lift '${name}': ${reg} is read on a path that never stores it, and lies outside ` +
@@ -421,8 +1027,19 @@ export function makeSsaBuilder(
421
1027
  const op = mkOp('undef', { results: [mkValue(T.unk(32))], attrs: { key: reg } });
422
1028
  irBlocks[b].ops.unshift(op); // ahead of everything in a block that nothing precedes
423
1029
  defs[b].set(reg, op.results[0]);
1030
+ if (off === null && model().hiddenInputRegs?.includes(reg)) {
1031
+ hiddenEntryValues.set(op.results[0], reg);
1032
+ }
424
1033
  return op.results[0];
425
1034
  }
1035
+ const rank = stackArgIndex(reg);
1036
+ const declared = model().ownArgWords;
1037
+ if (rank !== null && declared !== undefined && rank >= declared) {
1038
+ throw new FrontendUnsupportedError(
1039
+ `cannot lift '${name}': it reads incoming stack argument ${rank}, past the ${declared} argument ` +
1040
+ `words its own declaration fills — the declaration and the body disagree`,
1041
+ );
1042
+ }
426
1043
  // an incoming argument register → function parameter.
427
1044
  // If one was already asserted for this key (ensureParam), adopt it — minting a second
428
1045
  // parameter for the same key would put the key in the signature twice.
@@ -432,16 +1049,38 @@ export function makeSsaBuilder(
432
1049
  defs[b].set(reg, obliged);
433
1050
  return obliged;
434
1051
  }
435
- const p = mkValue(T.unk(32));
1052
+ const p = mkValue(unset(reg));
436
1053
  irBlocks[b].params.push(p);
437
1054
  defs[b].set(reg, p);
438
1055
  paramReg.set(p, reg);
439
1056
  return p;
440
1057
  }
441
1058
  if (ps.length === 1) {
442
- const v = readVar(reg, ps[0]);
443
- defs[b].set(reg, v);
444
- return v;
1059
+ // A walk up single predecessors that comes back to where it started has gone round a cycle
1060
+ // no edge enters from outside, so no path from the entry reaches it — unless the entry is on
1061
+ // it. agbcc leaves such a cycle behind a loop it proved never runs. No execution reads the
1062
+ // key there, which is the uninitialised read's answer. Through the entry, the value is a
1063
+ // live-in joined with the cycle's, which a single-predecessor block has no phi for.
1064
+ const at = `${b}|${reg}`;
1065
+ if (singlePredWalk.has(at)) {
1066
+ if (singlePredWalk.has(`0|${reg}`)) {
1067
+ throw new FrontendUnsupportedError(
1068
+ `cannot lift '${name}': ${reg} is read in a cycle through the entry block with no join to meet at`,
1069
+ );
1070
+ }
1071
+ const op = mkOp('undef', { results: [mkValue(T.unk(32))], attrs: { key: reg } });
1072
+ irBlocks[b].ops.unshift(op);
1073
+ defs[b].set(reg, op.results[0]);
1074
+ return op.results[0];
1075
+ }
1076
+ singlePredWalk.add(at);
1077
+ try {
1078
+ const v = readAny(reg, ps[0]);
1079
+ defs[b].set(reg, v);
1080
+ return v;
1081
+ } finally {
1082
+ singlePredWalk.delete(at);
1083
+ }
445
1084
  }
446
1085
  // sealed join: create the phi and wire every predecessor's terminator arg now.
447
1086
  const phi = newPhi(reg, b);
@@ -452,8 +1091,17 @@ export function makeSsaBuilder(
452
1091
  // write-order record is keyed by. `phi` is passed rather than looked up: by the time a deferred
453
1092
  // phi is wired the block may have written its key again, so `defs[b]` no longer names it.
454
1093
  const addPhiOperands = (reg: string, b: number, phi: Value) => {
1094
+ const walk = singlePredWalk;
1095
+ singlePredWalk = new Set();
1096
+ try {
1097
+ addEachPhiOperand(reg, b, phi);
1098
+ } finally {
1099
+ singlePredWalk = walk;
1100
+ }
1101
+ };
1102
+ const addEachPhiOperand = (reg: string, b: number, phi: Value) => {
455
1103
  for (const p of distinctPreds(b)) {
456
- appendSuccessorArg(p, b, readVar(reg, p));
1104
+ appendSuccessorArg(p, b, readAny(reg, p));
457
1105
  const at = lastWriteAt[p].get(reg);
458
1106
  if (at !== undefined) {
459
1107
  const rec = writeOrder.lastWrite.get(irBlocks[p]) ?? new Map<Value, number>();
@@ -505,19 +1153,25 @@ export function makeSsaBuilder(
505
1153
  if (preds[b].length > 0) {
506
1154
  return; // a parameter here is a phi; see the precondition on the interface
507
1155
  }
1156
+ if (slotKeyOffset(key) === null && model().uninitRegs?.includes(key)) {
1157
+ return; // no caller fills it (an argument register past the own declaration's words)
1158
+ }
508
1159
  for (const p of irBlocks[b].params) {
509
1160
  if (paramReg.get(p) === key) {
510
1161
  return; // already a parameter, however it got there
511
1162
  }
512
1163
  }
513
- const p = mkValue(T.unk(32));
1164
+ const p = mkValue(unset(key));
514
1165
  irBlocks[b].params.push(p);
515
1166
  paramReg.set(p, key); // ranked by the ABI sort like any other parameter
516
1167
  obligedParams[b].set(key, p);
517
1168
  };
518
1169
 
519
- const hasReachingDef = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean => {
1170
+ const reaches = (reg: string, b: number, accept: (v: Value) => boolean, throughCalls: boolean): boolean => {
520
1171
  const walk = (at: number, seen: Set<number>): boolean => {
1172
+ if (!throughCalls && clobberedLocal[at].has(reg)) {
1173
+ return false;
1174
+ }
521
1175
  const own = defs[at].get(reg);
522
1176
  // A def `accept` rejects does not fall through to the predecessors: it is still a def, and
523
1177
  // nothing older than it reaches past it.
@@ -532,28 +1186,556 @@ export function makeSsaBuilder(
532
1186
  };
533
1187
  return walk(b, new Set<number>());
534
1188
  };
1189
+ const hasReachingDef = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean =>
1190
+ reaches(reg, b, accept, true);
1191
+ const holdsValue = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean =>
1192
+ reaches(reg, b, accept, false);
1193
+ const everyReachingDef = (reg: string, b: number, accept: (v: Value) => boolean): boolean => {
1194
+ if (!filled.every(Boolean)) {
1195
+ throw new Error('everyReachingDef: asked before every block is filled');
1196
+ }
1197
+ // A block met again on a cycle adds no path the first visit did not already judge.
1198
+ const seen = new Set<number>();
1199
+ const walk = (at: number): boolean => {
1200
+ if (clobberedLocal[at].has(reg)) {
1201
+ return false;
1202
+ }
1203
+ const own = defs[at].get(reg);
1204
+ if (own !== undefined) {
1205
+ return accept(own);
1206
+ }
1207
+ if (seen.has(at)) {
1208
+ return true;
1209
+ }
1210
+ seen.add(at);
1211
+ return preds[at].length > 0 && preds[at].every(walk);
1212
+ };
1213
+ return walk(b);
1214
+ };
1215
+
1216
+ const holdsEntryValue = (reg: string, b: number): boolean => {
1217
+ // A predecessor is known only once filled, and `b` itself only up to here — so a path that comes
1218
+ // back round to `b` carries writes this walk has not seen.
1219
+ const untouched = (at: number, seen: Set<number>): boolean => {
1220
+ if (at === b || !filled[at]) {
1221
+ return false;
1222
+ }
1223
+ if (seen.has(at)) {
1224
+ return true;
1225
+ }
1226
+ seen.add(at);
1227
+ return clean(at, seen);
1228
+ };
1229
+ // `decidedLocal` holds a register a call destroyed as well as one this block wrote.
1230
+ const clean = (at: number, seen: Set<number>): boolean => {
1231
+ if (defs[at].has(reg) || decidedLocal[at].has(reg)) {
1232
+ return false;
1233
+ }
1234
+ const ps = distinctPreds(at);
1235
+ return ps.length === 0 ? at === 0 : ps.every((p) => untouched(p, seen));
1236
+ };
1237
+ return clean(b, new Set());
1238
+ };
1239
+ const entryValue = (reg: string, b: number): Value | undefined => {
1240
+ if (!holdsEntryValue(reg, b)) {
1241
+ return undefined;
1242
+ }
1243
+ ensureParam(reg, 0);
1244
+ return irBlocks[0].params.find((p) => paramReg.get(p) === reg);
1245
+ };
1246
+
1247
+ /** REFUSE a value the ABI destroyed. The builder is RIGHT that a caller-saved register has a
1248
+ * reaching definition after a call; what it cannot see is that the call destroyed the bytes, so
1249
+ * that definition names something the callee overwrote. Resolving the read to it is a silently
1250
+ * wrong VALUE at exit 0 — `mov r3,#0x2a; bl f; add r4,r3,#0` reading as `return 42` — which is
1251
+ * the one failure a frontend may not produce.
1252
+ *
1253
+ * It fires on a USE rather than on the read, and after two things in `finish` that legitimately
1254
+ * retract one: `trimClobberedCallArgs`, which drops a read that fed a guessed call's argument
1255
+ * list — the sound answer for those, and not a reason to refuse the function as well — and
1256
+ * `pruneDeadParams`, which drops one that fed a join nothing reads. A destroyed value nothing is
1257
+ * left holding is a dead register, not a wrong answer.
1258
+ *
1259
+ * TWO-SIDED, so both sides are stated. TOO STRICT costs a row and nothing else: a register the
1260
+ * compiler rematerialised in a way no `writeVar` saw reads as destroyed and the function
1261
+ * declines. TOO LOOSE is the wrong value, and the MAY direction below is what rules it out — a
1262
+ * register destroyed on ANY path into a block is destroyed there, so a join cannot launder one. */
1263
+ /** Per block, the registers a call destroyed on SOME path into it with nothing written since:
1264
+ * the UNION, against `trimClobberedCallArgs`'s intersection, and for the opposite reason. That
1265
+ * one proves the caller set a register up, which needs every path to agree; this one proves
1266
+ * nobody can name it, which one path is enough for. Meaningful once every block is filled. */
1267
+ const destroyedOnEntry = (images = false): Array<Set<string>> => {
1268
+ const destroyedIn: Array<Set<string>> = irBlocks.map(() => new Set());
1269
+ const outOf = (b: number): Set<string> => {
1270
+ const out = new Set([...clobberedLocal[b], ...(images ? imageLocal[b] : [])]);
1271
+ for (const r of destroyedIn[b]) {
1272
+ if (!decidedLocal[b].has(r)) {
1273
+ out.add(r);
1274
+ }
1275
+ }
1276
+ return out;
1277
+ };
1278
+ for (let changed = true; changed;) {
1279
+ changed = false;
1280
+ for (let b = 0; b < irBlocks.length; b++) {
1281
+ for (const p of distinctPreds(b)) {
1282
+ for (const r of outOf(p)) {
1283
+ if (!destroyedIn[b].has(r)) {
1284
+ destroyedIn[b].add(r);
1285
+ changed = true;
1286
+ }
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+ return destroyedIn;
1292
+ };
1293
+ /** A destroyed value a copy carries OUT of its block (`StaleRead.carriedBy`) is used where any
1294
+ * block the read's block reaches uses it — an edge argument included, which is how a slot the
1295
+ * copy stored reaches its reload at a join. The destroyed register itself needs none of this:
1296
+ * every block it reaches marks it destroyed on entry (`destroyedOnEntry`) and judges its own
1297
+ * reads. Every later use of the value counts, under whatever name, which can only refuse more. */
1298
+ const carriedOut = (c: StaleRead): boolean => {
1299
+ if (![...c.carriedBy].some((k) => defs[c.block].get(k) === c.value)) {
1300
+ return false;
1301
+ }
1302
+ const index = new Map(irBlocks.map((blk, i) => [blk, i]));
1303
+ const usesIt = (op: Op) => op.operands.includes(c.value) || op.successors.some((s) => s.args.includes(c.value));
1304
+ const seen = new Set<number>();
1305
+ const work = irBlocks[c.block].ops.slice(c.fromOp).flatMap((op) => op.successors.map((s) => s.block));
1306
+ if (irBlocks[c.block].ops.slice(c.fromOp).some(usesIt)) {
1307
+ return true;
1308
+ }
1309
+ for (let blk = work.pop(); blk !== undefined; blk = work.pop()) {
1310
+ const b = index.get(blk);
1311
+ if (b === undefined || seen.has(b)) {
1312
+ continue;
1313
+ }
1314
+ seen.add(b);
1315
+ if (blk.ops.some(usesIt) || usesOutsideOps.some((u) => u.value === c.value && u.block === b)) {
1316
+ return true;
1317
+ }
1318
+ work.push(...blk.ops.flatMap((op) => op.successors.map((s) => s.block)));
1319
+ }
1320
+ return false;
1321
+ };
1322
+ const refuseStaleCallerSavedReads = () => {
1323
+ if (staleCandidates.length === 0) {
1324
+ return;
1325
+ }
1326
+ const destroyedIn = destroyedOnEntry();
1327
+ for (const c of staleCandidates) {
1328
+ if (!c.local && !destroyedIn[c.block].has(c.reg)) {
1329
+ continue;
1330
+ }
1331
+ const ops = irBlocks[c.block].ops;
1332
+ if (
1333
+ ops.slice(c.fromOp).some((op) => op.operands.includes(c.value)) ||
1334
+ usesOutsideOps.some((u) => u.value === c.value && u.block === c.block && u.atOp >= c.fromOp) ||
1335
+ carriedOut(c)
1336
+ ) {
1337
+ throw new FrontendUnsupportedError(
1338
+ `cannot lift '${name}': ${c.reg} is read on a path where a call has destroyed it, so what ` +
1339
+ `it holds is the callee's and not a value this function named — not modelled`,
1340
+ );
1341
+ }
1342
+ }
1343
+ };
1344
+
1345
+ /** Where each value is read: the joins it is an edge argument of, and `null` for an op's operand. */
1346
+ const readersOf = (): Map<Value, Set<Value | null>> => {
1347
+ const readers = new Map<Value, Set<Value | null>>();
1348
+ const note = (v: Value, r: Value | null) => (readers.get(v) ?? readers.set(v, new Set()).get(v)!).add(r);
1349
+ for (const blk of irBlocks) {
1350
+ for (const op of blk.ops) {
1351
+ op.operands.forEach((v) => note(v, null));
1352
+ for (const s of op.successors) {
1353
+ s.args.forEach((v, i) => note(v, s.block.params[i]));
1354
+ }
1355
+ }
1356
+ }
1357
+ return readers;
1358
+ };
1359
+
1360
+ /** Each non-entry block param → the argument every in-edge passes it, in edge order. */
1361
+ const joinArgs = (): Map<Value, Value[]> => {
1362
+ const m = new Map<Value, Value[]>();
1363
+ for (const blk of irBlocks) {
1364
+ for (const op of blk.ops) {
1365
+ for (const s of op.successors) {
1366
+ if (s.block === irBlocks[0]) {
1367
+ continue;
1368
+ }
1369
+ s.block.params.forEach((p, i) => {
1370
+ (m.get(p) ?? m.set(p, []).get(p)!).push(s.args[i]);
1371
+ });
1372
+ }
1373
+ }
1374
+ }
1375
+ return m;
1376
+ };
1377
+
1378
+ /** ANOTHER STORAGE'S UNDEFINED VALUE IS AN ASSIGNMENT. `int x, y; if (c) x = y;` at ido7.1
1379
+ * copies the word at 0(sp) into 4(sp) on one path, so the join keyed `sp@4` takes `sp@0`'s
1380
+ * undefined value as an edge argument. The structurer drops an undefined edge copy (structure.ts
1381
+ * `undefCarriesNothing`), which is right for the join's OWN storage — that path left the local
1382
+ * unassigned — and wrong for another's, where the machine ran a load and a store. Each such edge
1383
+ * argument becomes its own undefined value under the source's key, marked `copied`, which the
1384
+ * structurer spells as the assignment `x = uninit_sp0` and names as the value it copies. A join
1385
+ * whose key the builder did not record is left as it was.
1386
+ *
1387
+ * FRAME WORDS ONLY, both of them. A compiler gives a stack local one word and moves no local from
1388
+ * word to word, so two words are two locals. A register is no such identity: agbcc keeps a local
1389
+ * in r8 and moves it to a low register to use it (`synthetic:uninit_spill`), and a spill writes a
1390
+ * register's local to a word — one source variable under two keys, where a spelled copy would
1391
+ * invent an assignment the source never wrote. So `x = y` between two locals in registers
1392
+ * (`uninit_both_saved` on agbcc) still collapses into one undefined value where nothing else
1393
+ * reads it (below).
1394
+ *
1395
+ * A REGISTER CACHING TWO FRAME WORDS. ido7.1 -O2 gives an uninitialised local its word and then
1396
+ * caches it in a register across a loop or an arm: `int x, y; if (c) x = y; else y = x;
1397
+ * return x - y;` is `lw a0,0(sp); move v1,a0` on one arm and `lw v1,4(sp); move a0,v1` on the
1398
+ * other, so the joins keyed v1 and a0 each take sp@0's undefined value on one edge and sp@4's on
1399
+ * the other, and collapsing them into two unrelated undefined values loses that x - y is 0.
1400
+ * Where the joins one register-keyed join is connected to by its edge arguments (a loop header's
1401
+ * and the arm join inside it, in `for (…) if (i & 1) x = y;`) take the undefined values of TWO
1402
+ * OR MORE frame words between them, the register is no one local's home, and each such edge
1403
+ * argument is marked `copied` too: every one is a load the machine ran. Of ONE frame word only
1404
+ * (an arm that leaves the cached local as it was) the web is that local's, and stays as it was —
1405
+ * so long as nothing else reads the value, below.
1406
+ *
1407
+ * A VALUE READ ELSEWHERE IS A RELATION, whatever the keys. Dropping an edge copy leaves the
1408
+ * join's local unassigned on that path, which is as undefined as the machine's word only where
1409
+ * that word is read through this join alone. `int x, z; if (c) z = x; else z = 7; return z - x;`
1410
+ * at ido7.1 caches x's word 4(sp) in z's register v1 on one arm and reads 4(sp) again for the
1411
+ * subtraction, so `c != 0` returns 0; with the copy dropped, z's local is unassigned there and
1412
+ * the C returns garbage. So an undefined edge argument, of any storage and into a join of any
1413
+ * key (registers too: gcc 2.7.2 keeps x in v1 and runs `move v0,v1`), is a copy here whenever
1414
+ * ANOTHER JOIN also takes it, since that join's local and this one hold the same value on the
1415
+ * machine. One the function also reads directly is left to the structurer, which names the
1416
+ * value as the join's local when it can (`g = x; if (c) x = 5; return x;`) and spells the copy
1417
+ * when it cannot (structure.ts `undefCarriesNothing`); `collapseUndefinedJoins` keeps such a
1418
+ * join for it. What agbcc's prologue relates by staging a high register for its push is not
1419
+ * this function's copy and never reaches here (thumb.ts `prologueSavedRegs`). */
1420
+ const markCopiedUndefs = () => {
1421
+ const defOf = defOpMap(fn);
1422
+ const replaced = new Set<Value>();
1423
+ const frameUndefKey = (v: Value): string | undefined => {
1424
+ const d = defOf.get(v);
1425
+ return d?.opcode === 'undef' && d.attrs.copied !== true && slotKeyOffset(String(d.attrs.key)) !== null
1426
+ ? String(d.attrs.key)
1427
+ : undefined;
1428
+ };
1429
+ // The register-keyed joins whose web of joins takes two or more frame words' undefined values.
1430
+ const mixedRegisterJoins = new Set<Value>();
1431
+ {
1432
+ const incoming = joinArgs();
1433
+ const isRegJoin = (p: Value) => {
1434
+ const k = phiKey.get(p);
1435
+ return incoming.has(p) && k !== undefined && slotKeyOffset(k) === null;
1436
+ };
1437
+ const parent = new Map<Value, Value>();
1438
+ const find = (v: Value): Value => {
1439
+ let r = v;
1440
+ while (parent.get(r) !== undefined && parent.get(r) !== r) {
1441
+ r = parent.get(r)!;
1442
+ }
1443
+ parent.set(v, r);
1444
+ return r;
1445
+ };
1446
+ for (const [p, args] of incoming) {
1447
+ if (!isRegJoin(p)) {
1448
+ continue;
1449
+ }
1450
+ if (!parent.has(p)) {
1451
+ parent.set(p, p);
1452
+ }
1453
+ for (const a of args) {
1454
+ if (isRegJoin(a)) {
1455
+ if (!parent.has(a)) {
1456
+ parent.set(a, a);
1457
+ }
1458
+ parent.set(find(a), find(p));
1459
+ }
1460
+ }
1461
+ }
1462
+ const keysOf = new Map<Value, Set<string>>();
1463
+ for (const [p, args] of incoming) {
1464
+ if (!isRegJoin(p)) {
1465
+ continue;
1466
+ }
1467
+ const root = find(p);
1468
+ const ks = keysOf.get(root) ?? keysOf.set(root, new Set()).get(root)!;
1469
+ for (const a of args) {
1470
+ const k = frameUndefKey(a);
1471
+ if (k !== undefined) {
1472
+ ks.add(k);
1473
+ }
1474
+ }
1475
+ }
1476
+ for (const p of parent.keys()) {
1477
+ if ((keysOf.get(find(p))?.size ?? 0) >= 2) {
1478
+ mixedRegisterJoins.add(p);
1479
+ }
1480
+ }
1481
+ }
1482
+ const readers = readersOf();
1483
+ const joinedElsewhere = (u: Value, p: Value) => [...(readers.get(u) ?? [])].some((r) => r !== null && r !== p);
1484
+ for (const blk of irBlocks) {
1485
+ for (const op of blk.ops) {
1486
+ for (const s of op.successors) {
1487
+ if (s.block === irBlocks[0]) {
1488
+ continue;
1489
+ }
1490
+ s.block.params.forEach((p, i) => {
1491
+ const d = defOf.get(s.args[i]);
1492
+ const key = phiKey.get(p);
1493
+ if (d?.opcode !== 'undef' || d.attrs.copied === true || key === undefined) {
1494
+ return;
1495
+ }
1496
+ const frameCopy =
1497
+ d.attrs.key !== key &&
1498
+ slotKeyOffset(String(d.attrs.key)) !== null &&
1499
+ (slotKeyOffset(key) !== null || mixedRegisterJoins.has(p));
1500
+ if (!frameCopy && !joinedElsewhere(s.args[i], p)) {
1501
+ return;
1502
+ }
1503
+ const copy = mkOp('undef', {
1504
+ results: [mkValue(s.args[i].type)],
1505
+ attrs: { key: d.attrs.key, copied: true },
1506
+ });
1507
+ irBlocks[0].ops.unshift(copy);
1508
+ replaced.add(s.args[i]);
1509
+ s.args[i] = copy.results[0];
1510
+ });
1511
+ }
1512
+ }
1513
+ }
1514
+ if (replaced.size > 0) {
1515
+ const used = new Set(
1516
+ irBlocks.flatMap((b) => b.ops.flatMap((o) => [...o.operands, ...o.successors.flatMap((s) => s.args)])),
1517
+ );
1518
+ for (const blk of irBlocks) {
1519
+ blk.ops = blk.ops.filter((o) => !replaced.has(o.results[0]) || used.has(o.results[0]));
1520
+ }
1521
+ }
1522
+ };
1523
+
1524
+ /** A JOIN NO PATH DEFINES IS ONE UNINITIALISED VALUE. A join whose every argument is its own
1525
+ * storage's undefined value (`markCopiedUndefs` has already turned another frame word's into an
1526
+ * assignment) would keep its local and lose every assignment to it, since the structurer drops
1527
+ * each such edge copy, and its own postcondition then reports a local read but never assigned.
1528
+ * Such a join is replaced by one fresh undefined value at the entry, keyed as the join was,
1529
+ * which dominates every use the join had. The greatest such set, so a loop that only carries an
1530
+ * undefined value round (`phi(undef, phi(…))`) collapses with it. Run after the hidden-input
1531
+ * check, which refuses the joins whose undefined arguments are a hand-written routine's inputs.
1532
+ * The fresh value lists the other keys whose undefined values it absorbed (`ABSORBED_KEYS`):
1533
+ * `x = a ? b : c` past a one-word declaration joins r1's and r2's into r4's, and a reader asking
1534
+ * whether the lift still reads r1 must find it there. */
1535
+ const collapseUndefinedJoins = () => {
1536
+ const incoming = joinArgs();
1537
+ const undefs = new Set<Value>();
1538
+ for (const blk of irBlocks) {
1539
+ for (const op of blk.ops) {
1540
+ if (op.opcode === 'undef' && op.attrs.copied !== true) {
1541
+ undefs.add(op.results[0]);
1542
+ }
1543
+ }
1544
+ }
1545
+ if (undefs.size === 0) {
1546
+ return;
1547
+ }
1548
+ // A join one of whose undefined arguments is also read directly keeps it: the fresh value it
1549
+ // would collapse into is a second undefined value where the machine holds one, and the
1550
+ // structurer spells the edge copy instead (`markCopiedUndefs`, A VALUE READ ELSEWHERE).
1551
+ const readers = readersOf();
1552
+ const readDirectly = (a: Value) => undefs.has(a) && (readers.get(a)?.has(null) ?? false);
1553
+ const doomed = new Set(
1554
+ [...incoming.keys()].filter((p) => incoming.get(p)!.length > 0 && !incoming.get(p)!.some(readDirectly)),
1555
+ );
1556
+ for (let changed = true; changed;) {
1557
+ changed = false;
1558
+ for (const p of doomed) {
1559
+ if (incoming.get(p)!.some((a) => a !== p && !undefs.has(a) && !doomed.has(a))) {
1560
+ doomed.delete(p);
1561
+ changed = true;
1562
+ }
1563
+ }
1564
+ }
1565
+ // ...and reaches an undefined value at all: a param only its own back edge feeds is a cycle no
1566
+ // entry reaches (`readRecursive`'s unreachable walk), which carries no value to collapse into.
1567
+ const grounded = new Set<Value>();
1568
+ for (let changed = true; changed;) {
1569
+ changed = false;
1570
+ for (const p of doomed) {
1571
+ if (!grounded.has(p) && incoming.get(p)!.some((a) => undefs.has(a) || grounded.has(a))) {
1572
+ grounded.add(p);
1573
+ changed = true;
1574
+ }
1575
+ }
1576
+ }
1577
+ for (const p of [...doomed]) {
1578
+ if (!grounded.has(p)) {
1579
+ doomed.delete(p);
1580
+ }
1581
+ }
1582
+ if (doomed.size === 0) {
1583
+ return;
1584
+ }
1585
+ const orphans = new Set([...doomed].flatMap((p) => incoming.get(p)!.filter((a) => undefs.has(a))));
1586
+ const defOf = defOpMap(fn);
1587
+ // The keys of the undefined values a join absorbs, through the doomed joins it takes.
1588
+ const absorbed = (p: Value, seen = new Set<Value>()): string[] =>
1589
+ seen.has(p)
1590
+ ? []
1591
+ : (seen.add(p),
1592
+ incoming
1593
+ .get(p)!
1594
+ .flatMap((a) =>
1595
+ undefs.has(a) ? [String(defOf.get(a)!.attrs.key)] : doomed.has(a) ? absorbed(a, seen) : [],
1596
+ ));
1597
+ for (const p of doomed) {
1598
+ const firstUndef = incoming.get(p)!.find((a) => undefs.has(a));
1599
+ const key = phiKey.get(p) ?? (firstUndef && defOf.get(firstUndef)?.attrs.key) ?? '';
1600
+ const from = [...new Set(absorbed(p))].filter((k) => k !== key).sort();
1601
+ const op = mkOp('undef', {
1602
+ results: [mkValue(T.unk(32))],
1603
+ attrs: from.length === 0 ? { key } : { key, [ABSORBED_KEYS]: from.join(',') },
1604
+ });
1605
+ irBlocks[0].ops.unshift(op);
1606
+ replaceAllUsesWith(fn, p, op.results[0]);
1607
+ }
1608
+ for (const blk of irBlocks) {
1609
+ for (let i = blk.params.length - 1; i >= 0; i--) {
1610
+ const p = blk.params[i];
1611
+ if (!doomed.has(p)) {
1612
+ continue;
1613
+ }
1614
+ dropPhiRecord(p);
1615
+ blk.params.splice(i, 1);
1616
+ for (const b2 of irBlocks) {
1617
+ for (const o of b2.ops) {
1618
+ for (const s of o.successors) {
1619
+ if (s.block === blk) {
1620
+ s.args.splice(i, 1);
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ // The undefined arguments the collapsed joins took and nothing else reads.
1628
+ const used = new Set(
1629
+ irBlocks.flatMap((b) => b.ops.flatMap((o) => [...o.operands, ...o.successors.flatMap((s) => s.args)])),
1630
+ );
1631
+ for (const blk of irBlocks) {
1632
+ blk.ops = blk.ops.filter((o) => !orphans.has(o.results[0]) || used.has(o.results[0]));
1633
+ }
1634
+ };
1635
+
1636
+ const dropPhiRecord = (p: Value) => {
1637
+ phiBlock.delete(p);
1638
+ phiKey.delete(p);
1639
+ forgetOrder(p);
1640
+ };
535
1641
 
536
1642
  return {
537
1643
  fn,
538
1644
  irBlocks,
539
1645
  readVar,
1646
+ readGuessedArg: readAny, // see the interface: the trim is this read's answer, not a refusal
1647
+ readAtEnd: (reg: string, b: number) => (filled[b] ? readAny(reg, b) : null),
540
1648
  writeVar,
1649
+ frameWrites,
541
1650
  paramReg,
542
1651
  ensureParam,
543
1652
  hasReachingDef,
544
- noteCall: (b: number) => {
1653
+ holdsValue,
1654
+ everyReachingDef,
1655
+ entryValue,
1656
+ holdsEntryValue,
1657
+ writtenSinceCall: (reg: string, b: number) => writtenSinceCall[b].has(reg),
1658
+ writtenOnEveryPath: (reg, b, stop, visit) => {
1659
+ // `b` is judged up to here on the first visit; met again round a loop, it is the end of `b`,
1660
+ // which a back edge brings, and a cycle adds no path the first visit did not judge
1661
+ const seen = new Set<number>([b]);
1662
+ const walk = (at: number): boolean => {
1663
+ if (writtenSinceCall[at].has(reg) || clobberedLocal[at].has(reg)) {
1664
+ return true;
1665
+ }
1666
+ const ps = distinctPreds(at);
1667
+ return (
1668
+ ps.length > 0 &&
1669
+ ps.every((p) => {
1670
+ if (seen.has(p)) {
1671
+ return true;
1672
+ }
1673
+ seen.add(p);
1674
+ if (!filled[p] || stop(p)) {
1675
+ return false;
1676
+ }
1677
+ visit?.(p);
1678
+ return walk(p);
1679
+ })
1680
+ );
1681
+ };
1682
+ return walk(b);
1683
+ },
1684
+ requireSoleReader: (value: Value, op: Op, refuse: () => void) => {
1685
+ soleReaders.push({ value, op, refuse });
1686
+ },
1687
+ requireReader: (value: Value, refuse: () => void) => {
1688
+ readObligations.push({ value, refuse });
1689
+ },
1690
+ noteIncidentalWrite: (v: Value) => {
1691
+ incidentalWrites.add(v);
1692
+ },
1693
+ isIncidentalWrite: (v: Value) => incidentalWrites.has(v),
1694
+ useOutsideOps: (v: Value, b: number) => {
1695
+ usesOutsideOps.push({ value: v, block: b, atOp: irBlocks[b].ops.length });
1696
+ },
1697
+ noteCall: (b: number, clobbers: readonly string[]) => {
545
1698
  callsIn.add(b);
546
1699
  // the callee clobbers the caller-saved registers, its own result register included — see
547
1700
  // the ordering contract on the interface
548
1701
  writtenSinceCall[b] = new Set();
1702
+ for (const r of clobbers) {
1703
+ clobberedLocal[b].add(r);
1704
+ decidedLocal[b].add(r);
1705
+ }
1706
+ imageLocal[b] = new Set();
1707
+ },
1708
+ noteStructImage: (b: number, reg: string, image: Value) => {
1709
+ imageLocal[b].add(reg);
1710
+ structImages.add(image);
549
1711
  },
550
- recordGuessedCall: (op: Op, b: number, abi: { argRegs: string[]; returnReg: string }) => {
1712
+ recordGuessedCall: (
1713
+ op: Op,
1714
+ b: number,
1715
+ abi: { argRegs: string[]; returnReg: string },
1716
+ passesCalleeResult?: (result: Value) => boolean,
1717
+ trimmed?: (cut: readonly string[], kept: readonly string[]) => void,
1718
+ withholdsSetupArgs?: () => boolean,
1719
+ asksEveryCut?: () => boolean,
1720
+ floor?: number,
1721
+ ) => {
551
1722
  abiSeen = abi;
552
1723
  guessedCalls.push({
553
1724
  block: b,
554
1725
  op,
1726
+ passesCalleeResult,
1727
+ trimmed,
1728
+ withholdsSetupArgs,
1729
+ asksEveryCut,
1730
+ ...(floor ? { floor } : {}),
555
1731
  freshBefore: new Set(writtenSinceCall[b]),
556
1732
  afterCallInBlock: callsIn.has(b), // `noteCall` runs after this, so this means an EARLIER call
1733
+ returnRegBefore:
1734
+ clobberedLocal[b].has(abi.returnReg) || imageLocal[b].has(abi.returnReg)
1735
+ ? 'destroyed'
1736
+ : decidedLocal[b].has(abi.returnReg)
1737
+ ? 'written'
1738
+ : 'inherited',
557
1739
  });
558
1740
  },
559
1741
  markFilled: (b: number) => {
@@ -575,31 +1757,197 @@ export function makeSsaBuilder(
575
1757
  }
576
1758
  }
577
1759
  trimClobberedCallArgs({
1760
+ name,
578
1761
  argRegs: abiSeen.argRegs,
579
1762
  returnReg: abiSeen.returnReg,
580
1763
  calleeResults,
1764
+ destroyedIn: destroyedOnEntry(true),
581
1765
  preds,
1766
+ blocks: irBlocks,
582
1767
  freshAtEnd: writtenSinceCall,
583
1768
  callsIn,
584
1769
  sites: guessedCalls,
585
1770
  });
1771
+ // what the trim leaves a guess passing a struct's image: a copy of it into another argument
1772
+ // register (`add r1, r0, #0`), which is as undecided as the image itself
1773
+ for (const site of guessedCalls) {
1774
+ const at = callArgs(site.op).findIndex((v) => structImages.has(v));
1775
+ if (at >= 0) {
1776
+ // worded as `trimClobberedCallArgs` refuses the image left in the return register itself
1777
+ const callee = site.op.attrs.target;
1778
+ throw new FrontendUnsupportedError(
1779
+ `cannot lift '${name}': argument ${at + 1} of the call to ${typeof callee === 'string' ? `'${callee}'` : 'a callee'} ` +
1780
+ `is a struct an earlier call handed back in ${abiSeen.returnReg}, and nothing here passes one on as an argument — not modelled`,
1781
+ );
1782
+ }
1783
+ }
586
1784
  }
587
1785
  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
- });
1786
+ // The phis nothing reads at all — a register two paths leave holding different junk (a loop
1787
+ // counter after its last use, a scratch the epilogue overwrites) still joins as a phi, and a
1788
+ // dead phi is not junk downstream: its edge args become post-loop copies in the emitted C and
1789
+ // block gates keyed on "this exit carries nothing".
1790
+ //
1791
+ // DEAD-PHI PRUNING RUNS FIRST, and `refuseStaleCallerSavedReads` is why. That check asks
1792
+ // whether a destroyed value still reaches a use, and a dead phi's edge arg is not one. The
1793
+ // trivial-phi
1794
+ // pass must not run before it for the opposite reason: that one REPLACES the phi's uses with
1795
+ // the arg and splices the arg away, which takes the last visible use of a destroyed value out
1796
+ // of sight. Running it twice is what keeps both true — trivial-phi removal can orphan a phi's
1797
+ // last reader, never the reverse, so the second sweep is the one that pays for the first.
1798
+ pruneDeadParams(fn, dropPhiRecord);
1799
+ refuseStaleCallerSavedReads();
1800
+ simplifyTrivialPhis(fn, dropPhiRecord);
1801
+ pruneDeadParams(fn, dropPhiRecord);
1802
+ // A SOLE-READER OBLIGATION (`requireSoleReader`), over the finished function: after both
1803
+ // prunes, so a join nothing reads is no reader.
1804
+ for (const { value, op, refuse } of soleReaders) {
1805
+ const reads = (o: Op) =>
1806
+ o !== op && (o.operands.includes(value) || o.successors.some((e) => e.args.includes(value)));
1807
+ if (
1808
+ frameWrites.has(value) ||
1809
+ usesOutsideOps.some((u) => u.value === value) ||
1810
+ irBlocks.some((blk) => blk.ops.some(reads))
1811
+ ) {
1812
+ refuse();
1813
+ }
1814
+ }
1815
+ // …AND A READER OBLIGATION (`requireReader`), over the same finished function. A join the
1816
+ // value reaches is followed to its own readers, and a `ret` is no reader: a frontend returns
1817
+ // the return register at every `ret` whatever the function's declared type.
1818
+ for (const { value, refuse } of readObligations) {
1819
+ const reached = new Set<Value>([value]);
1820
+ for (let grew = true; grew;) {
1821
+ grew = false;
1822
+ for (const blk of irBlocks) {
1823
+ for (const op of blk.ops) {
1824
+ for (const e of op.successors) {
1825
+ e.block.params.forEach((p, i) => {
1826
+ if (reached.has(e.args[i]) && !reached.has(p)) {
1827
+ reached.add(p);
1828
+ grew = true;
1829
+ }
1830
+ });
1831
+ }
1832
+ }
1833
+ }
1834
+ }
1835
+ const read = [...reached].some(
1836
+ (v) =>
1837
+ frameWrites.has(v) ||
1838
+ usesOutsideOps.some((u) => u.value === v) ||
1839
+ irBlocks.some((blk) => blk.ops.some((o) => o.opcode !== 'ret' && o.operands.includes(v))),
1840
+ );
1841
+ if (!read) {
1842
+ refuse();
1843
+ }
1844
+ }
1845
+ // A HIDDEN INPUT IS NO UNINITIALISED LOCAL (`LiveInModel.hiddenInputRegs`). After the
1846
+ // trivial-phi pass, so a join every path of which arrives undefined is no join. The
1847
+ // compiler's local is assigned on SOME path, and its entry value meets that definition as a
1848
+ // join's edge argument; once it does, a direct use is the same local read before that
1849
+ // assignment (`uninitfirst`). An entry value that meets no such definition is the input.
1850
+ //
1851
+ // MEETING A JOIN IS NOT MEETING A DEFINITION. A register that carries an input round a loop
1852
+ // (`addiu t0,t0,-1; bnez t0,top` on a hand-written count) sends its entry value into the
1853
+ // loop header's join too, and the back edge's argument is that input's own next value. So
1854
+ // the join must also take an INDEPENDENT definition (`independentOfEntry`): a value no chain
1855
+ // of operands or join arguments derives from the entry value, other than through a join one
1856
+ // of whose other arguments is itself independent. A conditional assignment inside a loop
1857
+ // (`for (…) if (c) x = i;`) meets that through the inner join, and so does one the loop then
1858
+ // transforms before its back edge — a bit reader's `if (!bits) code = *src++; … code <<= 1;`,
1859
+ // whose shift reaches the entry value only through the join the load also feeds. A counter
1860
+ // that only advances the entry value, and two undefined values joined (`phi(t0, v1)`), do
1861
+ // not, and refuse. A bit reader HANDED its buffer meets the same join and lifts with the
1862
+ // input dropped: the rule's blind side, at `O32_SCRATCH_REGS` in target.ts.
1863
+ if (hiddenEntryValues.size > 0) {
1864
+ const incoming = joinArgs();
1865
+ const defOf = defOpMap(fn);
1866
+ // Read forwards: who reads each value, as an operand or as a join's edge argument.
1867
+ const readers = new Map<Value, Value[]>();
1868
+ const addReader = (v: Value, r: Value) => (readers.get(v) ?? readers.set(v, []).get(v)!).push(r);
1869
+ for (const blk of irBlocks) {
1870
+ for (const op of blk.ops) {
1871
+ for (const v of op.operands) {
1872
+ for (const r of op.results) {
1873
+ addReader(v, r);
1874
+ }
1875
+ }
1876
+ }
1877
+ }
1878
+ for (const [p, args] of incoming) {
1879
+ for (const a of args) {
1880
+ addReader(a, p);
1881
+ }
1882
+ }
1883
+ const isJoin = (v: Value) => incoming.has(v) && !irBlocks[0].params.includes(v);
1884
+ /** The values independent of `u`, as a least fixpoint. A join is independent once one of
1885
+ * its arguments is; any other value once no chain of operands or join arguments reaches
1886
+ * `u` without passing through an independent join. Grown from nothing, so no join is
1887
+ * independent on the strength of its own back edge (`addiu t0,t0,-1` round a loop). */
1888
+ const independentOfEntry = (u: Value): ((v: Value) => boolean) => {
1889
+ const absorbing = new Set<Value>();
1890
+ for (;;) {
1891
+ const derived = new Set<Value>([u]);
1892
+ const work = [u];
1893
+ for (let w = work.pop(); w !== undefined; w = work.pop()) {
1894
+ for (const r of readers.get(w) ?? []) {
1895
+ if (!derived.has(r) && !absorbing.has(r)) {
1896
+ derived.add(r);
1897
+ work.push(r);
1898
+ }
1899
+ }
1900
+ }
1901
+ const indep = (v: Value) =>
1902
+ v !== u && defOf.get(v)?.opcode !== 'undef' && (absorbing.has(v) || (!isJoin(v) && !derived.has(v)));
1903
+ let grew = false;
1904
+ for (let changed = true; changed;) {
1905
+ changed = false;
1906
+ for (const [p, args] of incoming) {
1907
+ if (isJoin(p) && !absorbing.has(p) && args.some(indep)) {
1908
+ absorbing.add(p);
1909
+ changed = grew = true;
1910
+ }
1911
+ }
1912
+ }
1913
+ if (!grew) {
1914
+ return indep;
1915
+ }
1916
+ }
1917
+ };
1918
+ const metMemo = new Map<Value, boolean>();
1919
+ const met = (u: Value): boolean => {
1920
+ let m = metMemo.get(u);
1921
+ if (m === undefined) {
1922
+ const joins = [...incoming.entries()].filter(([p, args]) => isJoin(p) && args.includes(u));
1923
+ const indep = joins.length === 0 ? undefined : independentOfEntry(u);
1924
+ m = indep !== undefined && joins.some(([, args]) => args.some(indep));
1925
+ metMemo.set(u, m);
1926
+ }
1927
+ return m;
1928
+ };
1929
+ for (const blk of irBlocks) {
1930
+ for (const op of blk.ops) {
1931
+ const reg = [...op.operands, ...op.successors.flatMap((s) => s.args)]
1932
+ .filter((v) => hiddenEntryValues.has(v) && !met(v))
1933
+ .map((v) => hiddenEntryValues.get(v))
1934
+ .find((r) => r !== undefined);
1935
+ if (reg !== undefined) {
1936
+ throw new FrontendUnsupportedError(
1937
+ `cannot lift '${name}': ${reg} is read before anything writes it, and ` +
1938
+ `no join meets it with a definition of its own — an input handed over outside the ` +
1939
+ `C convention (a save routine's frame address, a member-function pointer, a register ` +
1940
+ `hand-written asm passes a value in, a count it carries round a loop), not modelled ` +
1941
+ `as an uninitialised local`,
1942
+ );
1943
+ }
1944
+ }
1945
+ }
1946
+ }
1947
+ dropUnreadGlobalRegisterLoads();
1948
+ markCopiedUndefs();
1949
+ collapseUndefinedJoins();
1950
+ simplifyTrivialPhis(fn, dropPhiRecord);
603
1951
  // SEAL THE PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`). Here and not at the store or the
604
1952
  // write, because `pruneDeadParams` above is the last thing that can retire an entry
605
1953
  // parameter, and an observation about a value no longer in the signature is one the reader
@@ -616,6 +1964,12 @@ export function makeSsaBuilder(
616
1964
  }
617
1965
  }
618
1966
  const defs0 = defOpMap(fn);
1967
+ for (const v of deadHomed) {
1968
+ const d = defs0.get(v);
1969
+ if (d?.opcode === 'undef') {
1970
+ d.attrs[HOMED_UNDEF] = true;
1971
+ }
1972
+ }
619
1973
  for (const p of irBlocks[0].params) {
620
1974
  const reg = paramReg.get(p);
621
1975
  const first = reg === undefined ? undefined : firstEntryWrite.get(reg);
@@ -662,25 +2016,177 @@ export function makeSsaBuilder(
662
2016
  `(partially-initialised local, or storage this function does not own) — not modelled`,
663
2017
  );
664
2018
  }
2019
+ // …AND NEITHER MAY A CALLEE-SAVED REGISTER THIS FUNCTION NEVER SAVED
2020
+ // (`LiveInModel.unsavedCalleeSavedRegs`): no caller passes an argument there, so as a
2021
+ // parameter it would be read from the argument register a C caller fills instead.
2022
+ if (key !== undefined && koff === null && model().unsavedCalleeSavedRegs?.includes(key)) {
2023
+ throw new FrontendUnsupportedError(
2024
+ `cannot lift '${name}': it reads callee-saved ${key} before writing it, and ${unsavedWhy([key])} — ` +
2025
+ `a value no caller passes as an argument${globalNote([key])} — not modelled`,
2026
+ );
2027
+ }
2028
+ }
2029
+ // A write to one is handed back to the caller (a global register variable's store,
2030
+ // `mov r8, r0`): no C local spells it, and dropping it drops an effect.
2031
+ const leaked = (model().unsavedCalleeSavedRegs ?? []).filter((r) => writtenKeys.has(r));
2032
+ if (leaked.length > 0) {
2033
+ throw new FrontendUnsupportedError(
2034
+ `cannot lift '${name}': it writes callee-saved ${leaked.join(', ')}, and ${unsavedWhy(leaked)} — ` +
2035
+ `the caller sees the write${globalNote(leaked)} — not modelled`,
2036
+ );
665
2037
  }
666
2038
  },
667
2039
  };
668
2040
  }
669
2041
 
670
- // ── shared frontend tail helpers ──
2042
+ // ── a guessed call arity: counted by `CallDeclarations.lower`, trimmed by `finish` ──
671
2043
 
672
2044
  /** Best-effort call arity when a callee has no prototype: the count of contiguous argument
673
2045
  * registers with a value reaching the call's block. Correct when the arguments are set up in
674
2046
  * the calling block; it can under-count pass-through parameters — which is why a prototype's
675
- * declared `params` is authoritative when available. */
2047
+ * declared `params` is authoritative when available.
2048
+ *
2049
+ * `accept` says which definitions are values (`SsaBuilder.hasReachingDef`). A pending high half
2050
+ * is a def but not a value, and counted it raises the arity: a `lis` hoisted into the prologue
2051
+ * leaves its half in r4 across an intervening call and makes `strlen(s)` into
2052
+ * `strlen(s, <half>)`, which then refuses at the read.
2053
+ *
2054
+ * `gap` MAKES A GAP REFUSE. The count is contiguous, so an argument register with nothing reaching
2055
+ * it ends it — and one is empty for two opposite reasons. Either the call really takes that few
2056
+ * arguments, or the register still holds this function's own untouched incoming argument, a value
2057
+ * the machine passes on that SSA has no definition for because nothing ever wrote it. When a
2058
+ * LATER argument register does hold a value the second reading is the only one left, and taking
2059
+ * the first drops that argument and every one after it: `ac-decomp:evw_anime_colreg_manual` sets
2060
+ * up seven registers for `evw_color_set` and, with r4 at its incoming value, lifts to
2061
+ * `evw_color_set(a0);` — its divide, its multiply and five arguments gone. Which reading it is
2062
+ * cannot be decided from the register file alone — the function's own arity is exactly what is
2063
+ * missing — so this refuses and names the gap rather than guessing, unless `decide` answers it:
2064
+ * the lowering may show that the register above the gap is surely an argument, and where argument
2065
+ * words fill in order the gap below it is then this function's own arguments passed on
2066
+ * (frontend/call-plan.ts `decideGap`). A declaration answers it outright, and this scan is never
2067
+ * weighed against one: it is a guess, and a guess that cannot fail cannot witness a width a
2068
+ * declaration left open. */
676
2069
  export function fallbackArgc(
677
- ssa: { hasReachingDef(reg: string, b: number): boolean },
678
- argRegs: string[],
2070
+ ssa: { hasReachingDef(reg: string, b: number, accept?: (v: Value) => boolean): boolean },
2071
+ argRegs: readonly string[],
679
2072
  bi: number,
2073
+ opts: {
2074
+ /** whether `v`, reaching argument register `reg`, is a value the caller may have set up */
2075
+ accept?: (v: Value, reg: string) => boolean;
2076
+ /** the call's function, where it is (`CallSite`'s `guess.at`), and the frontend's own refusal */
2077
+ gap: { name: string; at: number | string; fail: FrontendRefusal };
2078
+ /** the leading arguments a declaration states (`GuessedCallSite.floor`): counted whether or not
2079
+ * the caller set them up, so only the registers above them are guessed */
2080
+ floor?: number;
2081
+ /** whether argument register `reg` still holds this function's own incoming value at the call
2082
+ * (an entry parameter, which a read leaves behind as a definition) on some path the caller
2083
+ * did not set it up on: one a call destroyed it on ends there, and one this block wrote it on
2084
+ * since its last call set it up (`CallDeclarations.lower` asks it) */
2085
+ entry?: (reg: string) => boolean;
2086
+ /** whether the gap `gap` — argument registers nothing reaches, in order — below `set`, the next
2087
+ * one a value reaches, is decided: the frontend shows `set` holds an argument, so `gap` holds
2088
+ * arguments passed on unchanged (`CallDeclarations.lower`'s `decideGap`). Handed the gap's
2089
+ * refusal, owed should a proof it defers fail. Absent, or false, the gap refuses. It is asked
2090
+ * again with an empty `gap` for every register the count takes past a decided gap. Each must be
2091
+ * proven an argument the same way, or the gap refuses. */
2092
+ decide?: (gap: readonly string[], set: string, refuse: () => void) => boolean;
2093
+ /** whether what a jump-table dispatch parked in argument register `reg` reaches the call
2094
+ * (`CallSite.parked`). Where nothing `accept` takes reaches it too, the count stops below it: no
2095
+ * compiler hands a callee the dispatch's own register, so no register above it is an argument.
2096
+ *
2097
+ * WHY THIS STOPS WHERE A CALL-DESTROYED REGISTER REFUSES (`r2 holds a value and r1 holds
2098
+ * none`): where the value above came from. A call destroys every caller-saved register, so one
2099
+ * holding a value after it was written since, for a call — an argument as likely as not. A
2100
+ * dispatch clobbers only its idiom's registers, and agbcc moves a value it keeps live across the
2101
+ * switch out of them, upward, before the idiom runs (`add r2,r1,#0` ahead of the `lsl` in a
2102
+ * switch on r0 whose cases call): the value above the parked register was set up for the switch,
2103
+ * not for the call. The cost is a value a case itself writes above the parked register, dropped
2104
+ * too (`mov r2,#5; mov r0,#1; bl g` lifts `g(1)`): no compiler measured writes one there. A
2105
+ * parked register a call in the case destroyed is that call's hole, not the dispatch's, and it
2106
+ * stops the count only where no register above holds a value written since that call
2107
+ * (`SsaBuilder.holdsValue`): `bl h; mov r2,#5; mov r0,#1; bl g` refuses, while `add r3,r1,#0`
2108
+ * ahead of the idiom and `bl h; mov r0,#1; bl g` in a case lifts `g(1)` — the gap rule's
2109
+ * `setUp` walks through calls and would count r3's copy, which `h` destroyed. */
2110
+ parked?: (reg: string) => boolean;
2111
+ },
680
2112
  ): number {
681
- let n = 0;
682
- while (n < argRegs.length && ssa.hasReachingDef(argRegs[n], bi)) {
683
- n++;
2113
+ const accept = opts.accept ?? (() => true);
2114
+ // whether the caller set argument register k up: a definition reaches it, through a call too
2115
+ const setUp = (k: number) => ssa.hasReachingDef(argRegs[k], bi, (v) => accept(v, argRegs[k]));
2116
+ const { name, at, fail } = opts.gap;
2117
+ const call = typeof at === 'number' ? `at 0x${at.toString(16)}` : at;
2118
+ const floor = opts.floor ?? 0;
2119
+ // the registers past the lowest parked one are none of the call's: neither counted nor a gap's top
2120
+ const parkedAt = argRegs.findIndex((r, k) => k >= floor && opts.parked?.(r) === true && !setUp(k));
2121
+ const top = parkedAt < 0 ? argRegs.length : parkedAt;
2122
+ // the run of argument registers the caller set up from `from`, each past a declaration's
2123
+ // stated words asked whether it still holds the incoming value
2124
+ const run = (from: number): number => {
2125
+ let n = from;
2126
+ while (n < top && setUp(n)) {
2127
+ // PAST A DECLARATION'S STATED WORDS, A REGISTER STILL HOLDING ITS INCOMING VALUE REFUSES. A
2128
+ // read of it (a home store, a compare, a store to a global) leaves the entry parameter behind
2129
+ // as a definition, so it reads as set up, and it is exactly as undecidable as the gap below: the
2130
+ // function's own argument passed on, or one the call is not handed. Counted, it is an argument
2131
+ // the source never passed — `int logv(const char *, int, ...)` and `logv(s, c) + 1` in a
2132
+ // function whose unused third parameter IDO home-stores lifts `logv(a0, a1, a2)`. Below a
2133
+ // floor the scan never asks, and with none the guess keeps counting it: there it is the far
2134
+ // commoner `if (a) g(a)`, and the same refusal declines 17 lifts of kleod's 20 units and 40 of
2135
+ // sa3's (`DecompressAlloc(a0)`, `m4aSongNumStart(a1)`), where the floored rule moves none.
2136
+ //
2137
+ // ONLY A PATH THE ENTRY VALUE STILL ARRIVES BY IS ASKED. `setUp` walks through calls, so an
2138
+ // entry value a call destroyed reaches it too — `g(x); dprint(s)` — and that register holds
2139
+ // nothing; the trim cuts it (`trimClobberedCallArgs`) and `dprint(a0)` is the bytes. And a
2140
+ // register this block wrote since its last call was set up here, by the copy that put the
2141
+ // value back — `g(y); dprint(s, x)` re-sets r1 from a saved copy — whatever value SSA names.
2142
+ if (floor > 0 && opts.entry?.(argRegs[n])) {
2143
+ fail(
2144
+ `cannot lift '${name}': the call ${call} has a prototype stating only its first ${floor} ` +
2145
+ `argument${floor === 1 ? '' : 's'}, and ${argRegs[n]} past them still holds this function's own incoming ` +
2146
+ 'value — an argument passed on unchanged and one the call is not handed look the same here, so the ' +
2147
+ 'argument count is not decidable',
2148
+ );
2149
+ }
2150
+ n++;
2151
+ }
2152
+ return n;
2153
+ };
2154
+ let n = run(floor);
2155
+ // A GAP BELOW A REGISTER THAT HOLDS A VALUE refuses, unless the frontend decides it: then the run
2156
+ // goes on through the gap and the register above it, and the next gap is asked the same.
2157
+ //
2158
+ // PAST A DECIDED GAP, EVERY REGISTER THE RUN COUNTS MUST BE PROVEN TOO (asked of `decide` with an
2159
+ // empty gap), or the gap refuses. The decision says the register above the gap is an argument and
2160
+ // nothing about the ones above that. Without the gap's refusal the run would count whatever reaches
2161
+ // them: a store's address or value, a discarded volatile read, a temp, an incoming value read
2162
+ // elsewhere. Measured 2026-10-18 at canonical flags: `gp[1] = 3; return g(a, -gy) + 1;` would
2163
+ // lift `g(a0, -gy, &gp)` on agbcc, `return g(a, c + 1) + 1;` `g(a0, a2 + 1, a2)` on all seven
2164
+ // toolchains, and `*(volatile int *)K; g(a, b, (short)c)` made agbcc's discarded read a fourth
2165
+ // argument.
2166
+ for (let k = n + 1; k < top; k++) {
2167
+ if (setUp(k)) {
2168
+ const [gapAt, setAt] = [n, k];
2169
+ const refuse = () =>
2170
+ fail(
2171
+ `cannot lift '${name}': the call ${call} ` +
2172
+ (opts.floor
2173
+ ? `has a prototype stating only its first ${opts.floor} argument${opts.floor === 1 ? '' : 's'}`
2174
+ : 'has no prototype') +
2175
+ `, ${argRegs[setAt]} holds a ` +
2176
+ `value and ${argRegs[gapAt]} holds none — an argument register left at its incoming value and one ` +
2177
+ `the call does not pass look the same here, so the argument count is not decidable`,
2178
+ );
2179
+ if (!opts.decide?.(argRegs.slice(n, k), argRegs[k], refuse)) {
2180
+ refuse();
2181
+ }
2182
+ n = run(k);
2183
+ for (let above = k + 1; above < n; above++) {
2184
+ if (!opts.decide?.([], argRegs[above], refuse)) {
2185
+ refuse();
2186
+ }
2187
+ }
2188
+ k = n;
2189
+ }
684
2190
  }
685
2191
  return n;
686
2192
  }
@@ -689,15 +2195,43 @@ export function fallbackArgc(
689
2195
  * of its own block up to that instruction. */
690
2196
  export interface GuessedCallSite {
691
2197
  block: number;
692
- /** the `call` op — its operands are the guessed arguments, in argument-register order */
2198
+ /** the `call` op — its arguments (`callArgs`) are the guessed ones, in argument-register order */
693
2199
  op: Op;
694
2200
  /** argument registers written between the last call in this block (or the block's start) and here */
695
2201
  freshBefore: Set<string>;
696
2202
  /** did this block already make a call before this one? */
697
2203
  afterCallInBlock: boolean;
2204
+ /** what this block did to the return register before this call: a call here left it holding
2205
+ * nothing (`destroyed`), the block wrote it since (`written`), or neither, so the predecessors
2206
+ * decide (`inherited`) */
2207
+ returnRegBefore: 'destroyed' | 'written' | 'inherited';
2208
+ /** which earlier callee's result in the return register is an argument here, not a leftover:
2209
+ * the site's lowering says so ({@link SsaBuilder.recordGuessedCall}). Absent where none is. */
2210
+ passesCalleeResult?: (result: Value) => boolean;
2211
+ /** handed the FRESH argument registers the trim cuts from this site's guess, and the ones it
2212
+ * keeps — the lowering's `CallSite.leftOut` question, asked again where the arity shrinks a
2213
+ * second time. An unfresh cut is the trim's own proof; a fresh one went with a hole below it
2214
+ * (see {@link trimClobberedCallArgs}), which proves nothing. The `/setup-args` reading asks it a
2215
+ * third time, of the cut registers the call's own block wrote (of every one it cuts, where
2216
+ * `asksEveryCut`), and a refusal there withholds that reading for the site instead of declining.
2217
+ * Absent where nothing is asked. */
2218
+ trimmed?: (cut: readonly string[], kept: readonly string[]) => void;
2219
+ /** whether the frontend withholds the `/setup-args` reading from this site whatever it would cut
2220
+ * (`CallSite.withholdsSetupArgs`). Absent where it does not. */
2221
+ withholdsSetupArgs?: () => boolean;
2222
+ /** whether the `/setup-args` reading of this site asks `trimmed` of every register it cuts, an
2223
+ * earlier block's leftovers included ({@link narrowingKeepsFrameAddresses};
2224
+ * `CallSite.asksEveryCut`). Absent where it asks only of what the call's own block wrote. */
2225
+ asksEveryCut?: () => boolean;
2226
+ /** how many leading arguments a declaration STATES, so neither reading cuts below them: the
2227
+ * fixed parameters of a variadic declaration that are each one word (frontend/call-plan.ts
2228
+ * `variadicFloor`). Only the arguments past them are guessed. Absent where none is stated. */
2229
+ floor?: number;
698
2230
  }
699
2231
 
700
2232
  export interface CallArgTrim {
2233
+ /** the function being lifted, for a refusal */
2234
+ name: string;
701
2235
  argRegs: string[];
702
2236
  /** the ABI return register. Load-bearing only where it IS `argRegs[0]` (ARM r0, PPC r3) — that
703
2237
  * aliasing is what makes a callee's result indistinguishable from caller-side argument setup. */
@@ -705,8 +2239,14 @@ export interface CallArgTrim {
705
2239
  /** every value a `call` op produced. Tells a callee's own return apart from a join that merely
706
2240
  * PASSES THROUGH one, which the register file cannot: both leave argument 0 unfresh. */
707
2241
  calleeResults: ReadonlySet<Value>;
2242
+ /** per block, the registers a call destroyed on SOME path into it with nothing written since. A
2243
+ * call lists the return register there only when it leaves it holding nothing the caller can
2244
+ * name — a struct handed back in it ({@link SsaBuilder.noteCall}). */
2245
+ destroyedIn: ReadonlyArray<ReadonlySet<string>>;
708
2246
  /** one entry per CFG edge, as passed to {@link makeSsaBuilder} */
709
2247
  preds: number[][];
2248
+ /** the blocks `preds` indexes, terminators included — the edges a guard proves a value on */
2249
+ blocks: readonly Block[];
710
2250
  /** per block: the keys written since its LAST call (since its start if it makes none). Indexed by
711
2251
  * block, and it holds every key the builder saw, not only argument registers. */
712
2252
  freshAtEnd: Array<Set<string>>;
@@ -736,12 +2276,13 @@ export interface CallArgTrim {
736
2276
  * real argument CAN go with them: a fresh register above a hole stops the run (a 64-bit return
737
2277
  * occupies two registers and the frontend cannot express one, so the caller's r2 goes with the
738
2278
  * 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.
2279
+ * source did pass. A declared prototype is what closes either for a named callee; a call through a
2280
+ * register has none, so it keeps that return instead (`GuessedCallSite.passesCalleeResult`).
740
2281
  *
741
2282
  * Frontend-agnostic: the caller supplies what its own lifting scan observed, so nothing here
742
2283
  * re-derives which instruction writes which register. */
743
2284
  export function trimClobberedCallArgs(inp: CallArgTrim): void {
744
- const { argRegs, returnReg, calleeResults, preds, freshAtEnd, callsIn, sites } = inp;
2285
+ const { argRegs, returnReg, calleeResults, destroyedIn, preds, blocks, freshAtEnd, callsIn, sites } = inp;
745
2286
  const blockCount = freshAtEnd.length;
746
2287
  const all = () => new Set(argRegs);
747
2288
  const localEnd = (b: number) => freshAtEnd[b] ?? new Set<string>();
@@ -792,7 +2333,7 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
792
2333
  // (`bl __mulsf3; add r1,r4,#0; bl __addsf3` is `__addsf3(__mulsf3(a, b), c)`).
793
2334
  const setsUpLater = (fresh: Set<string>): boolean => argRegs.some((r, i) => i > 0 && fresh.has(r));
794
2335
  // 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
2336
+ // call lowering records a call's clobber AFTER its own result, so the result leaves the register
796
2337
  // UNfresh here. That disproves caller setup only where the callee's return is BOTH what the
797
2338
  // register still holds and all the site has to go on: with a later register set up (above), or
798
2339
  // with a value no call produced — a join of one path's return with another path's caller-computed
@@ -804,20 +2345,142 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
804
2345
  // `f` to return a value and `g` to accept one — a spelling the project's own header rejects
805
2346
  // outright when it does not. A declared prototype never reaches here, and stays the way `g(f())`
806
2347
  // is recovered.
807
- const argcAt = (fresh: Set<string>, op: Op): number => {
808
- if (argRegs[0] !== returnReg || fresh.has(argRegs[0])) {
2348
+ //
2349
+ // A SITE THAT PASSES THE RESULT ON keeps it (`GuessedCallSite.passesCalleeResult`).
2350
+ //
2351
+ // THE EXEMPTION NEEDS A RESULT IN THE REGISTER. A call that hands a struct back in it (`struct W1
2352
+ // mkw(s32)`) writes no value there — its bytes are a struct, not a word — so the register still
2353
+ // names what it held BEFORE that call, and neither clause above can tell: that value is no callee
2354
+ // result, which is the join clause's sign of a real argument. Destroyed on any path is no setup on
2355
+ // that path, so the register is an ordinary unfresh one there, and the run stops below it —
2356
+ // unless a later register is set up, which puts argument 0 below a proven one: that argument is
2357
+ // then the struct itself (`other(mv(a), b)`), a value no register here names, and it declines.
2358
+ //
2359
+ // WHETHER AN EDGE PROVES `v` CONSTANT EVERYWHERE `b` IS REACHED: up the chain of blocks with one
2360
+ // predecessor, a conditional branch on `v == K` arriving by its true edge, or on `v != K` by its
2361
+ // false one. K is any value arithmetic builds out of literals: agbcc compares with 1000 as
2362
+ // `mov r1,#250; lsl r1,#2; cmp r0,r1`. `v` holds every name the value goes by: compared past a join
2363
+ // the trivial-phi pass has yet to fold, a callee's result is that join's parameter.
2364
+ let defs: Map<Value, Op> | undefined;
2365
+ const defOf = (v: Value): Op | undefined => {
2366
+ defs ??= new Map(blocks.flatMap((blk) => blk.ops.flatMap((op) => op.results.map((r) => [r, op] as const))));
2367
+ return defs.get(v);
2368
+ };
2369
+ const isLiteral = (v: Value): boolean => {
2370
+ const d = defOf(v);
2371
+ return (
2372
+ d?.opcode === 'const' ||
2373
+ (d !== undefined && LITERAL_ARITHMETIC.has(d.opcode) && d.operands.length > 0 && d.operands.every(isLiteral))
2374
+ );
2375
+ };
2376
+ const provenConstant = (v: ReadonlySet<Value>, b: number): boolean => {
2377
+ for (let at = b, seen = new Set<number>(); !seen.has(at);) {
2378
+ seen.add(at);
2379
+ const ps = [...new Set(preds[at] ?? [])];
2380
+ if (ps.length !== 1) {
2381
+ return false;
2382
+ }
2383
+ const term = terminator(blocks[ps[0]]);
2384
+ const arrives = term?.successors.map((e) => e.block === blocks[at]) ?? [];
2385
+ const cmp = term?.opcode === 'cond_br' && arrives[0] !== arrives[1] ? defOf(term.operands[0]) : undefined;
2386
+ const equalEdge = cmp?.opcode === 'icmp_eq' ? 0 : cmp?.opcode === 'icmp_ne' ? 1 : -1;
2387
+ if (
2388
+ equalEdge >= 0 &&
2389
+ arrives[equalEdge] &&
2390
+ cmp!.operands.some((o) => v.has(o)) &&
2391
+ cmp!.operands.some((o) => !v.has(o) && isLiteral(o))
2392
+ ) {
2393
+ return true;
2394
+ }
2395
+ at = ps[0];
2396
+ }
2397
+ return false;
2398
+ };
2399
+ // THE RESULT A SITE PASSES ON is looked for through the joins the trivial-phi pass has yet to
2400
+ // fold: this trim runs before it, so a result r0 carries untouched across a loop reaches the call
2401
+ // as the loop header's parameter, not as the callee's own value. The site passes one where every
2402
+ // path brings it an earlier callee's result it passes (`GuessedCallSite.passesCalleeResult`) —
2403
+ // that value, or the join itself where the paths bring different ones (`v = x ? g() : h(); p(v);`).
2404
+ let joinArgs: Map<Value, Value[]> | undefined;
2405
+ const joinedInto = (v: Value): Value[] | undefined => {
2406
+ if (joinArgs === undefined) {
2407
+ joinArgs = new Map();
2408
+ for (const blk of blocks) {
2409
+ for (const op of blk.ops) {
2410
+ for (const e of op.successors) {
2411
+ // the entry block's parameters are the function's own, whatever a back edge brings
2412
+ if (e.block === blocks[0]) {
2413
+ continue;
2414
+ }
2415
+ e.block.params.forEach((param, i) => {
2416
+ const into = joinArgs!.get(param);
2417
+ if (into) {
2418
+ into.push(e.args[i]);
2419
+ } else {
2420
+ joinArgs!.set(param, [e.args[i]]);
2421
+ }
2422
+ });
2423
+ }
2424
+ }
2425
+ }
2426
+ }
2427
+ return joinArgs.get(v);
2428
+ };
2429
+ const passedResult = (s: GuessedCallSite): Value | undefined => {
2430
+ const arg = callArgs(s.op)[0];
2431
+ const passes = s.passesCalleeResult;
2432
+ if (arg === undefined || passes === undefined) {
2433
+ return undefined;
2434
+ }
2435
+ const results = new Set<Value>();
2436
+ const seen = new Set<Value>();
2437
+ for (const work = [arg]; work.length > 0;) {
2438
+ const v = work.pop()!;
2439
+ if (seen.has(v)) {
2440
+ continue;
2441
+ }
2442
+ seen.add(v);
2443
+ const into = joinedInto(v);
2444
+ if (into !== undefined) {
2445
+ work.push(...into);
2446
+ } else if (calleeResults.has(v) && passes(v)) {
2447
+ results.add(v);
2448
+ } else {
2449
+ return undefined;
2450
+ }
2451
+ }
2452
+ return results.size === 0 ? undefined : results.size === 1 ? [...results][0] : arg;
2453
+ };
2454
+ const argcAt = (s: GuessedCallSite, fresh: Set<string>): number => {
2455
+ const destroyed =
2456
+ s.returnRegBefore === 'destroyed' || (s.returnRegBefore === 'inherited' && destroyedIn[s.block].has(returnReg));
2457
+ if (argRegs[0] === returnReg && !fresh.has(argRegs[0]) && destroyed && setsUpLater(fresh)) {
2458
+ const callee = s.op.attrs.target;
2459
+ throw new FrontendUnsupportedError(
2460
+ `cannot lift '${inp.name}': argument 1 of the call to ${typeof callee === 'string' ? `'${callee}'` : 'a callee'} ` +
2461
+ `is a struct an earlier call handed back in ${returnReg}, and nothing here passes one on as an argument — not modelled`,
2462
+ );
2463
+ }
2464
+ if (argRegs[0] !== returnReg || fresh.has(argRegs[0]) || destroyed) {
809
2465
  return runOfFresh(fresh, 0);
810
2466
  }
811
- if (setsUpLater(fresh) || !calleeResults.has(op.operands[0])) {
2467
+ const op = s.op;
2468
+ if (setsUpLater(fresh) || !calleeResults.has(callArgs(op)[0]) || passedResult(s) !== undefined) {
812
2469
  return runOfFresh(new Set([argRegs[0], ...fresh]), 0);
813
2470
  }
814
2471
  return 0;
815
2472
  };
816
2473
  for (const s of sites) {
817
2474
  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;
2475
+ const n = Math.max(s.floor ?? 0, argcAt(s, fresh));
2476
+ if (n < callArgs(s.op).length) {
2477
+ // only the FRESH ones: an unfresh register is the trim's proof (a path reaches here with
2478
+ // nothing set up in it since a call), a fresh one went with a hole below it
2479
+ s.trimmed?.(
2480
+ argRegs.slice(n, callArgs(s.op).length).filter((r) => fresh.has(r)),
2481
+ argRegs.slice(0, n),
2482
+ );
2483
+ truncateCallArgs(s.op, n);
821
2484
  }
822
2485
  // The SHORTER arity the same evidence also allows, recorded for {@link narrowToSetupArgs}: the
823
2486
  // run over what THIS BLOCK wrote, dropping the registers that are fresh only because no call
@@ -825,25 +2488,256 @@ export function trimClobberedCallArgs(inp: CallArgTrim): void {
825
2488
  // is recorded rather than applied. A survivor is what it drops, so the join clause above has no
826
2489
  // place here — but `setsUpLater` still does: a register this block set up two instructions
827
2490
  // 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) {
2491
+ //
2492
+ // A SITE THAT PASSES AN EARLIER CALLEE'S RESULT ON keeps it in this reading too, except where an
2493
+ // edge proves that result equal to a constant on the way here (`if (g() == 0) p();`): a source
2494
+ // that passed it there makes the compiler load the constant it proved (`mov r0,#0`), so an asm
2495
+ // with no such load passed nothing. Per site, because this reading cuts every site at once, and a
2496
+ // drop that one site needs would otherwise drop a value another site passes.
2497
+ const result = n > 0 ? passedResult(s) : undefined;
2498
+ const passed = result !== undefined;
2499
+ const dropsPassed = passed && provenConstant(new Set([result, callArgs(s.op)[0]]), s.block);
2500
+ const localFresh =
2501
+ setsUpLater(s.freshBefore) || (passed && !dropsPassed) ? new Set([argRegs[0], ...s.freshBefore]) : s.freshBefore;
2502
+ const local = Math.min(Math.max(s.floor ?? 0, runOfFresh(localFresh, 0)), callArgs(s.op).length);
2503
+ if (
2504
+ local < callArgs(s.op).length &&
2505
+ s.withholdsSetupArgs?.() !== true &&
2506
+ narrowingKeepsFrameAddresses(s, argRegs, local)
2507
+ ) {
831
2508
  setupArgc.set(s.op, local);
2509
+ if (local === 0 && dropsPassed) {
2510
+ discardsPassedResult.add(s.op);
2511
+ }
832
2512
  }
833
2513
  }
834
2514
  }
835
2515
 
2516
+ /** Whether the narrower reading of `s` ({@link narrowToSetupArgs}, cutting it to `local` arguments)
2517
+ * passes the lowering's `CallSite.leftOut` question too (`GuessedCallSite.trimmed`). That reading
2518
+ * is applied in the ranker AFTER the lift and its frame audit, so nothing downstream re-asks it: a
2519
+ * register it cuts can hold a frame address set up in the call's own block (`mov r2, sp`) above an
2520
+ * argument that came from an earlier one, and the cut lifts `g(a, b, s)` as `g(a)`, leaving the
2521
+ * stores to `s` dead.
2522
+ *
2523
+ * ONLY WHAT THE CALL'S OWN BLOCK WROTE is asked (`freshBefore`). That a value an earlier block left
2524
+ * in a register is dead is this reading's whole guess, and a frame address is no exception: agbcc
2525
+ * keeps `mov r3, sp` as a store base across blocks and calls `TransformSprite(s, t)` with it still
2526
+ * in r3 (sa3 sub_805DADC), where the cut is the right arity. A register this block wrote is not
2527
+ * that guess — the block set it up for the call — so where it may hold a frame address the call
2528
+ * passes, the cut would drop an argument. A refusal withholds this candidate for this site only;
2529
+ * the default reading stands. A leftover the guess was wrong about is caught of the lifted IR
2530
+ * instead, where the cut leaves the object it addresses unread ({@link cutsUnreadFrameObjects}).
2531
+ *
2532
+ * …AND EVERY REGISTER IT CUTS where the frontend says so (`GuessedCallSite.asksEveryCut`): a
2533
+ * function whose frame verdict reads every argument register a call holds as possibly handed
2534
+ * over, where a frame address an earlier block left is not the dead base this reading guesses at,
2535
+ * and the IR question cannot settle it when the object is read elsewhere too
2536
+ * (`if (a) h(b, a, t); else *q = t[0];` with `&t` left in r2). The question refuses only frame
2537
+ * addresses, so a call that cuts none still narrows. */
2538
+ function narrowingKeepsFrameAddresses(s: GuessedCallSite, argRegs: readonly string[], local: number): boolean {
2539
+ const every = s.asksEveryCut?.() === true;
2540
+ const cut = argRegs.slice(local, callArgs(s.op).length).filter((r) => every || s.freshBefore.has(r));
2541
+ if (s.trimmed === undefined || cut.length === 0) {
2542
+ return true;
2543
+ }
2544
+ try {
2545
+ s.trimmed(cut, argRegs.slice(0, local));
2546
+ return true;
2547
+ } catch (e) {
2548
+ if (e instanceof FrontendUnsupportedError) {
2549
+ return false;
2550
+ }
2551
+ throw e;
2552
+ }
2553
+ }
2554
+
2555
+ /** The opcodes whose result is a literal when every operand is one ({@link trimClobberedCallArgs}). */
2556
+ const LITERAL_ARITHMETIC: ReadonlySet<string> = new Set([
2557
+ 'add',
2558
+ 'sub',
2559
+ 'mul',
2560
+ 'and',
2561
+ 'or',
2562
+ 'xor',
2563
+ 'shl',
2564
+ 'shr_u',
2565
+ 'shr_s',
2566
+ 'not',
2567
+ 'neg',
2568
+ ]);
2569
+
836
2570
  /** The narrower arity {@link narrowToSetupArgs} would cut each guessed call to. A SIDE table and
837
2571
  * not an attr: this is a fact about one LIFT, not part of the IR the rest of the pipeline compares
838
2572
  * and prints — `structure/hazards.ts` decides two ops equal by comparing their attrs verbatim, so
839
2573
  * an attr only one of an otherwise-matching pair carries would cost a recovery. */
840
2574
  const setupArgc = new WeakMap<Op, number>();
841
2575
 
2576
+ /** The calls whose narrower arity drops an earlier callee's result that the site passes on
2577
+ * (`GuessedCallSite.passesCalleeResult`): only where an edge proves that result constant on the way
2578
+ * to the call ({@link trimClobberedCallArgs}). Read by {@link setupArgsDiscardsPassedResult}. */
2579
+ const discardsPassedResult = new WeakSet<Op>();
2580
+
2581
+ /** Whether {@link narrowToSetupArgs} drops, somewhere in `fn`, an earlier callee's result that a
2582
+ * call through a register is passed — the fact that makes the narrowed lift's candidates lose a
2583
+ * score tie (`Candidate.discardsPassedResult`). */
2584
+ export function setupArgsDiscardsPassedResult(fn: Fn): boolean {
2585
+ return fn.blocks.some((b) => b.ops.some((op) => discardsPassedResult.has(op)));
2586
+ }
2587
+
842
2588
  /** Whether anything in `fn` HAS the narrower reading — the variation's gate, so the ~99% of functions
843
2589
  * with no narrowable call cost no re-lift. Read it off the lift itself: a later pipeline stage may
844
- * replace a `call` op (softdiv rewrites one to a division), and the table is keyed by op. */
2590
+ * replace a `call` op (softdiv rewrites one to a division), and the table is keyed by op. A call
2591
+ * {@link cutsUnreadFrameObjects} withholds has none. */
845
2592
  export function hasSetupArgsNarrowing(fn: Fn): boolean {
846
- return fn.blocks.some((b) => b.ops.some((op) => setupArgc.has(op)));
2593
+ if (!fn.blocks.some((b) => b.ops.some((op) => setupArgc.has(op)))) {
2594
+ return false;
2595
+ }
2596
+ const withheld = cutsUnreadFrameObjects(fn);
2597
+ return fn.blocks.some((b) => b.ops.some((op) => setupArgc.has(op) && !withheld.has(op)));
2598
+ }
2599
+
2600
+ /** A frame address in the lifted IR: a `laddr`'s offset plus the constants added to it. `exact` is
2601
+ * false once a term that is not a constant was added (`&buf[i]`), and `off` is then the address
2602
+ * that term was added to. `end` is where the `laddr`'s storage ends. */
2603
+ interface FrameAddress {
2604
+ readonly off: number;
2605
+ readonly exact: boolean;
2606
+ readonly end: number;
2607
+ }
2608
+
2609
+ /** The calls whose `/setup-args` reading cuts the address of a frame object nothing else in `fn`
2610
+ * reads, under that reading — which is then withheld for that call, the default reading standing.
2611
+ *
2612
+ * THE QUESTION THE IR CAN ANSWER of a frame address an earlier block left in a register the reading
2613
+ * cuts. That such a leftover is dead is the reading's whole guess, and agbcc does leave a dead
2614
+ * `mov r3, sp` store base in a call's argument registers (sa3 sub_805DADC, which reloads the
2615
+ * object after the call). Where the leftover was an argument after all, cutting it leaves the
2616
+ * object's stores with no reader: `if (a) g(b, s)` with `s` left in r1 by the entry block lifts as
2617
+ * `g(a1)` beside `s[0] = a0; s[1] = a1;`, which nothing reads. A source that compiled to those stores
2618
+ * read the object somewhere, so a cut that leaves it unread drops an argument. The register file
2619
+ * cannot tell the two leftovers apart; the object's readers can.
2620
+ *
2621
+ * A READER is any use of an address into the object but the address operand of a store into it
2622
+ * and an argument the narrower reading cuts — a load, an address stored or returned or carried
2623
+ * to another block, an argument some call still passes. The other calls are read under the same
2624
+ * narrowing, since the ranker applies it to every call at once.
2625
+ *
2626
+ * THE OBJECT is the span between the offsets the function forms addresses at or accesses. Where the
2627
+ * frame audit sized one `laddr` over two source objects (an escaped `sp+k`, which excuses every word
2628
+ * from k up), the address the compiler formed for the second one (`add r2, sp, #0xc`) is where it
2629
+ * starts: `int s[3]; char t[16]; ... if (a) g(b, s, t)` cut to `g(a1, sp0)` leaves `t` unread
2630
+ * though `sp0` is passed. An access's offset counts as well, because the lift folds a formed
2631
+ * `add r4, sp, #8` into the offsets of the loads and stores through r4, so the `laddr` they share
2632
+ * no longer says where the second object starts. Splitting finer only withholds more. A load or
2633
+ * argument at an offset reads the span it falls in. An address with a term that is not a constant
2634
+ * reads its base's span: an index stays inside the object its base addresses.
2635
+ *
2636
+ * NOT ANSWERED HERE: a cut whose object is read elsewhere too. That leftover cannot be told from the
2637
+ * sub_805DADC dead base, by the IR or by the register file, so it narrows, except where the frontend
2638
+ * withholds the reading at that call (`GuessedCallSite.withholdsSetupArgs`) or asks the register
2639
+ * question of every cut there (`GuessedCallSite.asksEveryCut`). */
2640
+ function cutsUnreadFrameObjects(fn: Fn): Set<Op> {
2641
+ const def = defOpMap(fn);
2642
+ const memo = new Map<Value, FrameAddress | null>();
2643
+ const frameAddress = (v: Value): FrameAddress | null => {
2644
+ if (memo.has(v)) {
2645
+ return memo.get(v)!;
2646
+ }
2647
+ memo.set(v, null); // a cycle through a phi's operands is no frame address
2648
+ const d = def.get(v);
2649
+ let r: FrameAddress | null = null;
2650
+ if (d?.opcode === 'laddr') {
2651
+ const off = d.attrs.off as number;
2652
+ r = { off, exact: true, end: off + (d.attrs.width as number) * (d.attrs.count as number) };
2653
+ } else if ((d?.opcode === 'add' || d?.opcode === 'sub') && d.operands.length === 2) {
2654
+ const konst = (x: Value): number | undefined => {
2655
+ const k = def.get(x);
2656
+ return k?.opcode === 'const' && typeof k.attrs.value === 'number' ? k.attrs.value : undefined;
2657
+ };
2658
+ const [x, y] = d.operands;
2659
+ const fx = frameAddress(x);
2660
+ const fy = d.opcode === 'add' ? frameAddress(y) : null; // `k - &s` is no address
2661
+ const base = fx === null ? fy : fy === null ? fx : null;
2662
+ if (base !== null) {
2663
+ const k = konst(fx === null ? x : y);
2664
+ r =
2665
+ k === undefined || !base.exact
2666
+ ? { ...base, exact: false }
2667
+ : { ...base, off: d.opcode === 'add' ? base.off + k : base.off - k };
2668
+ }
2669
+ }
2670
+ memo.set(v, r);
2671
+ return r;
2672
+ };
2673
+ const bounds = new Set<number>();
2674
+ for (const b of fn.blocks) {
2675
+ for (const op of b.ops) {
2676
+ for (const r of op.results) {
2677
+ frameAddress(r);
2678
+ }
2679
+ const at = op.opcode === 'load' || op.opcode === 'store' ? frameAddress(op.operands[0]) : null;
2680
+ if (at?.exact) {
2681
+ bounds.add(at.off + (op.attrs.off as number)); // an access's offset (see THE OBJECT)
2682
+ }
2683
+ }
2684
+ }
2685
+ for (const a of memo.values()) {
2686
+ if (a !== null) {
2687
+ bounds.add(a.end);
2688
+ if (a.exact) {
2689
+ bounds.add(a.off);
2690
+ }
2691
+ }
2692
+ }
2693
+ const sorted = [...bounds].sort((x, y) => x - y);
2694
+ /** the span an access at `a` (plus `extra` bytes) reads — see THE OBJECT above */
2695
+ const span = (a: FrameAddress, extra = 0): [number, number] => {
2696
+ const at = a.off + extra;
2697
+ const lo = sorted.filter((x) => x <= at).pop() ?? at;
2698
+ const hi = sorted.find((x) => x > at) ?? a.end;
2699
+ return [lo, Math.max(hi, lo + 1)];
2700
+ };
2701
+ const reads: [number, number][] = [];
2702
+ const cuts = new Map<Op, [number, number][]>();
2703
+ const read = (v: Value, extra = 0): void => {
2704
+ const a = frameAddress(v);
2705
+ if (a !== null) {
2706
+ reads.push(span(a, extra));
2707
+ }
2708
+ };
2709
+ for (const b of fn.blocks) {
2710
+ for (const op of b.ops) {
2711
+ if ((op.opcode === 'add' || op.opcode === 'sub') && frameAddress(op.results[0]) !== null) {
2712
+ continue; // a derivation: its result's uses are the ones that read
2713
+ }
2714
+ const args = op.opcode === 'call' ? callArgs(op) : [];
2715
+ const keep = setupArgc.get(op) ?? args.length;
2716
+ op.operands.forEach((v, i) => {
2717
+ if ((op.opcode === 'store' || op.opcode === 'astore') && i === 0) {
2718
+ return;
2719
+ }
2720
+ if (op.opcode === 'call' && i >= keep && i < args.length) {
2721
+ const a = frameAddress(v);
2722
+ if (a !== null) {
2723
+ cuts.set(op, [...(cuts.get(op) ?? []), span(a)]);
2724
+ }
2725
+ return;
2726
+ }
2727
+ read(v, op.opcode === 'load' && i === 0 ? (op.attrs.off as number) : 0);
2728
+ });
2729
+ for (const s of op.successors) {
2730
+ s.args.forEach((v) => read(v));
2731
+ }
2732
+ }
2733
+ }
2734
+ const withheld = new Set<Op>();
2735
+ for (const [op, spans] of cuts) {
2736
+ if (spans.some(([lo, hi]) => !reads.some(([rlo, rhi]) => rlo < hi && rhi > lo))) {
2737
+ withheld.add(op);
2738
+ }
2739
+ }
2740
+ return withheld;
847
2741
  }
848
2742
 
849
2743
  /** Cut every guessed call to the arity its OWN BLOCK set up, and report whether anything moved.
@@ -861,11 +2755,16 @@ export function hasSetupArgsNarrowing(fn: Fn): boolean {
861
2755
  * Applies only to arities that were GUESSED — a declared prototype never recorded the fact. */
862
2756
  export function narrowToSetupArgs(fn: Fn): boolean {
863
2757
  let changed = false;
2758
+ const withheld = cutsUnreadFrameObjects(fn);
864
2759
  for (const b of fn.blocks) {
865
2760
  for (const op of b.ops) {
2761
+ if (withheld.has(op)) {
2762
+ setupArgc.delete(op);
2763
+ discardsPassedResult.delete(op);
2764
+ }
866
2765
  const setup = setupArgc.get(op);
867
- if (setup !== undefined && setup < op.operands.length) {
868
- op.operands.length = setup;
2766
+ if (setup !== undefined && setup < callArgs(op).length) {
2767
+ truncateCallArgs(op, setup);
869
2768
  changed = true;
870
2769
  }
871
2770
  }
@@ -879,9 +2778,23 @@ export function narrowToSetupArgs(fn: Fn): boolean {
879
2778
  return changed;
880
2779
  }
881
2780
 
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. */
2781
+ // ── shared frontend tail helpers ──
2782
+
2783
+ /** The `undef` attribute naming the other keys whose undefined values a collapsed join absorbed
2784
+ * (`collapseUndefinedJoins`), comma-separated. Read once, by the own-entry bound (pipeline.ts `readDroppedArgRegs`),
2785
+ * and removed before anything else sees the lift. */
2786
+ export const ABSORBED_KEYS = 'absorbed';
2787
+
2788
+ /** The `undef` attribute marking an undefined value the machine stored to a stack slot nothing reads
2789
+ * back (`finish`, the same test as an entry parameter's `deadHome`). The store reaches no op, so the
2790
+ * value looks dead, yet the asm read the register: IDO at -g3 and -O0 homes every argument register
2791
+ * its definition declares (`sw a1,4(sp)`) whether or not the body uses it. Read once, by the
2792
+ * own-entry bound (pipeline.ts `readDroppedArgRegs`), and removed with `ABSORBED_KEYS`. */
2793
+ export const HOMED_UNDEF = 'homed';
2794
+
2795
+ /** The stack-slot key the MIPS, Thumb and PowerPC frontends use for a word-sized local in the
2796
+ * function's own frame. Shared so they spell it identically and the frame-partition rule can
2797
+ * recognise any frontend's slots. See the virtual-key note in the module header. */
885
2798
  const SLOT_PREFIX = 'sp@';
886
2799
  export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
887
2800
  /** The byte offset a slot key names, or null if `key` is not a slot key at all (an ordinary
@@ -890,20 +2803,116 @@ export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
890
2803
  export const slotKeyOffset = (key: string): number | null =>
891
2804
  key.startsWith(SLOT_PREFIX) ? Number(key.slice(SLOT_PREFIX.length)) : null;
892
2805
 
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. */
2806
+ /** The virtual key of incoming stack argument `index` (its ABI argument rank, the registers
2807
+ * counted), which the Thumb, MIPS and PPC frontends read an incoming stack word through. Reading it goes
2808
+ * through the ordinary Braun live-in path, which turns a read with no reaching def into a function
2809
+ * parameter — so it needs NO new representation, opcode or pass. The `@` cannot appear in a real
2810
+ * register token, so the key cannot collide with one. */
2811
+ const STACK_ARG_PREFIX = '@sarg';
2812
+ export const stackArgKey = (index: number): string => `${STACK_ARG_PREFIX}${index}`;
2813
+ /** The {@link stackArgKey} of the incoming word at `entryOff` bytes above the caller's stack pointer,
2814
+ * on an ABI whose caller's parameter area starts `paramAreaAt` bytes above it and whose first
2815
+ * `regArgs` argument words travel in registers: word k past them is at `paramAreaAt + 4k`
2816
+ * (O32: 16, past the a0..a3 home; the PPC EABI: 8, past the back chain and the LR save word).
2817
+ * Undefined below the area and for an offset off the word grid. */
2818
+ export const incomingStackArgKey = (entryOff: number, paramAreaAt: number, regArgs: number): string | undefined =>
2819
+ entryOff >= paramAreaAt && entryOff % 4 === 0 ? stackArgKey(regArgs + (entryOff - paramAreaAt) / 4) : undefined;
2820
+ /** The argument rank a {@link stackArgKey} names, or null for any other key. Defined beside its mint
2821
+ * site on purpose: the format string and its parser once drifted 650 lines apart. */
2822
+ export const stackArgIndex = (key: string): number | null => {
2823
+ const m = /^@sarg(\d+)$/.exec(key);
2824
+ return m ? +m[1] : null;
2825
+ };
2826
+
2827
+ /** Where a live-in of the entry block sits in the calling convention: `slotOf` gives its ABI
2828
+ * argument rank (a register or an incoming stack word), or null for a live-in no argument arrives
2829
+ * in — an uninitialised register the body reads; `holes` gives the keys of the argument slots the
2830
+ * convention proves precede the ones read (`readKeys`), whether or not the body reads them. One per
2831
+ * frontend, read by both halves of the entry-parameter rule: {@link mintArgSlotHoles} and
2832
+ * {@link abiSortEntryParams}.
2833
+ *
2834
+ * `holes` is a function of everything read, not a slot-to-key table, because where two register
2835
+ * files share one slot sequence (MIPS o32's floats) the file a hole comes from depends on what the
2836
+ * function reads — `frontend/fpu.ts` `fpuArgSlots`.
2837
+ *
2838
+ * Both halves need an entry block with NO predecessors, whose parameters are the function's own.
2839
+ * A loop header's are phis, index-aligned with its predecessors' edge arguments, and neither
2840
+ * minting one nor reordering them is legal there — so a frontend whose first block is a branch
2841
+ * target inserts an empty preheader ahead of it (each frontend's `preheader` note). */
2842
+ export interface ArgSlots {
2843
+ slotOf(key: string): number | null;
2844
+ holes(readKeys: readonly string[]): string[];
2845
+ }
2846
+
2847
+ /** The slots of a convention that passes arguments in `argRegs` only: every register below the
2848
+ * highest one read. */
2849
+ export const registerArgSlots = (argRegs: readonly string[]): ArgSlots => ({
2850
+ slotOf: (key) => (argRegs.includes(key) ? argRegs.indexOf(key) : null),
2851
+ holes: (readKeys) => argRegs.slice(0, Math.max(0, ...readKeys.map((k) => argRegs.indexOf(k)))),
2852
+ });
2853
+
2854
+ /** The slots of a convention that passes arguments past `argRegs` on the stack: `regs` for the
2855
+ * registers, then incoming stack word k as slot k ({@link stackArgKey}). Reading a stack word
2856
+ * proves every register slot and every lower stack word precede it, one word per argument.
2857
+ * `besideStackWords` is the convention's own refusal of a read it cannot place beside them (O32's
2858
+ * float arguments, which take integer slots this rule does not count), called with the highest
2859
+ * stack slot read. */
2860
+ export const stackArgSlots = (
2861
+ regs: ArgSlots,
2862
+ argRegs: readonly string[],
2863
+ besideStackWords?: (readKeys: readonly string[], top: number) => void,
2864
+ ): ArgSlots => ({
2865
+ slotOf: (key) => stackArgIndex(key) ?? regs.slotOf(key),
2866
+ holes: (readKeys) => {
2867
+ const top = Math.max(-1, ...readKeys.map((k) => stackArgIndex(k) ?? -1));
2868
+ if (top < 0) {
2869
+ return regs.holes(readKeys);
2870
+ }
2871
+ besideStackWords?.(readKeys, top);
2872
+ return Array.from({ length: top }, (_, k) => (k < argRegs.length ? argRegs[k] : stackArgKey(k)));
2873
+ },
2874
+ });
2875
+
2876
+ /** A rank past every argument slot, for a live-in that is not one. */
2877
+ const NOT_AN_ARGUMENT = Number.MAX_SAFE_INTEGER;
2878
+
2879
+ /** Give the entry block a parameter for every argument slot below the highest one it takes. Naming
2880
+ * is POSITIONAL (`a0`, `a1`, … in {@link abiSortEntryParams} order), so a slot the body never reads
2881
+ * drops out of the signature and binds every later argument one slot low, silently: `int f(int a,
2882
+ * int b, int c) { return a + c; }` reads r3 and r5, and lifted as `f(a0, a1)` with r5 in r4's slot.
2883
+ * Arguments take their slots in order, registers first and then the stack words, so reading slot
2884
+ * k proves slots 0..k-1 precede it — an obligation on the signature, which is what `ensureParam`
2885
+ * is for. A live-in that is no slot proves nothing about that sequence. Call it before `finish()`,
2886
+ * which records evidence for every entry parameter. */
2887
+ export function mintArgSlotHoles(
2888
+ ssa: Pick<SsaBuilder, 'irBlocks' | 'paramReg' | 'ensureParam'>,
2889
+ entryHasPreds: boolean,
2890
+ slots: ArgSlots,
2891
+ ): void {
2892
+ assertTrueEntry(entryHasPreds);
2893
+ const read = ssa.irBlocks[0].params.map((p) => ssa.paramReg.get(p)).filter((k): k is string => k !== undefined);
2894
+ for (const k of slots.holes(read)) {
2895
+ ssa.ensureParam(k, 0);
2896
+ }
2897
+ }
2898
+
2899
+ /** Order the entry block's parameters by ABI argument slot, so downstream naming (`a0`, `a1`, …)
2900
+ * matches the calling convention, not first-read order (a callee-saved copy can read a later
2901
+ * argument register first). A live-in that is no slot goes after every one that is: ranked
2902
+ * first, it takes `a0` and binds every real argument one slot high. */
900
2903
  export function abiSortEntryParams(
901
2904
  entry: { params: Value[] },
902
2905
  entryHasPreds: boolean,
903
- rank: (v: Value) => number,
2906
+ paramReg: ReadonlyMap<Value, string>,
2907
+ slots: ArgSlots,
904
2908
  ): void {
2909
+ assertTrueEntry(entryHasPreds);
2910
+ const rank = (v: Value): number => slots.slotOf(paramReg.get(v) ?? '') ?? NOT_AN_ARGUMENT;
2911
+ entry.params.sort((x, y) => rank(x) - rank(y));
2912
+ }
2913
+
2914
+ function assertTrueEntry(entryHasPreds: boolean): void {
905
2915
  if (entryHasPreds) {
906
- return;
2916
+ throw new Error('internal: the entry-parameter rule needs an entry block with no predecessors');
907
2917
  }
908
- entry.params.sort((x, y) => rank(x) - rank(y));
909
2918
  }