@asmlift/core 0.4.0 → 0.5.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.
@@ -14,7 +14,7 @@
14
14
  // modelling is needed; they simply fall through the decode/fill switch. Because agbcc may
15
15
  // copy a callee-saved argument (e.g. into r4) before touching r0, entry parameters are
16
16
  // ordered by ABI register (r0, r1, …), not by the order they were first read.
17
- import { Fn, Successor, Value, mkOp, mkValue } from '../ir/core';
17
+ import { Fn, Op, Successor, Value, mkOp, mkValue } from '../ir/core';
18
18
  import type { Opcode } from '../ir/opcodes';
19
19
  import { T } from '../ir/types';
20
20
  import { type Prototypes, protoArity } from '../proto';
@@ -27,7 +27,7 @@ import { FrontendUnsupportedError } from './errors';
27
27
  import { assertInputFormat } from './format';
28
28
  import type { Frontend } from './frontend';
29
29
  import { opaqueDest } from './opaque';
30
- import { abiSortEntryParams, fallbackArgc, makeSsaBuilder } from './ssa';
30
+ import { abiSortEntryParams, fallbackArgc, makeSsaBuilder, stackSlotKey } from './ssa';
31
31
 
32
32
  interface Instr {
33
33
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -71,17 +71,16 @@ interface AsmBlock {
71
71
  // than in decode arms like MIPS's `move` or PPC's `slwi`. That distinction, and why there is no
72
72
  // shared alias helper across the three frontends, is written up once in ./opaque.ts.
73
73
  //
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.)
74
+ // This table is about COVERAGE, not soundness: a spelling missing from it declines loudly like any
75
+ // other unmodelled instruction, and listing one buys that the function LIFTS instead. Every load
76
+ // spelling ARMv4T Thumb accepts is listed, because declining a whole function over a synonym is a
77
+ // poor trade. Note what may NOT justify an omission: "`as` rejects it, so it cannot appear". This
78
+ // frontend parses TEXT from luvdis/objdump/IDA/Ghidra and hand-written .s so what some
79
+ // assembler accepts says nothing about what it will be handed.
81
80
  //
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.
81
+ // `stmfd` is deliberately absent, and the asymmetry is real rather than an oversight: `stmfd` IS
82
+ // `stmdb` (decrement-before), and ARMv4T Thumb has no decrement-before store so there is nothing
83
+ // to normalise it TO. A fact about the instruction set, not about an assembler. It declines.
85
84
  //
86
85
  // Null-prototype so that an inherited key (`constructor`, `toString`) cannot be mistaken for an
87
86
  // entry. Unreachable from real assembly, but the lookup should not depend on that.
@@ -94,6 +93,15 @@ const LEGACY_MNEMONICS: Readonly<Record<string, string>> = Object.assign(Object.
94
93
  stmea: 'stmia',
95
94
  });
96
95
 
96
+ // An offset far above the frame cannot be an argument — agbcc passes at most a handful on the
97
+ // stack, and an absurd index would mint a signature with hundreds of parameters from one bad
98
+ // offset. 16 is well past any real agbcc call and still refuses nonsense loudly.
99
+ //
100
+ // It bounds TOTAL arity, register arguments included (12 stack slots on a 4-register ABI), because
101
+ // that is the unit the index it is compared against is counted in. A refusal bound, not an ABI
102
+ // fact: nothing may read it as a statement about how many arguments the convention allows.
103
+ const MAX_RECOVERED_ARITY = 16;
104
+
97
105
  function canonicalMnemonic(mn: string): string {
98
106
  return LEGACY_MNEMONICS[mn] ?? mn;
99
107
  }
