@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -8,13 +8,17 @@
8
8
  // filled. Trivial phis (one real operand) are removed afterwards so a loop-invariant
9
9
  // register does not leak a spurious block parameter.
10
10
  //
11
- // Callee-saved stack frames: `push`/`pop` (and the `pop {rN}; bx rN` return idiom) are
12
- // transparent to dataflow — the pushed registers are restored to the same values, and a
13
- // callee-saved register is always written in the body before it is read so no explicit
14
- // modelling is needed; they simply fall through the decode/fill switch. Because agbcc may
11
+ // Callee-saved stack frames: `push`/`pop` (and the `pop {rN}; bx rN` return idiom) emit nothing —
12
+ // the pushed registers are restored to the same values, so they fall through the decode/fill
13
+ // switch. They are not IGNORED, though, and two readers outside that switch depend on them.
14
+ // `savedRegs` reads the prologue's save set explicitly and hands it to `LiveInModel.uninitRegs`,
15
+ // which partitions a def-less read of a callee-saved register into an uninitialised LOCAL rather
16
+ // than a parameter — asm that saves nothing follows no such convention and keeps its live-in. And
17
+ // `makeFrameWalk` counts the pushed bytes (4 per register) as frame depth, which is what makes an
18
+ // incoming stack argument at `[sp, #N]` locatable at all. Because agbcc may
15
19
  // copy a callee-saved argument (e.g. into r4) before touching r0, entry parameters are
16
20
  // ordered by ABI register (r0, r1, …), not by the order they were first read.
17
- import { Fn, Successor, Value, mkOp, mkValue } from '../ir/core';
21
+ import { Block, Fn, Op, Successor, Value, mkOp, mkValue } from '../ir/core';
18
22
  import type { Opcode } from '../ir/opcodes';
19
23
  import { T } from '../ir/types';
20
24
  import { type Prototypes, protoArity } from '../proto';
@@ -27,7 +31,7 @@ import { FrontendUnsupportedError } from './errors';
27
31
  import { assertInputFormat } from './format';
28
32
  import type { Frontend } from './frontend';
29
33
  import { opaqueDest } from './opaque';
30
- import { abiSortEntryParams, fallbackArgc, makeSsaBuilder } from './ssa';
34
+ import { abiSortEntryParams, fallbackArgc, makeSsaBuilder, slotKeyOffset, stackSlotKey } from './ssa';
31
35
 