@@ -178,7 +186,11 @@ const imm = (s: string) => parseInt(s.replace(/^#/, ''), s.includes('0x') ? 16 :
178
186
  // ambiguous and left UNEXPANDED — but its endpoints ARE surfaced as separate tokens so pc/lr
179
187
  // detection sees them, and any consumer that needs the exact list rejects the leftover `-` token
180
188
  // loudly rather than treating the fused range as one phantom register.
181
- const REG_NUM: Record<string, number> = { sp: 13, lr: 14, pc: 15 };
189
+ // Null-prototype for the same reason LEGACY_MNEMONICS has one: this table is consulted with `in`
190
+ // to decide whether a reglist token is a DEFINITE register, and an inherited key (`constructor`,
191
+ // `toString`) answering true would let a junk token pass that test instead of poisoning the frame
192
+ // depth. Unreachable from real assembly; the guarantee should not depend on that.
193
+ const REG_NUM: Record<string, number> = Object.assign(Object.create(null), { sp: 13, lr: 14, pc: 15 });
182
194
 
183
195
  // Thumb-1 data-processing mnemonics that write the condition flags when their destination is a LOW
184
196
  // register — which is all of them on this ISA, `s`-suffix or not (the assembler picks the encoding).
@@ -247,6 +259,29 @@ function expandRegList(tokens: string[]): string[] {
247
259
  return out;
248
260
  }
249
261
 
262
+ // Expand a register list and vouch that every entry is a DEFINITE register, or return null.
263
+ //
264
+ // The two consumers tokenize differently (the ldm/stm arm has a base register to slice off, the
265
+ // frame walk does not), so each keeps its own tokenizing — but the VALIDATION has to be one
266
+ // function, because the two hand-rolled versions had drifted to unequal strength. The frame walk
267
+ // required every token to be a real register; the ldm/stm arm only rejected a leftover `-`, so
268
+ // `ldmia r1!, {foo}` lifted and emitted `s32 f(s32 a0, s32 a1) { return a0; }` — a parameter
269
+ // fabricated from a token that names no register, which is the phantom this frontend's guards
270
+ // exist to prevent.
271
+ //
272
+ // An unexpandable range leaves its raw `-` token (see expandRegList) and fails here; so does an
273
+ // unknown alias, which a `Number.isNaN(regNum(t))` test would MISS, since regNum returns undefined
274
+ // for one and `Number.isNaN(undefined)` is false. An empty list is a malformed list, not an empty
275
+ // transfer. Lowercase-only is deliberate and free: an uppercase mnemonic declines as unmodelled
276
+ // long before either consumer runs.
277
+ function definiteRegList(tokens: string[]): string[] | null {
278
+ const list = expandRegList(tokens);
279
+ if (list.length === 0) {
280
+ return null;
281
+ }
282
+ return list.every((s) => /^r\d+$/.test(s) || s in REG_NUM) ? list : null;
283
+ }
284
+
250
285
  // Split an operand list on commas that are NOT inside brackets, so a memory operand like
251
286
  // `[r0, #0x8]` (base + offset) stays a single token instead of being torn at its comma.
252
287
  function splitOperands(s: string): string[] {
@@ -841,7 +876,10 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
841
876
  // (they did — the drift fabricated phantom pointer params on symbol-pool loads).
842
877
  const POOL_LABEL = /^([A-Za-z_.$][\w.$]*)(?:\s*\+\s*(0x[0-9a-fA-F]+|\d+))?$/;
843
878
 
844
- type PoolRef = { kind: 'const'; value: number } | { kind: 'gaddr'; sym: string } | { kind: 'unmodelled'; why: string };
879
+ type PoolRef =
880
+ | { kind: 'const'; value: number }
881
+ | { kind: 'gaddr'; sym: string; addend: number }
882
+ | { kind: 'unmodelled'; why: string };
845
883
 
846
884
  /** Classify a word-load operand `LABEL[+N]` against the captured literal pools. Returns null when
847
885
  * the operand does NOT name a pool (a real register/memory base → the normal load path). When it
@@ -867,12 +905,21 @@ function poolRef(operand: string, dataWords: Map<string, string[]>): PoolRef | n
867
905
  const val = w.startsWith('-') ? -Number(w.slice(1)) : Number(w);
868
906
  return Number.isFinite(val) ? { kind: 'const', value: val } : { kind: 'unmodelled', why: `unparsable word '${w}'` };
869
907
  }
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 };
908
+ // A C identifier that is NOT a `.L` code label → the address of a named global, optionally with
909
+ // a byte ADDEND folded into the pool word (`gBgTilemapBufs+0x14a` agbcc pre-computes a fixed
910
+ // element's address into the pool rather than emitting an add). The addend stays in VALUE space:
911
+ // the consumer emits `gaddr` then an explicit `add`, the exact spelling the register-materialised
912
+ // `ldr rN,=gSym; add rN,#k` shape already lowers to — so it renders through the same audited
913
+ // cast-based path (`((u8 *)&gSym) + k`), never through a typed-pointer scale that a
914
+ // rendered-vs-value addend could silently multiply (the DEREF-TYPING class).
915
+ const sm = w.match(/^([A-Za-z_]\w*)\s*(?:([+-])\s*(0x[0-9a-fA-F]+|\d+))?$/);
916
+ if (sm && !sm[1].startsWith('.L')) {
917
+ const mag = sm[3] ? Number(sm[3]) : 0;
918
+ if (Number.isFinite(mag)) {
919
+ return { kind: 'gaddr', sym: sm[1], addend: sm[2] === '-' ? -mag : mag };
920
+ }
874
921
  }
875
- return { kind: 'unmodelled', why: `pool word '${w}' is a symbol offset or code label` };
922
+ return { kind: 'unmodelled', why: `pool word '${w}' is not a symbol, symbol±offset, or number` };
876
923
  }
877
924
 
878
925
  /** Does this function's literal pool name at least one EXTERNAL symbol?
@@ -895,10 +942,13 @@ function poolNamesASymbol(dataWords: Map<string, string[]>, blockLabels: Set<str
895
942
  for (const [, words] of dataWords) {
896
943
  for (const raw of words) {
897
944
  const w = raw.trim();
898
- if (!/^[A-Za-z_]\w*$/.test(w) || w.startsWith('.L')) {
945
+ // `gSym+0x14a` names a symbol as surely as `gSym` does — the witness must count both, or an
946
+ // asm whose pools carry only addend words would wrongly permit numeric promotion.
947
+ const sym = w.match(/^([A-Za-z_]\w*)\s*(?:[+-]\s*(?:0x[0-9a-fA-F]+|\d+))?$/)?.[1];
948
+ if (sym === undefined || sym.startsWith('.L')) {
899
949
  continue;
900
950
  }
901
- if (!dataWords.has(w) && !blockLabels.has(w)) {
951
+ if (!dataWords.has(sym) && !blockLabels.has(sym)) {
902
952
  return true;
903
953
  }
904
954
  }
@@ -923,6 +973,76 @@ interface JumpTable {
923
973
  caseLabels: string[];
924
974
  defaultLabel: string;
925
975
  }
976
+
977
+ // Accept a data-processing mnemonic in either the pre-UAL (`lsl`, `add`) or UAL (`lsls`, `adds`)
978
+ // spelling, WITHIN THE DISPATCH BLOCK ONLY.
979
+ //
980
+ // In Thumb-1 the trailing `s` is a DIALECT MARKER, not a modifier: `.syntax divided` spells the
981
+ // flag-setting data-processing instructions without it, `.syntax unified` with it. For a LOW-register
982
+ // destination each pair is one halfword — measured with this project's own assembler, one instruction
983
+ // per file, both dialects, encoding read back with objdump:
984
+ //
985
+ // add/adds 1888 sub/subs 1a88 lsl/lsls 0088 lsr/lsrs 0888 asr/asrs 1088 neg/negs 4248
986
+ //
987
+ // This is the dominant dialect of the input format asmlift advertises: every pret-style split wraps
988
+ // its `INCLUDE_ASM` bodies in `.syntax unified`, where the non-suffixed spelling is a syntax ERROR,
989
+ // and the split `.s` files carry no `.syntax` directive of their own — so this frontend cannot tell
990
+ // from its input which dialect it is reading, and must accept both. Counted under
991
+ // `asm/nonmatchings` of the Klonoa: Empire of Dreams tree: `lsls` 5795 against `lsl` 0, and `adds`
992
+ // 8883 against `add` 600 — where **every one** of those 600 is an `sp`/high-register/pc form and not
993
+ // one is the three-operand low-register `add rD, rN, rM` this idiom uses. Comparing the text alone
994
+ // therefore declined every jump table in that corpus, on input that is not malformed in any way.
995
+ // (agbcc's own output is the other dialect — 2957 `lsl` in this project's `build-gdwarf/src/*.s` —
996
+ // which is why the benchmark, built from compiler `.s`, never exercised this.)
997
+ //
998
+ // Why LOCAL rather than an entry in LEGACY_MNEMONICS, which is where synonyms belong: normalising
999
+ // the suffix away is safe for every input an ASSEMBLER ACCEPTS — the operands that distinguish `add`
1000
+ // from `adds` (the SP adjust `add sp, sp, #4` = b001, the high-register `add r8, r0` = 4480) have no
1001
+ // S-form at all, so `adds sp` and `adds r8, r0` are rejected in both dialects and cannot appear in
1002
+ // any assemblable file. But this frontend REFUSES those spellings loudly today, and a flat
1003
+ // `adds: 'add'` entry silently turns `adds sp` into ordinary frame bookkeeping instead: the entry
1004
+ // was written, and `decline-guards.test.ts` failed on exactly that case. So the reason to keep it
1005
+ // local is input VALIDATION, not semantics — a name-keyed table cannot say "only when the
1006
+ // destination is a low register", and giving up the refusal buys nothing, since no assembler emits
1007
+ // what it refuses.
1008
+ //
1009
+ // Known false declines, all loud, none with a corpus instance: a parenthesised immediate (`#(2)`,
1010
+ // which gas assembles), a two-operand `adds rA, rP` (the same add, but `addSrcs` has one source),
1011
+ // and `movs pc, rV` (which IS `mov pc, rV` — 4687 — under divided syntax, and which `classifyXfer`
1012
+ // accepts, so the frontend is internally inconsistent about it).
1013
+ //
1014
+ // Inside the dispatch block the distinction is additionally UNOBSERVABLE: the block computes
1015
+ // `table_base + index*4`, loads the target and writes `pc`. Nothing between the `lsl` and the
1016
+ // `mov pc` reads NZCV, no path leaves the block by falling through, and on a successful recovery the
1017
+ // block is ELIDED from the CFG entirely — the bounds test that does feed a conditional branch lives
1018
+ // in `bounds`, whose `cmp`/`bhi`/`bls` this function matches by exact name. Doubly moot in fact,
1019
+ // since `lsl rD, rS, #imm` is low-register-only, so the add here is always the low-register form.
1020
+ const isDataOp = (mn: string, base: 'lsl' | 'add'): boolean => mn === base || mn === `${base}s`;
1021
+
1022
+ // A shift amount is a NUMBER, not a spelling. `#2`, `#0x2` and `#0x02` are the same shift; the
1023
+ // recogniser used to compare the operand text and so rejected the third.
1024
+ //
1025
+ // The operand must be a plain integer LITERAL, and that shape check is the whole point of this
1026
+ // helper rather than an incidental guard. `imm()` is `parseInt`, which stops at the first character
1027
+ // it cannot consume, so it reads `#2*2` as 2 — and `#2*2` is not malformed, it is an expression gas
1028
+ // accepts and assembles to `lsls r0, r1, #4`. Matching it as a shift by two would recover a switch
1029
+ // whose stride is wrong by a factor of four: the emitted C is entirely ordinary and dispatches to
1030
+ // the wrong BLOCK, with no marker. `#2+1`, `#2-1` and `#2<<1` are the same trap. An adversarial
1031
+ // probe found this after the first cut of this helper shipped with exactly that hole, and the test
1032
+ // that claimed to pin the property sampled only `#3`/`#0x3`/`#0x1`/`r2` and so passed anyway.
1033
+ //
1034
+ // Anything that is not a bare decimal or hex literal therefore DECLINES, which is the safe
1035
+ // direction: a real dispatch spells its shift as a literal. Known false declines, all loud and none
1036
+ // observed in any corpus: a binary literal (`#0b10`) and a signed one (`#+2`).
1037
+ //
1038
+ // One divergence is knowingly left in, and it is inert AT THE ONLY VALUE THIS IS USED WITH.
1039
+ // A leading zero means octal to gas and decimal to `Number`, so `#010` is 8 there and 10 here —
1040
+ // but both readings are compared against 2, both fail, and the dispatch declines either way; `#02`
1041
+ // is 2 under both. **If this helper is ever reused for a `want` other than 2, that has to be
1042
+ // revisited**, because for e.g. `want === 8` the two readings disagree about `#010`.
1043
+ const IMM_LITERAL = /^#\s*(?:0[xX][0-9a-fA-F]+|[0-9]+)$/;
1044
+ const immEq = (op: string | undefined, want: number): boolean =>
1045
+ op !== undefined && IMM_LITERAL.test(op) && Number(op.slice(1).trim()) === want;
926
1046
  function recoverJumpTable(
927
1047
  bounds: AsmBlock,
928
1048
  disp: AsmBlock,
@@ -976,7 +1096,7 @@ function recoverJumpTable(
976
1096
  return null;
977
1097
  }
978
1098
  const [lsl, ldrP, add, ldrV, movpc] = d;
979
- if (lsl.mnemonic !== 'lsl' || lsl.ops[1] !== scrutReg || (lsl.ops[2] !== '#0x2' && lsl.ops[2] !== '#2')) {
1099
+ if (!isDataOp(lsl.mnemonic, 'lsl') || lsl.ops[1] !== scrutReg || !immEq(lsl.ops[2], 2)) {
980
1100
  return null;
981
1101
  }
982
1102
  const idxReg = lsl.ops[0]; // rY = rX << 2 (index*4, identity guard)
@@ -985,19 +1105,40 @@ function recoverJumpTable(
985
1105
  }
986
1106
  const ptrReg = ldrP.ops[0],
987
1107
  ptrLabel = ldrP.ops[1]; // rP = *(PTR literal)
988
- if (add.mnemonic !== 'add' || add.ops[0] !== idxReg) {
1108
+ if (!isDataOp(add.mnemonic, 'add') || add.ops[0] !== idxReg) {
989
1109
  return null;
990
1110
  }
991
1111
  // add rY, rY, rP (either operand order) — the address = table_base + index*4, nothing else.
1112
+ //
1113
+ // The two sources must be DISTINCT registers. Membership alone is satisfied by one register
1114
+ // listed twice, and that is not a hypothetical shape: if the pointer load targets the index
1115
+ // register (`lsl r0,r1,#2 ; ldr r0,=PTR ; add r0,r0,r0`) the index is overwritten before it is
1116
+ // ever added, `idxReg === ptrReg`, and both `includes` tests pass on `r0`. The address formed is
1117
+ // `2 * table_base` and the scrutinee is dead — yet the recogniser would emit `switch (a0)` and
1118
+ // dispatch on a value the hardware never uses. Wrong block, no marker. Found by an adversarial
1119
+ // probe; it predates the spelling fix this guard sits next to, and is fixed here because it is
1120
+ // the same identity-or-decline rule.
992
1121
  const addSrcs = [add.ops[1], add.ops[2]];
993
- if (!(addSrcs.includes(idxReg) && addSrcs.includes(ptrReg))) {
1122
+ if (idxReg === ptrReg || !(addSrcs.includes(idxReg) && addSrcs.includes(ptrReg))) {
994
1123
  return null;
995
1124
  }
996
1125
  if (ldrV.mnemonic !== 'ldr') {
997
1126
  return null;
998
1127
  }
999
- const { base } = parseAddr(ldrV.ops[1]); // rV = *(rY)
1000
- if (base !== idxReg || ldrV.ops[0] !== movpc.ops[1]) {
1128
+ // rV = *(rY), and the address must be EXACTLY rY: no displacement, no register index.
1129
+ //
1130
+ // `parseAddr` surfaces `off` and `regOff` for precisely this reason — its own comment says
1131
+ // "surfaced to the caller so load/store DECLINE loud: silently reading `[rB]` dropped the index
1132
+ // — a silent miscompile" — and this caller used to destructure `base` alone and throw both away.
1133
+ // `ldr rV, [rA, #4]` loads table[i+1]: the recovered switch says `case 0` while the hardware
1134
+ // reaches case 1's block, and the last case reads a word past the table. `ldr rV, [rA, r2]` adds
1135
+ // an unrelated register. Both used to recover an ordinary-looking `switch` — a wrong BLOCK, with
1136
+ // no marker. The header of this function already claimed to refuse an "extra offset"; now it does.
1137
+ //
1138
+ // `#0` is the spelling the corpus actually uses (`ldr r0, [r0, #0x00]`), so the check is on the
1139
+ // VALUE, not on the operand's absence.
1140
+ const { base, off, regOff } = parseAddr(ldrV.ops[1]);
1141
+ if (base !== idxReg || off !== 0 || regOff !== undefined || ldrV.ops[0] !== movpc.ops[1]) {
1001
1142
  return null;
1002
1143
  }
1003
1144
  if (movpc.mnemonic !== 'mov' || movpc.ops[0] !== 'pc') {
@@ -1198,16 +1339,73 @@ export function lift(
1198
1339
  };
1199
1340
  const reg = (s: string) => s.replace(/[[\]]/g, '');
1200
1341
 
1342
+ // THE one test for "is this token the stack pointer". Case-insensitive because GNU as accepts
1343
+ // uppercase register names, and a case-sensitive test here is a silent-wrong-answer hole rather
1344
+ // than a cosmetic one: `add r0, SP, #4` is `&local`, and missing it fabricates a phantom
1345
+ // parameter and emits confident arithmetic on it.
1346
+ const isSpReg = (s: string | undefined): boolean => {
1347
+ const r = reg(s ?? '').toLowerCase();
1348
+ return r === 'sp' || r === 'r13';
1349
+ };
1350
+
1351
+ // Writing sp is transparent frame bookkeeping ONLY in the one shape that cannot change anything
1352
+ // observable: `sp = sp ± immediate`. Two producers feed this frontend and each emits exactly ONE
1353
+ // spelling, which is why both must work and why handling only one silently halved the input:
1354
+ //
1355
+ // producer `add sp, #N` `add sp, sp, #N`
1356
+ // agbcc's own .s (checkouts/*/build/src) 0 98
1357
+ // disassembly (klonoa asm/ · sa3 asm/) 203 · 1250 0
1358
+ //
1359
+ // So this is not "one tool with two spellings" — it is the compiler's convention against the
1360
+ // disassembler's, and asmlift reads both kinds of file. (An earlier commit message on this branch
1361
+ // claimed agbcc emitted both; it does not, and the counts above are the check that settles it.)
1362
+ //
1363
+ // Everything else that writes sp is a
1364
+ // frame change this frontend cannot model: a register-sized adjustment (`add sp, r4`, agbcc's
1365
+ // way of spelling a frame too large for the 7-bit immediate), a computed stack pointer
1366
+ // (`add sp, r0, #4`), or `mov sp, rN`. Those must DECLINE, not vanish — dropping them deletes a
1367
+ // frame change with no diagnostic, which is the exact
1368
+ // loud-becomes-silent trade this frontend's guards exist to prevent.
1369
+ //
1370
+ // Flag-setting spellings (`adds`/`subs`) are excluded deliberately: ARMv4T's SP-adjust encoding
1371
+ // does not set flags, so a flag-setting one can only come from hand-written asm, where dropping
1372
+ // it would leave a stale compare for a following conditional branch to fold. There are 0 in the
1373
+ // benchmark corpus and 0 across the klonoa and sa3 checkouts, so excluding them costs nothing.
1374
+ // The decline names WHY the slot model is off when it is (slotsOffReason, assigned below —
1375
+ // referenced through the closure, so this reads the final value at throw time). The gap
1376
+ // histogram is the improvement loop's work-list, and "local stack frames not supported" was a
1377
+ // false attribution for a function whose frame IS modelled but whose blocker is, say, an
1378
+ // address-taken local or an outgoing stack argument — it sent the loop to build the wrong thing.
1379
+ const spAsDataError = () =>
1380
+ new FrontendUnsupportedError(
1381
+ `cannot lift '${name}': stack pointer used as data — ` +
1382
+ (slotsOffReason ?? 'not a modelled slot (address-taken local / frame arithmetic / above the local area)'),
1383
+ );
1384
+
1385
+ const isFrameAdjust = (
1386
+ mnemonic: string,
1387
+ dest: string | undefined,
1388
+ base: string | undefined,
1389
+ off: string | undefined,
1390
+ ): boolean =>
1391
+ (mnemonic === 'add' || mnemonic === 'sub') &&
1392
+ isSpReg(dest) &&
1393
+ (base === undefined || isSpReg(base)) &&
1394
+ (off?.startsWith('#') ?? false);
1395
+
1201
1396
  // Reading sp as a DATA operand means an address-taken local (`add rD, sp, #N` = `&local`),
1202
1397
  // 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`).
1398
+ // modellable without a stack abstraction. Without this guard Braun SSA would materialize sp as a
1399
+ // fabricated PHANTOM parameter that scrambles the signature. Fail LOUD instead, mirroring MIPS
1400
+ // (`isStackPtr`) and PPC (`r1`).
1401
+ //
1402
+ // sp is never WRITTEN either — but by `writeData` declining, NOT because sp-dest ops are inert.
1403
+ // That was this file's premise until the frame-adjust whitelist landed, and it was wrong: five
1404
+ // decode arms wrote sp and dropped it silently. The single transparent shape is `sp = sp ± imm`,
1405
+ // whitelisted in the add/sub arms. Read and write are now guarded symmetrically.
1206
1406
  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
- );
1407
+ if (isSpReg(r)) {
1408
+ throw spAsDataError();
1211
1409
  }
1212
1410
  if (r === 'pc' || r === 'r15') {
1213
1411
  // A pc-relative literal load is rewritten to a pool label before reaching here (decode's
@@ -1224,6 +1422,539 @@ export function lift(
1224
1422
  return readVar(r, b);
1225
1423
  };
1226
1424
 
1425
+ // A virtual register key per incoming stack argument. Reading it goes through the ordinary Braun
1426
+ // live-in path (frontend/ssa.ts), which turns a read with no reaching def into a function
1427
+ // parameter — so this needs NO new representation, opcode or pass. The `@` cannot appear in a
1428
+ // real register token, so the key cannot collide with one.
1429
+ const stackArgKey = (index: number) => `@sarg${index}`;
1430
+ // Defined beside its mint site on purpose: the format string and its parser drifted 650 lines
1431
+ // apart in the first version, with the convention explained only at one end.
1432
+ const stackArgIndex = (key: string): number | null => {
1433
+ const m = /^@sarg(\d+)$/.exec(key);
1434
+ return m ? +m[1] : null;
1435
+ };
1436
+
1437
+ // INCOMING STACK ARGUMENTS (AAPCS). Args 1-4 arrive in r0-r3; args 5+ are pushed by the CALLER,
1438
+ // so the callee reads them at `[sp, #N]` where N is at or above its own frame. Those are
1439
+ // PARAMETERS, not locals — declining them as "sp used as data" refuses a calling convention.
1440
+ //
1441
+ // The frame depth is tracked by a linear walk of the ENTRY BLOCK only, which is
1442
+ // why the gate is what it is: within one straight-line block the depth at each instruction is
1443
+ // exact and needs no CFG reasoning. Every case that would need more declines.
1444
+ //
1445
+ // `push {a,b,c}` deepens by 4 per register; `sub sp, #N` and `add sp, sp, #-N` deepen by N.
1446
+ //
1447
+ // The walk, its two invariants and the predicate that reads them are ONE object on purpose. They
1448
+ // were three loose pieces — a delta function, a pair of `let`s updated in the instruction loop,
1449
+ // and a seven-parameter predicate taking the pair by value — and both bugs the second review pass
1450
+ // found lived in the seams: an invariant the predicate's proof needed but only the loop could
1451
+ // enforce, and a `0` returned for an unrecognised shape that only a guard in a third place made
1452
+ // safe. Anything that changes how sp moves now has one place to change, and the proof it has to
1453
+ // preserve is written next to the state it is about.
1454
+ const makeFrameWalk = () => {
1455
+ // Bytes the frame has grown since function entry, along this block's linear order only.
1456
+ let depth = 0;
1457
+ let depthKnown = true;
1458
+
1459
+ // Returns null whenever the depth cannot be computed exactly — including for any write to sp
1460
+ // this does not model. The capability rests on the depth being EXACT, so an approximation is
1461
+ // never acceptable: understate the frame and a local sits above the computed top and gets
1462
+ // minted as a parameter reading uninitialised stack. A null poisons the depth for the rest of
1463
+ // the block, which disables argument recovery and leaves every `[sp,#N]` to decline as before.
1464
+ // Nothing here may return a NUMBER for a shape it merely failed to recognise.
1465
+ const delta = (ins: { mnemonic: string; ops: string[] }): number | null => {
1466
+ const m = ins.mnemonic;
1467
+ if (m === 'push' || m === 'pop') {
1468
+ // expandRegList, NOT a comma count: `push {r4-r7, lr}` is FIVE registers, and counting it as
1469
+ // two makes the frame 12 bytes too shallow — which turns a genuine LOCAL into a fabricated
1470
+ // parameter reading uninitialised stack. Caught by a probe, not by the corpus: agbcc emits no
1471
+ // range pushes and 0 of the 743 benchmark rows contain one, but GNU as accepts them and the
1472
+ // disassembly path can produce them.
1473
+ // Counting comma tokens instead would undercount `{r4-lr}` as two registers, and an
1474
+ // unexpandable range must poison the depth rather than be guessed at — definiteRegList owns
1475
+ // both rules, and owns them for the ldm/stm arm too.
1476
+ const list = definiteRegList(
1477
+ ins.ops
1478
+ .join(',')
1479
+ .replace(/[{}]/g, '')
1480
+ .split(',')
1481
+ .map((r) => r.trim())
1482
+ .filter(Boolean),
1483
+ );
1484
+ if (list === null) {
1485
+ return null;
1486
+ }
1487
+ return (m === 'push' ? 1 : -1) * 4 * list.length;
1488
+ }
1489
+ if ((m === 'add' || m === 'sub') && isSpReg(ins.ops[0])) {
1490
+ const off = ins.ops[2] ?? ins.ops[1];
1491
+ if (off?.startsWith('#')) {
1492
+ const v = imm(off);
1493
+ return m === 'sub' ? v : -v;
1494
+ }
1495
+ }
1496
+ // Any OTHER write to sp poisons the depth. This used to fall through to 0 — "no change" — for
1497
+ // shapes it does not model (`add sp, r4`, `mov sp, rN`, `add sp, r0, #4`), which was safe only
1498
+ // because writeData declines every one of them elsewhere. That is the same
1499
+ // enumeration-of-arms mistake writeData itself exists to end, exported one function away: a
1500
+ // number meaning "no change" is the wrong answer to "I do not understand this". Now the walk is
1501
+ // self-sufficient — unknown ⇒ depth poisoned ⇒ decline — and writeData's refusal is an
1502
+ // independent second guarantee instead of a load-bearing one.
1503
+ if (isSpReg(ins.ops[0])) {
1504
+ return null;
1505
+ }
1506
+ return 0;
1507
+ };
1508
+
1509
+ return {
1510
+ // Advance the walk over one instruction. Call for EVERY instruction, in order.
1511
+ step(ins: { mnemonic: string; ops: string[] }): void {
1512
+ const d = delta(ins);
1513
+ if (d === null) {
1514
+ depthKnown = false;
1515
+ } else {
1516
+ depth += d;
1517
+ }
1518
+ // sp ABOVE the incoming sp poisons the walk for the rest of the block, permanently — the
1519
+ // premise argIndex's proof rests on. See the proof there for what it costs to omit.
1520
+ if (depth < 0) {
1521
+ depthKnown = false;
1522
+ }
1523
+ },
1524
+
1525
+ // Is `[sp, #off]` an incoming stack argument, and which one? `null` means NO — and every null is
1526
+ // a DECLINE, because the caller falls through to readData's sp guard.
1527
+ //
1528
+ // Why a slot at or above the frame top cannot have been written by this function, which is the
1529
+ // whole soundness argument: every sp-relative STORE declines (readData, via the str arm), and a
1530
+ // `push` only ever writes strictly BELOW the current top. An argument slot is at or above the
1531
+ // incoming sp, so no instruction here can have defined it — the value can only be the caller's.
1532
+ //
1533
+ // That second step needs sp to have stayed at or below where it came in — `depth >= 0` at every
1534
+ // point of the walk — or a `push` reaches back up over the argument area and the conclusion is
1535
+ // false:
1536
+ //
1537
+ // add sp, sp, #8 ; sp = S+8
1538
+ // push {r4, r5, r6} ; sp = S-4, and this WROTE r5 to S+0
1539
+ // ldr r0, [sp, #4] ; = S+0 — r5's slot, not the caller's argument
1540
+ //
1541
+ // The depth is back to a plausible +4 by the load, so nothing downstream can tell: it emitted
1542
+ // `s32 f(s32 a0, …, s32 a4) { return a4; }`, the function's own incoming r5 handed back as
1543
+ // argument 5. `pop {r4}; push {r4,r5}` gets there without an `add sp` at all, and a sliced
1544
+ // fragment whose prologue was cut off is exactly this shape — which is the corpus this frontend
1545
+ // reads. `step` enforces it, and never un-poisons: once sp has been above the line, a push during
1546
+ // the excursion may have written the later slots too. This is a PREMISE, not a detail — leaving
1547
+ // it unstated is what let the first version hand back a callee-saved register as an argument.
1548
+ argIndex(addr: { base: string; off: number; regOff?: string }, width: number, bi: number): number | null {
1549
+ const { base, off, regOff } = addr;
1550
+ if (!isSpReg(base) || regOff !== undefined) {
1551
+ return null; // not sp, or `[sp, rX]` — not a fixed argument slot
1552
+ }
1553
+ if (bi !== 0 || preds[0].length > 0) {
1554
+ return null; // depth is exact only along the ENTRY block's linear order, and only when its
1555
+ // params are parameters rather than phis
1556
+ }
1557
+ if (!depthKnown || depth <= 0) {
1558
+ return null; // an unmeasurable frame, or none established: a headerless FRAGMENT whose
1559
+ // prologue was sliced off looks identical to a frameless function, and there the slots are
1560
+ // locals — minting one would be the silent-wrong trade this frontend refuses
1561
+ }
1562
+ if (width !== 4 || off < depth || (off - depth) % 4 !== 0) {
1563
+ return null; // the argument area is word-granular; BELOW the top is a local, which is the
1564
+ // separate slot-promotion capability
1565
+ }
1566
+ const index = target.argRegs.length + (off - depth) / 4;
1567
+ return index < MAX_RECOVERED_ARITY ? index : null; // a wild offset must not mint a
1568
+ // 400-parameter signature
1569
+ },
1570
+ };
1571
+ };
1572
+
1573
+ // LOCAL STACK SLOTS. A spill or a local that never has its address taken is transparent to
1574
+ // dataflow: `str rX,[sp,#k]` … `ldr rY,[sp,#k]` moves a value, it does not touch memory anyone
1575
+ // else can see. Modelling it as an SSA variable keyed by the offset (the same `sp@<off>` spelling
1576
+ // the MIPS frontend uses) makes it exactly that, and Braun's phi construction handles a slot that
1577
+ // is read-modify-written across a loop with no extra machinery.
1578
+ //
1579
+ // What Thumb does NOT inherit from MIPS: keying by the RAW sp offset is only sound while sp holds
1580
+ // the same value at every access, and MIPS gets that for free (IDO establishes sp with one
1581
+ // `addiu` and never moves it). Thumb's `push` moves sp, so constancy has to be PROVEN here.
1582
+ const slotKey = stackSlotKey; // shared spelling: frontend/ssa.ts
1583
+ // Deliberately OVER-inclusive: a `cmp sp, rN` only reads sp but counts here too. Every false
1584
+ // positive costs a decline, every false negative costs a wrong slot — so it errs loudly.
1585
+ const modifiesSp = (ins: Instr): boolean =>
1586
+ ins.mnemonic === 'push' || ins.mnemonic === 'pop' || isSpReg((ins.ops[0] ?? '').replace(/!$/, ''));
1587
+ // A `mov rD, sp` CAPTURES the frame address; the captured value means "the frame base" only if
1588
+ // sp still holds that base wherever the value is used — so a capture participates in the
1589
+ // constancy proof exactly like a literal [sp,#k] access, and ends the prologue for localArea.
1590
+ const capturesSp = (ins: Instr): boolean =>
1591
+ /^movs?$/.test(ins.mnemonic) && !isSpReg(ins.ops[0] ?? '') && isSpReg(ins.ops[1] ?? '');
1592
+ const touchesFrame = (ins: Instr): boolean => spMemAccess(ins) !== null || capturesSp(ins);
1593
+ const spMemAccess = (ins: Instr): { off: number; width: number; regOff: boolean } | null => {
1594
+ if (!/^(ldr|ldrb|ldrh|ldrsb|ldrsh|str|strb|strh)$/.test(ins.mnemonic)) {
1595
+ return null;
1596
+ }
1597
+ const mem = ins.ops[1];
1598
+ if (mem === undefined) {
1599
+ return null;
1600
+ }
1601
+ const { base, off, regOff } = parseAddr(mem);
1602
+ return isSpReg(base)
1603
+ ? { off, width: /b$/.test(ins.mnemonic) ? 1 : /h$/.test(ins.mnemonic) ? 2 : 4, regOff: regOff !== undefined }
1604
+ : null;
1605
+ };
1606
+ // Is the word-slot model safe for THIS function? Every disqualifier below leaves every `[sp,#k]`
1607
+ // access on the old path, which declines — so the answer to "not sure" is the loud one.
1608
+ // Returns null when the word-slot model is SAFE for this function, else the reason it is off —
1609
+ // which the sp declines append, so a refused function names the capability actually missing
1610
+ // instead of the generic "local stack frames". The gap histogram is the improvement loop's
1611
+ // work-list; a misattributed refusal sends that loop to build the wrong thing.
1612
+ const slotModelBlocker = (): string | null => {
1613
+ for (const ab of asmBlocks) {
1614
+ for (const ins of ab.instrs) {
1615
+ const acc = spMemAccess(ins);
1616
+ // A SUB-WORD access aliasing a word slot is the hazard MIPS paid for the hard way: routing
1617
+ // the word store to an SSA slot while the byte reload stays on the memory path drops the
1618
+ // store and reads uninitialised memory. One anywhere disables the model for the whole
1619
+ // function. A register offset can alias any slot, so it disqualifies the same way.
1620
+ if (acc && (acc.width !== 4 || acc.regOff)) {
1621
+ return acc.regOff
1622
+ ? 'a register-offset sp access can alias any slot'
1623
+ : 'a sub-word sp access aliases the word-slot model';
1624
+ }
1625
+ // sp escaping into a register: a computed form (`add rD, sp, #k`) is still a refusal, but a
1626
+ // plain COPY (`mov rD, sp`) is now the address-taken-local capability — the mov arm emits a
1627
+ // `laddr` for it and the post-lift frame-object audit proves every use, so the model's
1628
+ // remaining precondition is that the frame has a reserved local area for the object to
1629
+ // live in. A frameless function taking sp's address has nothing to model and refuses.
1630
+ if (ins.mnemonic === 'add' && !isSpReg(ins.ops[0] ?? '') && ins.ops.slice(1).some((o) => isSpReg(o))) {
1631
+ return `the address of a stack local is computed (\`${ins.mnemonic} ${ins.ops.join(', ')}\`) — only a plain \`mov rD, sp\` capture is modelled`;
1632
+ }
1633
+ }
1634
+ }
1635
+ // sp must be CONSTANT wherever a slot is keyed, because the key IS the raw offset. Two shapes
1636
+ // are legitimate and everything else refuses: a PROLOGUE (sp moves before the block touches the
1637
+ // frame) and an EPILOGUE (sp moves after it has finished touching it, and the block returns, so
1638
+ // nothing downstream can key a slot against the changed sp). A modification BETWEEN two
1639
+ // accesses moves the frame under a slot already keyed, and only the entry block may deepen —
1640
+ // elsewhere a pre-access modification would put the block at a different depth from the one the
1641
+ // prologue established.
1642
+ for (let bi = 0; bi < asmBlocks.length; bi++) {
1643
+ const ins = asmBlocks[bi].instrs;
1644
+ const mems = ins.map((x, i) => (touchesFrame(x) ? i : -1)).filter((i) => i >= 0);
1645
+ const mods = ins.map((x, i) => (modifiesSp(x) ? i : -1)).filter((i) => i >= 0);
1646
+ if (mods.length === 0) {
1647
+ continue;
1648
+ }
1649
+ const last = ins[ins.length - 1];
1650
+ const returns = last !== undefined && classifyXfer(last) === 'return';
1651
+ if (mems.length === 0) {
1652
+ // moves sp and never keys a slot itself: safe only if nothing downstream can key one
1653
+ if (!returns && bi !== 0) {
1654
+ return 'sp moves in a block that neither returns nor is the entry';
1655
+ }
1656
+ continue;
1657
+ }
1658
+ for (const m of mods) {
1659
+ if (m < mems[0]) {
1660
+ if (bi !== 0) {
1661
+ return 'a non-entry block establishes its own frame depth'; // not the prologue's
1662
+ }
1663
+ } else if (m > mems[mems.length - 1]) {
1664
+ if (!returns) {
1665
+ return 'sp unwinds mid-function and execution continues';
1666
+ }
1667
+ } else {
1668
+ return 'the frame moves between two accesses that keyed slots against it';
1669
+ }
1670
+ }
1671
+ }
1672
+ // OUTGOING ARGUMENTS. agbcc reserves the BOTTOM of the frame for arguments 5+ of the calls this
1673
+ // function makes: `add sp,sp,#-8` … `str r2,[sp]` / `str r3,[sp,#4]` … `bl callee`. Those
1674
+ // offsets are inside the frame and nothing this function does ever reloads them, so modelling
1675
+ // them as locals makes them dead defs and DCE deletes them — the arguments vanish from the call
1676
+ // with no diagnostic. Ground truth: sa3's CreateEntity_Platform_0_0 (platform.c:734) forwards
1677
+ // SIX arguments and came out as `CreateEntity_Platform(0, 0, a0, (u16)a1)`. "Inside my frame"
1678
+ // does not mean "private": the outgoing area belongs to the callee, which may even assign to a
1679
+ // stack parameter.
1680
+ //
1681
+ // How big is that area? A declared arity bounds it from BELOW — `4 * max(0, arity - 4)` — and
1682
+ // that is all the facts available here can support. It is used in exactly one direction: to
1683
+ // REFUSE. A callee declared with five parameters proves this frame has an outgoing area, and
1684
+ // consuming those stores as call operands is the dual capability, unbuilt, so the model
1685
+ // declines. A callee declared with four proves NOTHING, because a declaration is a lower bound
1686
+ // on the words a call actually pushes:
1687
+ //
1688
+ // * a parameter may occupy more than one word (`double`, `long long`, a struct by value),
1689
+ // * a variadic callee's list is a prefix — `sprintf` truthfully declares two and is handed six,
1690
+ // * a large struct return adds a hidden pointer argument that appears in no parameter list.
1691
+ //
1692
+ // None of those is recorded by `FnProto` or `SymbolSignature`, so no arity here can license an
1693
+ // ACCEPTANCE. An earlier cut treated `arity <= 4` as proof of an empty area and had all three
1694
+ // holes: supplying a TRUE fact (`{ sprintf: { params: 2 } }`) turned a correct decline into
1695
+ // `return sprintf(a0, a1)` with both stack arguments deleted, where supplying nothing declined.
1696
+ // A fact must only ever move a function toward refusal — never toward an acceptance the facts
1697
+ // do not entail. Refusing on a lower bound is monotone in exactly that way: a true arity larger
1698
+ // than declared can only make the area bigger, and the answer is already "decline".
1699
+ //
1700
+ // Measured, this costs nothing it was buying: forcing the old acceptance path off changed 0
1701
+ // lift/decline verdicts across 2686 corpus functions (sa3's vendored map carries no signatures
1702
+ // at all), so the path that carried those holes was never load-bearing.
1703
+ for (const ab of asmBlocks) {
1704
+ for (const ins of ab.instrs) {
1705
+ if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
1706
+ continue;
1707
+ }
1708
+ const c = ins.ops[0] ?? '';
1709
+ const arity = protoArity(prototypes[c]) ?? protoArity(RUNTIME_HELPERS[c]);
1710
+ if (arity !== undefined && arity > target.argRegs.length) {
1711
+ return `callee \`${c}\` is declared with ${arity} arguments, so this frame has an outgoing stack-argument area — consuming stack call arguments is not implemented`;
1712
+ }
1713
+ }
1714
+ }
1715
+ // Nothing above could prove the area empty, so fall back to reading the CODE. Two conditions,
1716
+ // covering different escapes:
1717
+ // (a) every slot store must be reloaded somewhere reachable. An outgoing argument is read by
1718
+ // the CALLEE, never by the caller, so a store never read back is the signature of one.
1719
+ // (b) no slot store may reach a `bl` unread ALONG A PATH.
1720
+ //
1721
+ // Neither is sound alone and the pair is not either, so keep two things straight. (a)'s real
1722
+ // theorem is not "the callee reads it, the caller does not" — it is that agbcc's
1723
+ // ACCUMULATE_OUTGOING_ARGS puts the outgoing area at the BOTTOM of localArea, disjoint from the
1724
+ // locals, so no local load can land on an argument offset. That disjointness is what a
1725
+ // tail-merged call site breaks, and agbcc DOES tail-merge: `Task_BonusFlower_Spawn` (sa3
1726
+ // bonus_game_enemies) stores argument 5 in both predecessors with the `bl` in the join.
1727
+ //
1728
+ // (b) is a forward may-analysis over the CFG, and it has been wrong twice in the other
1729
+ // direction. Scanning per block let a LABEL decide accept versus refuse; scanning the flat
1730
+ // listing let BLOCK ORDER decide, because a load in one arm of a branch cleared a store that
1731
+ // reaches the call through the other arm — swap the arms in the listing, same CFG and same
1732
+ // semantics, and the verdict flipped. Only the path-sensitive form is stable under layout.
1733
+ //
1734
+ // Only for a function that CALLS. With no call there is no outgoing area to mistake a local
1735
+ // for, and a never-reloaded store there is an ordinary dead local — which PR #30 modelled and
1736
+ // which must keep working.
1737
+ if (asmBlocks.some((ab) => ab.instrs.some((i) => i.mnemonic === 'bl' || i.mnemonic === 'blx'))) {
1738
+ const slotAcc = (ins: Instr) => {
1739
+ const a = spMemAccess(ins);
1740
+ return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off < localArea ? a.off : null;
1741
+ };
1742
+ const isStore = (ins: Instr) => /^str/.test(ins.mnemonic);
1743
+ // Entry-REACHABLE blocks only: a reload in dead code is not evidence that live code reads the
1744
+ // slot back, and counting it lets an argument store satisfy (a) on the strength of an
1745
+ // instruction that never executes.
1746
+ const live = new Set<number>([0]);
1747
+ for (let changed = true; changed;) {
1748
+ changed = false;
1749
+ for (let b = 0; b < asmBlocks.length; b++) {
1750
+ if (live.has(b)) {
1751
+ continue;
1752
+ }
1753
+ if (preds[b].some((q) => live.has(q))) {
1754
+ live.add(b);
1755
+ changed = true;
1756
+ }
1757
+ }
1758
+ }
1759
+ const reloaded = new Set<number>();
1760
+ for (const b of live) {
1761
+ for (const ins of asmBlocks[b].instrs) {
1762
+ const off = slotAcc(ins);
1763
+ if (off !== null && !isStore(ins)) {
1764
+ reloaded.add(off);
1765
+ }
1766
+ }
1767
+ }
1768
+ // CONTIGUITY. AAPCS lays the outgoing stack arguments at [sp,#0] upward, one word each, so an
1769
+ // argument block is CONTIGUOUS FROM ZERO: a store at [sp,#4] can be argument 6 of a call only
1770
+ // if argument 5 at [sp,#0] is also supplied on a path to that same call. A pending store
1771
+ // whose lower slots are nowhere supplied is therefore provably not an argument block, and
1772
+ // refusing it is a false alarm — the exact false alarm that blocked the commonest real shape,
1773
+ // a value spilled at [sp,#4] and kept live across calls (kleod's ProcessInputAndUpdateEntities
1774
+ // stores its `sp4` local and calls m4aSongNumStart 80 lines later, with offset 0 never stored
1775
+ // in the whole function).
1776
+ //
1777
+ // The calibration in this: a conforming caller stores EVERY argument slot of a call it makes,
1778
+ // so "slot 0 unsupplied" rules out "slot 4 is an argument". Hand-written asm could skip
1779
+ // storing an argument the callee never reads; agbcc cannot (no interprocedural dead-argument
1780
+ // elimination). That is the same producer assumption the reload conditions above already
1781
+ // make, stated once here.
1782
+ const prefixStored = (k: number, st: Set<number>): boolean => {
1783
+ for (let j = 0; j < k; j += 4) {
1784
+ if (!st.has(j)) {
1785
+ return false;
1786
+ }
1787
+ }
1788
+ return true;
1789
+ };
1790
+ // (a), contiguity-filtered: a store never reloaded ANYWHERE is an argument's signature only
1791
+ // if its lower slots are supplied somewhere too; otherwise it is an ordinary dead local.
1792
+ const storedAnywhere = new Set<number>();
1793
+ for (const b of live) {
1794
+ for (const ins of asmBlocks[b].instrs) {
1795
+ const off = slotAcc(ins);
1796
+ if (off !== null && isStore(ins)) {
1797
+ storedAnywhere.add(off);
1798
+ }
1799
+ }
1800
+ }
1801
+ for (const off of storedAnywhere) {
1802
+ if (!reloaded.has(off) && prefixStored(off, storedAnywhere)) {
1803
+ 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)
1804
+ }
1805
+ }
1806
+ // (b): `pendingOut[b]` = offsets stored and not yet reloaded on SOME path through b;
1807
+ // `storedOut[b]` = offsets stored on SOME path through b (a reload does not remove the value
1808
+ // from memory, so it does not remove the offset from this set — the callee would still read
1809
+ // what the store put there).
1810
+ const pendingOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
1811
+ const storedOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
1812
+ for (let changed = true; changed;) {
1813
+ changed = false;
1814
+ for (let b = 0; b < asmBlocks.length; b++) {
1815
+ if (!live.has(b)) {
1816
+ continue;
1817
+ }
1818
+ const pend = new Set<number>();
1819
+ const st = new Set<number>();
1820
+ for (const q of preds[b]) {
1821
+ for (const off of pendingOut[q]) {
1822
+ pend.add(off);
1823
+ }
1824
+ for (const off of storedOut[q]) {
1825
+ st.add(off);
1826
+ }
1827
+ }
1828
+ for (const ins of asmBlocks[b].instrs) {
1829
+ const off = slotAcc(ins);
1830
+ if (off !== null) {
1831
+ if (isStore(ins)) {
1832
+ pend.add(off);
1833
+ st.add(off);
1834
+ } else {
1835
+ pend.delete(off);
1836
+ }
1837
+ } else if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
1838
+ for (const k of pend) {
1839
+ if (prefixStored(k, st)) {
1840
+ // (b) — a plausible argument block reaches this call unread
1841
+ 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`;
1842
+ }
1843
+ }
1844
+ }
1845
+ }
1846
+ const grow = (out: Array<Set<number>>, cur: Set<number>): void => {
1847
+ if (cur.size !== out[b].size || [...cur].some((o) => !out[b].has(o))) {
1848
+ out[b] = cur;
1849
+ changed = true;
1850
+ }
1851
+ };
1852
+ grow(pendingOut, pend);
1853
+ grow(storedOut, st);
1854
+ }
1855
+ }
1856
+ }
1857
+ // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
1858
+ // taken while the local area is still reserved reads a slot this model has retargeted into SSA
1859
+ // — the load simply disagrees with the store. A real epilogue releases the locals first
1860
+ // (`add sp,#N; pop {…}`), which is what this requires; `pop {r1}` mid-frame does not.
1861
+ for (const ab of asmBlocks) {
1862
+ let released = 0;
1863
+ for (const ins of ab.instrs) {
1864
+ if ((ins.mnemonic === 'pop' || ins.mnemonic === 'ldmia') && released < localArea) {
1865
+ const base = ins.mnemonic === 'pop' ? 'sp' : (ins.ops[0] ?? '').replace(/!$/, '');
1866
+ if (isSpReg(base)) {
1867
+ return 'a pop reads the frame while the local area is still reserved';
1868
+ }
1869
+ }
1870
+ if ((ins.mnemonic === 'add' || ins.mnemonic === 'sub') && isSpReg(ins.ops[0])) {
1871
+ const d = spAdjust(ins);
1872
+ if (d !== null && d > 0) {
1873
+ released += d;
1874
+ }
1875
+ }
1876
+ }
1877
+ }
1878
+ return null;
1879
+ };
1880
+ // The frame the body sees: the entry block's PROLOGUE, i.e. everything up to the first
1881
+ // instruction that touches the frame (or the whole block if it never does). Stepped through the
1882
+ // same walk `argIndex` uses, so "what does this do to sp" has one implementation. A frame the
1883
+ // walk cannot measure yields 0, which disables every slot (`off < localArea` is then false).
1884
+ // How much sp moves for `add/sub sp, #imm`, positive = sp RISES (frame shrinks). null if not that
1885
+ // shape. Only used to recognise a release; the authoritative depth arithmetic is makeFrameWalk's.
1886
+ const spAdjust = (ins: Instr): number | null => {
1887
+ if (!/^(add|sub)$/.test(ins.mnemonic) || !isSpReg(ins.ops[0])) {
1888
+ return null;
1889
+ }
1890
+ const o = ins.ops[2] ?? ins.ops[1];
1891
+ if (o === undefined || !o.startsWith('#')) {
1892
+ return null;
1893
+ }
1894
+ const v = imm(o);
1895
+ return ins.mnemonic === 'sub' ? -v : v;
1896
+ };
1897
+ // The EXPLICITLY reserved local area — the prologue's `add sp,sp,#-N`. A slot must live strictly
1898
+ // inside this, not merely inside the whole frame: the rest of the frame is the callee-saved block
1899
+ // the entry `push` wrote, which belongs to the epilogue's `pop`, so a `str` there is retargeted
1900
+ // away from the memory the pop will read.
1901
+ const localArea = ((): number => {
1902
+ // The PROLOGUE only — everything before the entry block first touches the frame. Summing the
1903
+ // whole block instead let a store made BEFORE the reservation fall inside `off < localArea`, so
1904
+ // `str r0,[sp]; add sp,sp,#-4; …` claimed a write to the CALLER's frame as a private local and
1905
+ // deleted it.
1906
+ //
1907
+ // NET, not the sum of the negatives. Counting only reservations and discarding releases leaves
1908
+ // localArea larger than the region that is actually below the callee-saved block, and `off <
1909
+ // localArea` then claims the SAVED REGISTERS as private locals — which the epilogue's `pop`
1910
+ // reads back. `push {lr}; add sp,#-8; add sp,#8; str r0,[sp]; pop {r1}; bx r1` deleted the
1911
+ // store and rendered a computed `bx` as an ordinary return, and it fooled the pop gate too
1912
+ // (`released` is compared against this number). Not corpus-reachable — 0 of 2805 Thumb
1913
+ // functions adjust sp upward before their first frame access — which is exactly why only a
1914
+ // probe finds it.
1915
+ //
1916
+ // An sp write this cannot read poisons the whole thing to 0, disabling every slot, rather than
1917
+ // being skipped as if it were no movement: the same rule spDelta follows.
1918
+ const ins = asmBlocks[0].instrs;
1919
+ const firstMem = ins.findIndex((x) => touchesFrame(x));
1920
+ let reserved = 0;
1921
+ for (const x of ins.slice(0, firstMem === -1 ? ins.length : firstMem)) {
1922
+ const d = spAdjust(x);
1923
+ if (d !== null) {
1924
+ reserved -= d; // d > 0 = sp rises = the frame shrinks
1925
+ } else if (x.mnemonic !== 'push' && x.mnemonic !== 'pop' && isSpReg((x.ops[0] ?? '').replace(/!$/, ''))) {
1926
+ return 0; // an sp write of a shape this does not model
1927
+ }
1928
+ }
1929
+ return Math.max(0, reserved);
1930
+ })();
1931
+
1932
+ const slotsOffReason = slotModelBlocker();
1933
+ const slotsOk = slotsOffReason === null;
1934
+ // Every offset the body actually keys as an SSA slot — the frame-object audit checks the
1935
+ // address-taken object cannot overlap one (two models for one byte is a silent disagreement).
1936
+ const usedSlotOffsets = new Set<number>();
1937
+
1938
+ // The WRITE dual of readData, and the reason it exists is a lesson rather than a symmetry: the
1939
+ // first version of this guard checked sp in three decode arms (mov/add/sub) and its commit message
1940
+ // claimed "every write to sp declines". It did not — `lsl sp, r4, #2`, `neg sp, r4`, `mvn sp, r4`,
1941
+ // `ldr sp, [r0,#4]` and `ldmia r0!, {sp}` all still lifted, dropping the sp write silently, because
1942
+ // an enumeration of arms can only cover the arms someone thought of. Guarding the write ITSELF
1943
+ // cannot be incomplete.
1944
+ //
1945
+ // sp is writable in exactly one shape — the frame adjust the add/sub arms `break` on before
1946
+ // reaching here (see isFrameAdjust). Anything else that writes sp is a frame change this frontend
1947
+ // cannot model, and dropping it silently deletes that change while the function keeps compiling.
1948
+ //
1949
+ // Known residual, zero inhabitants: `pop {sp}` never reaches here (push/pop are skipSafe in the
1950
+ // opaque policy), so it stays silently transparent. ARMv4T Thumb cannot encode sp in a pop reglist.
1951
+ const writeData = (r: string, b: number, v: Value): void => {
1952
+ if (isSpReg(r)) {
1953
+ throw spAsDataError();
1954
+ }
1955
+ writeVar(r, b, v);
1956
+ };
1957
+
1227
1958
  // Best-effort call arity via the shared helper (frontend/ssa.ts).
1228
1959
  const fallbackArgcHere = (b: number): number => fallbackArgc(ssa, target.argRegs, b);
1229
1960
 
@@ -1231,10 +1962,13 @@ export function lift(
1231
1962
  const fillBlock = (ab: AsmBlock, bi: number) => {
1232
1963
  const irb = irBlocks[bi];
1233
1964
  let pendingCmp: { lhs: Value; rhs: Value } | null = null;
1965
+ // Tracks the frame through this block's linear instruction order. Meaningful for the entry
1966
+ // block; elsewhere a `[sp,#N]` access declines.
1967
+ const frame = makeFrameWalk();
1234
1968
 
1235
1969
  // 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
1970
+ // silently drop its destination register — emit an honest `opaque`, which fails LOUD at
1971
+ // assertResolved whether or not anything reads that register (see frontend/opaque.ts). Push/pop and sp
1238
1972
  // adjustments have no low-register data destination, so they fall through harmlessly;
1239
1973
  // terminators are handled in the terminator section below.
1240
1974
  const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
@@ -1247,8 +1981,8 @@ export function lift(
1247
1981
  const od = opaqueDest(ins.mnemonic, ins.ops, {
1248
1982
  isReg: isThumbReg,
1249
1983
  normalize: reg,
1250
- storeClass: /^(str|stm)/,
1251
- skipSafe: /^(push|pop|nop)$/,
1984
+ storeClass: /^(str|stm)/i,
1985
+ skipSafe: /^(push|pop|nop)$/i,
1252
1986
  context: name,
1253
1987
  display: ins.asWritten,
1254
1988
  });
@@ -1259,7 +1993,7 @@ export function lift(
1259
1993
  const res = mkValue(T.unk(32));
1260
1994
  // carry the mnemonic so annotate mode can name the gap (`ASMLIFT_ERROR("unmodelled 'rsb'")`)
1261
1995
  irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.asWritten ?? ins.mnemonic } }));
1262
- writeVar(od.dst, bi, res);
1996
+ writeData(od.dst, bi, res);
1263
1997
  };
1264
1998
  // 2-operand ALU form `op rD, op2` (rD = rD ⟨op⟩ op2). `op2` is an immediate (`#N`) or a
1265
1999
  // register. A destination that is NOT a low data register (`add sp, #8` / `sub sp, #N` frame
@@ -1267,9 +2001,25 @@ export function lift(
1267
2001
  // harmlessly, matching the documented sp handling. A malformed operand (missing / non-register
1268
2002
  // non-immediate) degrades to a loud opaque rather than a crash or a silent data-dest drop.
1269
2003
  const emit2op = (opc: Opcode, dReg: string, op2: string | undefined, bi: number) => {
2004
+ // The one sp guard writeData CANNOT supply: this path returns without ever producing a value
2005
+ // to write, so a bad sp destination would never reach the write. It is only reachable from the
2006
+ // add/sub arms, which have already let the whitelisted frame adjust `break` out — so an sp
2007
+ // destination here is by construction NOT that shape (`add sp, r4`: a register-sized frame
2008
+ // adjustment, how agbcc spells a frame too large for the 7-bit immediate).
2009
+ //
2010
+ // Honesty about what this is worth: the 4 real `add sp, rN` sites in the sa3 checkout all sit
2011
+ // in functions that ALSO do `mov rN, sp` 70+ times, so they declined before this guard and
2012
+ // decline after it. No wrong C was ever emitted by this shape. The guard is defence in depth
2013
+ // for the day a stack capability makes those functions liftable — not a miscompile fixed.
2014
+ //
2015
+ // This looked dead during review and is not: it becomes reachable the moment the arm-local
2016
+ // guards are removed, which is exactly what the test at 'add sp, r4' pins.
2017
+ if (isSpReg(dReg)) {
2018
+ throw spAsDataError();
2019
+ }
1270
2020
  if (!isThumbReg(reg(dReg))) {
1271
2021
  return;
1272
- } // sp/pc frame adjustment: transparent
2022
+ } // pc: claimed by classifyXfer first
1273
2023
  if (op2 === undefined) {
1274
2024
  emitOpaqueDest({ mnemonic: opc, ops: [dReg] });
1275
2025
  return;
@@ -1277,7 +2027,7 @@ export function lift(
1277
2027
  const rhs = op2.startsWith('#') ? constVal(imm(op2), bi) : readData(reg(op2), bi);
1278
2028
  const res = mkValue(T.unk(32));
1279
2029
  irb.ops.push(mkOp(opc, { operands: [readData(reg(dReg), bi), rhs], results: [res] }));
1280
- writeVar(reg(dReg), bi, res);
2030
+ writeData(reg(dReg), bi, res);
1281
2031
  };
1282
2032
 
1283
2033
  for (const ins of ab.instrs) {
@@ -1303,23 +2053,45 @@ export function lift(
1303
2053
  if (FLAG_SETTING.has(ins.mnemonic) && /^r[0-7]$/.test(reg(ins.ops[0] ?? ''))) {
1304
2054
  pendingCmp = null;
1305
2055
  }
2056
+ frame.step(ins);
1306
2057
  const [a, b, c] = ins.ops;
1307
2058
  switch (ins.mnemonic) {
1308
2059
  case 'mov':
1309
2060
  case 'movs': {
2061
+ // `mov rD, sp` captures the address of the frame's local area — the DMA-fill idiom
2062
+ // (`DmaFill16` expands to `vu16 tmp; DmaSet(…, &tmp, …)`) and any `&local` argument.
2063
+ // Emitted as `laddr`, gaddr's local twin; every use is proven by the frame-object audit
2064
+ // after the blocks are filled, and any use it cannot vouch for declines the function
2065
+ // loudly there. Gated on the slot model (the frame must be private and immovable) and on
2066
+ // a reserved local area for the object to live in.
2067
+ if (!b?.startsWith('#') && isSpReg(b ?? '') && !isSpReg(a ?? '')) {
2068
+ if (slotsOk && localArea > 0) {
2069
+ const res = mkValue(T.unk(32));
2070
+ irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: 0 } }));
2071
+ writeData(reg(a), bi, res);
2072
+ break;
2073
+ }
2074
+ throw spAsDataError();
2075
+ }
1310
2076
  const v = b?.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
1311
- writeVar(reg(a), bi, v);
2077
+ writeData(reg(a), bi, v);
1312
2078
  break;
1313
2079
  }
1314
2080
  case 'add':
1315
2081
  case 'adds': {
2082
+ // Frame bookkeeping first: it must outrank the `#0` copy idiom below, or `add sp, sp, #0`
2083
+ // takes the copy path and declines while `add sp, #0` is transparent — the same
2084
+ // two-spellings inconsistency one N lower down.
2085
+ if (isFrameAdjust(ins.mnemonic, a, c === undefined ? undefined : b, c ?? b)) {
2086
+ break;
2087
+ }
1316
2088
  // `add rD, rS, #0` is agbcc's low-register copy idiom (Thumb `mov rD, rS` between
1317
2089
  // low regs isn't always available). Model it as a pure copy — same SSA value — not
1318
2090
  // an `x + 0` add. This keeps output clean and, crucially, makes a value copied to a
1319
2091
  // callee-saved register before a call read as still-live *after* the call, which is
1320
2092
  // how call-argument liveness tells a passed argument from a preserved one.
1321
2093
  if (c === '#0') {
1322
- writeVar(reg(a), bi, readData(reg(b), bi));
2094
+ writeData(reg(a), bi, readData(reg(b), bi));
1323
2095
  break;
1324
2096
  }
1325
2097
  // 2-operand form `add rD, op2` (rD = rD + op2): op2 in `b`, no third operand.
@@ -1328,22 +2100,27 @@ export function lift(
1328
2100
  emit2op('add', a, b, bi);
1329
2101
  break;
1330
2102
  }
2103
+
1331
2104
  const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1332
2105
  const res = mkValue(T.unk(32));
1333
2106
  irb.ops.push(mkOp('add', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1334
- writeVar(reg(a), bi, res);
2107
+ writeData(reg(a), bi, res);
1335
2108
  break;
1336
2109
  }
1337
2110
  case 'sub':
1338
2111
  case 'subs': {
2112
+ if (isFrameAdjust(ins.mnemonic, a, c === undefined ? undefined : b, c ?? b)) {
2113
+ break;
2114
+ }
1339
2115
  if (c === undefined) {
1340
2116
  emit2op('sub', a, b, bi);
1341
2117
  break;
1342
2118
  } // `sub rD, op2` → rD = rD - op2
2119
+
1343
2120
  const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1344
2121
  const res = mkValue(T.unk(32));
1345
2122
  irb.ops.push(mkOp('sub', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1346
- writeVar(reg(a), bi, res);
2123
+ writeData(reg(a), bi, res);
1347
2124
  break;
1348
2125
  }
1349
2126
  case 'lsr':
@@ -1370,7 +2147,7 @@ export function lift(
1370
2147
  // register form `lsl rD, rS, rN` → rD = rS << rN
1371
2148
  irb.ops.push(mkOp(opc, { operands: [readData(reg(b), bi), readData(reg(c), bi)], results: [res] }));
1372
2149
  }
1373
- writeVar(reg(a), bi, res);
2150
+ writeData(reg(a), bi, res);
1374
2151
  break;
1375
2152
  }
1376
2153
  case 'neg':
@@ -1378,7 +2155,7 @@ export function lift(
1378
2155
  // `neg rD, rS` (and `rsb rD, rS, #0`) = arithmetic negation → -x
1379
2156
  const res = mkValue(T.unk(32));
1380
2157
  irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1381
- writeVar(reg(a), bi, res);
2158
+ writeData(reg(a), bi, res);
1382
2159
  break;
1383
2160
  }
1384
2161
  case 'rsb':
@@ -1389,7 +2166,7 @@ export function lift(
1389
2166
  if (c === '#0') {
1390
2167
  const res = mkValue(T.unk(32));
1391
2168
  irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1392
- writeVar(reg(a), bi, res);
2169
+ writeData(reg(a), bi, res);
1393
2170
  } else {
1394
2171
  emitOpaqueDest(ins);
1395
2172
  }
@@ -1400,7 +2177,7 @@ export function lift(
1400
2177
  // `mvn rD, rS` = bitwise NOT → ~x
1401
2178
  const res = mkValue(T.unk(32));
1402
2179
  irb.ops.push(mkOp('not', { operands: [readData(reg(b), bi)], results: [res] }));
1403
- writeVar(reg(a), bi, res);
2180
+ writeData(reg(a), bi, res);
1404
2181
  break;
1405
2182
  }
1406
2183
  case 'bic':
@@ -1417,7 +2194,7 @@ export function lift(
1417
2194
  irb.ops.push(mkOp('not', { operands: [readData(mr, bi)], results: [inv] }));
1418
2195
  const res = mkValue(T.unk(32));
1419
2196
  irb.ops.push(mkOp('and', { operands: [readData(xr, bi), inv], results: [res] }));
1420
- writeVar(reg(a), bi, res);
2197
+ writeData(reg(a), bi, res);
1421
2198
  break;
1422
2199
  }
1423
2200
  case 'ror':
@@ -1431,7 +2208,7 @@ export function lift(
1431
2208
  const [xr, nr] = c !== undefined ? [reg(b), reg(c)] : [reg(a), reg(b)];
1432
2209
  const res = mkValue(T.unk(32));
1433
2210
  irb.ops.push(mkOp('rotr', { operands: [readData(xr, bi), readData(nr, bi)], results: [res] }));
1434
- writeVar(reg(a), bi, res);
2211
+ writeData(reg(a), bi, res);
1435
2212
  break;
1436
2213
  }
1437
2214
  case 'ldmia':
@@ -1461,7 +2238,11 @@ export function lift(
1461
2238
  break;
1462
2239
  }
1463
2240
  const baseReg = reg(writeback ? baseTok.slice(0, -1) : baseTok);
1464
- const list = expandRegList(
2241
+ // Anything but a list of definite registers — an unexpandable range (alias endpoint,
2242
+ // e.g. `r4-lr`), a token naming no register, an empty list — leaves the transfer set
2243
+ // ambiguous, so degrade to the loud opaque rather than guess. Checking only for the
2244
+ // leftover `-` let `{foo}` through and fabricated a parameter out of it.
2245
+ const list = definiteRegList(
1465
2246
  ins.ops
1466
2247
  .slice(1)
1467
2248
  .join(',')
@@ -1470,13 +2251,7 @@ export function lift(
1470
2251
  .map((r) => r.trim())
1471
2252
  .filter(Boolean),
1472
2253
  );
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) {
2254
+ if (list === null) {
1480
2255
  emitOpaqueDest(ins);
1481
2256
  break;
1482
2257
  }
@@ -1515,7 +2290,7 @@ export function lift(
1515
2290
  irb.ops.push(
1516
2291
  mkOp('load', { operands: [base0], results: [res], attrs: { off: 4 * i, signed: true, width: 4 } }),
1517
2292
  );
1518
- writeVar(reg(r), bi, res);
2293
+ writeData(reg(r), bi, res);
1519
2294
  } else {
1520
2295
  irb.ops.push(mkOp('store', { operands: [base0, readData(reg(r), bi)], attrs: { off: 4 * i, width: 4 } }));
1521
2296
  }
@@ -1528,7 +2303,7 @@ export function lift(
1528
2303
  if (!wroteBase) {
1529
2304
  const adv = mkValue(T.unk(32));
1530
2305
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
1531
- writeVar(baseReg, bi, adv);
2306
+ writeData(baseReg, bi, adv);
1532
2307
  }
1533
2308
  break;
1534
2309
  }
@@ -1564,7 +2339,7 @@ export function lift(
1564
2339
  : [readData(reg(a), bi), readData(reg(b), bi)];
1565
2340
  const res = mkValue(T.unk(32));
1566
2341
  irb.ops.push(mkOp(opc, { operands: [x, y], results: [res] }));
1567
- writeVar(reg(a), bi, res);
2342
+ writeData(reg(a), bi, res);
1568
2343
  break;
1569
2344
  }
1570
2345
  case 'cmp': {
@@ -1616,7 +2391,7 @@ export function lift(
1616
2391
  attrs: { sym: si.name, ...(si.kind === 'code' ? { code: true } : {}) },
1617
2392
  }),
1618
2393
  );
1619
- writeVar(reg(a), bi, res);
2394
+ writeData(reg(a), bi, res);
1620
2395
  break;
1621
2396
  }
1622
2397
  // INTERIOR attribution: a value strictly inside a sized data symbol becomes
@@ -1633,18 +2408,28 @@ export function lift(
1633
2408
  irb.ops.push(mkOp('gaddr', { results: [g], attrs: { sym: interior.info.name } }));
1634
2409
  irb.ops.push(mkOp('const', { results: [k], attrs: { value: interior.offset } }));
1635
2410
  irb.ops.push(mkOp('add', { operands: [g, k], results: [res] }));
1636
- writeVar(reg(a), bi, res);
2411
+ writeData(reg(a), bi, res);
1637
2412
  break;
1638
2413
  }
1639
2414
  const res = mkValue(T.unk(32));
1640
2415
  irb.ops.push(mkOp('const', { results: [res], attrs: { value: pr.value } }));
1641
- writeVar(reg(a), bi, res);
2416
+ writeData(reg(a), bi, res);
1642
2417
  break;
1643
2418
  }
1644
2419
  if (pr?.kind === 'gaddr') {
1645
2420
  const res = mkValue(T.unk(32));
1646
2421
  irb.ops.push(mkOp('gaddr', { results: [res], attrs: { sym: pr.sym } }));
1647
- writeVar(reg(a), bi, res);
2422
+ if (pr.addend !== 0) {
2423
+ // `.word gSym+N` = the machine loads gSym's address plus N. Emitted as an explicit
2424
+ // add so the addend is a VALUE, not an attribute a renderer could re-scale.
2425
+ const k = mkValue(T.unk(32));
2426
+ irb.ops.push(mkOp('const', { results: [k], attrs: { value: pr.addend } }));
2427
+ const sum = mkValue(T.unk(32));
2428
+ irb.ops.push(mkOp('add', { operands: [res, k], results: [sum] }));
2429
+ writeData(reg(a), bi, sum);
2430
+ break;
2431
+ }
2432
+ writeData(reg(a), bi, res);
1648
2433
  break;
1649
2434
  }
1650
2435
  if (pr?.kind === 'unmodelled') {
@@ -1666,6 +2451,67 @@ export function lift(
1666
2451
  // the same address arithmetic the encoding performs. (parseAddr used to silently
1667
2452
  // read `[rB]`, dropping the index — a silent miscompile; ldrsh exists ONLY in this
1668
2453
  // form in Thumb-1, so every ldrsh went through here.)
2454
+ // An incoming stack argument, read before its base becomes an sp decline. Every
2455
+ // condition below is a refusal that keeps a LOCAL from being mistaken for a parameter:
2456
+ // • entry block only — the depth is exact only along this block's linear order
2457
+ // • entry has no preds — otherwise its params are phis, not parameters
2458
+ // • no register offset — `[sp, rX]` is not a fixed argument slot
2459
+ // • word width, word-aligned — the argument area is word-granular
2460
+ // • off >= the frame depth — BELOW the frame top is a local/spill: still declines,
2461
+ // that is the separate slot-promotion capability
2462
+ // • a sane arity bound — a wild offset must not mint a 400-parameter signature
2463
+ {
2464
+ const index = frame.argIndex({ base, off, regOff }, width, bi);
2465
+ if (index !== null) {
2466
+ // Mint EVERY argument below this one — the register half included. Downstream naming
2467
+ // is POSITIONAL (structure.ts), so any hole binds every later parameter to the wrong
2468
+ // ABI slot, silently: `push {r4,r5,lr}; add r4,r3,#0; ldr r0,[sp,#0xc]` emitted a
2469
+ // 2-parameter signature where the ABI proves 5, with both of them bound wrong.
2470
+ //
2471
+ // Reading slot k proves the caller passed arguments 0..k: the register arguments are
2472
+ // filled before any stack argument exists, and the stack area is contiguous with slot
2473
+ // 4 at the lowest offset. So this is entailed by the calling convention, not guessed —
2474
+ // which is what separates it from inventing parameters a function might not have.
2475
+ // (It assumes one word per argument, which is what this frontend assumes everywhere —
2476
+ // it types every parameter s32. An 8-byte argument, which AAPCS may align into r1 or
2477
+ // straddle across r3 and the stack, would break the index↔slot correspondence; no
2478
+ // agbcc row in the corpus has one, and recovering them is its own capability.)
2479
+ //
2480
+ // ensureParam, NOT readVar: a register the entry block DEFINES before this point
2481
+ // (`bl g` then a read of the frame, the commonest shape there is) answers readVar with
2482
+ // that local definition and no parameter appears — reopening the very hole this loop
2483
+ // closes. It emitted `s32 f(s32 a0, s32 a1, s32 a2, s32 a3) { return g() + a3; }`:
2484
+ // arity 4 where the ABI proves 5, with the stack argument bound to r3's slot.
2485
+ for (let j = 0; j < index; j++) {
2486
+ ssa.ensureParam(j < target.argRegs.length ? target.argRegs[j] : stackArgKey(j), bi);
2487
+ }
2488
+ writeData(reg(a), bi, readVar(stackArgKey(index), bi));
2489
+ break;
2490
+ }
2491
+ }
2492
+ // A word reload from this function's own frame — the dual of the spill in the str arm.
2493
+ //
2494
+ // The reaching-def test is the whole soundness of it, and it mirrors the MIPS guard
2495
+ // exactly: a slot that was never STORED holds nothing this function put there, so
2496
+ // `readVar` would mint a phantom entry parameter for it and hand back a value the machine
2497
+ // never had. Above the frame that reading is right and is the incoming-argument path
2498
+ // above; INSIDE the frame it is an uninitialised local (or one whose address escaped
2499
+ // through a path the model missed), and the honest answer is the decline this falls
2500
+ // through to.
2501
+ if (
2502
+ slotsOk &&
2503
+ isSpReg(base) &&
2504
+ regOff === undefined &&
2505
+ width === 4 &&
2506
+ off % 4 === 0 &&
2507
+ off >= 0 &&
2508
+ off < localArea &&
2509
+ ssa.hasReachingDef(slotKey(off), bi)
2510
+ ) {
2511
+ usedSlotOffsets.add(off);
2512
+ writeData(reg(a), bi, readVar(slotKey(off), bi));
2513
+ break;
2514
+ }
1669
2515
  let baseVal = readData(base, bi);
1670
2516
  if (regOff !== undefined) {
1671
2517
  const sum = mkValue(T.unk(32));
@@ -1674,7 +2520,7 @@ export function lift(
1674
2520
  }
1675
2521
  const res = mkValue(T.unk(32));
1676
2522
  irb.ops.push(mkOp('load', { operands: [baseVal], results: [res], attrs: { off, width, signed } }));
1677
- writeVar(reg(a), bi, res);
2523
+ writeData(reg(a), bi, res);
1678
2524
  break;
1679
2525
  }
1680
2526
  case 'str':
@@ -1687,6 +2533,27 @@ export function lift(
1687
2533
  }
1688
2534
  const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1689
2535
  const { base, off, regOff } = parseAddr(b);
2536
+ // A word spill into this function's own frame: record the slot's value in SSA rather than
2537
+ // emitting a store through sp. `slotsOk` has already proven the frame is private and does
2538
+ // not move; `off < localArea` keeps this strictly inside the EXPLICITLY reserved local
2539
+ // area — not merely inside the frame, whose upper part is the callee-saved block the
2540
+ // epilogue pops — so it can never
2541
+ // collide with the incoming-argument area above it (which the load path recovers as
2542
+ // parameters, and where a STORE is still a decline — writing a caller's slot is a
2543
+ // different capability). A spill that is never reloaded becomes a dead def and drops.
2544
+ if (
2545
+ slotsOk &&
2546
+ isSpReg(base) &&
2547
+ regOff === undefined &&
2548
+ width === 4 &&
2549
+ off % 4 === 0 &&
2550
+ off >= 0 &&
2551
+ off < localArea
2552
+ ) {
2553
+ usedSlotOffsets.add(off);
2554
+ writeVar(slotKey(off), bi, readData(reg(a), bi));
2555
+ break;
2556
+ }
1690
2557
  let storeBase = readData(base, bi);
1691
2558
  if (regOff !== undefined) {
1692
2559
  // register-offset store: same exact `rB + rX` lowering as the load path above
@@ -1705,15 +2572,23 @@ export function lift(
1705
2572
  const targetSym = a;
1706
2573
  // Caller-supplied prototype wins; otherwise a known runtime helper (`__divsi3` &c.)
1707
2574
  // 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);
2575
+ const declared = protoArity(prototypes[targetSym]) ?? protoArity(RUNTIME_HELPERS[targetSym]);
2576
+ const argc = declared ?? fallbackArgcHere(bi);
1710
2577
  const args: Value[] = [];
1711
2578
  for (let k = 0; k < argc; k++) {
1712
2579
  args.push(readVar(`r${k}`, bi));
1713
2580
  }
1714
2581
  const res = mkValue(T.unk(32));
1715
- irb.ops.push(mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } }));
1716
- writeVar('r0', bi, res);
2582
+ const callOp = mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } });
2583
+ irb.ops.push(callOp);
2584
+ // A GUESSED arity is revisited in `finish()`: only once the whole function is lifted is it
2585
+ // known whether every path to here passes through another call, which would have clobbered
2586
+ // the argument registers this guess just read.
2587
+ if (declared === undefined) {
2588
+ ssa.recordGuessedCall(callOp, bi, target.argRegs);
2589
+ }
2590
+ ssa.noteCall(bi); // the callee clobbers r0..r3 …
2591
+ writeData('r0', bi, res); // … and then defines r0, which IS fresh for the next call
1717
2592
  break;
1718
2593
  }
1719
2594
  default:
@@ -1790,15 +2665,150 @@ export function lift(
1790
2665
 
1791
2666
  ssa.finish();
1792
2667
 
2668
+ // FRAME-OBJECT AUDIT. Every `laddr` the mov arm emitted is only a CLAIM that the captured
2669
+ // address is used as "the address of one scalar local"; this proves it, over the finished
2670
+ // function, the same boundary-total style as the slot-escape assert in finish(). The address may
2671
+ // flow anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — but
2672
+ // every MEMORY access through it must be at offset 0 with one agreed width, and any use the
2673
+ // audit cannot vouch for declines the whole function loudly. Nothing here guesses: the object's
2674
+ // declared type is exactly the access type the machine used.
2675
+ {
2676
+ const laddrs: Op[] = [];
2677
+ for (const blk of irBlocks) {
2678
+ for (const op of blk.ops) {
2679
+ if (op.opcode === 'laddr') {
2680
+ laddrs.push(op);
2681
+ }
2682
+ }
2683
+ }
2684
+ if (laddrs.length > 0) {
2685
+ const fail = (why: string): never => {
2686
+ throw new FrontendUnsupportedError(`cannot lift '${name}': address-taken stack local — ${why}`);
2687
+ };
2688
+ // Taint = values that may hold the object's address, closed over phis: a tainted edge arg
2689
+ // taints the receiving block param. (A phi mixing the address with a non-address would taint
2690
+ // the param and then fail the use judgement below — mixing is not vouched for.)
2691
+ for (const op of laddrs) {
2692
+ if ((op.attrs.off as number) !== 0) {
2693
+ // the audit's offset arithmetic, overlap window and naming all assume the frame base;
2694
+ // if a computed capture ever mints off!==0, this line is what keeps it loud
2695
+ fail(`a capture at frame offset ${op.attrs.off} — only the frame base is modelled`);
2696
+ }
2697
+ }
2698
+ const taint = new Set<Value>(laddrs.flatMap((o) => o.results));
2699
+ for (let changed = true; changed;) {
2700
+ changed = false;
2701
+ for (const blk of irBlocks) {
2702
+ for (const op of blk.ops) {
2703
+ for (const s of op.successors ?? []) {
2704
+ s.args.forEach((arg, i) => {
2705
+ const param = s.block.params[i];
2706
+ if (taint.has(arg) && param !== undefined && !taint.has(param)) {
2707
+ taint.add(param);
2708
+ changed = true;
2709
+ }
2710
+ });
2711
+ }
2712
+ }
2713
+ }
2714
+ }
2715
+ // Judge every use of a tainted value.
2716
+ const accesses: { width: number; signed: boolean }[] = [];
2717
+ let escapes = false;
2718
+ for (const blk of irBlocks) {
2719
+ for (const op of blk.ops) {
2720
+ op.operands.forEach((v, idx) => {
2721
+ if (!taint.has(v)) {
2722
+ return;
2723
+ }
2724
+ if (op.opcode === 'load' && idx === 0) {
2725
+ if ((op.attrs.off as number) !== 0) {
2726
+ fail(
2727
+ `a load at [+${op.attrs.off}] through the captured address — only the scalar at offset 0 is modelled`,
2728
+ );
2729
+ }
2730
+ accesses.push({ width: op.attrs.width as number, signed: (op.attrs.signed as boolean) ?? false });
2731
+ return;
2732
+ }
2733
+ if (op.opcode === 'store' && idx === 0) {
2734
+ if ((op.attrs.off as number) !== 0) {
2735
+ fail(
2736
+ `a store at [+${op.attrs.off}] through the captured address — only the scalar at offset 0 is modelled`,
2737
+ );
2738
+ }
2739
+ accesses.push({ width: op.attrs.width as number, signed: false });
2740
+ return;
2741
+ }
2742
+ if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
2743
+ escapes = true; // the address ESCAPES as a value — the point of the capability
2744
+ return;
2745
+ }
2746
+ fail(`the captured address flows into \`${op.opcode}\` — not an access, an escape, or a phi`);
2747
+ });
2748
+ }
2749
+ }
2750
+ if (accesses.length === 0) {
2751
+ // nothing in-function pins the object's type, and a guessed declaration is the
2752
+ // plausible-but-wrong class — decline until an inhabitant needs this
2753
+ fail('the captured address is never dereferenced in this function, so nothing pins the local object type');
2754
+ }
2755
+ const widths = new Set(accesses.map((a) => a.width));
2756
+ if (widths.size > 1) {
2757
+ fail(`the accesses through the captured address disagree on width (${[...widths].join(' vs ')})`);
2758
+ }
2759
+ const width = accesses[0].width;
2760
+ const signed = accesses.some((a) => a.signed);
2761
+ if (width > localArea) {
2762
+ fail('the object extends past the reserved local area');
2763
+ }
2764
+ for (const off of usedSlotOffsets) {
2765
+ if (off < width) {
2766
+ fail(`the object at [0,${width}) overlaps the SSA slot at [sp,#${off}] — one byte, two models`);
2767
+ }
2768
+ }
2769
+ // Proven. Stamp the MACHINE FACTS the audit established — width and signedness are what the
2770
+ // accesses used, so the declaration downstream is a fact, not a guess. The C-level NAME is
2771
+ // deliberately NOT chosen here: identifiers live in the structurer's namespace (params,
2772
+ // locals, globals, the symbol map), which the frontend cannot see — a frontend-chosen `sp0`
2773
+ // silently shadowed a project global of the same name.
2774
+ // `volatile` iff the address escapes: gcc-2.9 DELETES a store to a non-volatile local nothing
2775
+ // in-function reads — measured, the recompiled loop loaded the value and never stored it —
2776
+ // and the reference idiom's own spelling is `vu16 tmp` for exactly that reason. An object
2777
+ // whose address never leaves the function needs no volatile and must not pay its codegen.
2778
+ for (const op of laddrs) {
2779
+ op.attrs = { ...op.attrs, width, signed, ...(escapes ? { volatile: true } : {}) };
2780
+ }
2781
+ }
2782
+ }
2783
+
1793
2784
  // Order the entry block's parameters by ABI register (r0, r1, r2, …) so downstream
1794
2785
  // naming (`a0`, `a1`, …) matches the calling convention, not the read order. Safe only
1795
2786
  // for the true entry (no predecessors) — a loop header's params are phis whose position
1796
2787
  // is index-aligned with predecessor terminator args and must not be reordered.
1797
2788
  const entry = irBlocks[0];
1798
2789
  // non-ABI live-in ranks LAST (99) — deliberate Thumb tie-break; MIPS/PPC's is -1/first
2790
+ //
2791
+ // "Non-ABI" means NOT AN ARGUMENT REGISTER, and it has to be tested that way rather than by the
2792
+ // shape of the name. A `/^r(\d+)$/` test ranked `r8` at 8 and `r4` at 4 while sending only `sl`
2793
+ // and `sb` to 99 — harmless while nothing else occupied ranks >= 4, and a positional miscompile
2794
+ // the moment incoming stack arguments started ranking there. `sub_80B6B3C` in sa3's
2795
+ // `asm/code_x.s` — still undecompiled, so not a benchmark row — takes 10 arguments (its caller
2796
+ // stores six words at [sp,#0]..[sp,#0x14] plus r0-r3) and saves r8 in its prologue;
2797
+ // the `r8` live-in and `@sarg8` tied at 8, the sort is stable, the prologue reads r8 first — so
2798
+ // ABI argument 8 was emitted as `a9` and every parameter after it was off by one.
2799
+ //
2800
+ // A callee-saved register read before it is written is not an argument in any case: it is a
2801
+ // fragment artifact or an unmodelled effect, and the honest place for it is after everything the
2802
+ // convention actually describes.
1799
2803
  abiSortEntryParams(entry, preds[0].length > 0, (v) => {
1800
- const m = /^r(\d+)$/.exec(paramReg.get(v) ?? '');
1801
- return m ? +m[1] : 99;
2804
+ const key = paramReg.get(v) ?? '';
2805
+ // an incoming STACK argument ranks by its ABI index, after every register argument
2806
+ const s = stackArgIndex(key);
2807
+ if (s !== null) {
2808
+ return s;
2809
+ }
2810
+ const i = target.argRegs.indexOf(key);
2811
+ return i >= 0 ? i : 99;
1802
2812
  });
1803
2813
  return fn;
1804
2814
  }