32
36
  interface Instr {
33
37
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -71,17 +75,16 @@ interface AsmBlock {
71
75
  // than in decode arms like MIPS's `move` or PPC's `slwi`. That distinction, and why there is no
72
76
  // shared alias helper across the three frontends, is written up once in ./opaque.ts.
73
77
  //
74
- // The LOAD aliases matter more than the store ones, and the asymmetry is worth knowing: an
75
- // unrecognised `stm*` matches `opaquePolicy.storeClass` and fails LOUD, but an unrecognised `ldm*`
76
- // does not it reaches opaqueDest, which takes ops[0] (the BASE) as the destination, so the opaque
77
- // is dead, DCE removes it, and the load silently vanishes. Measured: `ldmfd r1, {r0}; bx lr` lifted
78
- // to `return a0;` where the answer is `return *a0;`. Every load spelling ARMv4T Thumb accepts is
79
- // therefore listed. (`ldmed`/`ldmea` are NOT: they mean IB/DB, which Thumb-1 does not have, and
80
- // `as` rejects them — so they cannot appear.)
78
+ // This table is about COVERAGE, not soundness: a spelling missing from it declines loudly like any
79
+ // other unmodelled instruction, and listing one buys that the function LIFTS instead. Every load
80
+ // spelling ARMv4T Thumb accepts is listed, because declining a whole function over a synonym is a
81
+ // poor trade. Note what may NOT justify an omission: "`as` rejects it, so it cannot appear". This
82
+ // frontend parses TEXT from luvdis/objdump/IDA/Ghidra and hand-written .s so what some
83
+ // assembler accepts says nothing about what it will be handed.
81
84
  //
82
- // `stmfd` is deliberately absent, and the asymmetry is real rather than an oversight: `stmfd` is
83
- // `stmdb`, and ARMv4T Thumb has neither `as` rejects both with "selected processor does not
84
- // support ... in Thumb mode". There is nothing to normalise it TO.
85
+ // `stmfd` is deliberately absent, and the asymmetry is real rather than an oversight: `stmfd` IS
86
+ // `stmdb` (decrement-before), and ARMv4T Thumb has no decrement-before store so there is nothing
87
+ // to normalise it TO. A fact about the instruction set, not about an assembler. It declines.
85
88
  //
86
89
  // Null-prototype so that an inherited key (`constructor`, `toString`) cannot be mistaken for an
87
90
  // entry. Unreachable from real assembly, but the lookup should not depend on that.
@@ -94,6 +97,15 @@ const LEGACY_MNEMONICS: Readonly<Record<string, string>> = Object.assign(Object.
94
97
  stmea: 'stmia',
95
98
  });
96
99
 
100
+ // An offset far above the frame cannot be an argument — agbcc passes at most a handful on the
101
+ // stack, and an absurd index would mint a signature with hundreds of parameters from one bad
102
+ // offset. 16 is well past any real agbcc call and still refuses nonsense loudly.
103
+ //
104
+ // It bounds TOTAL arity, register arguments included (12 stack slots on a 4-register ABI), because
105
+ // that is the unit the index it is compared against is counted in. A refusal bound, not an ABI
106
+ // fact: nothing may read it as a statement about how many arguments the convention allows.
107
+ const MAX_RECOVERED_ARITY = 16;
108
+
97
109
  function canonicalMnemonic(mn: string): string {
98
110
  return LEGACY_MNEMONICS[mn] ?? mn;
99
111
  }
@@ -117,15 +129,74 @@ const COND_OPCODE: Record<string, Opcode> = {
117
129
  bhs: 'icmp_uge',
118
130
  };
119
131
 
120
- // Classify a block-terminating control transfer, or `null` for a non-transfer instruction (the block
121
- // falls through). The SINGLE source of truth for "what ends a Thumb block and how", used by decode
122
- // (block splitting), succLabels (CFG edges), the fill loop (skip transfers), and the terminator
123
- // emitter so a transfer form can't be modelled in one place and missed in another. A return via a
124
- // restored link register is distinguished from a COMPUTED/loaded PC write (jump table / computed
125
- // goto / register tail call), which this frontend does not model and must LOUD-FAIL rather than
126
- // silently drop — mirroring the MIPS `jr` and PPC `bctr` guards. (agbcc dispatches a dense switch
127
- // via `mov pc, rN`.)
132
+ // The halfword encodings that ARE alignment fill, and the instruction each one is. 0x0000 is
133
+ // `lsls r0, r0, #0`; 0x46C0 is the ARM7TDMI Thumb NOP what `nop` assembles to, and what objdump
134
+ // prints as `nop @ (mov r8, r8)`. It is decoded as `nop`, NOT as `mov r8, r8`: the two spell the
135
+ // same encoding, but `mov r8, r8` is modelled downstream as a READ of a callee-saved register,
136
+ // which invents a parameter the same two bytes would then carry a signature the object does not
137
+ // have.
138
+ //
139
+ // ONE table, because this knowledge is spelled twice: here as encodings, and in `isPadInstr` below
140
+ // as instruction shapes. A third encoding added to one and forgotten in the other would produce a
141
+ // pad nothing ever prunes, silently and with no test failing — so a test walks this table through
142
+ // that predicate.
143
+ const PAD_ENCODINGS: { hw: number; mnemonic: string; ops: string[] }[] = [
144
+ { hw: 0x0000, mnemonic: 'lsls', ops: ['r0', 'r0', '#0x00'] },
145
+ { hw: 0x46c0, mnemonic: 'nop', ops: [] },
146
+ ];
147
+
148
+ /** Is this instruction alignment fill, however a splitter spelled it? A block made ONLY of these
149
+ * is pool/section padding when unreachable — pruned in `decode`. A REACHABLE pad block is a real
150
+ * (degenerate) instruction and is kept. `lsl`/`lsls r0, r0, #0` is how 0x0000 is written; 0x46C0
151
+ * is `nop`, and the `mov r8, r8` spelling of it is normalised to `nop` at parse — so there is no
152
+ * r8 clause here, and this predicate judges exactly the shapes PAD_ENCODINGS can produce. */
153
+ const isPadInstr = (i: Instr) =>
154
+ i.mnemonic === 'nop' ||
155
+ ((i.mnemonic === 'lsl' || i.mnemonic === 'lsls') &&
156
+ i.ops[0] === 'r0' &&
157
+ i.ops[1] === 'r0' &&
158
+ /^#0x?0*$/.test(i.ops[2] ?? ''));
159
+
160
+ // A `.2byte`/`.hword`/`.short` operand this pass may read as an ENCODING: a complete 16-bit hex
161
+ // literal and nothing else. `parseInt` stops at the first character it cannot use, so `0x0000+2`
162
+ // parsed as 0x0000 and `0xD001+2` as 0xD001 — an expression, a relocation or a symbol would have
163
+ // been rewritten into the instruction its PREFIX encodes, which is not the instruction the
164
+ // assembler emits. Anything else returns null and falls through to the loud refusal.
165
+ function halfwordLiteral(text: string): number | null {
166
+ return /^0[xX][0-9a-fA-F]{1,4}$/.test(text) ? parseInt(text, 16) : null;
167
+ }
168
+
169
+ // A splitter that writes the pad as data (`.2byte 0x0000`) and one that writes it as an
170
+ // instruction are describing the SAME two bytes, so turning the halfword into the instruction it
171
+ // encodes is a decode, not a fabrication — the same move the raw-BRANCH decoder below already
172
+ // makes for `.2byte 0xD10E`. Everything that decides what padding MEANS (isPadInstr, the
173
+ // reachability prune) then sees one representation instead of three spellings.
174
+ // Returns a FRESH Instr per call: later passes rewrite operands in place.
175
+ function padHalfword(v: number): Instr | null {
176
+ const e = PAD_ENCODINGS.find((p) => p.hw === v);
177
+ return e ? { mnemonic: e.mnemonic, ops: [...e.ops] } : null;
178
+ }
179
+
128
180
  type XferKind = 'return' | 'uncond' | 'cond' | 'indirect';
181
+
182
+ /** Does straight-line control continue past this instruction into the bytes that follow it? The ONE
183
+ * definition of that question, so no caller can spell its own answer and drift from this one.
184
+ * Only an open instruction or a conditional branch continues. A `return`, an `uncond` branch and
185
+ * an `indirect` (computed/loaded PC write) all seal what follows: for `indirect` that is a
186
+ * DELIBERATE conservatism, not an oversight — this frontend does not model where a computed jump
187
+ * goes, so it cannot claim the next bytes are entered, and it declines on such a transfer
188
+ * elsewhere rather than guessing here. */
189
+ const controlContinuesPast = (ins: Instr) => {
190
+ const k = classifyXfer(ins);
191
+ return k === null || k === 'cond';
192
+ };
193
+ /** Classify a block-terminating control transfer, or `null` for a non-transfer instruction (the
194
+ * block falls through). The SINGLE source of truth for "what ends a Thumb block and how", so a
195
+ * transfer form cannot be modelled in one place and missed in another. A return via a restored
196
+ * link register is distinguished from a COMPUTED/loaded PC write (jump table / computed goto /
197
+ * register tail call), which this frontend does not model and must LOUD-FAIL rather than silently
198
+ * drop — mirroring the MIPS `jr` and PPC `bctr` guards. (agbcc dispatches a dense switch via
199
+ * `mov pc, rN`.) */
129
200
  function classifyXfer(ins: Instr): XferKind | null {
130
201
  const mn = ins.mnemonic;
131
202
  if (mn === 'b') {
@@ -142,9 +213,16 @@ function classifyXfer(ins: Instr): XferKind | null {
142
213
  }
143
214
  // A write to PC is a control transfer. `mov pc, lr` restores the link register → return; any other
144
215
  // computed/loaded PC write (`mov pc, rN` rN≠lr, `ldr pc, …`, `add/sub pc, …`) is an indirect jump.
216
+ //
217
+ // BY REGISTER, NOT BY ALIAS. `pc` and `r15` are one register, and `readData` already says so; here
218
+ // the alias decides CONTROL FLOW, so missing it does not degrade — the write is not a transfer at
219
+ // all, the terminator never forms, and execution runs on into the next block. `mov r15, lr` mid
220
+ // function deleted the early return outright AND minted a phantom parameter for the `lr` read that
221
+ // was left behind, with no diagnostic. `lr`/`r14` is the same question one operand over, and it is
222
+ // the safe half: missing it reads as an indirect jump, which declines loud.
145
223
  const dest = ins.ops[0]?.replace(/[[\]]/g, '');
146
- if (dest === 'pc') {
147
- if ((mn === 'mov' || mn === 'movs') && ins.ops[1] === 'lr') {
224
+ if (dest === 'pc' || dest === 'r15') {
225
+ if ((mn === 'mov' || mn === 'movs') && (ins.ops[1] === 'lr' || ins.ops[1] === 'r14')) {
148
226
  return 'return';
149
227
  }
150
228
  return 'indirect';
@@ -171,14 +249,66 @@ function classifyXfer(ins: Instr): XferKind | null {
171
249
  return null;
172
250
  }
173
251
 
174
- const imm = (s: string) => parseInt(s.replace(/^#/, ''), s.includes('0x') ? 16 : 10);
252
+ // A raw immediate's value. Case-insensitive on the radix because gas accepts `0X` and `parseInt`
253
+ // with the wrong one reads `#0X1` as 0 — see `immEq` below for why this stays loose about binary,
254
+ // octal and expressions, and which callers therefore have to refuse rather than ask this.
255
+ const imm = (s: string) => parseInt(s.replace(/^#/, ''), /0[xX]/.test(s) ? 16 : 10);
256
+
257
+ // AN IMMEDIATE IS A NUMBER, NOT A SPELLING, and which spelling appears is the producer's choice
258
+ // rather than the machine's. Counting the gated shapes (`add`/`rsb rD, rS, #0`) over the vendored
259
+ // checkouts, every producer writes `#0` — sa3's split 11158, sa3's build 9837, klonoa's build 1072,
260
+ // and `#0x0` not once between them — EXCEPT klonoa's own disassembly, which writes `#0x0` 3533
261
+ // times against 36. So an idiom keyed on the token is off for one whole project in silence, which
262
+ // is what this predicate exists to stop. `#2`, `#0x2` and `#0x02` are likewise one shift, and the
263
+ // jump-table shift recogniser used to compare the operand text and so rejected the third.
264
+ //
265
+ // The operand must be a plain integer LITERAL, and that shape check is the whole point of this
266
+ // helper rather than an incidental guard. `imm()` is `parseInt`, which stops at the first character
267
+ // it cannot consume — `#0b1` and `#0.5` read as 0, `#010` as 10 where gas means 8 — and it reads
268
+ // `#2*2` as 2, which is not malformed but an expression gas
269
+ // accepts and assembles to `lsls r0, r1, #4`. Matching it as a shift by two would recover a switch
270
+ // whose stride is wrong by a factor of four: the emitted C is entirely ordinary and dispatches to
271
+ // the wrong BLOCK, with no marker. `#2+1`, `#2-1` and `#2<<1` are the same trap. An adversarial
272
+ // probe found this after the first cut of this helper shipped with exactly that hole, and the test
273
+ // that claimed to pin the property sampled only `#3`/`#0x3`/`#0x1`/`r2` and so passed anyway.
274
+ //
275
+ // Anything that is not a bare decimal or hex literal therefore fails this test — which is a
276
+ // DECLINE only where the caller declines, and the three callers differ. The jump-table recogniser
277
+ // returns null; the `add` and `rsb` copy/negate arms fall through to their own lowering, and for
278
+ // `add`/`sub` that lowering is `constVal(imm(…))`, which is loose. So a non-match is a refusal at
279
+ // two of the three sites and a possibly-wrong constant at the third; `#0b1` renders `+ 0` there.
280
+ // The hole is `imm`'s rather than this predicate's, and a characterization test pins it.
281
+ //
282
+ // The refused class that DOES occur is the signed literal: 722 negative immediates across the
283
+ // corpus (`#-0x4` ×287, `#-0x004`, …), all of them inert here because neither 0 nor 2 is negative —
284
+ // but `imm` reads them correctly and this does not, so the two disagree on a populated class and a
285
+ // future `want` has to know that.
286
+ //
287
+ // One divergence is knowingly left in, and it is inert at both values this is used with.
288
+ // A leading zero means octal to gas and decimal to `Number`, so `#010` is 8 there and 10 here —
289
+ // but for `want === 2` both readings fail and the dispatch declines either way, and for
290
+ // `want === 0` every octal spelling of zero (`#0`, `#00`, `#000`) is zero under both. A `want`
291
+ // other than those two has to revisit this, because for e.g. `want === 8` the readings disagree.
292
+ const IMM_LITERAL = /^#\s*(?:0[xX][0-9a-fA-F]+|[0-9]+)$/;
293
+ const immEq = (op: string | undefined, want: number): boolean =>
294
+ op !== undefined && IMM_LITERAL.test(op) && Number(op.slice(1).trim()) === want;
175
295
 
176
296
  // Expand fused register-range tokens (`r4-r7` → r4,r5,r6,r7) in a register list. Ranges are
177
297
  // numeric-endpoint only (`rN-rM`); a range whose endpoint is an ALIAS (`r4-pc`/`-lr`/`-sp`) is
178
298
  // ambiguous and left UNEXPANDED — but its endpoints ARE surfaced as separate tokens so pc/lr
179
299
  // detection sees them, and any consumer that needs the exact list rejects the leftover `-` token
180
300
  // loudly rather than treating the fused range as one phantom register.
181
- const REG_NUM: Record<string, number> = { sp: 13, lr: 14, pc: 15 };
301
+ // Null-prototype for the same reason LEGACY_MNEMONICS has one: this table is consulted with `in`
302
+ // to decide whether a reglist token is a DEFINITE register, and an inherited key (`constructor`,
303
+ // `toString`) answering true would let a junk token pass that test instead of poisoning the frame
304
+ // depth. Unreachable from real assembly; the guarantee should not depend on that.
305
+ const REG_NUM: Record<string, number> = Object.assign(Object.create(null), { sp: 13, lr: 14, pc: 15 });
306
+
307
+ // Registers Thumb-1's `push` cannot name, in every spelling this ISA's asm uses for them. Saving
308
+ // one takes agbcc's `mov rLow, rHi; push {rLow}`, which is why the prologue's save set has to read
309
+ // the `mov` as well as the list (see `savedRegs`). Spellings, not numbers: nothing here normalises
310
+ // a register name, so `sl` and `r10` are separate keys everywhere the frontend uses one.
311
+ const HIGH_REGS: ReadonlySet<string> = new Set(['r8', 'r9', 'r10', 'r11', 'r12', 'sb', 'sl', 'fp', 'ip']);
182
312
 
183
313
  // Thumb-1 data-processing mnemonics that write the condition flags when their destination is a LOW
184
314
  // register — which is all of them on this ISA, `s`-suffix or not (the assembler picks the encoding).
@@ -247,6 +377,56 @@ function expandRegList(tokens: string[]): string[] {
247
377
  return out;
248
378
  }
249
379
 
380
+ // Expand a register list and vouch that every entry is a DEFINITE register, or return null.
381
+ //
382
+ // ONE validation for every consumer, because the hand-rolled versions had drifted to unequal
383
+ // strength. The frame walk required every token to be a real register; the ldm/stm arm only
384
+ // rejected a leftover `-`, so `ldmia r1!, {foo}` lifted and emitted
385
+ // `s32 f(s32 a0, s32 a1) { return a0; }` — a parameter fabricated from a token that names no
386
+ // register, which is the phantom this frontend's guards exist to prevent.
387
+ //
388
+ // An unexpandable range leaves its raw `-` token (see expandRegList) and fails here; so does an
389
+ // unknown alias, which a `Number.isNaN(regNum(t))` test would MISS, since regNum returns undefined
390
+ // for one and `Number.isNaN(undefined)` is false. An empty list is a malformed list, not an empty
391
+ // transfer.
392
+ //
393
+ // LOWERCASE-ONLY IS A HOLE, NOT A FREEBIE. GNU as accepts `PUSH {R4, LR}`, and the three consumers
394
+ // of this predicate do not agree
395
+ // about it. The ldm/stm arm degrades to a loud opaque and the frame walk poisons its depth, both of
396
+ // which decline; `savedRegs` does neither. It `break`s out of the prologue scan on an unreadable
397
+ // list, which yields a SMALLER save set rather than no answer, so the def-less `r4` it should have
398
+ // partitioned into an uninitialised local becomes a parameter instead. Measured 2026-09-06 on
399
+ // `push {r4,lr}; add r0,r4,#0; pop {r4}; bx lr`: lowercase gives `s32 f(void)` with `uninit_r4`,
400
+ // while both `push {R4, LR}` and `PUSH {r4, lr}` give `s32 f(s32 a0) { return a0; }`. Nothing in
401
+ // the corpus reaches it — 0 uppercase register tokens across the reference asm of the benchmark's
402
+ // agbcc rows — but folding case belongs in `expandRegList`, where it also changes classifyXfer's
403
+ // `popsPc`, i.e. block splitting. That is a measured change, not a free one.
404
+ function definiteRegList(tokens: string[]): string[] | null {
405
+ const list = expandRegList(tokens);
406
+ if (list.length === 0) {
407
+ return null;
408
+ }
409
+ return list.every((s) => /^r\d+$/.test(s) || s in REG_NUM) ? list : null;
410
+ }
411
+
412
+ /** The braced register list of a `push`/`pop`/`ldmia`/`stmia`, as definite registers or null.
413
+ *
414
+ * `splitOperands` has already torn `{r4, r5, lr}` at its commas, so the list arrives as several
415
+ * operand tokens with the braces on the outer two; re-joining and re-splitting is what puts it
416
+ * back together. Three consumers ask this — the frame walk's `delta`, `savedRegs`, and the ldm/stm
417
+ * arm (which slices its base register off first) — and they get ONE tokenizer, because the
418
+ * tokenizing is where the drift that {@link definiteRegList} documents started. */
419
+ function regListOf(ops: string[]): string[] | null {
420
+ return definiteRegList(
421
+ ops
422
+ .join(',')
423
+ .replace(/[{}]/g, '')
424
+ .split(',')
425
+ .map((r) => r.trim())
426
+ .filter(Boolean),
427
+ );
428
+ }
429
+
250
430
  // Split an operand list on commas that are NOT inside brackets, so a memory operand like
251
431
  // `[r0, #0x8]` (base + offset) stays a single token instead of being torn at its comma.
252
432
  function splitOperands(s: string): string[] {
@@ -272,15 +452,25 @@ function splitOperands(s: string): string[] {
272
452
  return out;
273
453
  }
274
454
 
275
- // Parse a Thumb memory addressing operand `[base]` or `[base, #off]` into base register +
276
- // constant byte offset. (Register-scaled indices like `[base, r1, lsl #2]` are not handled
277
- // yet — agbcc materialises those as explicit add/lsl before the load in the cases we target.)
455
+ // Parse a Thumb memory addressing operand `[base]`, `[base, #off]` or `[base, rX]` into base
456
+ // register + constant byte offset + optional register index.
457
+ //
458
+ // KNOWN HOLE: only the first TWO tokens are read. A third — the scale in `[rB, rX, lsl #2]`, or a
459
+ // second index register — is dropped with no diagnostic, and the load arm then lowers the result as
460
+ // a plain `rB + rX`. Measured 2026-09-06: `ldr r0, [r0, r1, lsl #2]` lifts to
461
+ // `*(s32 *)(a0 + a1)`, silently wrong by a factor of four. ARMv4T Thumb has no scaled-index load
462
+ // encoding, so nothing an agbcc-shaped input contains reaches it — but this frontend parses TEXT,
463
+ // and the honest fix is a decline here rather than a comment. Adding one is a behaviour change and
464
+ // has to be measured.
278
465
  function parseAddr(operand: string): { base: string; off: number; regOff?: string } {
279
466
  const inner = operand.replace(/[[\]]/g, '').trim();
280
467
  const parts = inner.split(',').map((s) => s.trim());
281
468
  const base = parts[0];
282
- // `[rB, rX]` — REGISTER-offset addressing. Surfaced to the caller so load/store DECLINE
283
- // loud: silently reading `[rB]` (the old behavior) dropped the index — a silent miscompile.
469
+ // `[rB, rX]` — REGISTER-offset addressing, surfaced rather than swallowed, for two different
470
+ // callers: the sp paths (argIndex, the slot arms, isFrameObjectAccess) all refuse a register
471
+ // index outright, and the ordinary load/store path lowers it as an explicit `rB + rX` add before
472
+ // a load at offset 0. Reading it as a bare `[rB]` drops the index, and `ldrsh` exists ONLY in
473
+ // this form in Thumb-1, so every `ldrsh` goes through this branch.
284
474
  if (parts[1] !== undefined && !parts[1].startsWith('#')) {
285
475
  return { base, off: 0, regOff: parts[1] };
286
476
  }
@@ -288,6 +478,227 @@ function parseAddr(operand: string): { base: string; off: number; regOff?: strin
288
478
  return { base, off };
289
479
  }
290
480
 
481
+ // ── FRAME AND REGISTER PREDICATES ────────────────────────────────────────────────────────────
482
+ // Capture-free helpers: pure functions of an instruction, an operand token or an explicit
483
+ // dependency bag. Nothing here reads lift state, which is why they sit at module level — the size
484
+ // of `lift` should reflect what it actually decides.
485
+
486
+ const reg = (s: string) => s.replace(/[[\]]/g, '');
487
+
488
+ // THE one test for "is this token the stack pointer". Case-insensitive because GNU as accepts
489
+ // uppercase register names, and a case-sensitive test here is a silent-wrong-answer hole rather
490
+ // than a cosmetic one: `add r0, SP, #4` is `&local`, and missing it fabricates a phantom
491
+ // parameter and emits confident arithmetic on it.
492
+ const isSpReg = (s: string | undefined): boolean => {
493
+ const r = reg(s ?? '').toLowerCase();
494
+ return r === 'sp' || r === 'r13';
495
+ };
496
+
497
+ const isFrameAdjust = (
498
+ mnemonic: string,
499
+ dest: string | undefined,
500
+ base: string | undefined,
501
+ off: string | undefined,
502
+ ): boolean =>
503
+ (mnemonic === 'add' || mnemonic === 'sub') &&
504
+ isSpReg(dest) &&
505
+ (base === undefined || isSpReg(base)) &&
506
+ (off?.startsWith('#') ?? false);
507
+
508
+ // A virtual register key per incoming stack argument. Reading it goes through the ordinary Braun
509
+ // live-in path (frontend/ssa.ts), which turns a read with no reaching def into a function
510
+ // parameter — so this needs NO new representation, opcode or pass. The `@` cannot appear in a
511
+ // real register token, so the key cannot collide with one.
512
+ const stackArgKey = (index: number) => `@sarg${index}`;
513
+ // Defined beside its mint site on purpose: the format string and its parser drifted 650 lines
514
+ // apart in the first version, with the convention explained only at one end.
515
+ const stackArgIndex = (key: string): number | null => {
516
+ const m = /^@sarg(\d+)$/.exec(key);
517
+ return m ? +m[1] : null;
518
+ };
519
+
520
+ // INCOMING STACK ARGUMENTS (AAPCS). Args 1-4 arrive in r0-r3; args 5+ are pushed by the CALLER,
521
+ // so the callee reads them at `[sp, #N]` where N is at or above its own frame. Those are
522
+ // PARAMETERS, not locals — declining them as "sp used as data" refuses a calling convention.
523
+ //
524
+ // The frame depth is tracked by a linear walk of the ENTRY BLOCK only, which is
525
+ // why the gate is what it is: within one straight-line block the depth at each instruction is
526
+ // exact and needs no CFG reasoning. Every case that would need more declines.
527
+ //
528
+ // `push {a,b,c}` deepens by 4 per register; `sub sp, #N` and `add sp, sp, #-N` deepen by N.
529
+ //
530
+ // The walk, its two invariants and the predicate that reads them are ONE object on purpose. They
531
+ // were three loose pieces — a delta function, a pair of `let`s updated in the instruction loop,
532
+ // and a seven-parameter predicate taking the pair by value — and both bugs the second review pass
533
+ // found lived in the seams: an invariant the predicate's proof needed but only the loop could
534
+ // enforce, and a `0` returned for an unrecognised shape that only a guard in a third place made
535
+ // safe. Anything that changes how sp moves now has one place to change, and the proof it has to
536
+ // preserve is written next to the state it is about.
537
+ //
538
+ // `deps` carries the only two things about the FUNCTION this needs — the ABI's argument-register
539
+ // count, and whether the entry block has predecessors — so the walk itself reads nothing but the
540
+ // instructions it is stepped over.
541
+ const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolean }) => {
542
+ // Bytes the frame has grown since function entry, along this block's linear order only.
543
+ let depth = 0;
544
+ let depthKnown = true;
545
+
546
+ // Returns null whenever the depth cannot be computed exactly — including for any write to sp
547
+ // this does not model. The capability rests on the depth being EXACT, so an approximation is
548
+ // never acceptable: understate the frame and a local sits above the computed top and gets
549
+ // minted as a parameter reading uninitialised stack. A null poisons the depth for the rest of
550
+ // the block, which disables argument recovery and leaves every `[sp,#N]` to decline as before.
551
+ // Nothing here may return a NUMBER for a shape it merely failed to recognise.
552
+ const delta = (ins: { mnemonic: string; ops: string[] }): number | null => {
553
+ const m = ins.mnemonic;
554
+ if (m === 'push' || m === 'pop') {
555
+ // expandRegList, NOT a comma count: `push {r4-r7, lr}` is FIVE registers, and counting it as
556
+ // two makes the frame 12 bytes too shallow — which turns a genuine LOCAL into a fabricated
557
+ // parameter reading uninitialised stack. Caught by a probe, not by the corpus: agbcc emits no
558
+ // range pushes at all — re-counted 2026-09-06 over the 269 of the benchmark's 404 agbcc rows
559
+ // whose reference asm the local bench cache holds, where NO `push`/`pop` list carries a range
560
+ // — but GNU as accepts them and the disassembly path can produce them. (The population is the
561
+ // rows, not the cache's file count: a cache also holds entries for rows that no longer exist,
562
+ // so two checkouts of this commit disagree about how many files there are and agree about
563
+ // this.)
564
+ // Counting comma tokens instead would undercount `{r4-lr}` as two registers, and an
565
+ // unexpandable range must poison the depth rather than be guessed at — definiteRegList owns
566
+ // both rules, and owns them for the ldm/stm arm too.
567
+ const list = regListOf(ins.ops);
568
+ if (list === null) {
569
+ return null;
570
+ }
571
+ return (m === 'push' ? 1 : -1) * 4 * list.length;
572
+ }
573
+ if ((m === 'add' || m === 'sub') && isSpReg(ins.ops[0])) {
574
+ const off = ins.ops[2] ?? ins.ops[1];
575
+ if (off?.startsWith('#')) {
576
+ const v = imm(off);
577
+ return m === 'sub' ? v : -v;
578
+ }
579
+ }
580
+ // Any OTHER write to sp poisons the depth. This used to fall through to 0 — "no change" — for
581
+ // shapes it does not model (`add sp, r4`, `mov sp, rN`, `add sp, r0, #4`), which was safe only
582
+ // because writeData declines every one of them elsewhere. That is the same
583
+ // enumeration-of-arms mistake writeData itself exists to end, exported one function away: a
584
+ // number meaning "no change" is the wrong answer to "I do not understand this". Now the walk is
585
+ // self-sufficient — unknown ⇒ depth poisoned ⇒ decline — and writeData's refusal is an
586
+ // independent second guarantee instead of a load-bearing one.
587
+ if (isSpReg(ins.ops[0])) {
588
+ return null;
589
+ }
590
+ return 0;
591
+ };
592
+
593
+ return {
594
+ // Advance the walk over one instruction. Call for EVERY instruction, in order.
595
+ step(ins: { mnemonic: string; ops: string[] }): void {
596
+ const d = delta(ins);
597
+ if (d === null) {
598
+ depthKnown = false;
599
+ } else {
600
+ depth += d;
601
+ }
602
+ // sp ABOVE the incoming sp poisons the walk for the rest of the block, permanently — the
603
+ // premise argIndex's proof rests on. See the proof there for what it costs to omit.
604
+ if (depth < 0) {
605
+ depthKnown = false;
606
+ }
607
+ },
608
+
609
+ // Is `[sp, #off]` an incoming stack argument, and which one? `null` means NO — and every null is
610
+ // a DECLINE, because the caller falls through to readData's sp guard.
611
+ //
612
+ // Why a slot at or above the frame top cannot have been written by this function, which is the
613
+ // whole soundness argument: every sp-relative STORE declines (readData, via the str arm), and a
614
+ // `push` only ever writes strictly BELOW the current top. An argument slot is at or above the
615
+ // incoming sp, so no instruction here can have defined it — the value can only be the caller's.
616
+ //
617
+ // That second step needs sp to have stayed at or below where it came in — `depth >= 0` at every
618
+ // point of the walk — or a `push` reaches back up over the argument area and the conclusion is
619
+ // false:
620
+ //
621
+ // add sp, sp, #8 ; sp = S+8
622
+ // push {r4, r5, r6} ; sp = S-4, and this WROTE r5 to S+0
623
+ // ldr r0, [sp, #4] ; = S+0 — r5's slot, not the caller's argument
624
+ //
625
+ // The depth is back to a plausible +4 by the load, so nothing downstream can tell: it emitted
626
+ // `s32 f(s32 a0, …, s32 a4) { return a4; }`, the function's own incoming r5 handed back as
627
+ // argument 5. `pop {r4}; push {r4,r5}` gets there without an `add sp` at all, and a sliced
628
+ // fragment whose prologue was cut off is exactly this shape — which is the corpus this frontend
629
+ // reads. `step` enforces it, and never un-poisons: once sp has been above the line, a push during
630
+ // the excursion may have written the later slots too. This is a PREMISE, not a detail — leaving
631
+ // it unstated is what let the first version hand back a callee-saved register as an argument.
632
+ argIndex(addr: { base: string; off: number; regOff?: string }, width: number, bi: number): number | null {
633
+ const { base, off, regOff } = addr;
634
+ if (!isSpReg(base) || regOff !== undefined) {
635
+ return null; // not sp, or `[sp, rX]` — not a fixed argument slot
636
+ }
637
+ if (bi !== 0 || deps.entryHasPreds) {
638
+ return null; // depth is exact only along the ENTRY block's linear order, and only when its
639
+ // params are parameters rather than phis
640
+ }
641
+ if (!depthKnown || depth <= 0) {
642
+ return null; // an unmeasurable frame, or none established: a headerless FRAGMENT whose
643
+ // prologue was sliced off looks identical to a frameless function, and there the slots are
644
+ // locals — minting one would be the silent-wrong trade this frontend refuses
645
+ }
646
+ if (width !== 4 || off < depth || (off - depth) % 4 !== 0) {
647
+ return null; // the argument area is word-granular; BELOW the top is a local, which is the
648
+ // separate slot-promotion capability
649
+ }
650
+ const index = deps.argRegs.length + (off - depth) / 4;
651
+ return index < MAX_RECOVERED_ARITY ? index : null; // a wild offset must not mint a
652
+ // 400-parameter signature
653
+ },
654
+ };
655
+ };
656
+
657
+ // Deliberately OVER-inclusive: a `cmp sp, rN` only reads sp but counts here too. Every false
658
+ // positive costs a decline, every false negative costs a wrong slot — so it errs loudly.
659
+ const modifiesSp = (ins: Instr): boolean =>
660
+ ins.mnemonic === 'push' || ins.mnemonic === 'pop' || isSpReg((ins.ops[0] ?? '').replace(/!$/, ''));
661
+ // A `mov rD, sp` CAPTURES the frame address; the captured value means "the frame base" only if
662
+ // sp still holds that base wherever the value is used — so a capture participates in the
663
+ // constancy proof exactly like a literal [sp,#k] access, and ends the prologue for localArea.
664
+ const capturesSp = (ins: Instr): boolean =>
665
+ /^movs?$/.test(ins.mnemonic) && !isSpReg(ins.ops[0] ?? '') && isSpReg(ins.ops[1] ?? '');
666
+ const touchesFrame = (ins: Instr): boolean => spMemAccess(ins) !== null || capturesSp(ins);
667
+ const spMemAccess = (ins: Instr): { off: number; width: number; regOff: boolean } | null => {
668
+ if (!/^(ldr|ldrb|ldrh|ldrsb|ldrsh|str|strb|strh)$/.test(ins.mnemonic)) {
669
+ return null;
670
+ }
671
+ const mem = ins.ops[1];
672
+ if (mem === undefined) {
673
+ return null;
674
+ }
675
+ const { base, off, regOff } = parseAddr(mem);
676
+ return isSpReg(base)
677
+ ? { off, width: /b$/.test(ins.mnemonic) ? 1 : /h$/.test(ins.mnemonic) ? 2 : 4, regOff: regOff !== undefined }
678
+ : null;
679
+ };
680
+
681
+ // How much sp moves for `add/sub sp, #imm`, positive = sp RISES (frame shrinks). It models THAT
682
+ // SHAPE AND NOTHING ELSE — a `push`, a register-sized adjust, a `mov sp, rN` all answer null,
683
+ // which each of its three readers takes as "this is not a reservation or a release" and handles
684
+ // for itself: `localArea` sums the prologue's reservations and poisons to 0 on an sp write it
685
+ // cannot read, `savedRegs` ends the prologue run at the first instruction that is neither a save
686
+ // nor an adjust, and the pop/release scan uses it to recognise the epilogue's release. The
687
+ // authoritative depth arithmetic is `makeFrameWalk`'s `delta`, which counts pushes too.
688
+ const spAdjust = (ins: Instr): number | null => {
689
+ if (!/^(add|sub)$/.test(ins.mnemonic) || !isSpReg(ins.ops[0])) {
690
+ return null;
691
+ }
692
+ const o = ins.ops[2] ?? ins.ops[1];
693
+ if (o === undefined || !o.startsWith('#')) {
694
+ return null;
695
+ }
696
+ const v = imm(o);
697
+ return ins.mnemonic === 'sub' ? -v : v;
698
+ };
699
+
700
+ const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
701
+
291
702
  /** Parse one function's GNU-as text into labelled basic blocks + the CFG, plus the inline `.word`
292
703
  * data tables (label → the list of label operands under it) — the jump-table target arrays agbcc
293
704
  * emits in `.text` (Regime B). Non-`.word` directives are skipped, EXCEPT sub-word data
@@ -304,9 +715,69 @@ interface FlatItem {
304
715
  instr?: Instr;
305
716
  /** a data directive's payload, kept in-stream so byte layout is computable */
306
717
  data?: { halfwords: boolean; values: string[]; inCode: boolean };
718
+ /** an alignment directive whose fill byte count depends on the function's base alignment —
719
+ * resolved into pad instructions by the layout pass, which is the first place that knows it.
720
+ * `fill` is the halfword encoding the assembler repeats: 0x0000 for an explicit `, 0`, 0x46C0
721
+ * (the Thumb nop) for no fill operand. Both are PAD_ENCODINGS entries. */
722
+ align?: { pow: number; fill: number };
307
723
  }
308
724
 
309
- function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map<string, string[]> } {
725
+ /** One hypothesis about a function's base alignment, laid out: the item stream with every
726
+ * alignment directive replaced by the pad instructions its fill bytes ARE, each item's byte
727
+ * offset, where each item came from in the unexpanded stream (-1 = an inserted fill), and the
728
+ * fill sites themselves. Two of these — base 0 and base 2 — are built for every slice. */
729
+ interface Candidate {
730
+ base: number;
731
+ items: FlatItem[];
732
+ offs: number[];
733
+ from: number[];
734
+ fills: { site: number; start: number; bytes: number }[];
735
+ }
736
+
737
+ /** One fact a candidate layout decided, as DATA rather than as a sentence. Two candidates are
738
+ * compared by these, so the comparison cannot depend on how a message happens to be worded — and
739
+ * a fact that must distinguish two layouts cannot lose the thing that distinguishes them. The
740
+ * branch target is an ITEM identity, never a byte offset: one layout's offsets mean nothing in
741
+ * the other. A target inside alignment fill carries WHICH fill and how far in, because two
742
+ * layouts that send the same branch into different fill sites disagree. */
743
+ type LayoutFact =
744
+ | { kind: 'branch'; at: number; to: { item: number } | { fill: number; off: number } | { off: number } }
745
+ | { kind: 'load'; at: number; value: string }
746
+ | { kind: 'fill'; site: number; bytes: number };
747
+
748
+ /** A candidate that survived resolution: the rewritten stream, the literal pools it discovered,
749
+ * and the WITNESS — every fact this layout decided, so two candidates can be COMPARED rather
750
+ * than merely counted. */
751
+ interface Resolved {
752
+ base: number;
753
+ items: FlatItem[];
754
+ pools: Map<string, string[]>;
755
+ witness: LayoutFact[];
756
+ }
757
+
758
+ /** The canonical serialisation a witness is compared BY, and the prose it is reported AS. Keeping
759
+ * them apart is the point: rewording `sayFact` changes a message, never a decision. */
760
+ const factKey = (f: LayoutFact) => JSON.stringify(f);
761
+ const sayFact = (f: LayoutFact): string => {
762
+ if (f.kind === 'load') {
763
+ return `pc-relative load at item ${f.at} → ${f.value}`;
764
+ }
765
+ if (f.kind === 'fill') {
766
+ return `alignment fill at item ${f.site} is ${f.bytes} bytes`;
767
+ }
768
+ const to =
769
+ 'item' in f.to
770
+ ? `item ${f.to.item}`
771
+ : 'fill' in f.to
772
+ ? `alignment fill at item ${f.to.fill} (+${f.to.off} bytes)`
773
+ : `byte offset 0x${f.to.off.toString(16)}, which is no item`;
774
+ return `raw branch at item ${f.at} → ${to}`;
775
+ };
776
+
777
+ function decode(
778
+ name: string,
779
+ asm: string,
780
+ ): { blocks: AsmBlock[]; dataWords: Map<string, string[]>; funcLabels: Set<string> } {
310
781
  // Flatten to (label | instr | data) items, then split into blocks at labels / after branches.
311
782
  // `.word LABEL` directives are captured into dataWords keyed by the most recent label (the
312
783
  // jump table); ALL word/halfword data also stays in-stream as items, so the raw-halfword and
@@ -316,10 +787,22 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
316
787
  const funcLabels: string[] = []; // labels marked as function starts (.thumb_func / pret macros)
317
788
  const armLabels = new Set<string>(); // function starts declared ARM-mode (arm_func_start)
318
789
  const subwordData = new Map<string, string>(); // label → sub-word data directive under it
319
- // Directives whose byte size we cannot know, recorded by allFlat POSITION so the layout check
790
+ // Directives this pass cannot read completely, recorded by allFlat POSITION so the layout check
320
791
  // is scoped to the SELECTED function's slice — a `.align` between two functions must not
321
- // poison a sibling that needs byte-accurate layout.
322
- const hazards: { at: number; what: string }[] = [];
792
+ // poison a sibling that needs byte-accurate layout. That scoping is what the `>= sliceStart`
793
+ // bound on the code-hazard scan below enforces; reaching one item lower admitted exactly the
794
+ // sibling this sentence forbids.
795
+ //
796
+ // `code` separates the two reasons an unreadable directive matters. An alignment directive emits its
797
+ // fill INTO THE INSTRUCTION STREAM, so when anything can execute those bytes the function
798
+ // cannot be lifted at all, whatever else the slice needs. A labelled data table's bytes are
799
+ // data: they can only shift the offsets of what follows, which matters only when something in
800
+ // the slice actually needs byte-accurate offsets.
801
+ //
802
+ // `why` says which question the directive left unanswered, so the refusal can state the fact it
803
+ // actually tested: 'size' (how many fill bytes) or 'fill' (which bytes they are). A data
804
+ // directive leaves only the first open.
805
+ const hazards: { at: number; what: string; code: boolean; why?: 'size' | 'fill' }[] = [];
323
806
  let dataLabel: string | null = null;
324
807
  let pendingFn = false;
325
808
  let pendingArm = false;
@@ -362,15 +845,33 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
362
845
  }
363
846
  const hw = rest.match(/^\.(2byte|hword|short)\s+(.+)$/);
364
847
  if (hw) {
848
+ const values = hw[2].split(',').map((w) => w.trim());
365
849
  // In the instruction stream these are raw undecoded instructions (luvdis emits branches
366
850
  // this way) — kept as items and DECODED (or declined) below. Under a label: a sub-word
367
851
  // data table — declines below iff the selected function references it.
368
- if (dataLabel !== null) {
852
+ if (dataLabel === null) {
853
+ // …EXCEPT the halfwords that ENCODE alignment fill, decoded here to the instruction they
854
+ // are (see padHalfword), so the pad enters the stream as an `instr` item — the same item
855
+ // the `lsls r0, r0, #0` spelling of the same two bytes produces, and the same 2 bytes to
856
+ // the layout walk. That hands padding-versus-real-instruction to the ONE place that
857
+ // decides it: isPadInstr plus the unreachable-block prune below. Decoding it HERE rather
858
+ // than in the raw-branch pass matters: a function whose only in-code data was pad then
859
+ // needs no byte-accurate layout at all, exactly as it needs none under the instruction
860
+ // spelling.
861
+ const pads = values.map((v) => {
862
+ const hw = halfwordLiteral(v);
863
+ return hw === null ? null : padHalfword(hw);
864
+ });
865
+ if (pads.every((p) => p !== null)) {
866
+ for (const p of pads) {
867
+ flat.push({ instr: p! });
868
+ }
869
+ continue;
870
+ }
871
+ } else {
369
872
  subwordData.set(dataLabel, hw[1]);
370
873
  }
371
- flat.push({
372
- data: { halfwords: true, values: hw[2].split(',').map((w) => w.trim()), inCode: dataLabel === null },
373
- });
874
+ flat.push({ data: { halfwords: true, values, inCode: dataLabel === null } });
374
875
  continue;
375
876
  }
376
877
  const raw = rest.match(
@@ -384,11 +885,77 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
384
885
  );
385
886
  }
386
887
  subwordData.set(dataLabel, raw[1]);
387
- hazards.push({ at: flat.length - 1, what: `.${raw[1]}` }); // byte size unknown / non-word
888
+ hazards.push({ at: flat.length - 1, what: `.${raw[1]}`, code: false }); // size unknown / non-word
388
889
  continue;
389
890
  }
390
- if (/^\.align\b/.test(rest)) {
391
- hazards.push({ at: flat.length - 1, what: '.align' });
891
+ // `.align N[, fill]` emits (-address) mod 2^N bytes of `fill` — the pad, spelled as the
892
+ // directive that produces it. Kept as an item so the layout walk can size it (below); its
893
+ // byte count is not knowable here, which is why it was a hazard before.
894
+ //
895
+ // It asks TWO questions, and fusing them is what made the old refusal describe neither the
896
+ // code nor the assembler ("makes item sizes unknowable" for a form whose size is plain
897
+ // arithmetic). HOW MANY bytes is `-(base + at) mod 2^N` and does not depend on the fill at
898
+ // all; WHICH bytes those are is the fill operand. Only the second can make a sizable
899
+ // alignment unliftable, and only when something can execute the bytes.
900
+ //
901
+ // size unknown — a max-skip third operand (padding suppressed past a threshold this
902
+ // frontend does not model), a non-numeric N, or N above the 4-byte
903
+ // boundary the base is recovered to. This bound is load-bearing rather
904
+ // than cautious: the layout pass recovers the function's base address only
905
+ // MOD 4 (from 4-aligned pool words), so sizing `.align 3, 0` would mean
906
+ // picking one of four base residues the input says nothing about.
907
+ // fill unknown — an explicit fill this pass cannot map to a pad encoding (`.align 2, 0xFF`
908
+ // emits a real, undefined instruction, not padding).
909
+ //
910
+ // The two fills it CAN map are ground truth from `arm-none-eabi-as`, not inference: an
911
+ // explicit `, 0` gives 0x0000 (`lsls r0, r0, #0`), and NO fill operand in a Thumb code
912
+ // section gives 0x46C0 — `movs r0, #1` then `.align 2` assembles to `0120 c046`, the nop
913
+ // that is already PAD_ENCODINGS[1]. (gas pads to the next even offset with 0x00 first, but
914
+ // base ∈ {0, 2} and every item size is even, so the fill is always whole halfwords.) Both
915
+ // are pad encodings, so the fill enters the stream as the same `Instr` item the other two
916
+ // spellings produce and `isPadInstr` + the reachability prune decide it the same way.
917
+ //
918
+ // `.p2align` is `.align` with the power-of-two reading made explicit and `.balign` is the
919
+ // same directive counting BYTES; GNU as emits identical fill for all three. They are read
920
+ // here for the same reason the pad halfword is decoded above: a directive this pass does not
921
+ // recognise contributes ZERO bytes to the layout while emitting real ones, which silently
922
+ // retargets every branch and pool load after it.
923
+ //
924
+ // Which is why this matches the FAMILY, not three literal spellings. gas directives are
925
+ // case-insensitive (`.ALIGN 2, 0` assembles identically — measured), and `.balignw` /
926
+ // `.balignl` / `.p2alignw` / `.p2alignl` fill with a repeated halfword or word PATTERN
927
+ // rather than a byte. A three-word lowercase whitelist skipped all of those as "other
928
+ // directives", giving them ZERO bytes in the layout while gas emitted real ones: measured,
929
+ // `.balignw 4, 0x0000` between a raw branch and its target retargeted the branch two bytes
930
+ // early and lifted the wrong C. A pattern fill stays a loud refusal — the byte COUNT is
931
+ // computable, but the bytes are a pattern this pass does not decode into pad encodings.
932
+ const ad = rest.match(/^\.(align|balign|p2align)([wl]?)\b\s*(.*)$/i);
933
+ if (ad) {
934
+ const kind = ad[1].toLowerCase();
935
+ const patternFill = ad[2] !== ''; // the `w`/`l` variants
936
+ const am = ad[3].trim().match(/^(\d{1,3})(?:\s*,\s*([^,]*?))?\s*$/); // N [, fill]; max-skip → no match
937
+ const n = am ? Number(am[1]) : NaN;
938
+ const pow = kind === 'balign' ? Math.log2(n) : n;
939
+ // No fill operand → the assembler's own code-section fill; `, 0` → zeros. Anything else
940
+ // (including an empty `.align 2,`) is a fill this pass does not model.
941
+ const fillText = am?.[2]?.trim();
942
+ const fill = fillText === undefined ? 0x46c0 : /^0[xX]?0*$/.test(fillText) ? 0x0000 : null;
943
+ const what = `.${ad[1]}${ad[2]}`; // as written, so the refusal names the reader's own line
944
+ if (!(Number.isInteger(pow) && pow <= 2)) {
945
+ hazards.push({ at: flat.length - 1, what, code: true, why: 'size' });
946
+ } else if (patternFill || fill === null) {
947
+ hazards.push({ at: flat.length - 1, what, code: true, why: 'fill' });
948
+ } else {
949
+ flat.push({ align: { pow, fill } });
950
+ }
951
+ continue;
952
+ }
953
+ // Belt and braces for the next spelling nobody has thought of: an unrecognised directive
954
+ // whose NAME says alignment is loud rather than silently zero-sized. Skipping one is the
955
+ // failure above, and it leaves no marker behind.
956
+ const alignish = rest.match(/^\.([\w.$]*align[\w.$]*)\b/i);
957
+ if (alignish) {
958
+ hazards.push({ at: flat.length - 1, what: `.${alignish[1]}`, code: true, why: 'size' });
392
959
  }
393
960
  continue; // other directives skipped
394
961
  }
@@ -408,10 +975,22 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
408
975
  }
409
976
  dataLabel = null; // a real instruction ends a data run
410
977
  const canon = canonicalMnemonic(m[1]);
978
+ const ops = m[2] ? splitOperands(m[2]) : [];
979
+ // `mov r8, r8` is the OTHER way to write 0x46C0 — objdump prints those two bytes as
980
+ // `nop @ (mov r8, r8)`, and a splitter emits either. It is normalised here, at the same seam
981
+ // the `.2byte 0x46C0` decode uses (padHalfword), because further down it would be modelled as
982
+ // a live read of a callee-saved register and mint a parameter the object does not have: the
983
+ // three spellings of one encoding gave two different signatures. `isPadInstr` only governs the
984
+ // prune of UNREACHABLE all-pad blocks, so it could not see the divergence — a REACHABLE
985
+ // `mov r8, r8` is kept, and was kept as a register read.
986
+ if ((canon === 'mov' || canon === 'movs') && ops.length === 2 && ops[0] === 'r8' && ops[1] === 'r8') {
987
+ flat.push({ instr: { ...padHalfword(0x46c0)!, asWritten: m[1] } });
988
+ continue;
989
+ }
411
990
  flat.push({
412
991
  instr: {
413
992
  mnemonic: canon,
414
- ops: m[2] ? splitOperands(m[2]) : [],
993
+ ops,
415
994
  ...(canon === m[1] ? {} : { asWritten: m[1] }),
416
995
  },
417
996
  });
@@ -506,161 +1085,414 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
506
1085
  // frontend already models; anything the decoder cannot prove declines loud.
507
1086
  const isPcRelLdr = (ins?: Instr) =>
508
1087
  ins?.mnemonic === 'ldr' && /^\[pc,\s*#(0x[0-9a-fA-F]+|\d+)\]$/.test(ins.ops[1] ?? '');
509
- const needsLayout = flat.some((f) => (f.data?.inCode ?? false) || isPcRelLdr(f.instr));
1088
+ // An alignment directive's fill bytes are INSTRUCTIONS in the code stream, not decoration: on
1089
+ // ARM7TDMI `.align 2, 0` emits the halfword 0x0000, which IS `lsls r0, r0, #0` and sets the
1090
+ // flags. They are padding only when nothing can execute them. When something CAN, their count
1091
+ // is part of the answer and the slice needs the byte-accurate layout below, exactly as a raw
1092
+ // branch halfword does — so this predicate, not the accident of whether some other item needed
1093
+ // offsets, is what decides. Scanning back from the align: an open or conditional instruction
1094
+ // means control falls into the fill, a label something in the slice NAMES means a branch can
1095
+ // land on it, and nothing at all means the fill sits at the function's entry. Only an
1096
+ // unconditional transfer seals it off, and a label nothing names does not re-open it — agbcc
1097
+ // writes a dead `.L10:` in front of every literal-pool alignment and never branches there, so
1098
+ // reading every label as a way in sends every agbcc function through the base solver for a
1099
+ // fill no instruction can execute (measured when the clause landed: 75 of the then-253 real-tier
1100
+ // reference functions lost; the real tier holds 252 rows today, and the 75 has not been re-run).
1101
+ //
1102
+ // `named` is every operand token, which OVERSHOOTS: `ldr r0, _pool` names `_pool` without
1103
+ // control ever going there. The bound that matters is the one the block builder already draws
1104
+ // — a label heading DATA is not a code entry, so naming it is a load, not a way in. Without
1105
+ // that clause an align between two words of a labelled literal pool read as executable fill
1106
+ // and declined the function, over bytes in the data stream that nothing can execute.
1107
+ const named = new Set<string>(); // labels an operand or a data word names
1108
+ const labelShape = /^([A-Za-z_.$][\w.$]*)/;
1109
+ for (const f of flat) {
1110
+ for (const tok of [...(f.instr?.ops ?? []), ...(f.data?.values ?? [])]) {
1111
+ const m = tok.match(labelShape);
1112
+ if (m) {
1113
+ named.add(m[1]);
1114
+ }
1115
+ }
1116
+ }
1117
+ const headsData = (l: string) => dataWords.has(l) || subwordData.has(l);
1118
+ // A LINEAR, PRE-LAYOUT APPROXIMATION of the CFG walk forty lines below — not a second opinion
1119
+ // about it. Ordering forces the approximation: the real walk needs blocks, blocks need the
1120
+ // item stream, and the item stream needs the fill sizes this predicate is being asked about.
1121
+ // So it answers "can control reach these bytes" by scanning backwards over `flat` instead of
1122
+ // forwards over the CFG, which makes it CONSERVATIVE in one direction on purpose: it looks at
1123
+ // the nearest preceding instruction and does not ask whether THAT instruction is itself
1124
+ // reachable, so a pad in front of an unsizable `.align` reads as "control continues". Loud
1125
+ // where the walk would be quiet, never the reverse. `controlContinuesPast` is shared with the
1126
+ // block-level scan so the two cannot drift on what a transfer kind means.
1127
+ const fillIsReachable = (upto: number) => {
1128
+ for (let j = upto - 1; j >= 0; j--) {
1129
+ const g = flat[j];
1130
+ if (g.label !== undefined) {
1131
+ if (named.has(g.label) && !headsData(g.label)) {
1132
+ return true;
1133
+ }
1134
+ continue; // a label nothing names — or one naming data — cannot bring control here
1135
+ }
1136
+ if (g.instr) {
1137
+ return controlContinuesPast(g.instr);
1138
+ }
1139
+ // data: not executed — keep looking for the instruction whose flow reaches this point
1140
+ }
1141
+ return true;
1142
+ };
1143
+ const sealedFill = flat.map((f, i) => f.align !== undefined && !fillIsReachable(i));
1144
+ // An alignment directive this pass could not READ — its byte count unknown (a max-skip limit,
1145
+ // an alignment wider than the base is known to) or its fill bytes unknown (a fill that is not
1146
+ // a pad encoding) — records a hazard instead of an item,
1147
+ // and its fill bytes are still in the instruction stream. If anything can execute them the
1148
+ // function cannot be lifted, whether or not the slice needs byte offsets for another reason;
1149
+ // reading the size question as a LAYOUT question only is what let a `.2byte 0x0000` pad, once
1150
+ // decoded at parse time, carry a `.align 2` past this check into a silent lift.
1151
+ //
1152
+ // The hazard is recorded at the item BEFORE the directive (`flat.length - 1` at push time), so
1153
+ // its fill occupies slice position `at - sliceStart + 1`. The lower bound is `sliceStart`, not
1154
+ // `sliceStart - 1`: `allFlat[sliceStart]` is the function's OWN label, so an align inside the
1155
+ // slice always records `at >= sliceStart` — and `at === sliceStart - 1` can only mean the
1156
+ // directive sits between the previous function's last item and this function's entry label,
1157
+ // i.e. its fill is emitted at addresses BELOW this function and belongs to the sibling.
1158
+ // Admitting it declined every function that merely FOLLOWED an unsizable alignment, which is
1159
+ // the poisoning the allFlat-position bookkeeping exists to prevent (see `hazards` above).
1160
+ const liveHazard = hazards.find(
1161
+ (h) => h.code && h.at >= sliceStart && h.at < boundaries[boundaryIdx] && fillIsReachable(h.at - sliceStart + 1),
1162
+ );
1163
+ if (liveHazard) {
1164
+ throw new FrontendUnsupportedError(
1165
+ `cannot lift '${name}': '${liveHazard.what}' emits fill into the code stream and ` +
1166
+ (liveHazard.why === 'fill'
1167
+ ? `its fill bytes are not a pad encoding this frontend models — they are real instructions`
1168
+ : `makes item sizes unknowable`),
1169
+ );
1170
+ }
1171
+ const needsLayout = flat.some(
1172
+ (f, i) => (f.data?.inCode ?? false) || isPcRelLdr(f.instr) || (f.align !== undefined && !sealedFill[i]),
1173
+ );
1174
+ // NO BENCHMARK REACH BELOW THIS LINE, and it is worth knowing which side of it you are on.
1175
+ // Everything ABOVE — the pad decoding, `fillIsReachable`, `sealedFill`, the live-hazard refusal
1176
+ // — runs on every agbcc function and is bench-load-bearing. The layout solver below runs on
1177
+ // none of them: instrumented and re-measured 2026-09-06 over the 269 of the benchmark's 404
1178
+ // agbcc rows whose reference asm the local bench cache holds, this branch was entered 0 times.
1179
+ // agbcc emits no `[pc, #N]` load and no sub-word data in `.text`, and every `.align` it writes
1180
+ // sits behind a dead `.L` label that the rule above seals. So a clean `bench diff` says NOTHING about any code inside this branch —
1181
+ // `thumb-pad-directives.test.ts` is its only gate, and a change here has to be argued there.
510
1182
  if (needsLayout) {
511
- // Only a hazard WITHIN this function's slice makes its layout unknowable.
1183
+ // Only a hazard WITHIN this function's slice makes its layout unknowable. A fill this pass
1184
+ // cannot model keeps the directive out of the item stream entirely, so it contributes no
1185
+ // size here even though its count is arithmetic — a different fact from an unknown count,
1186
+ // and the message says which one it is.
512
1187
  const sliceHazard = hazards.find((h) => h.at >= sliceStart && h.at < boundaries[boundaryIdx]);
513
1188
  if (sliceHazard) {
514
1189
  throw new FrontendUnsupportedError(
515
- `cannot lift '${name}': raw-encoded input needs byte-accurate layout, but '${sliceHazard.what}' makes item sizes unknowable`,
1190
+ `cannot lift '${name}': raw-encoded input needs byte-accurate layout, but '${sliceHazard.what}' ` +
1191
+ (sliceHazard.why === 'fill'
1192
+ ? `fills with bytes this frontend does not model, so it contributes no size to the layout`
1193
+ : `makes item sizes unknowable`),
516
1194
  );
517
1195
  }
518
- // Byte offset of every item (Thumb-1: 2 bytes per instruction, `bl` is the 4-byte pair).
519
- const itemOff: number[] = [];
520
- const labelOff = new Map<string, number>();
521
- const codeStart = new Set<number>(); // offsets that begin an instruction or carry a label
522
- let off = 0;
523
- flat.forEach((f, i) => {
524
- itemOff[i] = off;
525
- if (f.label && !labelOff.has(f.label)) {
526
- labelOff.set(f.label, off);
527
- codeStart.add(off);
528
- }
529
- if (f.instr) {
530
- codeStart.add(off);
531
- off += f.instr.mnemonic === 'bl' ? 4 : 2;
532
- }
533
- if (f.data) {
534
- off += f.data.values.length * (f.data.halfwords ? 2 : 4);
535
- }
536
- });
537
- const labelAt = new Map<number, string>();
538
- for (const [lab, lo] of labelOff) {
539
- if (!labelAt.has(lo)) {
540
- labelAt.set(lo, lab);
541
- }
1196
+ // ── the function's base alignment is SOLVED, not derived ─────────────────────────────────
1197
+ // `.align N, 0` emits (-(base + off)) mod 2^N bytes, where `base` is the function's own
1198
+ // address mod 4 — and `base` was until now recovered only AFTER this walk, from literal-pool
1199
+ // positions that the align itself moves. That cycle is why `.align` had to be a hazard.
1200
+ //
1201
+ // Break it by treating `base` as the unknown it is. A Thumb function starts on a 2-byte
1202
+ // boundary, so `base` is 0 or 2; lay the slice out BOTH ways and let the structural
1203
+ // invariants this frontend already enforces on a single layout eliminate the wrong one — a
1204
+ // pool word off a 4-byte boundary, a raw branch landing between instructions, a pc-relative
1205
+ // load landing outside every pool. With nothing to size, the two layouts are identical and
1206
+ // the first invariant alone picks the same base the old derivation did.
1207
+ //
1208
+ // What the input genuinely fails to determine STILL declines: if both bases survive every
1209
+ // invariant and disagree, there is no honest answer to give.
1210
+ const itemSize = (f: FlatItem) =>
1211
+ f.instr ? (f.instr.mnemonic === 'bl' ? 4 : 2) : f.data ? f.data.values.length * (f.data.halfwords ? 2 : 4) : 0;
1212
+ // Replace each `.align pow, 0` with the pad instructions its zero-fill bytes ARE — the same
1213
+ // item the other two spellings of a pad produce. The fill is always a whole number of
1214
+ // halfwords and cannot be otherwise: `base` is 0 or 2, every item size is even (2, 4, or a
1215
+ // whole number of 2/4-byte values), and `pow` is at most 2 — so `-(base + at) & 3` is even.
1216
+ // It was an assertion here; nothing could fail it, and being raised OUTSIDE the candidate
1217
+ // try/catch below it would have bypassed the base machinery it appeared to belong to.
1218
+ const expandAligns = (base: number): Candidate => {
1219
+ const items: FlatItem[] = [];
1220
+ const offs: number[] = [];
1221
+ const from: number[] = []; // items[k] came from flat[from[k]]; -1 marks an inserted fill
1222
+ const fills: { site: number; start: number; bytes: number }[] = [];
1223
+ let at = 0;
1224
+ flat.forEach((f, i) => {
1225
+ if (f.align) {
1226
+ const fill = -(base + at) & ((1 << f.align.pow) - 1);
1227
+ fills.push({ site: i, start: at, bytes: fill });
1228
+ for (let k = 0; k < fill; k += 2) {
1229
+ offs.push(at + k);
1230
+ from.push(-1);
1231
+ items.push({ instr: padHalfword(f.align.fill)! });
1232
+ }
1233
+ at += fill;
1234
+ return;
1235
+ }
1236
+ offs.push(at);
1237
+ from.push(i);
1238
+ items.push({ ...f }); // cloned: pass 2 replaces `instr` on the chosen candidate's items
1239
+ at += itemSize(f);
1240
+ });
1241
+ return { base, items, offs, from, fills };
1242
+ };
1243
+ // The one invariant that costs nothing to check: every literal-pool word is 4-aligned in the
1244
+ // ROM. This IS the old `basePar` derivation, read as a filter instead of an assignment.
1245
+ const laid = [0, 2].map(expandAligns);
1246
+ const viable = laid.filter(({ base, items, offs }) =>
1247
+ items.every((f, i) => !f.data || f.data.halfwords || (base + offs[i]) % 4 === 0),
1248
+ );
1249
+ if (viable.length === 0) {
1250
+ // With an align in the slice this is reachable with ONE pool word: the align's own size
1251
+ // moves it, and neither base lands it on a 4-byte boundary. Nothing is inconsistent with
1252
+ // anything, so the pre-existing multi-pool message would send a reader hunting for a
1253
+ // second pool that does not exist.
1254
+ throw new FrontendUnsupportedError(
1255
+ flat.some((f) => f.align)
1256
+ ? `cannot lift '${name}': no base alignment (0 or 2 mod 4) puts every literal pool word on a ` +
1257
+ `4-byte boundary once alignment fill is accounted for`
1258
+ : `cannot lift '${name}': literal pools at inconsistent alignments — cannot determine the function's base alignment`,
1259
+ );
542
1260
  }
543
- // Thumb-1 branch encodings this frontend models (cond codes 4–7 = mi/pl/vs/vc have no
544
- // lifted comparison semantics here; 14 is undefined, 15 is swi all decline).
545
- const COND_MN = ['beq', 'bne', 'bcs', 'bcc', '', '', '', '', 'bhi', 'bls', 'bge', 'blt', 'bgt', 'ble'];
546
- const decodeHalfword = (v: number, at: number): { mnemonic: string; target: number } | null => {
547
- if (v >= 0xd000 && v <= 0xddff) {
548
- const mn = COND_MN[(v >> 8) & 0xf];
549
- if (!mn) {
550
- return null;
1261
+ // Two viable bases that lay the slice out identically are one candidate: the base is then
1262
+ // unobservable except through a pc-relative load, which declines for want of a pool below.
1263
+ const candidates = viable.filter((c, i) => viable.findIndex((o) => o.offs.join() === c.offs.join()) === i);
1264
+
1265
+ // Everything from here down is the resolution of ONE candidate layout: byte offsets, raw
1266
+ // branch decode, pc-relative pool loads. It reads the candidate and returns the rewritten
1267
+ // stream; it must not touch anything shared, or a rejected candidate would leave its marks.
1268
+ //
1269
+ // It also returns a WITNESS: every fact about the input this layout decided — which pool
1270
+ // word each pc-relative load reads, which item each raw branch targets, and how many fill
1271
+ // bytes each align emits WHEN those bytes are observable. Two candidate bases are compared
1272
+ // by their witnesses, so "they disagree" is something the code establishes rather than
1273
+ // asserts. A sealed fill (nothing can execute it) that no branch targets is left out: those
1274
+ // pad instructions form an all-pad block the unreachable-pad prune below deletes, so their
1275
+ // count cannot reach the answer.
1276
+ const resolveAt = (
1277
+ { base, items, offs: itemOff, from, fills }: Candidate,
1278
+ basePar: number | undefined,
1279
+ ): Resolved => {
1280
+ const pools = new Map<string, string[]>();
1281
+ const observed: LayoutFact[] = [];
1282
+ const fillEntered = new Set<number>(); // fill sites a raw branch lands inside
1283
+ const labelOff = new Map<string, number>();
1284
+ const codeStart = new Set<number>(); // offsets that begin an instruction or carry a label
1285
+ items.forEach((f, i) => {
1286
+ if (f.label && !labelOff.has(f.label)) {
1287
+ labelOff.set(f.label, itemOff[i]);
1288
+ codeStart.add(itemOff[i]);
1289
+ }
1290
+ if (f.instr) {
1291
+ codeStart.add(itemOff[i]);
1292
+ }
1293
+ });
1294
+ const labelAt = new Map<number, string>();
1295
+ for (const [lab, lo] of labelOff) {
1296
+ if (!labelAt.has(lo)) {
1297
+ labelAt.set(lo, lab);
551
1298
  }
552
- const d = (v & 0xff) - (v & 0x80 ? 0x100 : 0);
553
- return { mnemonic: mn, target: at + 4 + d * 2 };
554
- }
555
- if (v >= 0xe000 && v <= 0xe7ff) {
556
- const d = (v & 0x7ff) - (v & 0x400 ? 0x800 : 0);
557
- return { mnemonic: 'b', target: at + 4 + d * 2 };
558
1299
  }
559
- return null;
560
- };
561
- // Pass 1: decode every in-code halfword; collect synthesized labels for branch targets.
562
- const synthLabels = new Map<number, string>(); // target offset label to ensure there
563
- const decoded = new Map<number, Instr>(); // flat index replacement branch instr
564
- flat.forEach((f, i) => {
565
- if (!f.data?.inCode) {
566
- return;
567
- }
568
- if (!f.data.halfwords) {
569
- return; // unlabelled word pool layout bytes only (reached via [pc, #off] below)
570
- }
571
- f.data.values.forEach((raw, k) => {
572
- const at = itemOff[i] + k * 2;
573
- const v = parseInt(raw, 16);
574
- const br = Number.isFinite(v) ? decodeHalfword(v, at) : null;
575
- if (!br) {
576
- throw new FrontendUnsupportedError(
577
- `cannot lift '${name}': raw halfword '${raw}' in the code stream is not a decodable branch — ` +
578
- `skipping it would silently delete its effect`,
579
- );
1300
+ // Thumb-1 branch encodings this frontend models (cond codes 4–7 = mi/pl/vs/vc have no
1301
+ // lifted comparison semantics here; 14 is undefined, 15 is swi — all decline).
1302
+ const COND_MN = ['beq', 'bne', 'bcs', 'bcc', '', '', '', '', 'bhi', 'bls', 'bge', 'blt', 'bgt', 'ble'];
1303
+ const decodeHalfword = (v: number, at: number): { mnemonic: string; target: number } | null => {
1304
+ if (v >= 0xd000 && v <= 0xddff) {
1305
+ const mn = COND_MN[(v >> 8) & 0xf];
1306
+ if (!mn) {
1307
+ return null;
1308
+ }
1309
+ const d = (v & 0xff) - (v & 0x80 ? 0x100 : 0);
1310
+ return { mnemonic: mn, target: at + 4 + d * 2 };
580
1311
  }
581
- if (!codeStart.has(br.target)) {
582
- throw new FrontendUnsupportedError(
583
- `cannot lift '${name}': raw branch '${raw}' targets byte offset 0x${br.target.toString(16)}, which is not an instruction boundary`,
584
- );
1312
+ if (v >= 0xe000 && v <= 0xe7ff) {
1313
+ const d = (v & 0x7ff) - (v & 0x400 ? 0x800 : 0);
1314
+ return { mnemonic: 'b', target: at + 4 + d * 2 };
585
1315
  }
586
- if (f.data!.values.length > 1) {
587
- throw new FrontendUnsupportedError(
588
- `cannot lift '${name}': multi-value raw halfword directive mixing branches is not supported`,
589
- );
1316
+ return null;
1317
+ };
1318
+ // Pass 1: decode every in-code halfword; collect synthesized labels for branch targets.
1319
+ const synthLabels = new Map<number, string>(); // target offset → label to ensure there
1320
+ const decoded = new Map<number, Instr>(); // item index → replacement branch instr
1321
+ items.forEach((f, i) => {
1322
+ if (!f.data?.inCode) {
1323
+ return;
590
1324
  }
591
- const lab = labelAt.get(br.target) ?? synthLabels.get(br.target) ?? `.Lraw_${br.target.toString(16)}`;
592
- synthLabels.set(br.target, lab);
593
- decoded.set(i, { mnemonic: br.mnemonic, ops: [lab] });
1325
+ if (!f.data.halfwords) {
1326
+ return; // unlabelled word pool — layout bytes only (reached via [pc, #off] below)
1327
+ }
1328
+ f.data.values.forEach((raw, k) => {
1329
+ const at = itemOff[i] + k * 2;
1330
+ const v = halfwordLiteral(raw);
1331
+ const br = v === null ? null : decodeHalfword(v, at);
1332
+ if (!br) {
1333
+ throw new FrontendUnsupportedError(
1334
+ `cannot lift '${name}': raw halfword '${raw}' in the code stream is not a decodable branch — ` +
1335
+ `skipping it would silently delete its effect`,
1336
+ );
1337
+ }
1338
+ if (!codeStart.has(br.target)) {
1339
+ throw new FrontendUnsupportedError(
1340
+ `cannot lift '${name}': raw branch '${raw}' targets byte offset 0x${br.target.toString(16)}, which is not an instruction boundary`,
1341
+ );
1342
+ }
1343
+ if (f.data!.values.length > 1) {
1344
+ throw new FrontendUnsupportedError(
1345
+ `cannot lift '${name}': multi-value raw halfword directive mixing branches is not supported`,
1346
+ );
1347
+ }
1348
+ const lab = labelAt.get(br.target) ?? synthLabels.get(br.target) ?? `.Lraw_${br.target.toString(16)}`;
1349
+ synthLabels.set(br.target, lab);
1350
+ decoded.set(i, { mnemonic: br.mnemonic, ops: [lab] });
1351
+ const inFill = fills.find((fl) => br.target >= fl.start && br.target < fl.start + fl.bytes);
1352
+ if (inFill) {
1353
+ fillEntered.add(inFill.site);
1354
+ }
1355
+ // The branch is witnessed by WHICH item it lands on, not by the byte offset: the
1356
+ // offsets of one layout mean nothing in the other. A target inside fill names the fill
1357
+ // SITE and the distance into it — rendering it as the bare words `alignment fill` made
1358
+ // two layouts that send the same branch into DIFFERENT fill sites compare equal, which
1359
+ // is precisely the disagreement this witness exists to establish.
1360
+ const ti = items.findIndex((_g, j) => itemOff[j] === br.target);
1361
+ observed.push({
1362
+ kind: 'branch',
1363
+ at: from[i],
1364
+ to:
1365
+ ti >= 0 && from[ti] >= 0
1366
+ ? { item: from[ti] }
1367
+ : inFill
1368
+ ? { fill: inFill.site, off: br.target - inFill.start }
1369
+ : { off: br.target },
1370
+ });
1371
+ });
594
1372
  });
595
- });
596
- // Pass 2: pc-relative literal loads rewrite to a synthesized pool label so the existing
597
- // resolvePoolConst/resolvePoolSymbol machinery applies. `(pc & ~3) + off` depends on the
598
- // function's absolute alignment (mod 4) derived STRUCTURALLY: pool words are 4-aligned in
599
- // the ROM, so the file-relative offset of any `.4byte` word fixes the base parity (the
600
- // luvdis `@ address` comments are not trusted).
601
- let basePar: number | undefined;
602
- flat.forEach((g, j) => {
603
- if (g.data && !g.data.halfwords) {
604
- const p = (4 - (itemOff[j] % 4)) % 4;
1373
+ // Pass 2: pc-relative literal loads → rewrite to a synthesized pool label so the existing
1374
+ // `poolRef` machinery applies to it like any other pool operand. `(pc & ~3) + off` depends on the
1375
+ // function's absolute alignment (mod 4) `basePar`, the candidate base this layout was
1376
+ // built at, which survived the pool-alignment filter above (the luvdis `@ address`
1377
+ // comments are not trusted). It is `undefined` when the slice holds no pool word at all.
1378
+ items.forEach((f, i) => {
1379
+ if (!isPcRelLdr(f.instr)) {
1380
+ return;
1381
+ }
605
1382
  if (basePar === undefined) {
606
- basePar = p;
607
- } else if (basePar !== p) {
608
1383
  throw new FrontendUnsupportedError(
609
- `cannot lift '${name}': literal pools at inconsistent alignments cannot determine the function's base alignment`,
1384
+ `cannot lift '${name}': pc-relative literal load with no literal pool in the function to resolve into`,
610
1385
  );
611
1386
  }
612
- }
613
- });
614
- flat.forEach((f, i) => {
615
- if (!isPcRelLdr(f.instr)) {
616
- return;
617
- }
618
- if (basePar === undefined) {
619
- throw new FrontendUnsupportedError(
620
- `cannot lift '${name}': pc-relative literal load with no literal pool in the function to resolve into`,
1387
+ const imm = parseInt(
1388
+ f.instr!.ops[1].match(/#(0x[0-9a-fA-F]+|\d+)/)![1],
1389
+ f.instr!.ops[1].includes('0x') ? 16 : 10,
621
1390
  );
622
- }
623
- const imm = parseInt(
624
- f.instr!.ops[1].match(/#(0x[0-9a-fA-F]+|\d+)/)![1],
625
- f.instr!.ops[1].includes('0x') ? 16 : 10,
626
- );
627
- const wordOff = ((basePar + itemOff[i] + 4) & ~3) - basePar + imm;
628
- // locate the word: a 4-byte data item covering [wordOff, wordOff+4)
629
- let value: string | undefined;
630
- flat.forEach((g, j) => {
631
- if (!g.data || g.data.halfwords) {
632
- return;
1391
+ const wordOff = ((basePar + itemOff[i] + 4) & ~3) - basePar + imm;
1392
+ // locate the word: a 4-byte data item covering [wordOff, wordOff+4)
1393
+ let value: string | undefined;
1394
+ items.forEach((g, j) => {
1395
+ if (!g.data || g.data.halfwords) {
1396
+ return;
1397
+ }
1398
+ const rel = wordOff - itemOff[j];
1399
+ if (rel >= 0 && rel < g.data.values.length * 4 && rel % 4 === 0) {
1400
+ value = g.data.values[rel / 4];
1401
+ }
1402
+ });
1403
+ if (value === undefined) {
1404
+ throw new FrontendUnsupportedError(
1405
+ `cannot lift '${name}': pc-relative load at offset 0x${itemOff[i].toString(16)} resolves to byte offset ` +
1406
+ `0x${wordOff.toString(16)}, which is not a word in a literal pool`,
1407
+ );
633
1408
  }
634
- const rel = wordOff - itemOff[j];
635
- if (rel >= 0 && rel < g.data.values.length * 4 && rel % 4 === 0) {
636
- value = g.data.values[rel / 4];
1409
+ const poolLab = `.Lpcpool_${wordOff.toString(16)}`;
1410
+ pools.set(poolLab, [value]);
1411
+ f.instr = { mnemonic: 'ldr', ops: [f.instr!.ops[0], poolLab] };
1412
+ observed.push({ kind: 'load', at: from[i], value });
1413
+ });
1414
+ // Pass 3: rebuild the stream — insert synthesized target labels, replace decoded halfwords.
1415
+ const next: FlatItem[] = [];
1416
+ items.forEach((f, i) => {
1417
+ const lab = synthLabels.get(itemOff[i]);
1418
+ if (lab && f.label !== lab && !labelAt.has(itemOff[i])) {
1419
+ next.push({ label: lab });
1420
+ }
1421
+ const br = decoded.get(i);
1422
+ if (br) {
1423
+ next.push({ instr: br });
1424
+ } else {
1425
+ next.push(f);
637
1426
  }
638
1427
  });
639
- if (value === undefined) {
640
- throw new FrontendUnsupportedError(
641
- `cannot lift '${name}': pc-relative load at offset 0x${itemOff[i].toString(16)} resolves to byte offset ` +
642
- `0x${wordOff.toString(16)}, which is not a word in a literal pool`,
643
- );
1428
+ for (const fl of fills) {
1429
+ if (fl.bytes > 0 && (!sealedFill[fl.site] || fillEntered.has(fl.site))) {
1430
+ observed.push({ kind: 'fill', site: fl.site, bytes: fl.bytes });
1431
+ }
644
1432
  }
645
- const poolLab = `.Lpcpool_${wordOff.toString(16)}`;
646
- dataWords.set(poolLab, [value]);
647
- f.instr = { mnemonic: 'ldr', ops: [f.instr!.ops[0], poolLab] };
648
- });
649
- // Pass 3: rebuild flat — insert synthesized target labels, replace decoded halfwords.
650
- const next: FlatItem[] = [];
651
- flat.forEach((f, i) => {
652
- const lab = synthLabels.get(itemOff[i]);
653
- if (lab && f.label !== lab && !labelAt.has(itemOff[i])) {
654
- next.push({ label: lab });
655
- }
656
- const br = decoded.get(i);
657
- if (br) {
658
- next.push({ instr: br });
659
- } else {
660
- next.push(f);
1433
+ return { base, items: next, pools, witness: observed.sort((a, b) => (factKey(a) < factKey(b) ? -1 : 1)) };
1434
+ };
1435
+
1436
+ const resolved: Resolved[] = [];
1437
+ const refusals: Error[] = [];
1438
+ for (const c of candidates) {
1439
+ // A slice with no pool word leaves the base unobservable, exactly as before: `basePar`
1440
+ // stays undefined and a pc-relative load declines for want of a pool to resolve into.
1441
+ const hasPool = c.items.some((f) => f.data && !f.data.halfwords);
1442
+ try {
1443
+ resolved.push(resolveAt(c, hasPool ? c.base : undefined));
1444
+ } catch (e) {
1445
+ // ONLY a refusal is a vote against this base. Swallowing every exception would let a
1446
+ // programming error inside resolveAt elect the other base, and the lift would then rest
1447
+ // on a hypothesis chosen because the code threw.
1448
+ if (!(e instanceof FrontendUnsupportedError)) {
1449
+ throw e;
1450
+ }
1451
+ refusals.push(e);
661
1452
  }
662
- });
663
- flat = next;
1453
+ }
1454
+ if (resolved.length === 0) {
1455
+ // With one candidate the message is the single hypothesis's own, byte for byte as before.
1456
+ // With two, reporting only candidate 0's message states one hypothesis as fact — including
1457
+ // byte offsets computed in a layout that was never established, which `onGap: 'annotate'`
1458
+ // writes into the emitted artifact.
1459
+ if (candidates.length === 1) {
1460
+ throw refusals[0];
1461
+ }
1462
+ const why = refusals.map(
1463
+ (e, i) => `base ${candidates[i].base}: ${e.message.replace(/^cannot lift '[^']*': /, '')}`,
1464
+ );
1465
+ throw new FrontendUnsupportedError(`cannot lift '${name}': neither base alignment fits — ${why.join('; ')}`);
1466
+ }
1467
+ // More than one base can survive and still decide EVERY question the same way — an align
1468
+ // whose fill is unreachable padding moves the two layouts apart without moving the answer,
1469
+ // which is the ordinary shape, not a corner. Only a difference in what the layouts decided
1470
+ // is a refusal, and the message quotes the difference it found.
1471
+ const seal = (r: Resolved) => r.witness.map(factKey).join('\n');
1472
+ const other = resolved.find((r) => seal(r) !== seal(resolved[0]));
1473
+ if (other) {
1474
+ const only = (a: Resolved, b: Resolved) => {
1475
+ const bk = new Set(b.witness.map(factKey));
1476
+ return a.witness.filter((w) => !bk.has(factKey(w)));
1477
+ };
1478
+ const say = (w: LayoutFact[]) => (w.length > 0 ? w.map(sayFact).join('; ') : 'nothing');
1479
+ throw new FrontendUnsupportedError(
1480
+ `cannot lift '${name}': alignment padding depends on the function's base alignment, which this ` +
1481
+ `input does not determine — base ${resolved[0].base} and base ${other.base} both decode ` +
1482
+ `consistently but disagree: base ${resolved[0].base} has ${say(only(resolved[0], other))}, ` +
1483
+ `base ${other.base} has ${say(only(other, resolved[0]))}`,
1484
+ );
1485
+ }
1486
+ for (const [lab, words] of resolved[0].pools) {
1487
+ dataWords.set(lab, words);
1488
+ }
1489
+ flat = resolved[0].items;
1490
+ } else {
1491
+ // Every align in this slice is sealed off by an unconditional transfer (see `sealedFill`),
1492
+ // so its fill is pool padding no instruction can execute — the same verdict the prune below
1493
+ // reaches for a pad block it finds unreachable, arrived at before the layout is needed. Its
1494
+ // bytes cannot move any answer, so the item is dropped and no base is solved.
1495
+ flat = flat.filter((f) => !f.align);
664
1496
  }
665
1497
 
666
1498
  const blocks: AsmBlock[] = [];
@@ -688,11 +1520,8 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
688
1520
  prev = blocks[j];
689
1521
  }
690
1522
  }
691
- if (prev) {
692
- const k = classifyXfer(prev.instrs[prev.instrs.length - 1]);
693
- if (k === null || k === 'cond') {
694
- fallsIntoData.add(prev.label);
695
- }
1523
+ if (prev && controlContinuesPast(prev.instrs[prev.instrs.length - 1])) {
1524
+ fallsIntoData.add(prev.label);
696
1525
  }
697
1526
  cur = null;
698
1527
  continue;
@@ -753,17 +1582,21 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
753
1582
  }
754
1583
  }
755
1584
  let live = blocks.filter((b) => b.instrs.length > 0);
756
- // Alignment-pad NOPs a splitter emits around returns and literal pools: `lsls r0, r0, #0`
757
- // is the 0x0000 halfword, `mov r8, r8` is 0x46C0, plus a literal `nop`. A block made ONLY
758
- // of these is pool/section padding when unreachable pruned below. A REACHABLE pad block
759
- // is a real (degenerate) instruction and is kept.
760
- const isPadInstr = (i: Instr) =>
761
- i.mnemonic === 'nop' ||
762
- ((i.mnemonic === 'lsl' || i.mnemonic === 'lsls') &&
763
- i.ops[0] === 'r0' &&
764
- i.ops[1] === 'r0' &&
765
- /^#0x?0*$/.test(i.ops[2] ?? '')) ||
766
- ((i.mnemonic === 'mov' || i.mnemonic === 'movs') && i.ops[0] === 'r8' && i.ops[1] === 'r8');
1585
+ // Alignment fill a splitter emits around returns and literal pools (isPadInstr, above, is the
1586
+ // single definition of which instructions those are). This is the ONE place padding-versus-real
1587
+ // -instruction is decided, for all three ways the same two bytes get spelled: as the
1588
+ // instruction itself (including the `mov r8, r8` spelling of the nop, normalised at parse), as
1589
+ // `.2byte 0x0000` (decoded at parse — padHalfword), or as an alignment directive (expanded
1590
+ // into these very instructions by the layout pass above whenever anything can execute the
1591
+ // fill; when nothing can, that pass reached the SAME verdict — padding — and dropped the
1592
+ // item). None of them arrives here as its own kind of thing, so none can be judged by a
1593
+ // different rule.
1594
+ //
1595
+ // That claim is about the PRUNE, and the prune only ever sees UNREACHABLE all-pad blocks: a
1596
+ // reachable pad is kept and modelled as the instruction it is. So a spelling that reaches this
1597
+ // point unnormalised is not caught here — `mov r8, r8` was kept as a live read of a
1598
+ // callee-saved register and minted a parameter, invisible to this predicate. Normalisation
1599
+ // belongs at parse, and that is where it now is.
767
1600
  const padBlocks = new Set(live.filter((b) => b.instrs.every(isPadInstr)).map((b) => b.label));
768
1601
  if (fallsIntoData.size > 0 || padBlocks.size > 0) {
769
1602
  // Targeted reachability: a block that falls into data is either luvdis's unreachable
@@ -784,7 +1617,7 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
784
1617
  if (kind === 'cond' || kind === 'uncond') {
785
1618
  targets.push(last.ops[0]);
786
1619
  }
787
- if (kind === null || kind === 'cond') {
1620
+ if (last && controlContinuesPast(last)) {
788
1621
  const fall = live[i + 1]?.label;
789
1622
  if (fall !== undefined && !fallsIntoData.has(b.label)) {
790
1623
  targets.push(fall);
@@ -801,7 +1634,7 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
801
1634
  for (const b of live) {
802
1635
  if (fallsIntoData.has(b.label) && reach.has(idx.get(b.label)!)) {
803
1636
  const last = b.instrs[b.instrs.length - 1];
804
- if (!last || classifyXfer(last) === null || classifyXfer(last) === 'cond') {
1637
+ if (!last || controlContinuesPast(last)) {
805
1638
  throw new FrontendUnsupportedError(
806
1639
  `cannot lift '${name}': reachable code in block '${b.label}' falls through into data bytes`,
807
1640
  );
@@ -826,29 +1659,30 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
826
1659
  boundaryIdx++;
827
1660
  continue;
828
1661
  }
829
- return { blocks: live, dataWords };
1662
+ return { blocks: live, dataWords, funcLabels: new Set(funcLabels) };
830
1663
  }
831
1664
  }
832
1665
 
833
- // Resolve an agbcc/Thumb literal-pool reference (`ldr rD, .Lpool` / `.Lpool+byteOff`) to the NUMERIC
834
- // 32-bit word it loads — the `ldr rD, =const` idiom. Returns null when the operand is NOT a numeric
835
- // pool constant: a register/`[base]` memory operand, an unknown label, a misaligned offset, or a
836
- // word that is a SYMBOL (an address / jump-table pointer — left for recoverJumpTable or the normal
837
- // load path). The byte offset selects the word (index = off/4). This keeps a real literal constant
838
- // (`.word 0x8408`) from being lifted as a phantom pointer parameter and dereferenced (`*a2`).
839
1666
  // The label-operand shape shared by BOTH pool paths: agbcc `.Lpool`, pret `_08012358`, with an
840
1667
  // optional `+N` byte offset. Kept in one place so the const and symbol resolvers cannot drift
841
1668
  // (they did — the drift fabricated phantom pointer params on symbol-pool loads).
842
1669
  const POOL_LABEL = /^([A-Za-z_.$][\w.$]*)(?:\s*\+\s*(0x[0-9a-fA-F]+|\d+))?$/;
843
1670
 
844
- type PoolRef = { kind: 'const'; value: number } | { kind: 'gaddr'; sym: string } | { kind: 'unmodelled'; why: string };
1671
+ type PoolRef =
1672
+ | { kind: 'const'; value: number }
1673
+ | { kind: 'gaddr'; sym: string; addend: number }
1674
+ | { kind: 'unmodelled'; why: string };
845
1675
 
846
1676
  /** Classify a word-load operand `LABEL[+N]` against the captured literal pools. Returns null when
847
1677
  * the operand does NOT name a pool (a real register/memory base → the normal load path). When it
848
1678
  * DOES name a pool the outcome is const | gaddr | unmodelled — NEVER a fall-through to the load
849
1679
  * path, which would materialise the pool label as a phantom pointer parameter (a silent
850
- * miscompile). `unmodelled` (a `sym+N` offset, a misaligned/out-of-range index, a `.L` code
851
- * label) is the caller's cue to decline loud. */
1680
+ * miscompile). `unmodelled` is the caller's cue to decline loud, and it has exactly three
1681
+ * inhabitants, all of them below: an offset that does not select a whole word of the pool
1682
+ * (misaligned, or past its end); a word that matched the numeric shape but does not parse to a
1683
+ * finite value; and a word that is neither a number nor `symbol±offset` — a `.L` code label is
1684
+ * here, since the symbol pattern admits no leading dot. A `sym+N` word is NOT unmodelled: it is
1685
+ * the gaddr-plus-addend path and lifts cleanly. */
852
1686
  function poolRef(operand: string, dataWords: Map<string, string[]>): PoolRef | null {
853
1687
  const m = operand.match(POOL_LABEL);
854
1688
  if (!m) {
@@ -867,12 +1701,21 @@ function poolRef(operand: string, dataWords: Map<string, string[]>): PoolRef | n
867
1701
  const val = w.startsWith('-') ? -Number(w.slice(1)) : Number(w);
868
1702
  return Number.isFinite(val) ? { kind: 'const', value: val } : { kind: 'unmodelled', why: `unparsable word '${w}'` };
869
1703
  }
870
- // A bare C identifier that is NOT a `.L` code label → the address of a named global. A `sym+N`
871
- // offset, or a `.L` label (jump table / code address surviving to here), is unmodelled.
872
- if (/^[A-Za-z_]\w*$/.test(w) && !w.startsWith('.L')) {
873
- return { kind: 'gaddr', sym: w };
1704
+ // A C identifier that is NOT a `.L` code label → the address of a named global, optionally with
1705
+ // a byte ADDEND folded into the pool word (`gBgTilemapBufs+0x14a` agbcc pre-computes a fixed
1706
+ // element's address into the pool rather than emitting an add). The addend stays in VALUE space:
1707
+ // the consumer emits `gaddr` then an explicit `add`, the exact spelling the register-materialised
1708
+ // `ldr rN,=gSym; add rN,#k` shape already lowers to — so it renders through the same audited
1709
+ // cast-based path (`((u8 *)&gSym) + k`), never through a typed-pointer scale that a
1710
+ // rendered-vs-value addend could silently multiply (the DEREF-TYPING class).
1711
+ const sm = w.match(/^([A-Za-z_]\w*)\s*(?:([+-])\s*(0x[0-9a-fA-F]+|\d+))?$/);
1712
+ if (sm && !sm[1].startsWith('.L')) {
1713
+ const mag = sm[3] ? Number(sm[3]) : 0;
1714
+ if (Number.isFinite(mag)) {
1715
+ return { kind: 'gaddr', sym: sm[1], addend: sm[2] === '-' ? -mag : mag };
1716
+ }
874
1717
  }
875
- return { kind: 'unmodelled', why: `pool word '${w}' is a symbol offset or code label` };
1718
+ return { kind: 'unmodelled', why: `pool word '${w}' is not a symbol, symbol±offset, or number` };
876
1719
  }
877
1720
 
878
1721
  /** Does this function's literal pool name at least one EXTERNAL symbol?
@@ -895,10 +1738,13 @@ function poolNamesASymbol(dataWords: Map<string, string[]>, blockLabels: Set<str
895
1738
  for (const [, words] of dataWords) {
896
1739
  for (const raw of words) {
897
1740
  const w = raw.trim();
898
- if (!/^[A-Za-z_]\w*$/.test(w) || w.startsWith('.L')) {
1741
+ // `gSym+0x14a` names a symbol as surely as `gSym` does — the witness must count both, or an
1742
+ // asm whose pools carry only addend words would wrongly permit numeric promotion.
1743
+ const sym = w.match(/^([A-Za-z_]\w*)\s*(?:[+-]\s*(?:0x[0-9a-fA-F]+|\d+))?$/)?.[1];
1744
+ if (sym === undefined || sym.startsWith('.L')) {
899
1745
  continue;
900
1746
  }
901
- if (!dataWords.has(w) && !blockLabels.has(w)) {
1747
+ if (!dataWords.has(sym) && !blockLabels.has(sym)) {
902
1748
  return true;
903
1749
  }
904
1750
  }
@@ -923,6 +1769,52 @@ interface JumpTable {
923
1769
  caseLabels: string[];
924
1770
  defaultLabel: string;
925
1771
  }
1772
+
1773
+ // Accept a data-processing mnemonic in either the pre-UAL (`lsl`, `add`) or UAL (`lsls`, `adds`)
1774
+ // spelling, WITHIN THE DISPATCH BLOCK ONLY.
1775
+ //
1776
+ // In Thumb-1 the trailing `s` is a DIALECT MARKER, not a modifier: `.syntax divided` spells the
1777
+ // flag-setting data-processing instructions without it, `.syntax unified` with it. For a LOW-register
1778
+ // destination each pair is one halfword — measured with this project's own assembler, one instruction
1779
+ // per file, both dialects, encoding read back with objdump:
1780
+ //
1781
+ // add/adds 1888 sub/subs 1a88 lsl/lsls 0088 lsr/lsrs 0888 asr/asrs 1088 neg/negs 4248
1782
+ //
1783
+ // This is the dominant dialect of the input format asmlift advertises: every pret-style split wraps
1784
+ // its `INCLUDE_ASM` bodies in `.syntax unified`, where the non-suffixed spelling is a syntax ERROR,
1785
+ // and the split `.s` files carry no `.syntax` directive of their own — so this frontend cannot tell
1786
+ // from its input which dialect it is reading, and must accept both. Counted under
1787
+ // `asm/nonmatchings` of the Klonoa: Empire of Dreams tree: `lsls` 5795 against `lsl` 0, and `adds`
1788
+ // 8883 against `add` 600 — where **every one** of those 600 is an `sp`/high-register/pc form and not
1789
+ // one is the three-operand low-register `add rD, rN, rM` this idiom uses. Comparing the text alone
1790
+ // therefore declined every jump table in that corpus, on input that is not malformed in any way.
1791
+ // (agbcc's own output is the other dialect — 2957 `lsl` in this project's `build-gdwarf/src/*.s` —
1792
+ // which is why the benchmark, built from compiler `.s`, never exercised this.)
1793
+ //
1794
+ // Why LOCAL rather than an entry in LEGACY_MNEMONICS, which is where synonyms belong: normalising
1795
+ // the suffix away is safe for every input an ASSEMBLER ACCEPTS — the operands that distinguish `add`
1796
+ // from `adds` (the SP adjust `add sp, sp, #4` = b001, the high-register `add r8, r0` = 4480) have no
1797
+ // S-form at all, so `adds sp` and `adds r8, r0` are rejected in both dialects and cannot appear in
1798
+ // any assemblable file. But this frontend REFUSES those spellings loudly today, and a flat
1799
+ // `adds: 'add'` entry silently turns `adds sp` into ordinary frame bookkeeping instead: the entry
1800
+ // was written, and `decline-guards.test.ts` failed on exactly that case. So the reason to keep it
1801
+ // local is input VALIDATION, not semantics — a name-keyed table cannot say "only when the
1802
+ // destination is a low register", and giving up the refusal buys nothing, since no assembler emits
1803
+ // what it refuses.
1804
+ //
1805
+ // Known false declines, all loud, none with a corpus instance: a parenthesised immediate (`#(2)`,
1806
+ // which gas assembles), a two-operand `adds rA, rP` (the same add, but `addSrcs` has one source),
1807
+ // and `movs pc, rV` (which IS `mov pc, rV` — 4687 — under divided syntax, and which `classifyXfer`
1808
+ // accepts, so the frontend is internally inconsistent about it).
1809
+ //
1810
+ // Inside the dispatch block the distinction is additionally UNOBSERVABLE: the block computes
1811
+ // `table_base + index*4`, loads the target and writes `pc`. Nothing between the `lsl` and the
1812
+ // `mov pc` reads NZCV, no path leaves the block by falling through, and on a successful recovery the
1813
+ // block is ELIDED from the CFG entirely — the bounds test that does feed a conditional branch lives
1814
+ // in `bounds`, whose `cmp`/`bhi`/`bls` this function matches by exact name. Doubly moot in fact,
1815
+ // since `lsl rD, rS, #imm` is low-register-only, so the add here is always the low-register form.
1816
+ const isDataOp = (mn: string, base: 'lsl' | 'add'): boolean => mn === base || mn === `${base}s`;
1817
+
926
1818
  function recoverJumpTable(
927
1819
  bounds: AsmBlock,
928
1820
  disp: AsmBlock,
@@ -935,11 +1827,13 @@ function recoverJumpTable(
935
1827
  // direct cmp rX,#M ; bhi DEF → fall through to the dispatch
936
1828
  // long jump cmp rX,#M ; bls DISP ; b DEF → branch TO the dispatch, long-branch the default
937
1829
  //
938
- // The second is what agbcc emits whenever the default is out of a conditional branch's reach
1830
+ // The second is what agbcc emits whenever the default is out of a conditional branch's reach:
939
1831
  // Thumb-1 `B<cond>` carries a signed 8-bit HALFWORD offset, so ±256 BYTES, about 128
940
- // instructions which on a real switch it usually is: five of the six benchmark
941
- // functions with a table use it, and only the sixth uses the direct form. `longDefault` is the
942
- // target of that trailing `b`, read by the caller from the block after `bounds`.
1832
+ // instructions. Both forms occur and both must be read, and no census says which DOMINATES:
1833
+ // re-counted 2026-09-06 over the 269 of the benchmark's 404 agbcc rows whose reference asm the
1834
+ // local bench cache holds, six carry a table and all six take the DIRECT form. That is a proper
1835
+ // subset of the corpus, so a claim about which form dominates needs a full run to make. `longDefault` is the target of the trailing `b`, read by the caller from the
1836
+ // block after `bounds`.
943
1837
  const bi = bounds.instrs;
944
1838
  const guard = bi[bi.length - 1],
945
1839
  cmp = bi[bi.length - 2];
@@ -976,7 +1870,7 @@ function recoverJumpTable(
976
1870
  return null;
977
1871
  }
978
1872
  const [lsl, ldrP, add, ldrV, movpc] = d;
979
- if (lsl.mnemonic !== 'lsl' || lsl.ops[1] !== scrutReg || (lsl.ops[2] !== '#0x2' && lsl.ops[2] !== '#2')) {
1873
+ if (!isDataOp(lsl.mnemonic, 'lsl') || lsl.ops[1] !== scrutReg || !immEq(lsl.ops[2], 2)) {
980
1874
  return null;
981
1875
  }
982
1876
  const idxReg = lsl.ops[0]; // rY = rX << 2 (index*4, identity guard)
@@ -985,19 +1879,40 @@ function recoverJumpTable(
985
1879
  }
986
1880
  const ptrReg = ldrP.ops[0],
987
1881
  ptrLabel = ldrP.ops[1]; // rP = *(PTR literal)
988
- if (add.mnemonic !== 'add' || add.ops[0] !== idxReg) {
1882
+ if (!isDataOp(add.mnemonic, 'add') || add.ops[0] !== idxReg) {
989
1883
  return null;
990
1884
  }
991
1885
  // add rY, rY, rP (either operand order) — the address = table_base + index*4, nothing else.
1886
+ //
1887
+ // The two sources must be DISTINCT registers. Membership alone is satisfied by one register
1888
+ // listed twice, and that is not a hypothetical shape: if the pointer load targets the index
1889
+ // register (`lsl r0,r1,#2 ; ldr r0,=PTR ; add r0,r0,r0`) the index is overwritten before it is
1890
+ // ever added, `idxReg === ptrReg`, and both `includes` tests pass on `r0`. The address formed is
1891
+ // `2 * table_base` and the scrutinee is dead — yet the recogniser would emit `switch (a0)` and
1892
+ // dispatch on a value the hardware never uses. Wrong block, no marker. Found by an adversarial
1893
+ // probe; it predates the spelling fix this guard sits next to, and is fixed here because it is
1894
+ // the same identity-or-decline rule.
992
1895
  const addSrcs = [add.ops[1], add.ops[2]];
993
- if (!(addSrcs.includes(idxReg) && addSrcs.includes(ptrReg))) {
1896
+ if (idxReg === ptrReg || !(addSrcs.includes(idxReg) && addSrcs.includes(ptrReg))) {
994
1897
  return null;
995
1898
  }
996
1899
  if (ldrV.mnemonic !== 'ldr') {
997
1900
  return null;
998
1901
  }
999
- const { base } = parseAddr(ldrV.ops[1]); // rV = *(rY)
1000
- if (base !== idxReg || ldrV.ops[0] !== movpc.ops[1]) {
1902
+ // rV = *(rY), and the address must be EXACTLY rY: no displacement, no register index.
1903
+ //
1904
+ // `parseAddr` surfaces `off` and `regOff` for precisely this reason — its own comment says
1905
+ // "surfaced to the caller so load/store DECLINE loud: silently reading `[rB]` dropped the index
1906
+ // — a silent miscompile" — and this caller used to destructure `base` alone and throw both away.
1907
+ // `ldr rV, [rA, #4]` loads table[i+1]: the recovered switch says `case 0` while the hardware
1908
+ // reaches case 1's block, and the last case reads a word past the table. `ldr rV, [rA, r2]` adds
1909
+ // an unrelated register. Both used to recover an ordinary-looking `switch` — a wrong BLOCK, with
1910
+ // no marker. The header of this function already claimed to refuse an "extra offset"; now it does.
1911
+ //
1912
+ // `#0` is the spelling the corpus actually uses (`ldr r0, [r0, #0x00]`), so the check is on the
1913
+ // VALUE, not on the operand's absence.
1914
+ const { base, off, regOff } = parseAddr(ldrV.ops[1]);
1915
+ if (base !== idxReg || off !== 0 || regOff !== undefined || ldrV.ops[0] !== movpc.ops[1]) {
1001
1916
  return null;
1002
1917
  }
1003
1918
  if (movpc.mnemonic !== 'mov' || movpc.ops[0] !== 'pc') {
@@ -1038,6 +1953,639 @@ function recoverJumpTable(
1038
1953
  return { scrutReg, caseLabels, defaultLabel };
1039
1954
  }
1040
1955
 
1956
+ interface FrameObjectAudit {
1957
+ name: string;
1958
+ irBlocks: Block[];
1959
+ localArea: number;
1960
+ usedSlotOffsets: ReadonlySet<number>;
1961
+ capturedObjectIsTheWholeFrame: boolean;
1962
+ prototypes: Prototypes;
1963
+ symbols: SymbolMap | undefined;
1964
+ target: TargetDescription;
1965
+ }
1966
+
1967
+ /** FRAME-OBJECT AUDIT. Every `laddr` the frontend emitted is only a CLAIM that the address it
1968
+ * names is used as "the address of one scalar local"; this proves it, over the finished function,
1969
+ * the same boundary-total style as the slot-escape assert in finish(). The address may flow
1970
+ * anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — but every
1971
+ * MEMORY access through it must be at offset 0, with one agreed width and one agreed extension,
1972
+ * its bytes must belong to nothing else in the frame, and any use the audit cannot vouch for declines the whole function
1973
+ * loudly. Nothing here guesses: the object's declared type is exactly the access type the machine
1974
+ * used.
1975
+ *
1976
+ * Takes its inputs explicitly rather than closing over `lift`. All eight are READ, none is
1977
+ * reassigned, and the only mutation is to the ops reachable through `irBlocks` — the widths,
1978
+ * signedness and `volatile` this stamps onto each surviving `laddr`. */
1979
+ function auditFrameObjects({
1980
+ name,
1981
+ irBlocks,
1982
+ localArea,
1983
+ usedSlotOffsets,
1984
+ capturedObjectIsTheWholeFrame,
1985
+ prototypes,
1986
+ symbols,
1987
+ target,
1988
+ }: FrameObjectAudit): void {
1989
+ let laddrs: Op[] = [];
1990
+ for (const blk of irBlocks) {
1991
+ for (const op of blk.ops) {
1992
+ if (op.opcode === 'laddr') {
1993
+ laddrs.push(op);
1994
+ }
1995
+ }
1996
+ }
1997
+ // …and it runs for a licensed acceptance with no object at all, so the premise re-check below
1998
+ // is total rather than resting on "the capture always survives into the IR".
1999
+ if (laddrs.length > 0 || capturedObjectIsTheWholeFrame) {
2000
+ const readOnlySinks = new Set(target.capabilities.readOnlyAddressSinks ?? []);
2001
+ const defOf = new Map<Value, Op>();
2002
+ for (const blk of irBlocks) {
2003
+ for (const op of blk.ops) {
2004
+ for (const res of op.results) {
2005
+ defOf.set(res, op);
2006
+ }
2007
+ }
2008
+ }
2009
+ // A NAME IS NOT AN ADDRESS. The same symbol name can sit at two addresses — a symbol map is
2010
+ // free to carry one — and a `gaddr`'s `sym` can also come straight from the assembly text
2011
+ // (`.word REG_DMA3SAD`), where nothing looked it up at all. So names resolve to an address
2012
+ // here or they resolve to nothing: a name at more than one address vouches for neither.
2013
+ const addrOfName = new Map<string, number | null>();
2014
+ for (const [addr, infos] of symbols ?? []) {
2015
+ for (const si of infos) {
2016
+ addrOfName.set(si.name, addrOfName.has(si.name) ? null : addr);
2017
+ }
2018
+ }
2019
+ // The literal address a value denotes, or undefined when this cannot say. `const` is the
2020
+ // bare pool word, `gaddr` is the same word after the symbol map named it, and `add` is the
2021
+ // base+displacement form an interior attribution produces — three spellings of one address,
2022
+ // which is the point: the answer must not turn on which one the assembly happened to use.
2023
+ const literalAddrOf = (v: Value, depth = 0): number | undefined => {
2024
+ const d = defOf.get(v);
2025
+ if (d === undefined || depth > 2) {
2026
+ return undefined;
2027
+ }
2028
+ if (d.opcode === 'const') {
2029
+ return d.attrs.value as number;
2030
+ }
2031
+ if (d.opcode === 'gaddr') {
2032
+ return addrOfName.get(d.attrs.sym as string) ?? undefined;
2033
+ }
2034
+ if (d.opcode === 'add' && d.operands.length === 2) {
2035
+ const base = literalAddrOf(d.operands[0], depth + 1);
2036
+ const disp = defOf.get(d.operands[1]);
2037
+ if (base !== undefined && disp?.opcode === 'const') {
2038
+ return base + (disp.attrs.value as number);
2039
+ }
2040
+ }
2041
+ return undefined;
2042
+ };
2043
+ // Does this store hand the WHOLE address to something that only reads through it? Word stores
2044
+ // only: a `strh` to a source register hands over half an address, so the device's source is
2045
+ // not this object. A base this cannot resolve — computed, register-offset, merged by a phi —
2046
+ // is the conservative answer.
2047
+ const readsThrough = (op: Op): boolean => {
2048
+ if (readOnlySinks.size === 0 || (op.attrs.width as number) !== 4) {
2049
+ return false;
2050
+ }
2051
+ const base = literalAddrOf(op.operands[0]);
2052
+ return base !== undefined && readOnlySinks.has(base + (op.attrs.off as number));
2053
+ };
2054
+ const fail = (why: string): never => {
2055
+ throw new FrontendUnsupportedError(`cannot lift '${name}': address-taken stack local — ${why}`);
2056
+ };
2057
+ // A FRAME BASE ADDRESSED THROUGH IS NOT A CAPTURE. Thumb-1 gives `ldr`/`str` an `[sp,#imm]`
2058
+ // encoding and gives the sub-word forms none, so a byte or halfword spill can only be spelled
2059
+ // by copying sp into a register and addressing through the copy:
2060
+ //
2061
+ // mov r2, sp
2062
+ // strh r3, [r2, #0x30]
2063
+ //
2064
+ // That is an ADDRESSING MODE. The copy never becomes a value, and the access is the
2065
+ // `[sp,#0x30]` the instruction set cannot spell — so what the machine named is one object at
2066
+ // frame offset 48, not a `[+48]` reach through the frame base.
2067
+ //
2068
+ // A captured address whose every use is a fixed-offset sub-word ACCESS is that shape, and
2069
+ // each of its accesses names its own object: re-root them onto a `laddr` at their own offset,
2070
+ // read at 0, and the rest of this audit judges the objects. A capture with ANY other use is a
2071
+ // real capture and keeps the frame base.
2072
+ //
2073
+ // What makes that judgement total is that the walk below enumerates every ROLE a value can
2074
+ // appear in — every operand of every op, and every edge argument — instead of asking what an
2075
+ // instruction looks like. One instruction can hold two roles: `str rD, [rD, #k]` stores the
2076
+ // frame address through itself, a base use AND an escape, and the escape is what stops the
2077
+ // split.
2078
+ // Why a capture was NOT split, when the reason is one no later message carries — the
2079
+ // `slotsOffReason` idiom: a refusal reported as the wrong capability sends the improvement
2080
+ // loop to build the wrong thing.
2081
+ let splitRefusal: string | null = null;
2082
+ {
2083
+ const uses = new Map<Value, { op: Op; idx: number; blk: Block }[]>();
2084
+ const record = (v: Value, op: Op, idx: number, blk: Block) =>
2085
+ (uses.get(v) ?? uses.set(v, []).get(v)!).push({ op, idx, blk });
2086
+ for (const blk of irBlocks) {
2087
+ for (const op of blk.ops) {
2088
+ op.operands.forEach((v, idx) => record(v, op, idx, blk));
2089
+ // An EDGE ARGUMENT is a use role too, and never an access: a capture that reaches a
2090
+ // block parameter is live past this block, so the taint closure below is what judges
2091
+ // it. Recorded at index -1 so it can never be counted as an access — a split there
2092
+ // would delete a capture the successor argument still names.
2093
+ for (const succ of op.successors ?? []) {
2094
+ for (const a of succ.args) {
2095
+ record(a, op, -1, blk);
2096
+ }
2097
+ }
2098
+ }
2099
+ }
2100
+ const minted: Op[] = [];
2101
+ const consumed = new Set<Op>();
2102
+ for (const capture of laddrs) {
2103
+ const at = uses.get(capture.results[0]) ?? [];
2104
+ const accesses = at.filter((u) => (u.op.opcode === 'load' || u.op.opcode === 'store') && u.idx === 0);
2105
+ // SUB-WORD ONLY, because that is the whole of what the encoding gap forces: `ldr`/`str` DO
2106
+ // have an `[sp,#imm]` form, so a WORD access through a copy is some other shape and must
2107
+ // not be read as this one. It is also what keeps the outgoing-argument area safe — that
2108
+ // guard reads `[sp,#k]` accesses (spMemAccess), which an access through a copy is not, and
2109
+ // agbcc stages arguments 5+ there with `str`.
2110
+ const subWord = accesses.every((u) => (u.op.attrs.width as number) < 4);
2111
+ // A use that is not an access leaves the capture naming the frame base, and the judgement
2112
+ // below reports that use itself — an escape, a phi, arithmetic — so it needs no reason
2113
+ // here. The WIDTH does: nothing downstream mentions it, so a refused word access would be
2114
+ // reported as "a store at [+4]" and the histogram would be asked for the wrong capability.
2115
+ if (at.length === 0 || accesses.length !== at.length) {
2116
+ continue;
2117
+ }
2118
+ if (!subWord) {
2119
+ splitRefusal ??= 'a WORD access through the copy, and `ldr`/`str` have an `[sp,#imm]` form';
2120
+ continue;
2121
+ }
2122
+ // Nothing to split when the capture already names ONE object: every access at offset 0 is
2123
+ // the frame base itself, which is what the DMA-fill idiom captures.
2124
+ if (accesses.every((u) => u.op.attrs.off === 0)) {
2125
+ continue;
2126
+ }
2127
+ for (const u of accesses) {
2128
+ const res = mkValue(T.unk(32));
2129
+ const object = mkOp('laddr', { results: [res], attrs: { off: u.op.attrs.off as number } });
2130
+ u.blk.ops.splice(u.blk.ops.indexOf(u.op), 0, object);
2131
+ minted.push(object);
2132
+ // The ADDRESS operand only — the stored value (operand 1) is passed through
2133
+ // untouched, so no slot home moves (ir/core.ts `SlotHomes`). These accesses go through
2134
+ // a COPY of `sp` rather than the `[sp,#k]` keys the stamp reads, so none of them
2135
+ // carried one to begin with.
2136
+ u.op.operands = [res, ...u.op.operands.slice(1)];
2137
+ u.op.attrs = { ...u.op.attrs, off: 0 };
2138
+ }
2139
+ consumed.add(capture);
2140
+ }
2141
+ if (consumed.size > 0) {
2142
+ for (const blk of irBlocks) {
2143
+ blk.ops = blk.ops.filter((op) => !consumed.has(op));
2144
+ }
2145
+ laddrs = [...laddrs.filter((op) => !consumed.has(op)), ...minted];
2146
+ }
2147
+ }
2148
+ // ONE OBJECT PER FRAME OFFSET. Two `laddr` at the same offset name the same storage; two at
2149
+ // different offsets are different objects, so width, signedness, escape and the overlap
2150
+ // window are decided per offset — one width shared by every capture in the function would
2151
+ // declare a halfword spill and a word spill as one object.
2152
+ const objects = new Map<number, Op[]>();
2153
+ for (const op of laddrs) {
2154
+ const off = op.attrs.off as number;
2155
+ (objects.get(off) ?? objects.set(off, []).get(off)!).push(op);
2156
+ }
2157
+ // Taint maps a value to the OBJECT whose address it may hold, closed over phis: a tainted
2158
+ // edge arg taints the receiving block param. A phi that merges two objects has no single
2159
+ // answer, and picking one would put an access on the wrong storage. Nothing builds one today,
2160
+ // and the reason is worth knowing before changing the split: an object at a nonzero offset
2161
+ // exists only where the split ran, the split refuses any capture with an edge-argument use,
2162
+ // and every frame-base object is the same object.
2163
+ const taint = new Map<Value, number>();
2164
+ for (const [off, ops] of objects) {
2165
+ for (const op of ops) {
2166
+ taint.set(op.results[0], off);
2167
+ }
2168
+ }
2169
+ for (let changed = true; changed;) {
2170
+ changed = false;
2171
+ for (const blk of irBlocks) {
2172
+ for (const op of blk.ops) {
2173
+ for (const s of op.successors ?? []) {
2174
+ s.args.forEach((arg, i) => {
2175
+ const from = taint.get(arg);
2176
+ const param = s.block.params[i];
2177
+ if (from === undefined || param === undefined) {
2178
+ return;
2179
+ }
2180
+ const had = taint.get(param);
2181
+ if (had === from) {
2182
+ return;
2183
+ }
2184
+ if (had !== undefined) {
2185
+ fail(`a phi merges the frame objects at [sp,#${had}] and [sp,#${from}] — one value, two objects`);
2186
+ }
2187
+ taint.set(param, from);
2188
+ changed = true;
2189
+ });
2190
+ }
2191
+ }
2192
+ }
2193
+ }
2194
+ // Judge every use of a tainted value, against the object it names.
2195
+ const accesses = new Map<number, { width: number; signed: boolean; isLoad: boolean }[]>();
2196
+ const escaped = new Set<number>();
2197
+ // TWO QUESTIONS, not one. `escaped` asks whether the address LEFT the function, which is what
2198
+ // decides `volatile`. `mayWrite` asks whether it reached something that could write the frame
2199
+ // BACK, which is what every "a callee may write any frame offset" refusal below rests on. A
2200
+ // store into a device's SOURCE register answers yes to the first and no to the second: the
2201
+ // hardware reads the object, and the DMA-fill idiom this capability was built for
2202
+ // (`vu16 tmp; DmaSet(n, &tmp, …)`) is exactly that shape.
2203
+ const mayWrite = new Set<number>();
2204
+ // …and the two escapes SPLIT, because each decides something the other does not.
2205
+ // `passedToCallee` is the address handed to a callee as an argument — the one escape whose
2206
+ // writer this frontend can name, which is what the struct-return premise re-check below rests
2207
+ // on, and what tells a refusal message which escape it is talking about. `published` is the
2208
+ // address WRITTEN TO MEMORY, how the DMA idiom hands the object to hardware, and what
2209
+ // `volatile` at the stamp keys on. Reading either off `escaped` gets the other one wrong.
2210
+ const passedToCallee = new Set<number>();
2211
+ const published = new Set<number>();
2212
+ // …and WHICH ARGUMENT it was passed as, because argument 0 is the one position a hidden
2213
+ // struct-return pointer can occupy. A `call`'s operand index IS the argument index here (the
2214
+ // `bl` arm reads r0..r<argc-1> in order), so an address handed over at r1 or above is an
2215
+ // argument the source wrote.
2216
+ const passedAboveArg0 = new Set<number>();
2217
+ // …and WHICH CALLEE took it at argument 0, because that callee's declared RETURN TYPE is the
2218
+ // one fact that tells an out-parameter from a hidden struct return. `null` is the narrowing of
2219
+ // an unstamped `target` attr — the `bl`/`blx` lowering always stamps one — and reads as a
2220
+ // callee nothing can be declared about, so it refuses.
2221
+ const arg0Callees = new Map<number, Set<string | null>>();
2222
+ for (const off of objects.keys()) {
2223
+ accesses.set(off, []);
2224
+ }
2225
+ for (const blk of irBlocks) {
2226
+ for (const op of blk.ops) {
2227
+ op.operands.forEach((v, idx) => {
2228
+ const off = taint.get(v);
2229
+ if (off === undefined) {
2230
+ return;
2231
+ }
2232
+ const scalar = (kind: string) => {
2233
+ if ((op.attrs.off as number) !== 0) {
2234
+ fail(
2235
+ `a ${kind} at [+${op.attrs.off}] through the captured address — ` +
2236
+ (splitRefusal ?? 'only a scalar at the captured address is modelled'),
2237
+ );
2238
+ }
2239
+ };
2240
+ if (op.opcode === 'load' && idx === 0) {
2241
+ scalar('load');
2242
+ accesses.get(off)!.push({
2243
+ width: op.attrs.width as number,
2244
+ signed: (op.attrs.signed as boolean) ?? false,
2245
+ isLoad: true,
2246
+ });
2247
+ return;
2248
+ }
2249
+ if (op.opcode === 'store' && idx === 0) {
2250
+ scalar('store');
2251
+ accesses.get(off)!.push({ width: op.attrs.width as number, signed: false, isLoad: false });
2252
+ return;
2253
+ }
2254
+ if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
2255
+ escaped.add(off); // the address ESCAPES as a value — the point of the capability
2256
+ if (!(op.opcode === 'store' && readsThrough(op))) {
2257
+ mayWrite.add(off);
2258
+ }
2259
+ if (op.opcode === 'call') {
2260
+ passedToCallee.add(off);
2261
+ if (idx > 0) {
2262
+ passedAboveArg0.add(off);
2263
+ } else {
2264
+ const t = op.attrs.target;
2265
+ const cs = arg0Callees.get(off) ?? new Set<string | null>();
2266
+ cs.add(typeof t === 'string' ? t : null);
2267
+ arg0Callees.set(off, cs);
2268
+ }
2269
+ } else {
2270
+ published.add(off); // written to memory — the DMA idiom's `*dmaReg = &tmp`
2271
+ }
2272
+ return;
2273
+ }
2274
+ fail(`the captured address flows into \`${op.opcode}\` — not an access, an escape, or a phi`);
2275
+ });
2276
+ }
2277
+ }
2278
+
2279
+ // THE ACCEPTANCE'S PREMISE, RE-ASKED OF THE IR. `capturedObjectIsTheWholeFrame` is a reading
2280
+ // of the TEXT and it is the one thing in this file that switches a refusal OFF, so the two
2281
+ // facts it claims are re-proven here, where they are exact, rather than left to the
2282
+ // approximation that licensed them. Neither is a second opinion on the same evidence: the
2283
+ // scan asks what a REGISTER holds at a `bl`; this asks what the finished function does with
2284
+ // the OBJECT.
2285
+ //
2286
+ // PASSED TO A CALLEE. The whole licence is "a callee is holding the address of this frame",
2287
+ // and the pre-lift scan can say that of a register whose value never reaches a call operand —
2288
+ // a declared arity trims it away, or the register is dead by the time the call is built. When
2289
+ // it does, [sp,#0] has been re-modelled as an addressable object on no evidence at all, and
2290
+ // the outgoing argument that really lived there is gone from the call.
2291
+ //
2292
+ // NOT A STRUCT-RETURN TEMP. A one-word frame rules out agbcc's block-copy bases (each needs
2293
+ // two words) but NOT the hidden return pointer of a <=4-byte non-integer-like struct, which is
2294
+ // exactly one word: `struct S4 { char a,b,c,d; }; struct S4 s = mk(x);` compiles to `add
2295
+ // sp,#-4 / mov r0,sp / bl mk / ldr r0,[sp]`, instruction for instruction an out-parameter
2296
+ // call. Left alone that lifted as `mk(&sp0, a0)` — a call the real prototype rejects.
2297
+ //
2298
+ // THREE facts rule it out and any one will do, because a return temp is storage the CALLEE
2299
+ // owns outright: it is written only by the callee, its pointer is argument 0, always
2300
+ // (compiled — `struct S4 mk3(int,int,int)` puts sp in r0 and shifts all three real arguments
2301
+ // up), and the callee RETURNS the struct. So a store of our own says the object is one this
2302
+ // function fills; an address handed over at r1 or above says the same by position; and a
2303
+ // callee the project declares `void` says it by the ABI — a function that returns nothing has
2304
+ // no hidden return pointer to be given, whatever sits in r0.
2305
+ //
2306
+ // THE THIRD IS A DECLARATION, NOT AN INFERENCE, and it is the ONLY refusal this frontend
2307
+ // switches off on something other than the instruction stream. `FnProto.returnsVoid` is the
2308
+ // project's own header fact, arriving through the same table whose `params` this file already
2309
+ // trusts to decide a call's arity. It is asked of EVERY callee that took the address at
2310
+ // argument 0, because the object gets one decision: one callee undeclared or declared
2311
+ // non-void leaves the ambiguity standing and the refusal fires.
2312
+ //
2313
+ // WHAT IT COSTS WHEN THE DECLARATION IS WRONG, measured rather than compared. On the `sret`
2314
+ // shape above, with `mk` (which really returns `struct S4`) declared `params: 1,
2315
+ // returnsVoid: true`, the lift succeeds and emits `s32 sret(s32 a0) { s32 sp0; mk(&sp0);
2316
+ // return (u8)sp0; }` — a compiling, plausible, WRONG program with the real argument dropped,
2317
+ // where a loud decline stood. Not a smaller cost than a wrong ARITY, either: the same entry
2318
+ // supplies both facts, so a wrong `returnsVoid` drops the argument too, and the frame re-model
2319
+ // is the silent half. The trade is accepted because there IS no other discriminator: compiled
2320
+ // through
2321
+ // the benchmark's own agbcc command, the hidden struct return and the out-parameter emit the
2322
+ // same instructions in the same order, the slot is read back at a scalar width in both, and
2323
+ // in both the value read back is what the function returns — so an asm-side corroboration
2324
+ // would be a rule with no discriminating input. The mitigation is that under-declaring is the
2325
+ // safe direction (an undeclared or non-void callee still declines) and that `FnProto` says
2326
+ // so at the field.
2327
+ //
2328
+ // The residual cost is stated rather than hidden: an OUTPUT-only parameter taken at argument
2329
+ // 0 of a callee the project has NOT declared is still byte-for-byte a struct return, and
2330
+ // still declines with it.
2331
+ const arg0AllDeclaredVoid = (off: number): boolean => {
2332
+ const cs = arg0Callees.get(off);
2333
+ return cs !== undefined && cs.size > 0 && [...cs].every((c) => c !== null && prototypes[c]?.returnsVoid === true);
2334
+ };
2335
+ if (capturedObjectIsTheWholeFrame) {
2336
+ if (!passedToCallee.has(0)) {
2337
+ fail(
2338
+ 'the one-word-frame proof licensed this lift on the frame base reaching a callee, and ' +
2339
+ 'no call in the lifted function takes it — so nothing rules out an outgoing stack argument at [sp,#0]',
2340
+ );
2341
+ }
2342
+ if (!accesses.get(0)?.some((a) => !a.isLoad) && !passedAboveArg0.has(0) && !arg0AllDeclaredVoid(0)) {
2343
+ fail(
2344
+ 'the one-word frame is handed to a callee as argument 0 and never written here, which ' +
2345
+ 'is how a hidden struct-return pointer looks — and the callee is not declared `void`, so ' +
2346
+ 'nothing says it does not own the storage',
2347
+ );
2348
+ }
2349
+ }
2350
+
2351
+ // The declared type of each object, and then that its bytes belong to nothing else.
2352
+ const extent = new Map<number, number>();
2353
+ for (const [off, acc] of accesses) {
2354
+ if (acc.length === 0) {
2355
+ // nothing in-function pins the object's type, and a guessed declaration is the
2356
+ // plausible-but-wrong class — decline until an inhabitant needs this
2357
+ fail('the captured address is never dereferenced in this function, so nothing pins the local object type');
2358
+ }
2359
+ const widths = new Set(acc.map((a) => a.width));
2360
+ if (widths.size > 1) {
2361
+ fail(`the accesses through the captured address disagree on width (${[...widths].join(' vs ')})`);
2362
+ }
2363
+ // …and on SIGNEDNESS, over the loads, for the same reason: one declared type extends one
2364
+ // way, so an object read by both `ldrsb` and `ldrb` has no faithful declaration —
2365
+ // `sp4 - sp4` would fold to 0 where the machine computes sext(b) - zext(b). Loads only: a
2366
+ // store extends nothing, and `strb` beside `ldrsb` is not a disagreement.
2367
+ const signs = new Set(acc.filter((a) => a.isLoad).map((a) => a.signed));
2368
+ if (signs.size > 1) {
2369
+ fail('the loads through the captured address disagree on signedness — one declared type extends one way');
2370
+ }
2371
+ extent.set(off, acc[0].width);
2372
+ }
2373
+ // TWO MODELS FOR ONE BYTE is a silent disagreement, so each object must own its bytes
2374
+ // outright: inside the reserved local area, clear of every SSA slot (which the slot model
2375
+ // keeps in registers, so a store through the object would not be seen there), and clear of
2376
+ // every other object.
2377
+ const overlaps = (a: number, aw: number, b: number, bw: number) => a < b + bw && b < a + aw;
2378
+ const objs = [...extent].sort((x, y) => x[0] - y[0]);
2379
+ for (const [off, width] of objs) {
2380
+ if (off < 0 || off + width > localArea) {
2381
+ fail(`the object at [sp,#${off}) of width ${width} lies outside the reserved local area`);
2382
+ }
2383
+ for (const slot of usedSlotOffsets) {
2384
+ if (overlaps(off, width, slot, 4)) {
2385
+ fail(`the object at [sp,#${off}) overlaps the SSA slot at [sp,#${slot}] — one byte, two models`);
2386
+ }
2387
+ }
2388
+ }
2389
+ for (let i = 1; i < objs.length; i++) {
2390
+ const [off, width] = objs[i];
2391
+ const [prev, prevWidth] = objs[i - 1];
2392
+ if (overlaps(prev, prevWidth, off, width)) {
2393
+ fail(`the objects at [sp,#${prev}) and [sp,#${off}) overlap — one byte, two models`);
2394
+ }
2395
+ }
2396
+ // WHAT AN ESCAPE COSTS. The audit bounds what WE access through an object, never what a callee
2397
+ // does with the address it was handed — and a callee may write any offset from it. So an
2398
+ // escape retracts two claims, both of them function-wide because one address reaches the
2399
+ // whole frame.
2400
+ //
2401
+ // The first is that the other ADDRESS-TAKEN objects are private, and it keys on ANY escape —
2402
+ // this is the rule `mayWrite` does NOT narrow. Its argument is about LAYOUT, and layout is
2403
+ // symmetric: two objects are two separate C locals with no guaranteed adjacency, so a device
2404
+ // that READS past the one it was given is as wrong as a callee that writes past it. `DmaCopy`
2405
+ // with a count of two halfwords off `&sp0` transfers `[sp,#2]` too, and the emitted source
2406
+ // transfers whatever the recompiler put after `sp0`, and the second object's own store is
2407
+ // whatever the recompiler made of it. Marking both volatile would not repair that: the locals
2408
+ // are still placed independently.
2409
+ //
2410
+ // ACCEPTED RESIDUE, so the rule is not read as wider than it is: it counts `laddr` objects,
2411
+ // so a neighbour that is merely SPILLED to an SSA slot is over-read just the same and nothing
2412
+ // refuses, and the audit never reads the transfer's control word, so an incrementing source
2413
+ // is vouched for exactly as a fixed one is. Both are reads, so the `undef` argument holds
2414
+ // either way, and both predate this rule.
2415
+ if (escaped.size > 0 && objects.size > 1) {
2416
+ fail(
2417
+ 'the captured address escapes, so something outside this function reaches the whole ' +
2418
+ 'frame — including another object',
2419
+ );
2420
+ }
2421
+ // The second is `undef`, which rests on this function's own stores being the ONLY writer of
2422
+ // its frame. A wider real object (`struct P p; g(&p);` where only `p.x` is read here) has its
2423
+ // later words written by `g` and read back at a slot no store of ours reaches — declaring
2424
+ // those uninitialised spells the callee's value as garbage. The extents here are inferred
2425
+ // from OUR accesses, which is the number that is too small in this shape.
2426
+ //
2427
+ // On an escape and not on "a laddr exists": an address dereferenced only in-function cannot
2428
+ // be written by anyone else, and the overlap checks above cover its aliasing.
2429
+ //
2430
+ // FRAME undefs only. A register-keyed one says a local lives in a register the ABI does not
2431
+ // pass arguments in, and no address reaches a register — the escape this retraction is about
2432
+ // cannot touch it, and counting it would refuse the whole function for an unrelated escape.
2433
+ const undefSlots = irBlocks.some((blk) =>
2434
+ blk.ops.some((op) => op.opcode === 'undef' && slotKeyOffset(op.attrs.key as string) !== null),
2435
+ );
2436
+ if (mayWrite.size > 0 && undefSlots) {
2437
+ fail(
2438
+ 'the captured address escapes, so a callee may write any frame offset and an unstored slot is not provably uninitialised',
2439
+ );
2440
+ }
2441
+
2442
+ // …and the SLOT MODEL is the third claim an escape retracts — the undef rule's argument
2443
+ // taken one step further. The extents above are inferred from OUR accesses, so an object
2444
+ // wider in the SOURCE than the bytes this function touches has its later words written by
2445
+ // the callee — and any of those modelled as an SSA slot is a value the slot model forwards
2446
+ // ACROSS the call that overwrote it.
2447
+ //
2448
+ // Not a hypothetical, and not new with the outgoing-argument gate above either: this shape
2449
+ // reached the old capture path and lifted wrongly. The object has to be reached ONLY through
2450
+ // the captured pointer (an `[sp,#0]` access of its own collides with the slot model and
2451
+ // declines at the overlap check), which is what four corpus functions do:
2452
+ //
2453
+ // mov r2, sp / str r0, [r2] @ the object, written through the captured address
2454
+ // str r1, [sp, #0x4] @ a word the slot model keys
2455
+ // mov r0, r2 / bl g @ the base escapes; `g` may write [sp,#4]
2456
+ // ldr r0, [sp, #0x4] @ …and the machine RELOADS it after the call
2457
+ //
2458
+ // and the lift emitted `use2(a1)` — the reload replaced by the value from BEFORE the call,
2459
+ // the callee's write dropped, no diagnostic. Exactly the silent-wrong-answer trade the sp
2460
+ // guards exist to prevent, so it refuses.
2461
+ //
2462
+ // WHAT IT COSTS, stated because the benchmark cannot see it: it refuses every word slot above
2463
+ // a `mayWrite` object, which is blunter than the hazard it names — four corpus functions
2464
+ // decline on it (sa3 `sub_809C274`, `UpdateAnimations`, `sub_801C4A0`, `sub_8062CFC`), none
2465
+ // of them a benchmark row. Narrowing it needs the object's real extent, and this model does
2466
+ // not carry one: `extent` is a single width from a single access. The asm sometimes cannot
2467
+ // supply it either — the compiled twin at `capturedObjectIsTheWholeFrame` is exactly this
2468
+ // rule's shape, a slot THIS FUNCTION stores and reloads, undecidable between a spill and a
2469
+ // member.
2470
+ //
2471
+ // ABOVE the object only: a C object extends upward from its base, so a slot BELOW it cannot
2472
+ // be part of it, and the overlap checks above already own the bytes it does cover.
2473
+ //
2474
+ // `mayWrite`, the same predicate the undef rule takes, because the two rules rest on one
2475
+ // argument and a callee is not the only writer. `struct M { u8 b; u8 pad[3]; s32 t; };
2476
+ // gp = &m; g2(); use2(m.t);` PUBLISHES the base to an ordinary global and the machine reloads
2477
+ // [sp,#4] after `bl g2` — `g2` writes through `gp`, which points here. Keyed on
2478
+ // `passedToCallee` that lifted as `use2(v0)`, the reload replaced by the value from before
2479
+ // the call, no diagnostic: the same silent wrong answer as the call shape, one escape over.
2480
+ //
2481
+ // Not `escaped`, which is the strictly wider set and the one that costs: the DMA-fill idiom
2482
+ // publishes to a device SOURCE register, which reads the object and never writes it, and
2483
+ // `readsThrough` is exactly the exemption that keeps `mayWrite` off those rows. What stays
2484
+ // residue is a base stored through a pointer this cannot resolve: unresolvable is the
2485
+ // conservative answer there, so such a store IS in `mayWrite` and such a frame declines.
2486
+ for (const off of mayWrite) {
2487
+ for (const slot of usedSlotOffsets) {
2488
+ if (slot > off) {
2489
+ const how = passedToCallee.has(off) ? 'is passed to a callee' : 'is stored to memory';
2490
+ fail(
2491
+ `the captured address at [sp,#${off}) ${how}, which may write the ` +
2492
+ `slot at [sp,#${slot}] — this function's own store there would be forwarded past the write`,
2493
+ );
2494
+ }
2495
+ }
2496
+ }
2497
+ // …and the FOURTH claim an escape retracts is the object's TOP, which the three rules above
2498
+ // leave to whatever this function happened to touch. `extent` is one width from one access,
2499
+ // so an object wider in the SOURCE than those bytes is declared too small — and a callee
2500
+ // holding its address writes frame bytes the emitted C never allocated. Compiled:
2501
+ //
2502
+ // u8 buf[12]; buf[0] = x; garr(buf); use2(buf[0]);
2503
+ // → add sp,sp,#-0xc / mov r1,sp / strb r0,[r1] / mov r0,sp / bl garr
2504
+ //
2505
+ // lifted as `u8 sp0; garr(&sp0); use2(sp0)` — a 12-byte object declared one byte, in a frame
2506
+ // the recompile makes 4 bytes wide, with `garr` writing the other 8 into the caller's. The
2507
+ // three rules above all pass it: one object, no `undef` op, no slot above it.
2508
+ //
2509
+ // What licenses an answer is the frame being ACCOUNTED FOR, word by word. Every word of the
2510
+ // reserved local area has to be an object this audit modelled or a slot the slot model keys;
2511
+ // a word that is neither is storage nothing here describes, so the emitted C reserves less
2512
+ // than the machine did and the writer reaches past what it allocated. Whole local area and
2513
+ // not only the words above the object: a word BELOW cannot be part of the object, but it is
2514
+ // still frame the declaration has to account for. Word granularity, not byte — the stack is
2515
+ // word-aligned, so a halfword object owns its word and the padding beside it is not a second
2516
+ // local.
2517
+ //
2518
+ // `mayWrite`, the predicate the two rules above take, and for the same reason: a device
2519
+ // SOURCE register reads through the address and cannot write the frame back.
2520
+ //
2521
+ // WHAT IT LEAVES, since this is the extent question the gate comment above is about: a
2522
+ // `mayWrite` escape is accepted only where the modelled objects and the keyed slots tile the
2523
+ // reserved area between them — a word above the object is a slot (refused above), a second
2524
+ // object (refused above), or unaccounted (refused here). That is not a wider extent model; it
2525
+ // is the same one-scalar `extent`, made to say when it does not fit. An object of two words
2526
+ // cannot be built here at all — the second access that would reach it is a `[+4]` the
2527
+ // `scalar()` guard refuses — so no widening of the frame licence admits a shape this rule
2528
+ // would then have to judge.
2529
+ if (mayWrite.size > 0) {
2530
+ const accountedWords = new Set<number>();
2531
+ for (const [off, width] of extent) {
2532
+ for (let w = off - (off % 4); w < off + width; w += 4) {
2533
+ accountedWords.add(w);
2534
+ }
2535
+ }
2536
+ for (const slot of usedSlotOffsets) {
2537
+ accountedWords.add(slot - (slot % 4));
2538
+ }
2539
+ for (let w = 0; w < localArea; w += 4) {
2540
+ if (!accountedWords.has(w)) {
2541
+ fail(
2542
+ `the word at [sp,#${w}] is neither an object this lift models nor a slot it keys, ` +
2543
+ `and the captured address reaches something that may write it — nothing accounts for the ` +
2544
+ `rest of the frame, so nothing bounds the captured object's extent`,
2545
+ );
2546
+ }
2547
+ }
2548
+ }
2549
+ // Proven. Stamp the MACHINE FACTS the audit established — width and signedness are what the
2550
+ // accesses used, so the declaration downstream is a fact, not a guess. The C-level NAME is
2551
+ // deliberately NOT chosen here: identifiers live in the structurer's namespace (params,
2552
+ // locals, globals, the symbol map), which the frontend cannot see — a frontend-chosen `sp0`
2553
+ // silently shadowed a project global of the same name.
2554
+ // `volatile` iff the address is PUBLISHED — written to memory, rather than handed to a
2555
+ // callee. That is the DMA idiom this rule was written for and it IS the source's own
2556
+ // spelling there: klonoa's `DMA_FILL` writes `vu##bit tmp` outright, sa3's does under
2557
+ // `PLATFORM_GBA`, pokeemerald's inside `DMA_FILL_UNCHECKED`, and the address goes to a device
2558
+ // register through a store. Reproducing that source means reproducing the qualifier.
2559
+ //
2560
+ // NOT on an ordinary `&local` ARGUMENT, where no source in the corpus writes one and the
2561
+ // qualifier is not free. `void f(u32 i){ s32 w; w = gEnts[i].h; use(&w); four(w,w,w,w); }`
2562
+ // compiles to one `ldr` reloaded into four registers by copies; the structurer emits one C
2563
+ // read per USE rather than per machine load, so `volatile` forbids the CSE and makes it four
2564
+ // `ldr`s — a byte-exact candidate turned into a four-instruction nonmatch (compiled, agbcc
2565
+ // 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm -fprologue-bugfix`). It is free
2566
+ // only where the object is read at most once, which is all the rows that first shipped it
2567
+ // did. agbcc also warns `discards qualifiers` at every such call.
2568
+ //
2569
+ // NOT because gcc would otherwise delete the store. That claim was here for several releases
2570
+ // and does not reproduce: taking `&tmp` makes the local addressable, so gcc-2.9 keeps the
2571
+ // store with or without the qualifier, measured on store-then-escape, publish-then-fill, and
2572
+ // a loop that stores and escapes each iteration. What the qualifier does change is register
2573
+ // ALLOCATION — the same function compiled `vu16` and `u16` is 98 instructions either way and
2574
+ // differs in three register assignments — which is why it still has to be right. asmlift's
2575
+ // OWN dead-store pass used to key on it; it keys on address-taken now (l3/dce.ts), so
2576
+ // dropping the qualifier here cannot cost a store.
2577
+ //
2578
+ // An object whose address never leaves the function needs no volatile and must not pay it.
2579
+ for (const [off, ops] of objects) {
2580
+ const width = extent.get(off)!;
2581
+ const signed = accesses.get(off)!.some((a) => a.signed);
2582
+ for (const op of ops) {
2583
+ op.attrs = { ...op.attrs, width, signed, ...(published.has(off) ? { volatile: true } : {}) };
2584
+ }
2585
+ }
2586
+ }
2587
+ }
2588
+
1041
2589
  /** Lift decoded asm → an L1 Fn with block-argument SSA. `prototypes` supplies each callee's
1042
2590
  * declared parameter count (from the project's headers); it is authoritative for recovering
1043
2591
  * how many argument registers a `bl` passes (falling back to a heuristic when absent). */
@@ -1050,7 +2598,7 @@ export function lift(
1050
2598
  symbols?: SymbolMap,
1051
2599
  ): Fn {
1052
2600
  assertInputFormat('thumb', 'gnu-as', asm);
1053
- const { blocks: rawBlocks, dataWords } = decode(name, asm);
2601
+ const { blocks: rawBlocks, dataWords, funcLabels } = decode(name, asm);
1054
2602
 
1055
2603
  // Regime B: recover agbcc jump tables. A dispatch block (`mov pc, rN`) plus its bounds
1056
2604
  // predecessor (`cmp; bhi DEF`) collapse into a `switch_br` emitted from the BOUNDS block; the
@@ -1116,6 +2664,25 @@ export function lift(
1116
2664
  // ends the block at it, but it has no static successor, so it must be a catchable "out of scope"
1117
2665
  // signal, not a vanished branch. Mirrors MIPS `jr`/PPC `bctr`. (A RECOGNISED jump table's
1118
2666
  // dispatch block is already elided above, so it is not scanned here.)
2667
+ //
2668
+ // The same rule for a `bl` whose target is inside this function's own text. agbcc relays a
2669
+ // conditional branch past Thumb's ±256-byte reach through an unconditional one, and past THAT
2670
+ // branch's own ±2 KB reach the relay becomes `bl .Lfar @far jump` — an intra-function long
2671
+ // branch wearing the call mnemonic. The decode switch reads it as a call, so without this the
2672
+ // lift emits a call to a block label (`.L3(a0, a1, a2)`): not a branch that vanished but a
2673
+ // transfer turned into something that RETURNS, and syntactically not C.
2674
+ //
2675
+ // The refusal is wider than that one shape because the asm does not separate it from a
2676
+ // locally-defined call thunk — `call_r3: bx r3`, ARMv4T's stand-in for the `blx rN` it has no
2677
+ // encoding for, which pokeemerald's m4a_1.s calls four times from inside MPlayMain's own slice.
2678
+ // Both are a `bl` to a bare label the slice defines, both sit under a conditional branch that
2679
+ // skips them, and both leave `lr` clobbered, so no liveness or layout fact tells them apart.
2680
+ // Lifting the relay as a `br` is the other half of the gap and needs the same distinction plus a
2681
+ // proof that `lr` is dead there, since `bl` overwrites it and a branch must not.
2682
+ //
2683
+ // DECLARED function starts are excluded: a `.thumb_func`/`thumb_func_start` label is a function
2684
+ // by declaration, so `bl` to one is a call however the slice came to contain it — the entry
2685
+ // itself (recursion), or a sibling a shared-tail slice extends through.
1119
2686
  for (const ab of asmBlocks) {
1120
2687
  for (const ins of ab.instrs) {
1121
2688
  if (classifyXfer(ins) === 'indirect') {
@@ -1124,6 +2691,14 @@ export function lift(
1124
2691
  `— jump tables / computed gotos / register tail calls not supported`,
1125
2692
  );
1126
2693
  }
2694
+ const callee = (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') && ins.ops.length === 1 ? ins.ops[0] : undefined;
2695
+ if (callee !== undefined && !funcLabels.has(callee) && blockLabels.has(callee)) {
2696
+ throw new FrontendUnsupportedError(
2697
+ `cannot lift '${name}': '${ins.mnemonic} ${callee}' targets a label inside this function ` +
2698
+ `and not a declared function — an intra-function long branch and a locally-defined call ` +
2699
+ `thunk are the same shape here, so this declines rather than guessing which`,
2700
+ );
2701
+ }
1127
2702
  }
1128
2703
  }
1129
2704
 
@@ -1187,8 +2762,69 @@ export function lift(
1187
2762
  ({ labelIndex, preds } = buildCfg(asmBlocks));
1188
2763
  }
1189
2764
 
2765
+ // ENTRY-REACHABLE BLOCKS. Dead code is not evidence about anything: a reload in a block that
2766
+ // never executes is not a read of the slot, and an instruction there is not a fact about the
2767
+ // frame this function actually builds. Two analyses below rest on that, so it is computed once
2768
+ // rather than once each — the second one was written without it and a single unreachable
2769
+ // `mov r0, sp; bl use` appended to a five-argument forwarder was enough to turn a loud decline
2770
+ // into a call with every argument dropped.
2771
+ const entryReachable = ((): Set<number> => {
2772
+ const live = new Set<number>([0]);
2773
+ for (let changed = true; changed;) {
2774
+ changed = false;
2775
+ for (let b = 0; b < asmBlocks.length; b++) {
2776
+ if (!live.has(b) && preds[b].some((q) => live.has(q))) {
2777
+ live.add(b);
2778
+ changed = true;
2779
+ }
2780
+ }
2781
+ }
2782
+ return live;
2783
+ })();
2784
+
2785
+ // A `scratchRegs` entry outside `nonArgRegs` is inert, and inert is how a partition rots: the
2786
+ // list would go on reading as if it exempted something. Refused as a target bug, like the
2787
+ // argRegs/uninitRegs contradiction, rather than declined as a property of the input.
2788
+ const scratchRegs: ReadonlySet<string> = new Set(target.scratchRegs ?? []);
2789
+ for (const r of scratchRegs) {
2790
+ if (!(target.nonArgRegs ?? []).includes(r)) {
2791
+ throw new Error(`target '${target.id}': scratch register ${r} is not among the non-argument registers`);
2792
+ }
2793
+ }
2794
+
1190
2795
  // --- ISA-neutral SSA construction (shared Braun builder) ---
1191
- const ssa = makeSsaBuilder(name, asmBlocks.length, preds);
2796
+ // THE LIVE-IN PARTITION (frontend/ssa.ts, LiveInModel). `[0, localArea)` is the whole of what this
2797
+ // function owns: an incoming stack argument is keyed `@sarg<k>` rather than `sp@<off>` precisely
2798
+ // because it sits at or above this frame, so `callerParams` is empty. `localArea` is 0 whenever
2799
+ // the prologue walk cannot measure the frame, and the empty range then refuses every slot —
2800
+ // `slotOff` applies the same bound when minting keys, so this is the independent check.
2801
+ //
2802
+ // The register half needs both of its facts, and they come from different places. The target says
2803
+ // which registers no caller can hand a value over in; `savedRegs` says which ones THIS function
2804
+ // saved, and so could have homed a local in. A register in only the first is one the ABI does not
2805
+ // describe — hand-written asm with a private convention, or a mid-function fragment — and it keeps
2806
+ // the treatment a target claiming no partition gets. The save is asked only of the registers the
2807
+ // ABI requires preserving: `target.scratchRegs` need none, so demanding one there would refuse a
2808
+ // local the compiler was entitled to put in place with no prologue at all.
2809
+ const ssa = makeSsaBuilder(name, asmBlocks.length, preds, () => ({
2810
+ ownedLocals: { from: 0, to: localArea },
2811
+ // THE SAME RANGE, AND NOT THE SAME CLAIM. `ownedLocals` answers "is a def-less read here an
2812
+ // uninitialised local?"; `declaredLocals` answers "is a spill here a DECLARATION RANK?", which
2813
+ // `ir/core.ts` `SlotHomes` and `l3/slotorder.ts` consume. Under agbcc's
2814
+ // ACCUMULATE_OUTGOING_ARGS the outgoing stack-argument area sits at the BOTTOM of `localArea`
2815
+ // (see the decline below), so this range would be WRONG for the second question on any
2816
+ // function that has one — and it is written as the same range only because `prefixStored`
2817
+ // declines every such function before it gets here. Lifting that decline obliges narrowing
2818
+ // THIS range above the argument block; the two fields exist apart so that obligation is
2819
+ // visible where it is created rather than inferred from a guard in another module.
2820
+ declaredLocals: { from: 0, to: localArea },
2821
+ ...(target.nonArgRegs
2822
+ ? {
2823
+ uninitRegs: target.nonArgRegs.filter((r) => scratchRegs.has(r) || savedRegs.has(r)),
2824
+ argRegs: target.argRegs,
2825
+ }
2826
+ : {}),
2827
+ }));
1192
2828
  const { fn, irBlocks, readVar, writeVar, paramReg } = ssa;
1193
2829
 
1194
2830
  const constVal = (n: number, b: number): Value => {
@@ -1196,18 +2832,54 @@ export function lift(
1196
2832
  irBlocks[b].ops.push(mkOp('const', { results: [v], attrs: { value: n } }));
1197
2833
  return v;
1198
2834
  };
1199
- const reg = (s: string) => s.replace(/[[\]]/g, '');
2835
+
2836
+ // Writing sp is transparent frame bookkeeping ONLY in the one shape that cannot change anything
2837
+ // observable: `sp = sp ± immediate`. Two producers feed this frontend and each emits exactly ONE
2838
+ // spelling, which is why both must work and why handling only one silently halved the input:
2839
+ //
2840
+ // producer `add sp, #N` `add sp, sp, #N`
2841
+ // agbcc's own .s (checkouts/*/build/src) 0 98
2842
+ // disassembly (klonoa asm/ · sa3 asm/) 203 · 1250 0
2843
+ //
2844
+ // So this is not "one tool with two spellings" — it is the compiler's convention against the
2845
+ // disassembler's, and asmlift reads both kinds of file.
2846
+ //
2847
+ // Everything else that writes sp is a
2848
+ // frame change this frontend cannot model: a register-sized adjustment (`add sp, r4`, agbcc's
2849
+ // way of spelling a frame too large for the 7-bit immediate), a computed stack pointer
2850
+ // (`add sp, r0, #4`), or `mov sp, rN`. Those must DECLINE, not vanish — dropping them deletes a
2851
+ // frame change with no diagnostic, which is the exact
2852
+ // loud-becomes-silent trade this frontend's guards exist to prevent.
2853
+ //
2854
+ // Flag-setting spellings (`adds`/`subs`) are excluded deliberately: ARMv4T's SP-adjust encoding
2855
+ // does not set flags, so a flag-setting one can only come from hand-written asm, where dropping
2856
+ // it would leave a stale compare for a following conditional branch to fold. There are 0 in the
2857
+ // benchmark corpus and 0 across the klonoa and sa3 checkouts, so excluding them costs nothing.
2858
+ // The decline names WHY the slot model is off when it is (slotsOffReason, assigned below —
2859
+ // referenced through the closure, so this reads the final value at throw time). The gap
2860
+ // histogram is the improvement loop's work-list, and "local stack frames not supported" was a
2861
+ // false attribution for a function whose frame IS modelled but whose blocker is, say, an
2862
+ // address-taken local or an outgoing stack argument — it sent the loop to build the wrong thing.
2863
+ const spAsDataError = () =>
2864
+ new FrontendUnsupportedError(
2865
+ `cannot lift '${name}': stack pointer used as data — ` +
2866
+ (slotsOffReason ?? 'not a modelled slot (address-taken local / frame arithmetic / above the local area)'),
2867
+ );
1200
2868
 
1201
2869
  // Reading sp as a DATA operand means an address-taken local (`add rD, sp, #N` = `&local`),
1202
2870
  // an sp-relative spill slot (`ldr/str …, [sp, #N]`), or frame-pointer arithmetic — none
1203
- // modellable without a stack abstraction. sp is never WRITTEN (sp-dest ops are transparent
1204
- // frame bookkeeping), so Braun SSA would materialize it as a fabricated PHANTOM parameter that
1205
- // scrambles the signature. Fail LOUD instead, mirroring MIPS (`isStackPtr`) and PPC (`r1`).
2871
+ // modellable without a stack abstraction. Without this guard Braun SSA would materialize sp as a
2872
+ // fabricated PHANTOM parameter that scrambles the signature. Fail LOUD instead, mirroring MIPS
2873
+ // (`isStackPtr`) and PPC (`r1`).
2874
+ //
2875
+ // sp is never WRITTEN either — but by `writeData` declining, NOT because sp-dest ops are inert.
2876
+ // Five decode arms (`lsl`/`neg`/`mvn` with an sp destination, `ldr sp`, `ldmia` with sp in the
2877
+ // list) reach a write with no arm-local sp check of their own, so the guard has to sit on the
2878
+ // write itself. The single transparent shape is `sp = sp ± imm`, whitelisted in the add/sub arms.
2879
+ // Read and write are guarded symmetrically.
1206
2880
  const readData = (r: string, b: number): Value => {
1207
- if (r === 'sp' || r === 'r13') {
1208
- throw new FrontendUnsupportedError(
1209
- `cannot lift '${name}': stack pointer used as data (address-taken local / sp-relative slot / frame arithmetic) — local stack frames not supported`,
1210
- );
2881
+ if (isSpReg(r)) {
2882
+ throw spAsDataError();
1211
2883
  }
1212
2884
  if (r === 'pc' || r === 'r15') {
1213
2885
  // A pc-relative literal load is rewritten to a pool label before reaching here (decode's
@@ -1224,6 +2896,646 @@ export function lift(
1224
2896
  return readVar(r, b);
1225
2897
  };
1226
2898
 
2899
+ // LOCAL STACK SLOTS. A spill or a local that never has its address taken is transparent to
2900
+ // dataflow: `str rX,[sp,#k]` … `ldr rY,[sp,#k]` moves a value, it does not touch memory anyone
2901
+ // else can see. Modelling it as an SSA variable keyed by the offset (the same `sp@<off>` spelling
2902
+ // the MIPS frontend uses) makes it exactly that, and Braun's phi construction handles a slot that
2903
+ // is read-modify-written across a loop with no extra machinery.
2904
+ //
2905
+ // What Thumb does NOT inherit from MIPS: keying by the RAW sp offset is only sound while sp holds
2906
+ // the same value at every access, and MIPS gets that for free (IDO establishes sp with one
2907
+ // `addiu` and never moves it). Thumb's `push` moves sp, so constancy has to be PROVEN here.
2908
+ const slotKey = stackSlotKey; // shared spelling: frontend/ssa.ts
2909
+
2910
+ // THE FRAME BASE PASSED TO A CALLEE. What this computes is exactly what its name says and nothing
2911
+ // more: somewhere in entry-reachable code a bare `mov rD, sp` copies the frame base into a
2912
+ // register that is still an ARGUMENT register at a `bl`. It is a fact about a REGISTER, not about
2913
+ // the frame's layout — an earlier cut of this treated it as proof that "[sp,#0] is an addressable
2914
+ // local", and agbcc falsified that outright. The layout question is settled by the gate this
2915
+ // feeds (`capturedObjectIsTheWholeFrame`, below `localArea`), not here.
2916
+ //
2917
+ // ENTRY-REACHABLE, BLOCK-LOCAL AND KILL-ON-MENTION, because this feeds an ACCEPTANCE and so may
2918
+ // never over-approximate. Unreachable blocks are skipped for the same reason (a)'s reload scan
2919
+ // skips them — an instruction that never executes is not a fact about the frame, and one appended
2920
+ // `mov r0, sp; bl use` after the return was enough to license a whole function. A block is
2921
+ // straight-line, so a capture that is still held when the `bl` is decoded is held on every
2922
+ // execution that reaches it; and a register is dropped the moment ANY other instruction so much
2923
+ // as MENTIONS it, since a write cannot happen without the token appearing. That over-kills (a
2924
+ // `cmp` on the register between the capture and the call ends it) and over-killing only costs a
2925
+ // decline. ARGUMENT registers only: the frame base merely live across a call is not evidence that
2926
+ // it was passed to one, and for `blx rN` the TARGET register is not an argument either.
2927
+ const frameBasePassedToCallee = ((): boolean => {
2928
+ for (const b of entryReachable) {
2929
+ const ab = asmBlocks[b];
2930
+ const held = new Set<string>();
2931
+ for (const ins of ab.instrs) {
2932
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
2933
+ // `blx rN` names its TARGET in the operand slot, so exclude it: `mov r3, sp; blx r3`
2934
+ // branches THROUGH the frame base, it does not pass it.
2935
+ const targetReg = ins.mnemonic === 'blx' ? reg(ins.ops[0] ?? '') : null;
2936
+ if ([...held].some((r) => target.argRegs.includes(r) && r !== targetReg)) {
2937
+ return true;
2938
+ }
2939
+ held.clear(); // the callee clobbers the argument registers
2940
+ continue;
2941
+ }
2942
+ // The two shapes that can carry the base forward: the capture itself, and a bare register
2943
+ // copy of a value already held. Everything else only kills.
2944
+ const carried = capturesSp(ins)
2945
+ ? reg(ins.ops[0] ?? '')
2946
+ : /^movs?$/.test(ins.mnemonic) && held.has(reg(ins.ops[1] ?? ''))
2947
+ ? reg(ins.ops[0] ?? '')
2948
+ : null;
2949
+ // The OPERAND TOKENS, not the mnemonic: `asWritten` carries only the normalised mnemonic,
2950
+ // so the operands are the only place a written register can appear — and they are
2951
+ // RANGE-EXPANDED first, because a range spells none of the registers it writes. `pop
2952
+ // {r0-r3}` writes r2 with the string `r2` nowhere in the instruction, so the bare `\bR\b`
2953
+ // test let a dead capture survive the `pop` that destroyed it, the acceptance fired on a
2954
+ // frame that really did stage an outgoing argument, and the lift dropped all five of that
2955
+ // call's arguments. Two spellings of one instruction must not give two verdicts, and CASE
2956
+ // is a third spelling of the same one — `expandRegList` is lowercase-only, so `{R0-R3}`
2957
+ // leaked through the expansion exactly as the range leaked through the regex.
2958
+ const mentions = expandRegList(
2959
+ ins.ops
2960
+ .join(' ')
2961
+ .toLowerCase()
2962
+ .replace(/[[\]{}!#]/g, ' ')
2963
+ .split(/[,\s]+/)
2964
+ .filter(Boolean),
2965
+ );
2966
+ for (const r of [...held]) {
2967
+ if (mentions.includes(r) || ins.ops.some((o) => new RegExp(`\\b${r}\\b`, 'i').test(o))) {
2968
+ held.delete(r);
2969
+ }
2970
+ }
2971
+ if (carried !== null) {
2972
+ held.add(carried);
2973
+ }
2974
+ }
2975
+ }
2976
+ return false;
2977
+ })();
2978
+
2979
+ // Is the word-slot model safe for THIS function? Every disqualifier below leaves every `[sp,#k]`
2980
+ // access on the old path, which declines — so the answer to "not sure" is the loud one.
2981
+ // Returns null when the word-slot model is SAFE for this function, else the reason it is off —
2982
+ // which the sp declines append, so a refused function names the capability actually missing
2983
+ // instead of the generic "local stack frames". The gap histogram is the improvement loop's
2984
+ // work-list; a misattributed refusal sends that loop to build the wrong thing.
2985
+ const slotModelBlocker = (): string | null => {
2986
+ for (const ab of asmBlocks) {
2987
+ for (const ins of ab.instrs) {
2988
+ const acc = spMemAccess(ins);
2989
+ // A SUB-WORD access aliasing a word slot is the hazard MIPS paid for the hard way: routing
2990
+ // the word store to an SSA slot while the byte reload stays on the memory path drops the
2991
+ // store and reads uninitialised memory. One anywhere disables the model for the whole
2992
+ // function. A register offset can alias any slot, so it disqualifies the same way.
2993
+ if (acc && (acc.width !== 4 || acc.regOff)) {
2994
+ return acc.regOff
2995
+ ? 'a register-offset sp access can alias any slot'
2996
+ : 'a sub-word sp access aliases the word-slot model';
2997
+ }
2998
+ // sp escaping into a register: a computed form (`add rD, sp, #k`) is still a refusal, but a
2999
+ // plain COPY (`mov rD, sp`) is now the address-taken-local capability — the mov arm emits a
3000
+ // `laddr` for it and the post-lift frame-object audit proves every use, so the model's
3001
+ // remaining precondition is that the frame has a reserved local area for the object to
3002
+ // live in. A frameless function taking sp's address has nothing to model and refuses.
3003
+ if (ins.mnemonic === 'add' && !isSpReg(ins.ops[0] ?? '') && ins.ops.slice(1).some((o) => isSpReg(o))) {
3004
+ return `the address of a stack local is computed (\`${ins.mnemonic} ${ins.ops.join(', ')}\`) — only a plain \`mov rD, sp\` capture is modelled`;
3005
+ }
3006
+ }
3007
+ }
3008
+ // sp must be CONSTANT wherever a slot is keyed, because the key IS the raw offset. Two shapes
3009
+ // are legitimate and everything else refuses: a PROLOGUE (sp moves before the block touches the
3010
+ // frame) and an EPILOGUE (sp moves after it has finished touching it, and the block returns, so
3011
+ // nothing downstream can key a slot against the changed sp). A modification BETWEEN two
3012
+ // accesses moves the frame under a slot already keyed, and only the entry block may deepen —
3013
+ // elsewhere a pre-access modification would put the block at a different depth from the one the
3014
+ // prologue established.
3015
+ for (let bi = 0; bi < asmBlocks.length; bi++) {
3016
+ const ins = asmBlocks[bi].instrs;
3017
+ const mems = ins.map((x, i) => (touchesFrame(x) ? i : -1)).filter((i) => i >= 0);
3018
+ const mods = ins.map((x, i) => (modifiesSp(x) ? i : -1)).filter((i) => i >= 0);
3019
+ if (mods.length === 0) {
3020
+ continue;
3021
+ }
3022
+ const last = ins[ins.length - 1];
3023
+ const returns = last !== undefined && classifyXfer(last) === 'return';
3024
+ if (mems.length === 0) {
3025
+ // moves sp and never keys a slot itself: safe only if nothing downstream can key one
3026
+ if (!returns && bi !== 0) {
3027
+ return 'sp moves in a block that neither returns nor is the entry';
3028
+ }
3029
+ continue;
3030
+ }
3031
+ for (const m of mods) {
3032
+ if (m < mems[0]) {
3033
+ if (bi !== 0) {
3034
+ return 'a non-entry block establishes its own frame depth'; // not the prologue's
3035
+ }
3036
+ } else if (m > mems[mems.length - 1]) {
3037
+ if (!returns) {
3038
+ return 'sp unwinds mid-function and execution continues';
3039
+ }
3040
+ } else {
3041
+ return 'the frame moves between two accesses that keyed slots against it';
3042
+ }
3043
+ }
3044
+ }
3045
+ // OUTGOING ARGUMENTS. agbcc reserves the BOTTOM of the frame for arguments 5+ of the calls this
3046
+ // function makes: `add sp,sp,#-8` … `str r2,[sp]` / `str r3,[sp,#4]` … `bl callee`. Those
3047
+ // offsets are inside the frame and nothing this function does ever reloads them, so modelling
3048
+ // them as locals makes them dead defs and DCE deletes them — the arguments vanish from the call
3049
+ // with no diagnostic. Ground truth: sa3's CreateEntity_Platform_0_0 (platform.c:734) forwards
3050
+ // SIX arguments and came out as `CreateEntity_Platform(0, 0, a0, (u16)a1)`. "Inside my frame"
3051
+ // does not mean "private": the outgoing area belongs to the callee, which may even assign to a
3052
+ // stack parameter.
3053
+ //
3054
+ // How big is that area? A declared arity bounds it from BELOW — `4 * max(0, arity - 4)` — and
3055
+ // that is all the facts available here can support. It is used in exactly one direction: to
3056
+ // REFUSE. A callee declared with five parameters proves this frame has an outgoing area, and
3057
+ // consuming those stores as call operands is the dual capability, unbuilt, so the model
3058
+ // declines. A callee declared with four proves NOTHING, because a declaration is a lower bound
3059
+ // on the words a call actually pushes:
3060
+ //
3061
+ // * a parameter may occupy more than one word (`double`, `long long`, a struct by value),
3062
+ // * a variadic callee's list is a prefix — `sprintf` truthfully declares two and is handed six,
3063
+ // * a large struct return adds a hidden pointer argument that appears in no parameter list.
3064
+ //
3065
+ // None of those is recorded by `FnProto` or `SymbolSignature`, so no arity here can license an
3066
+ // ACCEPTANCE. An earlier cut treated `arity <= 4` as proof of an empty area and had all three
3067
+ // holes: supplying a TRUE fact (`{ sprintf: { params: 2 } }`) turned a correct decline into
3068
+ // `return sprintf(a0, a1)` with both stack arguments deleted, where supplying nothing declined.
3069
+ // A fact must only ever move a function toward refusal — never toward an acceptance the facts
3070
+ // do not entail. Refusing on a lower bound is monotone in exactly that way: a true arity larger
3071
+ // than declared can only make the area bigger, and the answer is already "decline".
3072
+ //
3073
+ // Measured, this costs nothing it was buying: forcing the old acceptance path off changed 0
3074
+ // lift/decline verdicts across 2686 corpus functions (sa3's vendored map carries no signatures
3075
+ // at all), so the path that carried those holes was never load-bearing. That sweep was run over
3076
+ // the sa3/klonoa CHECKOUTS, which this repo does not vendor — it is not the benchmark's 404
3077
+ // agbcc rows, and it has not been re-run since it was taken.
3078
+ for (const ab of asmBlocks) {
3079
+ for (const ins of ab.instrs) {
3080
+ if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3081
+ continue;
3082
+ }
3083
+ const c = ins.ops[0] ?? '';
3084
+ const arity = protoArity(prototypes[c]) ?? protoArity(RUNTIME_HELPERS[c]);
3085
+ if (arity !== undefined && arity > target.argRegs.length) {
3086
+ return `callee \`${c}\` is declared with ${arity} arguments, so this frame has an outgoing stack-argument area — consuming stack call arguments is not implemented`;
3087
+ }
3088
+ }
3089
+ }
3090
+ // Nothing above could prove the area empty, so fall back to reading the CODE. Two conditions,
3091
+ // covering different escapes:
3092
+ // (a) every slot store must be reloaded somewhere reachable. An outgoing argument is read by
3093
+ // the CALLEE, never by the caller, so a store never read back is the signature of one.
3094
+ // (b) no slot store may reach a `bl` unread ALONG A PATH.
3095
+ //
3096
+ // Neither is sound alone and the pair is not either, so keep two things straight. (a)'s real
3097
+ // theorem is not "the callee reads it, the caller does not" — it is that agbcc's
3098
+ // ACCUMULATE_OUTGOING_ARGS puts the outgoing area at the BOTTOM of localArea, disjoint from the
3099
+ // locals, so no local load can land on an argument offset. That disjointness is what a
3100
+ // tail-merged call site breaks, and agbcc DOES tail-merge: `Task_BonusFlower_Spawn` (sa3
3101
+ // bonus_game_enemies) stores argument 5 in both predecessors with the `bl` in the join.
3102
+ //
3103
+ // A SECOND THING NOW DEPENDS ON THIS DECLINE, and it is not in this file. `SlotHomes`
3104
+ // (ir/core.ts) reads a `[sp,#k]` spill as a DECLARATION RANK and `l3/slotorder.ts` orders the
3105
+ // declaration list by it. The partition that admits an offset is `LiveInModel.declaredLocals`,
3106
+ // which this frontend sets to `[0, localArea)` — a range that CONTAINS the outgoing area. The
3107
+ // only thing keeping an argument slot from becoming a declaration rank is that this decline
3108
+ // removes such functions from the population first. So lifting or narrowing this guard is not
3109
+ // a local change: it must come with a narrowed `declaredLocals`, or the ordering starts
3110
+ // ranking argument positions with no diagnostic. The class is populated — of 2,001 lifted real
3111
+ // agbcc functions, 12 carry an L1 slot home AND call, and 11 of those home offset 0. "Real
3112
+ // agbcc functions" there means a CHECKOUT sweep this repo does not vendor, not benchmark rows
3113
+ // (the benchmark has 126 real agbcc rows in total), and it has not been re-run since.
3114
+ //
3115
+ // (b) is a forward may-analysis over the CFG, and it has been wrong twice in the other
3116
+ // direction. Scanning per block let a LABEL decide accept versus refuse; scanning the flat
3117
+ // listing let BLOCK ORDER decide, because a load in one arm of a branch cleared a store that
3118
+ // reaches the call through the other arm — swap the arms in the listing, same CFG and same
3119
+ // semantics, and the verdict flipped. Only the path-sensitive form is stable under layout.
3120
+ //
3121
+ // Only for a function that CALLS. With no call there is no outgoing area to mistake a local
3122
+ // for, and a never-reloaded store there is an ordinary dead local — which PR #30 modelled and
3123
+ // which must keep working.
3124
+ //
3125
+ // …and not when the WHOLE FRAME is an object whose address a callee holds
3126
+ // (`capturedObjectIsTheWholeFrame` — a one-word frame, passed by a bare `mov rD, sp`). Both
3127
+ // conditions hunt for an argument block; every argument block starts at [sp,#0] (that is what
3128
+ // `prefixStored` encodes); and a one-word frame that is entirely an addressable local has no
3129
+ // room for one. So there is no argument block to find and both conditions can only fire as
3130
+ // FALSE ALARMS — which is what they did: the three address-taken rows in the synthetic tier
3131
+ // declined at (a) on a store that is never reloaded for the ordinary reason, that the CALLEE
3132
+ // reads it through the pointer.
3133
+ //
3134
+ // This is the only acceptance in this function, so keep straight what licenses it. NOT arity: a
3135
+ // callee declared with four arguments proves nothing (see above), and the layout gate is
3136
+ // deliberately independent of the declarations. The arity refusal still runs FIRST and still
3137
+ // wins — in a one-word frame a declared fifth argument and an addressable local at offset 0 are
3138
+ // contradictory claims about the same word, so the honest answer there is the decline, not a
3139
+ // guess about which one to believe.
3140
+ if (
3141
+ !capturedObjectIsTheWholeFrame &&
3142
+ asmBlocks.some((ab) => ab.instrs.some((i) => i.mnemonic === 'bl' || i.mnemonic === 'blx'))
3143
+ ) {
3144
+ const slotAcc = (ins: Instr) => {
3145
+ const a = spMemAccess(ins);
3146
+ return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off + 4 <= localArea
3147
+ ? a.off
3148
+ : null;
3149
+ };
3150
+ const isStore = (ins: Instr) => /^str/.test(ins.mnemonic);
3151
+ // Entry-REACHABLE blocks only: a reload in dead code is not evidence that live code reads the
3152
+ // slot back, and counting it lets an argument store satisfy (a) on the strength of an
3153
+ // instruction that never executes.
3154
+ const live = entryReachable;
3155
+ const reloaded = new Set<number>();
3156
+ for (const b of live) {
3157
+ for (const ins of asmBlocks[b].instrs) {
3158
+ const off = slotAcc(ins);
3159
+ if (off !== null && !isStore(ins)) {
3160
+ reloaded.add(off);
3161
+ }
3162
+ }
3163
+ }
3164
+ // CONTIGUITY. AAPCS lays the outgoing stack arguments at [sp,#0] upward, one word each, so an
3165
+ // argument block is CONTIGUOUS FROM ZERO: a store at [sp,#4] can be argument 6 of a call only
3166
+ // if argument 5 at [sp,#0] is also supplied on a path to that same call. A pending store
3167
+ // whose lower slots are nowhere supplied is therefore provably not an argument block, and
3168
+ // refusing it is a false alarm — the exact false alarm that blocked the commonest real shape,
3169
+ // a value spilled at [sp,#4] and kept live across calls (kleod's ProcessInputAndUpdateEntities
3170
+ // stores its `sp4` local and calls m4aSongNumStart 80 lines later, with offset 0 never stored
3171
+ // in the whole function).
3172
+ //
3173
+ // The calibration in this: a conforming caller stores EVERY argument slot of a call it makes,
3174
+ // so "slot 0 unsupplied" rules out "slot 4 is an argument". Hand-written asm could skip
3175
+ // storing an argument the callee never reads; agbcc cannot (no interprocedural dead-argument
3176
+ // elimination). That is the same producer assumption the reload conditions above already
3177
+ // make, stated once here.
3178
+ const prefixStored = (k: number, st: Set<number>): boolean => {
3179
+ for (let j = 0; j < k; j += 4) {
3180
+ if (!st.has(j)) {
3181
+ return false;
3182
+ }
3183
+ }
3184
+ return true;
3185
+ };
3186
+ // (a), contiguity-filtered: a store never reloaded ANYWHERE is an argument's signature only
3187
+ // if its lower slots are supplied somewhere too; otherwise it is an ordinary dead local.
3188
+ const storedAnywhere = new Set<number>();
3189
+ for (const b of live) {
3190
+ for (const ins of asmBlocks[b].instrs) {
3191
+ const off = slotAcc(ins);
3192
+ if (off !== null && isStore(ins)) {
3193
+ storedAnywhere.add(off);
3194
+ }
3195
+ }
3196
+ }
3197
+ for (const off of storedAnywhere) {
3198
+ if (!reloaded.has(off) && prefixStored(off, storedAnywhere)) {
3199
+ return `the store to [sp,#${off}] is never reloaded and its lower slots are supplied — it may be an outgoing stack argument of one of this function's calls`; // (a)
3200
+ }
3201
+ }
3202
+ // (b): `pendingOut[b]` = offsets stored and not yet reloaded on SOME path through b;
3203
+ // `storedOut[b]` = offsets stored on SOME path through b (a reload does not remove the value
3204
+ // from memory, so it does not remove the offset from this set — the callee would still read
3205
+ // what the store put there).
3206
+ const pendingOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
3207
+ const storedOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
3208
+ for (let changed = true; changed;) {
3209
+ changed = false;
3210
+ for (let b = 0; b < asmBlocks.length; b++) {
3211
+ if (!live.has(b)) {
3212
+ continue;
3213
+ }
3214
+ const pend = new Set<number>();
3215
+ const st = new Set<number>();
3216
+ for (const q of preds[b]) {
3217
+ for (const off of pendingOut[q]) {
3218
+ pend.add(off);
3219
+ }
3220
+ for (const off of storedOut[q]) {
3221
+ st.add(off);
3222
+ }
3223
+ }
3224
+ for (const ins of asmBlocks[b].instrs) {
3225
+ const off = slotAcc(ins);
3226
+ if (off !== null) {
3227
+ if (isStore(ins)) {
3228
+ pend.add(off);
3229
+ st.add(off);
3230
+ } else {
3231
+ pend.delete(off);
3232
+ }
3233
+ } else if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
3234
+ for (const k of pend) {
3235
+ if (prefixStored(k, st)) {
3236
+ // (b) — a plausible argument block reaches this call unread
3237
+ return `the store to [sp,#${k}] reaches \`bl ${ins.ops[0] ?? '?'}\` unread with its lower slots supplied — it may be that call's outgoing stack argument`;
3238
+ }
3239
+ }
3240
+ }
3241
+ }
3242
+ const grow = (out: Array<Set<number>>, cur: Set<number>): void => {
3243
+ if (cur.size !== out[b].size || [...cur].some((o) => !out[b].has(o))) {
3244
+ out[b] = cur;
3245
+ changed = true;
3246
+ }
3247
+ };
3248
+ grow(pendingOut, pend);
3249
+ grow(storedOut, st);
3250
+ }
3251
+ }
3252
+ }
3253
+ // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
3254
+ // taken while the local area is still reserved reads a slot this model has retargeted into SSA
3255
+ // — the load simply disagrees with the store. A real epilogue releases the locals first
3256
+ // (`add sp,#N; pop {…}`), which is what this requires; `pop {r1}` mid-frame does not.
3257
+ for (const ab of asmBlocks) {
3258
+ let released = 0;
3259
+ for (const ins of ab.instrs) {
3260
+ if ((ins.mnemonic === 'pop' || ins.mnemonic === 'ldmia') && released < localArea) {
3261
+ const base = ins.mnemonic === 'pop' ? 'sp' : (ins.ops[0] ?? '').replace(/!$/, '');
3262
+ if (isSpReg(base)) {
3263
+ return 'a pop reads the frame while the local area is still reserved';
3264
+ }
3265
+ }
3266
+ if ((ins.mnemonic === 'add' || ins.mnemonic === 'sub') && isSpReg(ins.ops[0])) {
3267
+ const d = spAdjust(ins);
3268
+ if (d !== null && d > 0) {
3269
+ released += d;
3270
+ }
3271
+ }
3272
+ }
3273
+ }
3274
+ return null;
3275
+ };
3276
+ // THE EXPLICITLY RESERVED LOCAL AREA — the prologue's `add sp,sp,#-N`, and the frame the body
3277
+ // sees. "Prologue" is everything before the entry block first touches the frame (or the whole
3278
+ // block if it never does). A frame this cannot measure yields 0, which disables every slot
3279
+ // (`off + 4 <= localArea` is then false).
3280
+ //
3281
+ // A slot must live strictly inside this, not merely inside the whole frame: the rest of the frame
3282
+ // is the callee-saved block the entry `push` wrote, which belongs to the epilogue's `pop`, so a
3283
+ // `str` there is retargeted away from the memory the pop will read. Which is also why this is NOT
3284
+ // the walk `argIndex` uses — `makeFrameWalk`'s `delta` counts a `push` at 4 bytes per register and
3285
+ // this skips it, because the callee-saved block sits ABOVE the local area. The cost of that
3286
+ // difference is the push/pop arm below.
3287
+ const localArea = ((): number => {
3288
+ // The PROLOGUE only — everything before the entry block first touches the frame. Summing the
3289
+ // whole block instead let a store made BEFORE the reservation fall inside the window, so
3290
+ // `str r0,[sp]; add sp,sp,#-4; …` claimed a write to the CALLER's frame as a private local and
3291
+ // deleted it.
3292
+ //
3293
+ // NET, not the sum of the negatives. Counting only reservations and discarding releases leaves
3294
+ // localArea larger than the region that is actually below the callee-saved block, and `off <
3295
+ // localArea` then claims the SAVED REGISTERS as private locals — which the epilogue's `pop`
3296
+ // reads back. `push {lr}; add sp,#-8; add sp,#8; str r0,[sp]; pop {r1}; bx r1` deleted the
3297
+ // store and rendered a computed `bx` as an ordinary return, and it fooled the pop gate too
3298
+ // (`released` is compared against this number). Not corpus-reachable — 0 of 2805 Thumb
3299
+ // functions adjust sp upward before their first frame access — which is exactly why only a
3300
+ // probe finds it. That 2805 is a CHECKOUT sweep this repo does not vendor, not a benchmark
3301
+ // count, and it has not been re-run since.
3302
+ //
3303
+ // An sp write this cannot read poisons the whole thing to 0, disabling every slot, rather than
3304
+ // being skipped as if it were no movement: the same rule `makeFrameWalk`'s `delta` follows.
3305
+ const ins = asmBlocks[0].instrs;
3306
+ const firstMem = ins.findIndex((x) => touchesFrame(x));
3307
+ let reserved = 0;
3308
+ let reservedYet = false;
3309
+ for (const x of ins.slice(0, firstMem === -1 ? ins.length : firstMem)) {
3310
+ const d = spAdjust(x);
3311
+ if (d !== null) {
3312
+ reserved -= d; // d > 0 = sp rises = the frame shrinks
3313
+ reservedYet ||= reserved > 0;
3314
+ } else if (x.mnemonic === 'push' || x.mnemonic === 'pop') {
3315
+ // BEFORE any reservation this is the callee-saved block, measured from below — the
3316
+ // ordinary agbcc prologue, and why push/pop are skipped at all. AFTER one it slides sp
3317
+ // under the window just measured, so `[0, localArea)` stops naming the reserved area and
3318
+ // starts naming the pushed words. Those words ARE written, by an instruction that is
3319
+ // dataflow-transparent, so nothing downstream can tell:
3320
+ // `push {r4,lr}; add sp,#-8; push {r0}; … ldr r1,[sp]` reads back the pushed `r0` on every
3321
+ // path and the def-less shape renders it as an uninitialised local — 0 from the machine,
3322
+ // garbage from the C. Poisoned to 0 rather than corrected by subtracting the push bytes,
3323
+ // the same rule the sp-write arm follows. Not corpus-reachable — agbcc pushes before it
3324
+ // reserves in all 2343 Thumb functions of the same un-vendored CHECKOUT sweep as the 2805
3325
+ // above, not re-run since — which is why only a probe finds it.
3326
+ if (reservedYet) {
3327
+ return 0;
3328
+ }
3329
+ } else if (isSpReg((x.ops[0] ?? '').replace(/!$/, ''))) {
3330
+ return 0; // an sp write of a shape this does not model
3331
+ }
3332
+ }
3333
+ return Math.max(0, reserved);
3334
+ })();
3335
+
3336
+ // WHAT THE PROLOGUE SAVED — the second half of the register partition (frontend/ssa.ts,
3337
+ // LiveInModel.uninitRegs), in operand spellings. "The ABI does not pass arguments here" is a fact
3338
+ // about the CALLER and cannot on its own make a def-less read an uninitialised local; what does is
3339
+ // that the compiler homed a local in the register, which it may only do after saving it. Asm that
3340
+ // saves nothing follows no such convention, and some of it really is handed live values there:
3341
+ // the MP2K engine's hand-written `ChnVolSetAsm`, vendored in klonoa, sa3 and pokeemerald alike,
3342
+ // takes two pointers in r4/r5 and has no prologue at all — classified by the ABI alone it lost its
3343
+ // signature and stored through reads of registers nothing ever wrote.
3344
+ //
3345
+ // PER REGISTER, because saving r5 says nothing about r4 — a mid-function fragment reached by
3346
+ // agbcc's `bl`-as-a-long-branch saves what it uses and is handed the rest.
3347
+ //
3348
+ // The prologue is the LEADING run of saves and reservations, so a `push` in the body cannot join
3349
+ // the set. `HIGH_REGS` have no `push` encoding, so agbcc saves them as `mov rLow, rHi; push
3350
+ // {rLow}` and the source of such a `mov` joins the set when its low register is pushed — every
3351
+ // high-register inhabitant in the corpus goes through that idiom.
3352
+ const savedRegs = ((): ReadonlySet<string> => {
3353
+ const saved = new Set<string>();
3354
+ const carries = new Map<string, string>(); // low register ← the high one moved into it
3355
+ for (const x of asmBlocks[0].instrs) {
3356
+ if (x.mnemonic === 'push') {
3357
+ const list = regListOf(x.ops);
3358
+ if (list === null) {
3359
+ break; // a list this cannot read is a save set this cannot vouch for
3360
+ }
3361
+ for (const r of list) {
3362
+ saved.add(r);
3363
+ const hi = carries.get(r);
3364
+ if (hi !== undefined) {
3365
+ saved.add(hi);
3366
+ }
3367
+ }
3368
+ } else if (/^movs?$/.test(x.mnemonic) && HIGH_REGS.has(x.ops[1] ?? '') && !HIGH_REGS.has(x.ops[0] ?? '')) {
3369
+ carries.set(x.ops[0], x.ops[1]);
3370
+ } else if (spAdjust(x) === null) {
3371
+ break;
3372
+ }
3373
+ }
3374
+ return saved;
3375
+ })();
3376
+
3377
+ // …AND THE FRAME IS THAT OBJECT. `frameBasePassedToCallee` is a fact about a register; this is
3378
+ // the layout fact, and it is the one that licenses turning the outgoing-argument refusals off.
3379
+ //
3380
+ // The reason it is needed at all: a bare `mov rD, sp` names frame offset 0, but agbcc emits one
3381
+ // for TWO different things, and only one of them is an addressable local. The other is a
3382
+ // BLOCK-COPY BASE — the destination of a by-value struct argument (which IS the outgoing argument
3383
+ // area) and the hidden return pointer of a struct-returning call. Both compiled, agbcc
3384
+ // 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm -fprologue-bugfix`:
3385
+ //
3386
+ // by-value struct argument, `void f(struct S *p){ take(*p); }`, by stack words beyond r0-r3:
3387
+ // 1 word → `ldr r0,[r3,#0x10]; str r0,[sp]` — no capture at all, frame 4
3388
+ // 2 words → `mov r1, sp; ldmia r0!,{…}; stmia r1!,{…}` — frame 8
3389
+ // 13+ → `mov r0, sp; mov r2,#0x44; bl memcpy` — frame 0x44 and up
3390
+ // struct return, `void f(int x){ struct R r = mk(x); use2(r.a[0]); }`:
3391
+ // 1 word, INTEGER-LIKE (`{int a;}`, `{int a[1];}`, `{float f;}`) → r0, no frame temp
3392
+ // 1 word, otherwise (`{char a,b,c,d;}`, `{short a,b;}`) → `mov r0, sp; bl mk` — frame 4
3393
+ // 2 words → `mov r0, sp; bl mk` — frame 8
3394
+ //
3395
+ // Left unguarded that cost an argument: `void g3(struct Huge *p, int x){ takesH(*p);
3396
+ // five(1,2,3,4,x); }` puts `str r5,[sp]` — argument 5 of `five` — in a frame whose offset 0 the
3397
+ // memcpy also names, and the lift emitted `five(1, 2, 3, 4)` with the fifth argument written into
3398
+ // a fabricated 4-byte local, in a frame declared 4 bytes where the machine reserves 0x90. Order
3399
+ // is not the discriminator either: with the five-argument call FIRST the same thing happens.
3400
+ //
3401
+ // So the gate is the MODEL'S OWN EXTENT. This frontend models the captured object as the single
3402
+ // word at [sp,#0] (`isFrameObjectAccess` below), and a one-word model is a description of the
3403
+ // frame only when the frame IS that word. `localArea === 4` says exactly that. It excludes every
3404
+ // block-copy base — each needs two frame words before agbcc names its base with a register at
3405
+ // all — and the two-word struct returns with them. Any larger frame has bytes this model does not
3406
+ // describe, and they are either the rest of a wider object the callee writes or another call's
3407
+ // argument slots; neither is provable here, so the answer stays the decline.
3408
+ //
3409
+ // What the frame size does NOT exclude is the one-word struct return in the table above, which is
3410
+ // instruction-for-instruction an out-parameter call. That one is settled after the lift, by the
3411
+ // premise re-check in the frame-object audit — which is also where the licence's other half is
3412
+ // re-proven.
3413
+ //
3414
+ // WHAT THIS GATE IS NOT. It switches the outgoing-argument refusals off, and nothing else. The
3415
+ // object model runs on ANY frame — the `laddrs` path in the audit below — so a `u8 buf[12]`
3416
+ // handed to a callee arrives there through a frame this conjunct never looks at, and what bounds
3417
+ // its extent is the audit's frame-accounting rule rather than anything here.
3418
+ //
3419
+ // WHY IT IS NOT WIDENED anyway, since a wider frame is the obvious next lever. Three shapes,
3420
+ // each compiled with agbcc 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm
3421
+ // -fprologue-bugfix`, and only the first is about extent at all:
3422
+ //
3423
+ // UNDECIDABLE — a slot THIS FUNCTION stores and reloads. Two sources that disagree about who
3424
+ // owns [sp,#4] compile to one instruction stream, byte for byte, with eight values live across
3425
+ // the calls so one of them spills:
3426
+ //
3427
+ // s32 loc; s32 t0..t7; loc = x; t0 = h(0); … g(&loc); k(loc + t0 + …);
3428
+ // struct P { s32 a, b; } p; s32 t1..t7; p.a = x; p.b = h(0); … g(&p); k(p.a + p.b + …);
3429
+ //
3430
+ // The first says a callee may not touch [sp,#4]; the second says it may, and the reload after the
3431
+ // call must read what it wrote. Nothing distinguishes them, so a licence over THAT slot would be
3432
+ // a guess — which is why the slot rule in the audit refuses the shape rather than deciding it.
3433
+ //
3434
+ // PINNED, and refused by the MODEL — sub-word members. Thumb has no sp-relative `strb`, so a
3435
+ // byte or halfword member is reached through a copy of sp, and the access at +4 witnesses that
3436
+ // the object reaches past its first word: `struct Q { u8 a; u8 pad[3]; u8 b; }` filled and then
3437
+ // handed over compiles to `mov r1, sp / strb r0, [r1] / strb r0, [r1, #0x4] / mov r0, sp / bl g`.
3438
+ // The escape is a use that is not an access, so the capture cannot be split per offset, and the
3439
+ // audit judges the [+4] access against the one object it does model — "only a scalar at the
3440
+ // captured address is modelled", and the SAME message when this conjunct is widened to
3441
+ // `localArea >= 4` (measured).
3442
+ //
3443
+ // PINNED, and ambiguous in its ROLE — a frame-covering block copy. `struct Big { s32 a[17]; };
3444
+ // b = gK; g(&b);` compiles to `add sp,#-0x44 / mov r0,sp / mov r2,#0x44 / bl memcpy`: the copy
3445
+ // bounds the object from below and the reservation from above, so the extent is exact. What is
3446
+ // NOT pinned is what the object IS — the producer table above records those same instructions
3447
+ // for a by-value struct ARGUMENT block and for a struct-return temp. Widening this conjunct
3448
+ // moves it exactly there: it then declines at "which is how a hidden struct-return pointer
3449
+ // looks" (measured), never at anything about extent.
3450
+ //
3451
+ // So a wider frame licence admits nothing this model can describe. `extent` is one scalar width
3452
+ // from one access, and the second access that would build a wider object is a `[+k]` the audit
3453
+ // refuses first: widened, the sub-word shape declines on the very same message and the
3454
+ // block-copy one moves onto the struct-return refusal (both measured).
3455
+ //
3456
+ // RESIDUE: the producer table is agbcc's, so hand-written asm that reserves one word, stages it
3457
+ // as a call's fifth argument and ALSO puts sp in an argument register defeats this — the same
3458
+ // producer assumption the contiguity filter below makes. The producer is named in the gate
3459
+ // rather than left to the prose: `armv4t` has one compiler entry today, and a second one free to
3460
+ // overlay a dead one-word local with a one-word outgoing area would inherit an acceptance whose
3461
+ // only evidence is an agbcc compile table.
3462
+ //
3463
+ // WHICH CONJUNCT REFUSES WHAT, for a reader arriving with a wide frame in hand. klonoa's
3464
+ // `LoadBGTilemapData` (a checkout function, not a benchmark row) reserves 0x3C and fails the
3465
+ // OTHER conjunct: its `mov r5, sp` is the DMA-fill PUBLISH (`strh r7, [r5]` / `mov r0, sp` /
3466
+ // `str r0, [r2]`, r2 = 0x040000D4), not a base live in an argument register at a `bl`.
3467
+ // Instrumented, it arrives with localArea=60 and frameBasePassedToCallee=false, lifts today with
3468
+ // the object modelled as `volatile u16 sp0`, and its lift is byte-identical with this conjunct
3469
+ // widened to `localArea >= 4` (measured). No answer to the frame size moves it.
3470
+ const capturedObjectIsTheWholeFrame = target.compiler === 'agbcc' && frameBasePassedToCallee && localArea === 4;
3471
+
3472
+ const slotsOffReason = slotModelBlocker();
3473
+ const slotsOk = slotsOffReason === null;
3474
+ // Every offset the body actually keys as an SSA slot — the frame-object audit checks the
3475
+ // address-taken object cannot overlap one (two models for one byte is a silent disagreement).
3476
+ const usedSlotOffsets = new Set<number>();
3477
+
3478
+ // …AND THE ONE OFFSET THAT MUST NOT BE A SLOT. When `capturedObjectIsTheWholeFrame` holds, the
3479
+ // frame is one word and a callee is being handed its address, so an `[sp,#0]` access is an access
3480
+ // to THAT OBJECT — provisionally, since the audit is what proves the object is a local at all. Keying it as an SSA slot instead moves the value into a
3481
+ // register and deletes the store from memory — and the callee reading it through the pointer is
3482
+ // invisible to every check the slot model makes, so the deletion would be silent.
3483
+ //
3484
+ // The frame-object audit does catch the collision today ("one byte, two models"), as a DECLINE.
3485
+ // Routing the access through an `laddr` here is what turns that decline into the lift, and it
3486
+ // hands the audit the same object it would have judged anyway — offset, width and escape all
3487
+ // come from the machine.
3488
+ //
3489
+ // Word accesses only, and only while the slot model is on: a sub-word or register-offset
3490
+ // `[sp,#k]` anywhere turns the whole model off (slotModelBlocker), and the `mov rD, sp` arm then
3491
+ // declines the capture rather than reaching this.
3492
+ const isFrameObjectAccess = (base: string, off: number, regOff: string | undefined, width: number): boolean =>
3493
+ slotsOk && capturedObjectIsTheWholeFrame && isSpReg(base) && regOff === undefined && off === 0 && width === 4;
3494
+
3495
+ // A WHOLE WORD OF THIS FUNCTION'S OWN RESERVED LOCAL AREA — the shape the ldr and str arms model
3496
+ // as an SSA slot (`sp@<off>`) instead of memory. The two arms spelled these seven terms out
3497
+ // twice — the ldr arm with the reaching-def test as an eighth — which is one edit away from
3498
+ // disagreeing about which bytes are private.
3499
+ //
3500
+ // Each term is a refusal:
3501
+ // * `slotsOk` — the frame must be proven private and immovable (slotModelBlocker)
3502
+ // * sp base, no register index — `[sp, rX]` names no fixed slot
3503
+ // * word width, word-aligned, non-negative — the model keys whole aligned words and nothing else
3504
+ // * `off + 4 <= localArea` — strictly inside the EXPLICITLY reserved area. Not merely inside the
3505
+ // frame: above it sits the callee-saved block the epilogue pops, and above THAT the incoming
3506
+ // argument area, so a slot straying up there would either fight the `pop` or claim the
3507
+ // caller's word.
3508
+ //
3509
+ // What is NOT here, deliberately: the load side's reaching-def test. That one is impure (it asks
3510
+ // the SSA builder) and one-sided — a store needs no reaching def — so it stays at the ldr arm.
3511
+ const isOwnFrameWordSlot = (base: string, off: number, regOff: string | undefined, width: number): boolean =>
3512
+ slotsOk &&
3513
+ isSpReg(base) &&
3514
+ regOff === undefined &&
3515
+ width === 4 &&
3516
+ off % 4 === 0 &&
3517
+ off >= 0 &&
3518
+ off + 4 <= localArea;
3519
+
3520
+ // The WRITE dual of readData, and it guards the WRITE rather than the arms that perform one,
3521
+ // because an enumeration of arms can only cover the arms someone thought of. Checking sp in the
3522
+ // mov/add/sub arms alone leaves `lsl sp, r4, #2`, `neg sp, r4`, `mvn sp, r4`, `ldr sp, [r0,#4]`
3523
+ // and `ldmia r0!, {sp}` lifting, each dropping the sp write silently. Guarding the write ITSELF
3524
+ // cannot be incomplete.
3525
+ //
3526
+ // sp is writable in exactly one shape — the frame adjust the add/sub arms `break` on before
3527
+ // reaching here (see isFrameAdjust). Anything else that writes sp is a frame change this frontend
3528
+ // cannot model, and dropping it silently deletes that change while the function keeps compiling.
3529
+ //
3530
+ // Known residual, zero inhabitants: `pop {sp}` never reaches here (push/pop are skipSafe in the
3531
+ // opaque policy), so it stays silently transparent. ARMv4T Thumb cannot encode sp in a pop reglist.
3532
+ const writeData = (r: string, b: number, v: Value): void => {
3533
+ if (isSpReg(r)) {
3534
+ throw spAsDataError();
3535
+ }
3536
+ writeVar(r, b, v);
3537
+ };
3538
+
1227
3539
  // Best-effort call arity via the shared helper (frontend/ssa.ts).
1228
3540
  const fallbackArgcHere = (b: number): number => fallbackArgc(ssa, target.argRegs, b);
1229
3541
 
@@ -1231,13 +3543,18 @@ export function lift(
1231
3543
  const fillBlock = (ab: AsmBlock, bi: number) => {
1232
3544
  const irb = irBlocks[bi];
1233
3545
  let pendingCmp: { lhs: Value; rhs: Value } | null = null;
3546
+ // Tracks the frame through this block's linear instruction order. Meaningful for the entry
3547
+ // block; elsewhere a `[sp,#N]` access declines. Both dependencies are read HERE rather than
3548
+ // closed over: `preds` is final long before the first `fillBlock` runs, so the boolean is the
3549
+ // same for every block, and passing it in makes that a property of this line instead of a
3550
+ // property of wherever the walk happens to be declared.
3551
+ const frame = makeFrameWalk({ argRegs: target.argRegs, entryHasPreds: preds[0].length > 0 });
1234
3552
 
1235
3553
  // TRUSTWORTHINESS GUARD (mirrors the MIPS/PPC frontends): an unmodelled instruction must not
1236
- // silently drop its destination register — emit an honest `opaque`: dead it vanishes; live ⇒
1237
- // assertResolved fails LOUD (see frontend/opaque.ts for the policy). Push/pop and sp
3554
+ // silently drop its destination register — emit an honest `opaque`, which fails LOUD at
3555
+ // assertResolved whether or not anything reads that register (see frontend/opaque.ts). Push/pop and sp
1238
3556
  // adjustments have no low-register data destination, so they fall through harmlessly;
1239
3557
  // terminators are handled in the terminator section below.
1240
- const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
1241
3558
  const emitOpaqueDest = (ins: { mnemonic: string; ops: string[]; asWritten?: string }) => {
1242
3559
  // storeClass: unmodelled Thumb stores are str*/stm* — `stmia rN!, {…}`'s dest token `r0!`
1243
3560
  // fails isReg, so without this it would be skipped as "no reg dest", silently deleting the
@@ -1247,8 +3564,8 @@ export function lift(
1247
3564
  const od = opaqueDest(ins.mnemonic, ins.ops, {
1248
3565
  isReg: isThumbReg,
1249
3566
  normalize: reg,
1250
- storeClass: /^(str|stm)/,
1251
- skipSafe: /^(push|pop|nop)$/,
3567
+ storeClass: /^(str|stm)/i,
3568
+ skipSafe: /^(push|pop|nop)$/i,
1252
3569
  context: name,
1253
3570
  display: ins.asWritten,
1254
3571
  });
@@ -1259,7 +3576,7 @@ export function lift(
1259
3576
  const res = mkValue(T.unk(32));
1260
3577
  // carry the mnemonic so annotate mode can name the gap (`ASMLIFT_ERROR("unmodelled 'rsb'")`)
1261
3578
  irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.asWritten ?? ins.mnemonic } }));
1262
- writeVar(od.dst, bi, res);
3579
+ writeData(od.dst, bi, res);
1263
3580
  };
1264
3581
  // 2-operand ALU form `op rD, op2` (rD = rD ⟨op⟩ op2). `op2` is an immediate (`#N`) or a
1265
3582
  // register. A destination that is NOT a low data register (`add sp, #8` / `sub sp, #N` frame
@@ -1267,9 +3584,26 @@ export function lift(
1267
3584
  // harmlessly, matching the documented sp handling. A malformed operand (missing / non-register
1268
3585
  // non-immediate) degrades to a loud opaque rather than a crash or a silent data-dest drop.
1269
3586
  const emit2op = (opc: Opcode, dReg: string, op2: string | undefined, bi: number) => {
3587
+ // The one sp guard writeData CANNOT supply: this path returns without ever producing a value
3588
+ // to write, so a bad sp destination would never reach the write. It is only reachable from the
3589
+ // add/sub arms, which have already let the whitelisted frame adjust `break` out — so an sp
3590
+ // destination here is by construction NOT that shape (`add sp, r4`: a register-sized frame
3591
+ // adjustment, how agbcc spells a frame too large for the 7-bit immediate).
3592
+ //
3593
+ // This IS the site that declines `add sp, r4` today — traced 2026-09-06, the throw comes from
3594
+ // here, not from `writeData` and not from an arm. The add/sub arms let only the whitelisted
3595
+ // `sp = sp ± imm` break out, so everything else with an sp destination and no third operand
3596
+ // arrives here.
3597
+ //
3598
+ // Honesty about what that is worth on real input: the 4 `add sp, rN` sites in the sa3
3599
+ // checkout all sit in functions that ALSO do `mov rN, sp` 70+ times, so they declined before
3600
+ // this guard existed and decline after it. No wrong C was ever emitted by this shape.
3601
+ if (isSpReg(dReg)) {
3602
+ throw spAsDataError();
3603
+ }
1270
3604
  if (!isThumbReg(reg(dReg))) {
1271
3605
  return;
1272
- } // sp/pc frame adjustment: transparent
3606
+ } // pc: claimed by classifyXfer first
1273
3607
  if (op2 === undefined) {
1274
3608
  emitOpaqueDest({ mnemonic: opc, ops: [dReg] });
1275
3609
  return;
@@ -1277,7 +3611,7 @@ export function lift(
1277
3611
  const rhs = op2.startsWith('#') ? constVal(imm(op2), bi) : readData(reg(op2), bi);
1278
3612
  const res = mkValue(T.unk(32));
1279
3613
  irb.ops.push(mkOp(opc, { operands: [readData(reg(dReg), bi), rhs], results: [res] }));
1280
- writeVar(reg(dReg), bi, res);
3614
+ writeData(reg(dReg), bi, res);
1281
3615
  };
1282
3616
 
1283
3617
  for (const ins of ab.instrs) {
@@ -1303,23 +3637,53 @@ export function lift(
1303
3637
  if (FLAG_SETTING.has(ins.mnemonic) && /^r[0-7]$/.test(reg(ins.ops[0] ?? ''))) {
1304
3638
  pendingCmp = null;
1305
3639
  }
3640
+ frame.step(ins);
1306
3641
  const [a, b, c] = ins.ops;
1307
3642
  switch (ins.mnemonic) {
1308
3643
  case 'mov':
1309
3644
  case 'movs': {
3645
+ // `mov rD, sp` captures the address of the frame's local area — the DMA-fill idiom
3646
+ // (`DmaFill16` expands to `vu16 tmp; DmaSet(…, &tmp, …)`) and any `&local` argument.
3647
+ // Emitted as `laddr`, gaddr's local twin; every use is proven by the frame-object audit
3648
+ // after the blocks are filled, and any use it cannot vouch for declines the function
3649
+ // loudly there. Gated on the slot model (the frame must be private and immovable) and on
3650
+ // a reserved local area for the object to live in.
3651
+ if (!b?.startsWith('#') && isSpReg(b ?? '') && !isSpReg(a ?? '')) {
3652
+ if (slotsOk && localArea > 0) {
3653
+ const res = mkValue(T.unk(32));
3654
+ irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: 0 } }));
3655
+ writeData(reg(a), bi, res);
3656
+ break;
3657
+ }
3658
+ throw spAsDataError();
3659
+ }
1310
3660
  const v = b?.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
1311
- writeVar(reg(a), bi, v);
3661
+ writeData(reg(a), bi, v);
1312
3662
  break;
1313
3663
  }
1314
3664
  case 'add':
1315
3665
  case 'adds': {
3666
+ // Frame bookkeeping first: it must outrank the `#0` copy idiom below, or `add sp, sp, #0`
3667
+ // takes the copy path and declines while `add sp, #0` is transparent — the same
3668
+ // two-spellings inconsistency one N lower down.
3669
+ if (isFrameAdjust(ins.mnemonic, a, c === undefined ? undefined : b, c ?? b)) {
3670
+ break;
3671
+ }
1316
3672
  // `add rD, rS, #0` is agbcc's low-register copy idiom (Thumb `mov rD, rS` between
1317
- // low regs isn't always available). Model it as a pure copy — same SSA value — not
1318
- // an `x + 0` add. This keeps output clean and, crucially, makes a value copied to a
1319
- // callee-saved register before a call read as still-live *after* the call, which is
1320
- // how call-argument liveness tells a passed argument from a preserved one.
1321
- if (c === '#0') {
1322
- writeVar(reg(a), bi, readData(reg(b), bi));
3673
+ // low regs isn't always available). Model it as a pure copy — the SAME SSA VALUE — not
3674
+ // an `x + 0` add. Value identity is what it buys: the pattern engine matches on it
3675
+ // (`{same:'X'}`), and the structurer's pre-update loop test compares a back-edge argument
3676
+ // against an exit argument by identity, so an `x + 0` between them reads as a different
3677
+ // value and declines a loop that is perfectly ordinary.
3678
+ //
3679
+ // NOT call-argument liveness, which this comment claimed for several releases: both arms
3680
+ // end in `writeData(reg(a), …)`, and the arity machinery (`fallbackArgc`,
3681
+ // `trimClobberedCallArgs`) is keyed on the register, never on the value — measured, zero
3682
+ // arity changes across 3337 corpus functions even with this idiom ablated entirely. That
3683
+ // 3337 is a CHECKOUT sweep this repo does not vendor, not a benchmark count, and it has
3684
+ // not been re-run since.
3685
+ if (immEq(c, 0)) {
3686
+ writeData(reg(a), bi, readData(reg(b), bi));
1323
3687
  break;
1324
3688
  }
1325
3689
  // 2-operand form `add rD, op2` (rD = rD + op2): op2 in `b`, no third operand.
@@ -1328,22 +3692,27 @@ export function lift(
1328
3692
  emit2op('add', a, b, bi);
1329
3693
  break;
1330
3694
  }
3695
+
1331
3696
  const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1332
3697
  const res = mkValue(T.unk(32));
1333
3698
  irb.ops.push(mkOp('add', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1334
- writeVar(reg(a), bi, res);
3699
+ writeData(reg(a), bi, res);
1335
3700
  break;
1336
3701
  }
1337
3702
  case 'sub':
1338
3703
  case 'subs': {
3704
+ if (isFrameAdjust(ins.mnemonic, a, c === undefined ? undefined : b, c ?? b)) {
3705
+ break;
3706
+ }
1339
3707
  if (c === undefined) {
1340
3708
  emit2op('sub', a, b, bi);
1341
3709
  break;
1342
3710
  } // `sub rD, op2` → rD = rD - op2
3711
+
1343
3712
  const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1344
3713
  const res = mkValue(T.unk(32));
1345
3714
  irb.ops.push(mkOp('sub', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1346
- writeVar(reg(a), bi, res);
3715
+ writeData(reg(a), bi, res);
1347
3716
  break;
1348
3717
  }
1349
3718
  case 'lsr':
@@ -1370,7 +3739,7 @@ export function lift(
1370
3739
  // register form `lsl rD, rS, rN` → rD = rS << rN
1371
3740
  irb.ops.push(mkOp(opc, { operands: [readData(reg(b), bi), readData(reg(c), bi)], results: [res] }));
1372
3741
  }
1373
- writeVar(reg(a), bi, res);
3742
+ writeData(reg(a), bi, res);
1374
3743
  break;
1375
3744
  }
1376
3745
  case 'neg':
@@ -1378,7 +3747,7 @@ export function lift(
1378
3747
  // `neg rD, rS` (and `rsb rD, rS, #0`) = arithmetic negation → -x
1379
3748
  const res = mkValue(T.unk(32));
1380
3749
  irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1381
- writeVar(reg(a), bi, res);
3750
+ writeData(reg(a), bi, res);
1382
3751
  break;
1383
3752
  }
1384
3753
  case 'rsb':
@@ -1386,10 +3755,10 @@ export function lift(
1386
3755
  // Reverse subtract. `rsb rD, rS, #0` is the negate idiom (0 - rS) → -x. Any other form
1387
3756
  // (`rsb rD, rS, #N`, N≠0 — not a Thumb-1 encoding, but be safe) is NOT modelled: degrade
1388
3757
  // to a loud `opaque` rather than silently leaving rD unwritten (a silent miscompile).
1389
- if (c === '#0') {
3758
+ if (immEq(c, 0)) {
1390
3759
  const res = mkValue(T.unk(32));
1391
3760
  irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1392
- writeVar(reg(a), bi, res);
3761
+ writeData(reg(a), bi, res);
1393
3762
  } else {
1394
3763
  emitOpaqueDest(ins);
1395
3764
  }
@@ -1400,7 +3769,7 @@ export function lift(
1400
3769
  // `mvn rD, rS` = bitwise NOT → ~x
1401
3770
  const res = mkValue(T.unk(32));
1402
3771
  irb.ops.push(mkOp('not', { operands: [readData(reg(b), bi)], results: [res] }));
1403
- writeVar(reg(a), bi, res);
3772
+ writeData(reg(a), bi, res);
1404
3773
  break;
1405
3774
  }
1406
3775
  case 'bic':
@@ -1417,7 +3786,7 @@ export function lift(
1417
3786
  irb.ops.push(mkOp('not', { operands: [readData(mr, bi)], results: [inv] }));
1418
3787
  const res = mkValue(T.unk(32));
1419
3788
  irb.ops.push(mkOp('and', { operands: [readData(xr, bi), inv], results: [res] }));
1420
- writeVar(reg(a), bi, res);
3789
+ writeData(reg(a), bi, res);
1421
3790
  break;
1422
3791
  }
1423
3792
  case 'ror':
@@ -1431,7 +3800,7 @@ export function lift(
1431
3800
  const [xr, nr] = c !== undefined ? [reg(b), reg(c)] : [reg(a), reg(b)];
1432
3801
  const res = mkValue(T.unk(32));
1433
3802
  irb.ops.push(mkOp('rotr', { operands: [readData(xr, bi), readData(nr, bi)], results: [res] }));
1434
- writeVar(reg(a), bi, res);
3803
+ writeData(reg(a), bi, res);
1435
3804
  break;
1436
3805
  }
1437
3806
  case 'ldmia':
@@ -1461,22 +3830,12 @@ export function lift(
1461
3830
  break;
1462
3831
  }
1463
3832
  const baseReg = reg(writeback ? baseTok.slice(0, -1) : baseTok);
1464
- const list = expandRegList(
1465
- ins.ops
1466
- .slice(1)
1467
- .join(',')
1468
- .replace(/[{}]/g, '')
1469
- .split(',')
1470
- .map((r) => r.trim())
1471
- .filter(Boolean),
1472
- );
1473
- // An unexpandable range (alias endpoint, e.g. `r4-lr`) leaves a raw `-` token — the
1474
- // exact transfer set is ambiguous, so degrade to the loud opaque rather than guess.
1475
- if (list.some((r) => r.includes('-'))) {
1476
- emitOpaqueDest(ins);
1477
- break;
1478
- }
1479
- if (list.length === 0) {
3833
+ // Anything but a list of definite registers — an unexpandable range (alias endpoint,
3834
+ // e.g. `r4-lr`), a token naming no register, an empty list — leaves the transfer set
3835
+ // ambiguous, so degrade to the loud opaque rather than guess. Checking only for the
3836
+ // leftover `-` let `{foo}` through and fabricated a parameter out of it.
3837
+ const list = regListOf(ins.ops.slice(1));
3838
+ if (list === null) {
1480
3839
  emitOpaqueDest(ins);
1481
3840
  break;
1482
3841
  }
@@ -1513,9 +3872,17 @@ export function lift(
1513
3872
  if (ins.mnemonic === 'ldmia') {
1514
3873
  const res = mkValue(T.unk(32));
1515
3874
  irb.ops.push(
1516
- mkOp('load', { operands: [base0], results: [res], attrs: { off: 4 * i, signed: true, width: 4 } }),
3875
+ // `listOrder: true` this load's stream position is the LIST position, not the
3876
+ // order the source evaluated it (structure.ts's def-order re-spelling must not
3877
+ // trust it; the aload rebuilds in raise/arrays.ts and raise/struct-arrays.ts
3878
+ // must carry it forward)
3879
+ mkOp('load', {
3880
+ operands: [base0],
3881
+ results: [res],
3882
+ attrs: { off: 4 * i, signed: true, width: 4, listOrder: true },
3883
+ }),
1517
3884
  );
1518
- writeVar(reg(r), bi, res);
3885
+ writeData(reg(r), bi, res);
1519
3886
  } else {
1520
3887
  irb.ops.push(mkOp('store', { operands: [base0, readData(reg(r), bi)], attrs: { off: 4 * i, width: 4 } }));
1521
3888
  }
@@ -1528,7 +3895,7 @@ export function lift(
1528
3895
  if (!wroteBase) {
1529
3896
  const adv = mkValue(T.unk(32));
1530
3897
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
1531
- writeVar(baseReg, bi, adv);
3898
+ writeData(baseReg, bi, adv);
1532
3899
  }
1533
3900
  break;
1534
3901
  }
@@ -1564,7 +3931,7 @@ export function lift(
1564
3931
  : [readData(reg(a), bi), readData(reg(b), bi)];
1565
3932
  const res = mkValue(T.unk(32));
1566
3933
  irb.ops.push(mkOp(opc, { operands: [x, y], results: [res] }));
1567
- writeVar(reg(a), bi, res);
3934
+ writeData(reg(a), bi, res);
1568
3935
  break;
1569
3936
  }
1570
3937
  case 'cmp': {
@@ -1616,7 +3983,7 @@ export function lift(
1616
3983
  attrs: { sym: si.name, ...(si.kind === 'code' ? { code: true } : {}) },
1617
3984
  }),
1618
3985
  );
1619
- writeVar(reg(a), bi, res);
3986
+ writeData(reg(a), bi, res);
1620
3987
  break;
1621
3988
  }
1622
3989
  // INTERIOR attribution: a value strictly inside a sized data symbol becomes
@@ -1633,18 +4000,28 @@ export function lift(
1633
4000
  irb.ops.push(mkOp('gaddr', { results: [g], attrs: { sym: interior.info.name } }));
1634
4001
  irb.ops.push(mkOp('const', { results: [k], attrs: { value: interior.offset } }));
1635
4002
  irb.ops.push(mkOp('add', { operands: [g, k], results: [res] }));
1636
- writeVar(reg(a), bi, res);
4003
+ writeData(reg(a), bi, res);
1637
4004
  break;
1638
4005
  }
1639
4006
  const res = mkValue(T.unk(32));
1640
4007
  irb.ops.push(mkOp('const', { results: [res], attrs: { value: pr.value } }));
1641
- writeVar(reg(a), bi, res);
4008
+ writeData(reg(a), bi, res);
1642
4009
  break;
1643
4010
  }
1644
4011
  if (pr?.kind === 'gaddr') {
1645
4012
  const res = mkValue(T.unk(32));
1646
4013
  irb.ops.push(mkOp('gaddr', { results: [res], attrs: { sym: pr.sym } }));
1647
- writeVar(reg(a), bi, res);
4014
+ if (pr.addend !== 0) {
4015
+ // `.word gSym+N` = the machine loads gSym's address plus N. Emitted as an explicit
4016
+ // add so the addend is a VALUE, not an attribute a renderer could re-scale.
4017
+ const k = mkValue(T.unk(32));
4018
+ irb.ops.push(mkOp('const', { results: [k], attrs: { value: pr.addend } }));
4019
+ const sum = mkValue(T.unk(32));
4020
+ irb.ops.push(mkOp('add', { operands: [res, k], results: [sum] }));
4021
+ writeData(reg(a), bi, sum);
4022
+ break;
4023
+ }
4024
+ writeData(reg(a), bi, res);
1648
4025
  break;
1649
4026
  }
1650
4027
  if (pr?.kind === 'unmodelled') {
@@ -1666,6 +4043,64 @@ export function lift(
1666
4043
  // the same address arithmetic the encoding performs. (parseAddr used to silently
1667
4044
  // read `[rB]`, dropping the index — a silent miscompile; ldrsh exists ONLY in this
1668
4045
  // form in Thumb-1, so every ldrsh went through here.)
4046
+ // An incoming stack argument, read before its base becomes an sp decline. Every refusal
4047
+ // `argIndex` can make, and the soundness proof that each one is needed for, lives on the
4048
+ // predicate itself — restating them here is how the two copies drifted.
4049
+ {
4050
+ const index = frame.argIndex({ base, off, regOff }, width, bi);
4051
+ if (index !== null) {
4052
+ // Mint EVERY argument below this one — the register half included. Downstream naming
4053
+ // is POSITIONAL (structure.ts), so any hole binds every later parameter to the wrong
4054
+ // ABI slot, silently: `push {r4,r5,lr}; add r4,r3,#0; ldr r0,[sp,#0xc]` emitted a
4055
+ // 2-parameter signature where the ABI proves 5, with both of them bound wrong.
4056
+ //
4057
+ // Reading slot k proves the caller passed arguments 0..k: the register arguments are
4058
+ // filled before any stack argument exists, and the stack area is contiguous with slot
4059
+ // 4 at the lowest offset. So this is entailed by the calling convention, not guessed —
4060
+ // which is what separates it from inventing parameters a function might not have.
4061
+ // (It assumes one word per argument, which is what this frontend assumes everywhere —
4062
+ // it types every parameter s32. An 8-byte argument, which AAPCS may align into r1 or
4063
+ // straddle across r3 and the stack, would break the index↔slot correspondence; no
4064
+ // agbcc row in the corpus has one, and recovering them is its own capability.)
4065
+ //
4066
+ // ensureParam, NOT readVar: a register the entry block DEFINES before this point
4067
+ // (`bl g` then a read of the frame, the commonest shape there is) answers readVar with
4068
+ // that local definition and no parameter appears — reopening the very hole this loop
4069
+ // closes. It emitted `s32 f(s32 a0, s32 a1, s32 a2, s32 a3) { return g() + a3; }`:
4070
+ // arity 4 where the ABI proves 5, with the stack argument bound to r3's slot.
4071
+ for (let j = 0; j < index; j++) {
4072
+ ssa.ensureParam(j < target.argRegs.length ? target.argRegs[j] : stackArgKey(j), bi);
4073
+ }
4074
+ writeData(reg(a), bi, readVar(stackArgKey(index), bi));
4075
+ break;
4076
+ }
4077
+ }
4078
+ // The address-taken object at offset 0 comes FIRST: it is memory, not a slot, so it is
4079
+ // read with a real `load` through its `laddr` (see isFrameObjectAccess). No
4080
+ // reaching-def test — the callee holding the address is a writer this function cannot
4081
+ // see, so "never stored here" is not "holds nothing".
4082
+ if (isFrameObjectAccess(base, off, regOff, width)) {
4083
+ const addr = mkValue(T.unk(32));
4084
+ irb.ops.push(mkOp('laddr', { results: [addr], attrs: { off: 0 } }));
4085
+ const res = mkValue(T.unk(32));
4086
+ irb.ops.push(mkOp('load', { operands: [addr], results: [res], attrs: { off: 0, width, signed } }));
4087
+ writeData(reg(a), bi, res);
4088
+ break;
4089
+ }
4090
+ // A word reload from this function's own frame — the dual of the spill in the str arm.
4091
+ //
4092
+ // The reaching-def test is the whole soundness of it, and it mirrors the MIPS guard
4093
+ // exactly: a slot that was never STORED holds nothing this function put there, so
4094
+ // `readVar` would mint a phantom entry parameter for it and hand back a value the machine
4095
+ // never had. Above the frame that reading is right and is the incoming-argument path
4096
+ // above; INSIDE the frame it is an uninitialised local (or one whose address escaped
4097
+ // through a path the model missed), and the honest answer is the decline this falls
4098
+ // through to.
4099
+ if (isOwnFrameWordSlot(base, off, regOff, width) && ssa.hasReachingDef(slotKey(off), bi)) {
4100
+ usedSlotOffsets.add(off);
4101
+ writeData(reg(a), bi, readVar(slotKey(off), bi));
4102
+ break;
4103
+ }
1669
4104
  let baseVal = readData(base, bi);
1670
4105
  if (regOff !== undefined) {
1671
4106
  const sum = mkValue(T.unk(32));
@@ -1674,7 +4109,7 @@ export function lift(
1674
4109
  }
1675
4110
  const res = mkValue(T.unk(32));
1676
4111
  irb.ops.push(mkOp('load', { operands: [baseVal], results: [res], attrs: { off, width, signed } }));
1677
- writeVar(reg(a), bi, res);
4112
+ writeData(reg(a), bi, res);
1678
4113
  break;
1679
4114
  }
1680
4115
  case 'str':
@@ -1687,6 +4122,22 @@ export function lift(
1687
4122
  }
1688
4123
  const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1689
4124
  const { base, off, regOff } = parseAddr(b);
4125
+ // A word spill into this function's own frame: record the slot's value in SSA rather than
4126
+ // emitting a store through sp (which bytes qualify: see isOwnFrameWordSlot). A spill that
4127
+ // is never reloaded becomes a dead def and drops.
4128
+ // …unless offset 0 is the address-taken object (see isFrameObjectAccess), where the
4129
+ // store is a real write to memory that the callee holding the address reads back.
4130
+ if (isFrameObjectAccess(base, off, regOff, width)) {
4131
+ const addr = mkValue(T.unk(32));
4132
+ irb.ops.push(mkOp('laddr', { results: [addr], attrs: { off: 0 } }));
4133
+ irb.ops.push(mkOp('store', { operands: [addr, readData(reg(a), bi)], attrs: { off: 0, width } }));
4134
+ break;
4135
+ }
4136
+ if (isOwnFrameWordSlot(base, off, regOff, width)) {
4137
+ usedSlotOffsets.add(off);
4138
+ writeVar(slotKey(off), bi, readData(reg(a), bi));
4139
+ break;
4140
+ }
1690
4141
  let storeBase = readData(base, bi);
1691
4142
  if (regOff !== undefined) {
1692
4143
  // register-offset store: same exact `rB + rX` lowering as the load path above
@@ -1705,15 +4156,23 @@ export function lift(
1705
4156
  const targetSym = a;
1706
4157
  // Caller-supplied prototype wins; otherwise a known runtime helper (`__divsi3` &c.)
1707
4158
  // supplies its arity so its arguments are recovered; only then fall back to guessing.
1708
- const argc =
1709
- protoArity(prototypes[targetSym]) ?? protoArity(RUNTIME_HELPERS[targetSym]) ?? fallbackArgcHere(bi);
4159
+ const declared = protoArity(prototypes[targetSym]) ?? protoArity(RUNTIME_HELPERS[targetSym]);
4160
+ const argc = declared ?? fallbackArgcHere(bi);
1710
4161
  const args: Value[] = [];
1711
4162
  for (let k = 0; k < argc; k++) {
1712
4163
  args.push(readVar(`r${k}`, bi));
1713
4164
  }
1714
4165
  const res = mkValue(T.unk(32));
1715
- irb.ops.push(mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } }));
1716
- writeVar('r0', bi, res);
4166
+ const callOp = mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } });
4167
+ irb.ops.push(callOp);
4168
+ // A GUESSED arity is revisited in `finish()`: only once the whole function is lifted is it
4169
+ // known whether every path to here passes through another call, which would have clobbered
4170
+ // the argument registers this guess just read.
4171
+ if (declared === undefined) {
4172
+ ssa.recordGuessedCall(callOp, bi, target);
4173
+ }
4174
+ writeData('r0', bi, res); // the callee defines r0 …
4175
+ ssa.noteCall(bi); // … and the clobber is recorded after it, so that def is the CALLEE's
1717
4176
  break;
1718
4177
  }
1719
4178
  default:
@@ -1790,18 +4249,59 @@ export function lift(
1790
4249
 
1791
4250
  ssa.finish();
1792
4251
 
4252
+ // Prove every `laddr` this function emitted really does name one scalar local, or decline
4253
+ // (auditFrameObjects).
4254
+ auditFrameObjects({
4255
+ name,
4256
+ irBlocks,
4257
+ localArea,
4258
+ usedSlotOffsets,
4259
+ capturedObjectIsTheWholeFrame,
4260
+ prototypes,
4261
+ symbols,
4262
+ target,
4263
+ });
4264
+
1793
4265
  // Order the entry block's parameters by ABI register (r0, r1, r2, …) so downstream
1794
4266
  // naming (`a0`, `a1`, …) matches the calling convention, not the read order. Safe only
1795
4267
  // for the true entry (no predecessors) — a loop header's params are phis whose position
1796
4268
  // is index-aligned with predecessor terminator args and must not be reordered.
1797
4269
  const entry = irBlocks[0];
1798
4270
  // non-ABI live-in ranks LAST (99) — deliberate Thumb tie-break; MIPS/PPC's is -1/first
4271
+ //
4272
+ // "Non-ABI" means NOT AN ARGUMENT REGISTER, and it has to be tested that way rather than by the
4273
+ // shape of the name. A `/^r(\d+)$/` test ranked `r8` at 8 and `r4` at 4 while sending only `sl`
4274
+ // and `sb` to 99 — harmless while nothing else occupied ranks >= 4, and a positional miscompile
4275
+ // the moment incoming stack arguments started ranking there. `sub_80B6B3C` in sa3's
4276
+ // `asm/code_x.s` — still undecompiled, so not a benchmark row — takes 10 arguments (its caller
4277
+ // stores six words at [sp,#0]..[sp,#0x14] plus r0-r3) and saves r8 in its prologue;
4278
+ // the `r8` live-in and `@sarg8` tied at 8, the sort is stable, the prologue reads r8 first — so
4279
+ // ABI argument 8 was emitted as `a9` and every parameter after it was off by one.
4280
+ //
4281
+ // The register partition (LiveInModel.uninitRegs) takes most of r4-sl before they reach here — one
4282
+ // the ABI does not pass arguments in and this function saved is an uninitialised local, not a
4283
+ // parameter. What still ranks 99 is `lr`/`pc`, which the partition does not list, and an r4-sl the
4284
+ // prologue did not save. Not an argument either way, and the honest place for one is after
4285
+ // everything the convention actually describes.
1799
4286
  abiSortEntryParams(entry, preds[0].length > 0, (v) => {
1800
- const m = /^r(\d+)$/.exec(paramReg.get(v) ?? '');
1801
- return m ? +m[1] : 99;
4287
+ const key = paramReg.get(v) ?? '';
4288
+ // an incoming STACK argument ranks by its ABI index, after every register argument
4289
+ const s = stackArgIndex(key);
4290
+ if (s !== null) {
4291
+ return s;
4292
+ }
4293
+ const i = target.argRegs.indexOf(key);
4294
+ return i >= 0 ? i : 99;
1802
4295
  });
1803
4296
  return fn;
1804
4297
  }
1805
4298
 
1806
4299
  /** The ARMv4T / Thumb (agbcc) frontend, registered for the `armv4t` target. */
1807
4300
  export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lift };
4301
+
4302
+ /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
4303
+ * every source path under `./*`, so a plain `export` here would put the pad table, the pad
4304
+ * predicate and the witness serialisation into the package's public API — three things no
4305
+ * consumer should be able to depend on, exported only so a cross-check test could reach them.
4306
+ * The name says what it is; nothing in `src` reads it. */
4307
+ export const __testing = { PAD_ENCODINGS, isPadInstr, factKey, sayFact };