@asmlift/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/package.json +14 -0
  4. package/src/backend/c.ts +20 -0
  5. package/src/backend/cfamily.ts +352 -0
  6. package/src/backend/cpp.ts +145 -0
  7. package/src/backend/pascal.ts +279 -0
  8. package/src/contracts.ts +131 -0
  9. package/src/detect.ts +12 -0
  10. package/src/frontend/asmdata.ts +170 -0
  11. package/src/frontend/disasm.ts +102 -0
  12. package/src/frontend/emit.ts +57 -0
  13. package/src/frontend/errors.ts +14 -0
  14. package/src/frontend/format.ts +47 -0
  15. package/src/frontend/frontend.ts +22 -0
  16. package/src/frontend/mips.ts +875 -0
  17. package/src/frontend/opaque.ts +82 -0
  18. package/src/frontend/ppc.ts +990 -0
  19. package/src/frontend/registry.ts +34 -0
  20. package/src/frontend/ssa.ts +214 -0
  21. package/src/frontend/thumb.ts +1419 -0
  22. package/src/ir/core.ts +104 -0
  23. package/src/ir/opcodes.ts +143 -0
  24. package/src/ir/parse.ts +221 -0
  25. package/src/ir/print.ts +77 -0
  26. package/src/ir/types.ts +106 -0
  27. package/src/ir/verify.ts +221 -0
  28. package/src/l3/ast.ts +301 -0
  29. package/src/l3/basecse.ts +218 -0
  30. package/src/l3/dce.ts +256 -0
  31. package/src/l3/regspell.ts +331 -0
  32. package/src/l3/reindex.ts +447 -0
  33. package/src/l3/typing.ts +145 -0
  34. package/src/mangle.ts +135 -0
  35. package/src/pattern/engine.ts +392 -0
  36. package/src/pipeline.ts +272 -0
  37. package/src/proto.ts +42 -0
  38. package/src/raise/arrays.ts +84 -0
  39. package/src/raise/const.ts +52 -0
  40. package/src/raise/errors.ts +10 -0
  41. package/src/raise/magicdiv.ts +386 -0
  42. package/src/raise/pre-recovery.ts +71 -0
  43. package/src/raise/recover.ts +215 -0
  44. package/src/raise/retsink.ts +72 -0
  45. package/src/raise/shortcircuit.ts +207 -0
  46. package/src/raise/softdiv.ts +62 -0
  47. package/src/raise/struct-arrays.ts +257 -0
  48. package/src/raise/structs.ts +223 -0
  49. package/src/rank.ts +208 -0
  50. package/src/structure/analysis.ts +410 -0
  51. package/src/structure/hazards.ts +142 -0
  52. package/src/structure/loops.ts +169 -0
  53. package/src/structure/structure.ts +1726 -0
  54. package/src/structure/switch-recover.ts +410 -0
  55. package/src/target.ts +140 -0
  56. package/src/trace.ts +233 -0
@@ -0,0 +1,1419 @@
1
+ // asmlift ISA frontend — ARMv4T / Thumb (agbcc). Decode GNU-as text → CFG of basic
2
+ // blocks → L1 with multi-block SSA via Braun et al. 2013 ("Simple and Efficient
3
+ // Construction of SSA Form"), emitting block-arguments at joins.
4
+ //
5
+ // `cmp`+`b<cond>` become `cond_br` over a real join. Loops: a back-edge target is read
6
+ // before its back-edge predecessor is filled, so Braun's incomplete-phi + sealBlock
7
+ // schedule handles it — a block's phis are wired only once all its predecessors are
8
+ // filled. Trivial phis (one real operand) are removed afterwards so a loop-invariant
9
+ // register does not leak a spurious block parameter.
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
15
+ // copy a callee-saved argument (e.g. into r4) before touching r0, entry parameters are
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';
18
+ import type { Opcode } from '../ir/opcodes';
19
+ import { T } from '../ir/types';
20
+ import { type Prototypes, protoArity } from '../proto';
21
+ import { RUNTIME_HELPERS } from '../raise/softdiv';
22
+ import type { TargetDescription } from '../target';
23
+ import { pushSwitchBr } from './emit';
24
+ import { FrontendUnsupportedError } from './errors';
25
+ import { assertInputFormat } from './format';
26
+ import type { Frontend } from './frontend';
27
+ import { opaqueDest } from './opaque';
28
+ import { abiSortEntryParams, fallbackArgc, makeSsaBuilder } from './ssa';
29
+
30
+ interface Instr {
31
+ mnemonic: string;
32
+ ops: string[];
33
+ }
34
+ interface AsmBlock {
35
+ label: string;
36
+ instrs: Instr[];
37
+ }
38
+
39
+ // Map a Thumb conditional-branch mnemonic to the icmp opcode for "branch taken". The signed forms
40
+ // (`blt`/`ble`/`bgt`/`bge`) follow a signed `cmp`; the UNSIGNED forms carry the carry/borrow sense:
41
+ // `bhi` = unsigned > (higher), `bls` = unsigned <= (lower-or-same), `bcc`/`blo` = unsigned <
42
+ // (carry-clear / lower), `bcs`/`bhs` = unsigned >= (carry-set / higher-or-same).
43
+ const COND_OPCODE: Record<string, Opcode> = {
44
+ beq: 'icmp_eq',
45
+ bne: 'icmp_ne',
46
+ blt: 'icmp_slt',
47
+ ble: 'icmp_sle',
48
+ bgt: 'icmp_sgt',
49
+ bge: 'icmp_sge',
50
+ bhi: 'icmp_ugt',
51
+ bls: 'icmp_ule',
52
+ bcc: 'icmp_ult',
53
+ blo: 'icmp_ult',
54
+ bcs: 'icmp_uge',
55
+ bhs: 'icmp_uge',
56
+ };
57
+
58
+ // Classify a block-terminating control transfer, or `null` for a non-transfer instruction (the block
59
+ // falls through). The SINGLE source of truth for "what ends a Thumb block and how", used by decode
60
+ // (block splitting), succLabels (CFG edges), the fill loop (skip transfers), and the terminator
61
+ // emitter — so a transfer form can't be modelled in one place and missed in another. A return via a
62
+ // restored link register is distinguished from a COMPUTED/loaded PC write (jump table / computed
63
+ // goto / register tail call), which this frontend does not model and must LOUD-FAIL rather than
64
+ // silently drop — mirroring the MIPS `jr` and PPC `bctr` guards. (agbcc dispatches a dense switch
65
+ // via `mov pc, rN`.)
66
+ type XferKind = 'return' | 'uncond' | 'cond' | 'indirect';
67
+ function classifyXfer(ins: Instr): XferKind | null {
68
+ const mn = ins.mnemonic;
69
+ if (mn === 'b') {
70
+ return 'uncond';
71
+ }
72
+ if (COND_OPCODE[mn]) {
73
+ return 'cond';
74
+ }
75
+ // `bx rN`: agbcc's return is `bx lr` or `pop {rN}; bx rN` (rN holds the restored LR) — a return.
76
+ // (agbcc emits jump tables via `mov pc`, NOT `bx`; a computed tail-call `bx rN` is out of scope and
77
+ // would need call modelling — accepted limitation, not a jump-table dispatch form.)
78
+ if (mn === 'bx') {
79
+ return 'return';
80
+ }
81
+ // A write to PC is a control transfer. `mov pc, lr` restores the link register → return; any other
82
+ // computed/loaded PC write (`mov pc, rN` rN≠lr, `ldr pc, …`, `add/sub pc, …`) is an indirect jump.
83
+ const dest = ins.ops[0]?.replace(/[[\]]/g, '');
84
+ if (dest === 'pc') {
85
+ if ((mn === 'mov' || mn === 'movs') && ins.ops[1] === 'lr') {
86
+ return 'return';
87
+ }
88
+ return 'indirect';
89
+ }
90
+ // `pop {…, pc}` restores the saved LR into PC → return. `ldmia rN!, {…, pc}` is a return iff the base
91
+ // is sp (a stack unwind); any other base is a computed multi-load jump → indirect. The register
92
+ // list is EXPANDED first, so `pc` inside a fused range (`{r4-pc}`) is seen — an unexpanded
93
+ // detection silently deleted the return.
94
+ const popsPc =
95
+ (mn === 'pop' || mn === 'ldmia' || mn === 'ldmfd') &&
96
+ expandRegList(
97
+ ins.ops
98
+ .join(' ')
99
+ .replace(/[{}!]/g, '')
100
+ .split(/[,\s]+/)
101
+ .filter(Boolean),
102
+ ).includes('pc');
103
+ if (popsPc) {
104
+ if (mn === 'pop') {
105
+ return 'return';
106
+ }
107
+ return ins.ops[0]?.replace(/!$/, '') === 'sp' ? 'return' : 'indirect';
108
+ }
109
+ return null;
110
+ }
111
+
112
+ const imm = (s: string) => parseInt(s.replace(/^#/, ''), s.includes('0x') ? 16 : 10);
113
+
114
+ // Expand fused register-range tokens (`r4-r7` → r4,r5,r6,r7) in a register list. Ranges are
115
+ // numeric-endpoint only (`rN-rM`); a range whose endpoint is an ALIAS (`r4-pc`/`-lr`/`-sp`) is
116
+ // ambiguous and left UNEXPANDED — but its endpoints ARE surfaced as separate tokens so pc/lr
117
+ // detection sees them, and any consumer that needs the exact list rejects the leftover `-` token
118
+ // loudly rather than treating the fused range as one phantom register.
119
+ const REG_NUM: Record<string, number> = { sp: 13, lr: 14, pc: 15 };
120
+ const regNum = (r: string) => (r[0] === 'r' ? Number(r.slice(1)) : REG_NUM[r]);
121
+ function expandRegList(tokens: string[]): string[] {
122
+ const out: string[] = [];
123
+ for (const t of tokens) {
124
+ const dash = t.indexOf('-');
125
+ if (dash === -1) {
126
+ out.push(t);
127
+ continue;
128
+ }
129
+ const lo = t.slice(0, dash);
130
+ const hi = t.slice(dash + 1);
131
+ const a = regNum(lo);
132
+ const b = regNum(hi);
133
+ if (/^r\d+$/.test(lo) && /^r\d+$/.test(hi) && Number.isFinite(a) && Number.isFinite(b) && a <= b) {
134
+ for (let i = a; i <= b; i++) {
135
+ out.push(`r${i}`);
136
+ }
137
+ } else {
138
+ // alias-endpoint or malformed range: surface both endpoints (so pc/lr is visible) AND keep
139
+ // the raw token (so a list consumer sees the unexpanded `-` and declines).
140
+ out.push(lo, hi, t);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ // Split an operand list on commas that are NOT inside brackets, so a memory operand like
147
+ // `[r0, #0x8]` (base + offset) stays a single token instead of being torn at its comma.
148
+ function splitOperands(s: string): string[] {
149
+ const out: string[] = [];
150
+ let depth = 0,
151
+ cur = '';
152
+ for (const ch of s) {
153
+ if (ch === '[' || ch === '{') {
154
+ depth++;
155
+ } else if (ch === ']' || ch === '}') {
156
+ depth--;
157
+ }
158
+ if (ch === ',' && depth === 0) {
159
+ out.push(cur.trim());
160
+ cur = '';
161
+ continue;
162
+ }
163
+ cur += ch;
164
+ }
165
+ if (cur.trim()) {
166
+ out.push(cur.trim());
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Parse a Thumb memory addressing operand `[base]` or `[base, #off]` into base register +
172
+ // constant byte offset. (Register-scaled indices like `[base, r1, lsl #2]` are not handled
173
+ // yet — agbcc materialises those as explicit add/lsl before the load in the cases we target.)
174
+ function parseAddr(operand: string): { base: string; off: number } {
175
+ const inner = operand.replace(/[[\]]/g, '').trim();
176
+ const parts = inner.split(',').map((s) => s.trim());
177
+ const base = parts[0];
178
+ const off = parts[1]?.startsWith('#') ? imm(parts[1]) : 0;
179
+ return { base, off };
180
+ }
181
+
182
+ /** Parse one function's GNU-as text into labelled basic blocks + the CFG, plus the inline `.word`
183
+ * data tables (label → the list of label operands under it) — the jump-table target arrays agbcc
184
+ * emits in `.text` (Regime B). Non-`.word` directives are skipped, EXCEPT sub-word data
185
+ * directives, which fail loud: disassembler-extracted asm (pret projects' `.s` splits) spells
186
+ * raw undecoded instructions as `.2byte 0xD101` — skipping one would silently delete a branch.
187
+ *
188
+ * Two input dialects share this parser: agbcc compiler output (`.thumb_func` + `.L` labels) and
189
+ * pret-project splits (luvdis-extracted: `thumb_func_start NAME` macros, `_08xxxxxx` labels,
190
+ * `LABEL: .4byte VALUE` literal pools on one line). The pret function macros are bookkeeping
191
+ * (they expand to `.align`/`.global`/`.thumb_func`/`.type`) except for the mode they declare:
192
+ * `arm_func_start` marks an ARM-mode body this Thumb frontend must refuse to lift. */
193
+ interface FlatItem {
194
+ label?: string;
195
+ instr?: Instr;
196
+ /** a data directive's payload, kept in-stream so byte layout is computable */
197
+ data?: { halfwords: boolean; values: string[]; inCode: boolean };
198
+ }
199
+
200
+ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map<string, string[]> } {
201
+ // Flatten to (label | instr | data) items, then split into blocks at labels / after branches.
202
+ // `.word LABEL` directives are captured into dataWords keyed by the most recent label (the
203
+ // jump table); ALL word/halfword data also stays in-stream as items, so the raw-halfword and
204
+ // pc-relative resolution below can compute byte-accurate layout.
205
+ let flat: FlatItem[] = [];
206
+ const dataWords = new Map<string, string[]>();
207
+ const funcLabels: string[] = []; // labels marked as function starts (.thumb_func / pret macros)
208
+ const armLabels = new Set<string>(); // function starts declared ARM-mode (arm_func_start)
209
+ const subwordData = new Map<string, string>(); // label → sub-word data directive under it
210
+ // Directives whose byte size we cannot know, recorded by allFlat POSITION so the layout check
211
+ // is scoped to the SELECTED function's slice — a `.align` between two functions must not
212
+ // poison a sibling that needs byte-accurate layout.
213
+ const hazards: { at: number; what: string }[] = [];
214
+ let dataLabel: string | null = null;
215
+ let pendingFn = false;
216
+ let pendingArm = false;
217
+ for (const rawLine of asm.split('\n')) {
218
+ let rest = rawLine.split('@')[0].trim();
219
+ if (!rest) {
220
+ continue;
221
+ }
222
+ // A label may share the line with what follows it (pret pools: `_08x: .4byte 0x…`) — peel it.
223
+ const lm = rest.match(/^([A-Za-z_.$][\w.$]*):\s*(.*)$/);
224
+ if (lm) {
225
+ const lab = lm[1];
226
+ if (pendingFn || pendingArm) {
227
+ funcLabels.push(lab);
228
+ if (pendingArm) {
229
+ armLabels.add(lab);
230
+ }
231
+ pendingFn = pendingArm = false;
232
+ }
233
+ dataLabel = lab;
234
+ flat.push({ label: lab });
235
+ rest = lm[2];
236
+ if (!rest) {
237
+ continue;
238
+ }
239
+ }
240
+ if (rest.startsWith('.')) {
241
+ if (rest === '.thumb_func') {
242
+ pendingFn = true;
243
+ }
244
+ const wm = rest.match(/^\.(word|4byte|long)\s+(.+)$/);
245
+ if (wm) {
246
+ const values = wm[2].split(',').map((w) => w.trim()); // one-per-line and comma lists
247
+ if (dataLabel) {
248
+ const arr = dataWords.get(dataLabel) ?? dataWords.set(dataLabel, []).get(dataLabel)!;
249
+ arr.push(...values);
250
+ }
251
+ flat.push({ data: { halfwords: false, values, inCode: dataLabel === null } });
252
+ continue;
253
+ }
254
+ const hw = rest.match(/^\.(2byte|hword|short)\s+(.+)$/);
255
+ if (hw) {
256
+ // In the instruction stream these are raw undecoded instructions (luvdis emits branches
257
+ // this way) — kept as items and DECODED (or declined) below. Under a label: a sub-word
258
+ // data table — declines below iff the selected function references it.
259
+ if (dataLabel !== null) {
260
+ subwordData.set(dataLabel, hw[1]);
261
+ }
262
+ flat.push({
263
+ data: { halfwords: true, values: hw[2].split(',').map((w) => w.trim()), inCode: dataLabel === null },
264
+ });
265
+ continue;
266
+ }
267
+ const raw = rest.match(
268
+ /^\.(byte|ascii|asciz|string|space|skip|quad|8byte|octa|double|float|single|incbin|fill|zero)\b/,
269
+ );
270
+ if (raw) {
271
+ if (dataLabel === null) {
272
+ throw new FrontendUnsupportedError(
273
+ `cannot lift '${name}': raw data directive '.${raw[1]}' in the code stream — ` +
274
+ `it may encode an instruction the disassembler left undecoded (skipping it would silently delete its effect)`,
275
+ );
276
+ }
277
+ subwordData.set(dataLabel, raw[1]);
278
+ hazards.push({ at: flat.length - 1, what: `.${raw[1]}` }); // byte size unknown / non-word
279
+ continue;
280
+ }
281
+ if (/^\.align\b/.test(rest)) {
282
+ hazards.push({ at: flat.length - 1, what: '.align' });
283
+ }
284
+ continue; // other directives skipped
285
+ }
286
+ // pret function macros (asm/macros.inc): pure bookkeeping except the declared mode.
287
+ const macro = rest.match(/^(non_word_aligned_thumb_func_start|thumb_func_start|arm_func_start)\s+\S+$/);
288
+ if (macro) {
289
+ pendingFn = macro[1] !== 'arm_func_start';
290
+ pendingArm = macro[1] === 'arm_func_start';
291
+ continue;
292
+ }
293
+ if (/^(thumb_func_end|arm_func_end)\b/.test(rest)) {
294
+ continue;
295
+ }
296
+ const m = rest.match(/^(\w+)\s*(.*)$/);
297
+ if (!m) {
298
+ continue;
299
+ }
300
+ dataLabel = null; // a real instruction ends a data run
301
+ flat.push({ instr: { mnemonic: m[1], ops: m[2] ? splitOperands(m[2]) : [] } });
302
+ }
303
+ if (
304
+ armLabels.has(name) ||
305
+ (funcLabels.length === 1 && armLabels.has(funcLabels[0]) && !flat.some((f) => f.label === name))
306
+ ) {
307
+ throw new FrontendUnsupportedError(
308
+ `cannot lift '${name}': ARM-mode function (arm_func_start) — this frontend lifts Thumb only`,
309
+ );
310
+ }
311
+
312
+ // FUNCTION SELECTION. `.thumb_func`-marked labels are function starts; when any exist, the
313
+ // requested `name` must resolve to exactly one of them and the text is sliced to it — emitting
314
+ // some OTHER symbol's body under `name` is precisely the silent miscompile the cardinal rule
315
+ // forbids. A fragment with no `.thumb_func` markers is lifted whole, as a single body.
316
+ //
317
+ // A slice may END without a terminator because the function genuinely FALLS THROUGH into the
318
+ // next `.thumb_func` entry (a shared tail — splitters mark the tail as its own function). The
319
+ // build below then retries with the slice extended through that next function: the machine
320
+ // code executed IS the continuation, so including it is the faithful lift. Falling into an
321
+ // ARM-mode function declines.
322
+ const allFlat = flat;
323
+ let sliceStart = 0;
324
+ let boundaries: number[] = [allFlat.length];
325
+ if (funcLabels.length > 0) {
326
+ const fi = funcLabels.indexOf(name);
327
+ if (fi !== -1) {
328
+ sliceStart = allFlat.findIndex((f) => f.label === name);
329
+ const starts = funcLabels
330
+ .map((l) => allFlat.findIndex((f) => f.label === l))
331
+ .filter((s) => s > sliceStart)
332
+ .sort((a, b) => a - b);
333
+ boundaries = [...starts, allFlat.length];
334
+ } else if (funcLabels.length >= 2) {
335
+ throw new FrontendUnsupportedError(
336
+ `cannot lift '${name}': not a function label in this asm (functions present: ${funcLabels.join(', ')})`,
337
+ );
338
+ } else if (allFlat.some((f) => f.label === name)) {
339
+ throw new FrontendUnsupportedError(
340
+ `cannot lift '${name}': '${name}' is a label here but not a function (the function is '${funcLabels[0]}')`,
341
+ );
342
+ } else {
343
+ // `name` absent entirely + exactly one function: an intentional rename of that function —
344
+ // slice from its start so preceding data labels never masquerade as its code.
345
+ sliceStart = allFlat.findIndex((f) => f.label === funcLabels[0]);
346
+ boundaries = [allFlat.length];
347
+ }
348
+ }
349
+ let boundaryIdx = 0;
350
+ // eslint-disable-next-line no-constant-condition
351
+ while (true) {
352
+ flat = allFlat.slice(sliceStart, boundaries[boundaryIdx]);
353
+
354
+ // Sub-word data tables are unmodelled: lifting a load through one fabricates values (the old
355
+ // silent-skip emitted wrong-but-compiling code). Decline iff the SELECTED code reaches such a
356
+ // table — via a direct label operand, or via a literal-pool word naming the table's symbol.
357
+ if (subwordData.size > 0) {
358
+ const reachable = new Set<string>();
359
+ const labelShape = /^([A-Za-z_.$][\w.$]*)/;
360
+ for (const f of flat) {
361
+ if (f.label && dataWords.has(f.label)) {
362
+ for (const w of dataWords.get(f.label)!) {
363
+ const wm = w.match(labelShape);
364
+ if (wm) {
365
+ reachable.add(wm[1]);
366
+ }
367
+ }
368
+ }
369
+ for (const op of f.instr?.ops ?? []) {
370
+ const om = op.match(labelShape);
371
+ if (om) {
372
+ reachable.add(om[1]);
373
+ }
374
+ }
375
+ }
376
+ for (const [lab, directive] of subwordData) {
377
+ if (reachable.has(lab)) {
378
+ throw new FrontendUnsupportedError(
379
+ `cannot lift '${name}': reads the sub-word data table '${lab}' (.${directive}) — sub-word table data is not modelled`,
380
+ );
381
+ }
382
+ }
383
+ }
384
+
385
+ // ── luvdis raw-encoding mode ─────────────────────────────────────────────────────────────
386
+ // Disassembler-extracted splits carry two things only byte-accurate LAYOUT can resolve:
387
+ // raw branch halfwords (`.2byte 0xD10E` — the target exists only as an encoded offset) and
388
+ // pc-relative literal loads (`ldr rD, [pc, #off]` into an unlabelled pool). Both are decoded
389
+ // here against computed byte offsets and rewritten into the labelled forms the rest of the
390
+ // frontend already models; anything the decoder cannot prove declines loud.
391
+ const isPcRelLdr = (ins?: Instr) =>
392
+ ins?.mnemonic === 'ldr' && /^\[pc,\s*#(0x[0-9a-fA-F]+|\d+)\]$/.test(ins.ops[1] ?? '');
393
+ const needsLayout = flat.some((f) => (f.data?.inCode ?? false) || isPcRelLdr(f.instr));
394
+ if (needsLayout) {
395
+ // Only a hazard WITHIN this function's slice makes its layout unknowable.
396
+ const sliceHazard = hazards.find((h) => h.at >= sliceStart && h.at < boundaries[boundaryIdx]);
397
+ if (sliceHazard) {
398
+ throw new FrontendUnsupportedError(
399
+ `cannot lift '${name}': raw-encoded input needs byte-accurate layout, but '${sliceHazard.what}' makes item sizes unknowable`,
400
+ );
401
+ }
402
+ // Byte offset of every item (Thumb-1: 2 bytes per instruction, `bl` is the 4-byte pair).
403
+ const itemOff: number[] = [];
404
+ const labelOff = new Map<string, number>();
405
+ const codeStart = new Set<number>(); // offsets that begin an instruction or carry a label
406
+ let off = 0;
407
+ flat.forEach((f, i) => {
408
+ itemOff[i] = off;
409
+ if (f.label && !labelOff.has(f.label)) {
410
+ labelOff.set(f.label, off);
411
+ codeStart.add(off);
412
+ }
413
+ if (f.instr) {
414
+ codeStart.add(off);
415
+ off += f.instr.mnemonic === 'bl' ? 4 : 2;
416
+ }
417
+ if (f.data) {
418
+ off += f.data.values.length * (f.data.halfwords ? 2 : 4);
419
+ }
420
+ });
421
+ const labelAt = new Map<number, string>();
422
+ for (const [lab, lo] of labelOff) {
423
+ if (!labelAt.has(lo)) {
424
+ labelAt.set(lo, lab);
425
+ }
426
+ }
427
+ // Thumb-1 branch encodings this frontend models (cond codes 4–7 = mi/pl/vs/vc have no
428
+ // lifted comparison semantics here; 14 is undefined, 15 is swi — all decline).
429
+ const COND_MN = ['beq', 'bne', 'bcs', 'bcc', '', '', '', '', 'bhi', 'bls', 'bge', 'blt', 'bgt', 'ble'];
430
+ const decodeHalfword = (v: number, at: number): { mnemonic: string; target: number } | null => {
431
+ if (v >= 0xd000 && v <= 0xddff) {
432
+ const mn = COND_MN[(v >> 8) & 0xf];
433
+ if (!mn) {
434
+ return null;
435
+ }
436
+ const d = (v & 0xff) - (v & 0x80 ? 0x100 : 0);
437
+ return { mnemonic: mn, target: at + 4 + d * 2 };
438
+ }
439
+ if (v >= 0xe000 && v <= 0xe7ff) {
440
+ const d = (v & 0x7ff) - (v & 0x400 ? 0x800 : 0);
441
+ return { mnemonic: 'b', target: at + 4 + d * 2 };
442
+ }
443
+ return null;
444
+ };
445
+ // Pass 1: decode every in-code halfword; collect synthesized labels for branch targets.
446
+ const synthLabels = new Map<number, string>(); // target offset → label to ensure there
447
+ const decoded = new Map<number, Instr>(); // flat index → replacement branch instr
448
+ flat.forEach((f, i) => {
449
+ if (!f.data?.inCode) {
450
+ return;
451
+ }
452
+ if (!f.data.halfwords) {
453
+ return; // unlabelled word pool — layout bytes only (reached via [pc, #off] below)
454
+ }
455
+ f.data.values.forEach((raw, k) => {
456
+ const at = itemOff[i] + k * 2;
457
+ const v = parseInt(raw, 16);
458
+ const br = Number.isFinite(v) ? decodeHalfword(v, at) : null;
459
+ if (!br) {
460
+ throw new FrontendUnsupportedError(
461
+ `cannot lift '${name}': raw halfword '${raw}' in the code stream is not a decodable branch — ` +
462
+ `skipping it would silently delete its effect`,
463
+ );
464
+ }
465
+ if (!codeStart.has(br.target)) {
466
+ throw new FrontendUnsupportedError(
467
+ `cannot lift '${name}': raw branch '${raw}' targets byte offset 0x${br.target.toString(16)}, which is not an instruction boundary`,
468
+ );
469
+ }
470
+ if (f.data!.values.length > 1) {
471
+ throw new FrontendUnsupportedError(
472
+ `cannot lift '${name}': multi-value raw halfword directive mixing branches is not supported`,
473
+ );
474
+ }
475
+ const lab = labelAt.get(br.target) ?? synthLabels.get(br.target) ?? `.Lraw_${br.target.toString(16)}`;
476
+ synthLabels.set(br.target, lab);
477
+ decoded.set(i, { mnemonic: br.mnemonic, ops: [lab] });
478
+ });
479
+ });
480
+ // Pass 2: pc-relative literal loads → rewrite to a synthesized pool label so the existing
481
+ // resolvePoolConst/resolvePoolSymbol machinery applies. `(pc & ~3) + off` depends on the
482
+ // function's absolute alignment (mod 4) — derived STRUCTURALLY: pool words are 4-aligned in
483
+ // the ROM, so the file-relative offset of any `.4byte` word fixes the base parity (the
484
+ // luvdis `@ address` comments are not trusted).
485
+ let basePar: number | undefined;
486
+ flat.forEach((g, j) => {
487
+ if (g.data && !g.data.halfwords) {
488
+ const p = (4 - (itemOff[j] % 4)) % 4;
489
+ if (basePar === undefined) {
490
+ basePar = p;
491
+ } else if (basePar !== p) {
492
+ throw new FrontendUnsupportedError(
493
+ `cannot lift '${name}': literal pools at inconsistent alignments — cannot determine the function's base alignment`,
494
+ );
495
+ }
496
+ }
497
+ });
498
+ flat.forEach((f, i) => {
499
+ if (!isPcRelLdr(f.instr)) {
500
+ return;
501
+ }
502
+ if (basePar === undefined) {
503
+ throw new FrontendUnsupportedError(
504
+ `cannot lift '${name}': pc-relative literal load with no literal pool in the function to resolve into`,
505
+ );
506
+ }
507
+ const imm = parseInt(
508
+ f.instr!.ops[1].match(/#(0x[0-9a-fA-F]+|\d+)/)![1],
509
+ f.instr!.ops[1].includes('0x') ? 16 : 10,
510
+ );
511
+ const wordOff = ((basePar + itemOff[i] + 4) & ~3) - basePar + imm;
512
+ // locate the word: a 4-byte data item covering [wordOff, wordOff+4)
513
+ let value: string | undefined;
514
+ flat.forEach((g, j) => {
515
+ if (!g.data || g.data.halfwords) {
516
+ return;
517
+ }
518
+ const rel = wordOff - itemOff[j];
519
+ if (rel >= 0 && rel < g.data.values.length * 4 && rel % 4 === 0) {
520
+ value = g.data.values[rel / 4];
521
+ }
522
+ });
523
+ if (value === undefined) {
524
+ throw new FrontendUnsupportedError(
525
+ `cannot lift '${name}': pc-relative load at offset 0x${itemOff[i].toString(16)} resolves to byte offset ` +
526
+ `0x${wordOff.toString(16)}, which is not a word in a literal pool`,
527
+ );
528
+ }
529
+ const poolLab = `.Lpcpool_${wordOff.toString(16)}`;
530
+ dataWords.set(poolLab, [value]);
531
+ f.instr = { mnemonic: 'ldr', ops: [f.instr!.ops[0], poolLab] };
532
+ });
533
+ // Pass 3: rebuild flat — insert synthesized target labels, replace decoded halfwords.
534
+ const next: FlatItem[] = [];
535
+ flat.forEach((f, i) => {
536
+ const lab = synthLabels.get(itemOff[i]);
537
+ if (lab && f.label !== lab && !labelAt.has(itemOff[i])) {
538
+ next.push({ label: lab });
539
+ }
540
+ const br = decoded.get(i);
541
+ if (br) {
542
+ next.push({ instr: br });
543
+ } else {
544
+ next.push(f);
545
+ }
546
+ });
547
+ flat = next;
548
+ }
549
+
550
+ const blocks: AsmBlock[] = [];
551
+ const fallsIntoData = new Set<string>(); // blocks whose straight-line next bytes are data
552
+ let cur: AsmBlock | null = null;
553
+ let anon = 0,
554
+ first = true;
555
+ for (const f of flat) {
556
+ if (f.label) {
557
+ cur = { label: f.label, instrs: [] };
558
+ blocks.push(cur);
559
+ first = false;
560
+ continue;
561
+ }
562
+ if (f.data) {
563
+ // Data in the stream: never part of a block. Find the nearest preceding block WITH
564
+ // instructions (a bare `LABEL:` on the data — a labelled pool/table — pushes an empty
565
+ // block that must NOT hide the real code block behind it; skipping that was the silent
566
+ // deletion of a branch that fell into labelled data). If that block's straight-line path
567
+ // continues (open, or a conditional branch), it falls into these bytes — record it;
568
+ // reachable ⇒ decline below, unreachable ⇒ luvdis pool-alignment padding, pruned.
569
+ let prev: AsmBlock | null = cur && cur.instrs.length > 0 ? cur : null;
570
+ for (let j = blocks.length - 1; prev === null && j >= 0; j--) {
571
+ if (blocks[j].instrs.length > 0) {
572
+ prev = blocks[j];
573
+ }
574
+ }
575
+ if (prev) {
576
+ const k = classifyXfer(prev.instrs[prev.instrs.length - 1]);
577
+ if (k === null || k === 'cond') {
578
+ fallsIntoData.add(prev.label);
579
+ }
580
+ }
581
+ cur = null;
582
+ continue;
583
+ }
584
+ if (!cur) {
585
+ cur = { label: first ? name : `.L_anon${anon++}`, instrs: [] };
586
+ blocks.push(cur);
587
+ first = false;
588
+ }
589
+ cur.instrs.push(f.instr!);
590
+ // Any control transfer ends a block (see classifyXfer — the single source of truth: `b`, a
591
+ // conditional branch, a `bx`/PC-write return, and a computed/loaded PC write).
592
+ if (classifyXfer(f.instr!)) {
593
+ cur = null;
594
+ }
595
+ }
596
+ // Raw data INTERLEAVED with instructions under one label: the lifted block would silently
597
+ // omit whatever the data encodes — decline instead.
598
+ const mixed = blocks.find((b) => b.instrs.length > 0 && subwordData.has(b.label));
599
+ if (mixed) {
600
+ throw new FrontendUnsupportedError(
601
+ `cannot lift '${name}': block '${mixed.label}' interleaves raw data (.${subwordData.get(mixed.label)}) with instructions`,
602
+ );
603
+ }
604
+ let live = blocks.filter((b) => b.instrs.length > 0);
605
+ // Alignment-pad NOPs a splitter emits around returns and literal pools: `lsls r0, r0, #0`
606
+ // is the 0x0000 halfword, `mov r8, r8` is 0x46C0, plus a literal `nop`. A block made ONLY
607
+ // of these is pool/section padding when unreachable — pruned below. A REACHABLE pad block
608
+ // is a real (degenerate) instruction and is kept.
609
+ const isPadInstr = (i: Instr) =>
610
+ i.mnemonic === 'nop' ||
611
+ ((i.mnemonic === 'lsl' || i.mnemonic === 'lsls') &&
612
+ i.ops[0] === 'r0' &&
613
+ i.ops[1] === 'r0' &&
614
+ /^#0x?0*$/.test(i.ops[2] ?? '')) ||
615
+ ((i.mnemonic === 'mov' || i.mnemonic === 'movs') && i.ops[0] === 'r8' && i.ops[1] === 'r8');
616
+ const padBlocks = new Set(live.filter((b) => b.instrs.every(isPadInstr)).map((b) => b.label));
617
+ if (fallsIntoData.size > 0 || padBlocks.size > 0) {
618
+ // Targeted reachability: a block that falls into data is either luvdis's unreachable
619
+ // pool-alignment padding (pruned) or genuinely reachable (decline — its fall-through
620
+ // successor would silently skip over the data bytes); an all-pad block after the final
621
+ // return (before a labelled pool or EOF) is pruned when unreachable. Other unreachable
622
+ // blocks are LEFT ALONE — this pass judges only those two sets, so genuine truncation
623
+ // still declines "falls off the end".
624
+ const idx = new Map(live.map((b, i) => [b.label, i] as const));
625
+ const reach = new Set<number>([0]);
626
+ const work = [0];
627
+ while (work.length > 0) {
628
+ const i = work.pop()!;
629
+ const b = live[i];
630
+ const last = b.instrs[b.instrs.length - 1];
631
+ const kind = last ? classifyXfer(last) : null;
632
+ const targets: string[] = [];
633
+ if (kind === 'cond' || kind === 'uncond') {
634
+ targets.push(last.ops[0]);
635
+ }
636
+ if (kind === null || kind === 'cond') {
637
+ const fall = live[i + 1]?.label;
638
+ if (fall !== undefined && !fallsIntoData.has(b.label)) {
639
+ targets.push(fall);
640
+ }
641
+ }
642
+ for (const t of targets) {
643
+ const ti = idx.get(t);
644
+ if (ti !== undefined && !reach.has(ti)) {
645
+ reach.add(ti);
646
+ work.push(ti);
647
+ }
648
+ }
649
+ }
650
+ for (const b of live) {
651
+ if (fallsIntoData.has(b.label) && reach.has(idx.get(b.label)!)) {
652
+ const last = b.instrs[b.instrs.length - 1];
653
+ if (!last || classifyXfer(last) === null || classifyXfer(last) === 'cond') {
654
+ throw new FrontendUnsupportedError(
655
+ `cannot lift '${name}': reachable code in block '${b.label}' falls through into data bytes`,
656
+ );
657
+ }
658
+ }
659
+ }
660
+ live = live.filter(
661
+ (b) => (!fallsIntoData.has(b.label) && !padBlocks.has(b.label)) || reach.has(idx.get(b.label)!),
662
+ );
663
+ }
664
+ // Fall-through into the NEXT function (shared tail): the slice's last block has no
665
+ // terminator, and a further function region exists — retry with the slice extended.
666
+ const lastLive = live[live.length - 1];
667
+ const lastInstr = lastLive?.instrs[lastLive.instrs.length - 1];
668
+ if (lastInstr && classifyXfer(lastInstr) === null && boundaryIdx + 1 < boundaries.length) {
669
+ const nextLab = allFlat[boundaries[boundaryIdx]]?.label;
670
+ if (nextLab !== undefined && armLabels.has(nextLab)) {
671
+ throw new FrontendUnsupportedError(
672
+ `cannot lift '${name}': control falls through into the ARM-mode function '${nextLab}'`,
673
+ );
674
+ }
675
+ boundaryIdx++;
676
+ continue;
677
+ }
678
+ return { blocks: live, dataWords };
679
+ }
680
+ }
681
+
682
+ // Resolve an agbcc/Thumb literal-pool reference (`ldr rD, .Lpool` / `.Lpool+byteOff`) to the NUMERIC
683
+ // 32-bit word it loads — the `ldr rD, =const` idiom. Returns null when the operand is NOT a numeric
684
+ // pool constant: a register/`[base]` memory operand, an unknown label, a misaligned offset, or a
685
+ // word that is a SYMBOL (an address / jump-table pointer — left for recoverJumpTable or the normal
686
+ // load path). The byte offset selects the word (index = off/4). This keeps a real literal constant
687
+ // (`.word 0x8408`) from being lifted as a phantom pointer parameter and dereferenced (`*a2`).
688
+ // The label-operand shape shared by BOTH pool paths: agbcc `.Lpool`, pret `_08012358`, with an
689
+ // optional `+N` byte offset. Kept in one place so the const and symbol resolvers cannot drift
690
+ // (they did — the drift fabricated phantom pointer params on symbol-pool loads).
691
+ const POOL_LABEL = /^([A-Za-z_.$][\w.$]*)(?:\s*\+\s*(0x[0-9a-fA-F]+|\d+))?$/;
692
+
693
+ type PoolRef = { kind: 'const'; value: number } | { kind: 'gaddr'; sym: string } | { kind: 'unmodelled'; why: string };
694
+
695
+ /** Classify a word-load operand `LABEL[+N]` against the captured literal pools. Returns null when
696
+ * the operand does NOT name a pool (a real register/memory base → the normal load path). When it
697
+ * DOES name a pool the outcome is const | gaddr | unmodelled — NEVER a fall-through to the load
698
+ * path, which would materialise the pool label as a phantom pointer parameter (a silent
699
+ * miscompile). `unmodelled` (a `sym+N` offset, a misaligned/out-of-range index, a `.L` code
700
+ * label) is the caller's cue to decline loud. */
701
+ function poolRef(operand: string, dataWords: Map<string, string[]>): PoolRef | null {
702
+ const m = operand.match(POOL_LABEL);
703
+ if (!m) {
704
+ return null;
705
+ }
706
+ const words = dataWords.get(m[1]);
707
+ if (!words) {
708
+ return null; // not a pool — an ordinary register/memory operand
709
+ }
710
+ const byteOff = m[2] ? Number(m[2]) : 0;
711
+ if (byteOff % 4 !== 0 || byteOff / 4 >= words.length) {
712
+ return { kind: 'unmodelled', why: `offset ${byteOff} is not a whole word in pool '${m[1]}'` };
713
+ }
714
+ const w = words[byteOff / 4].trim();
715
+ if (/^-?(0x[0-9a-fA-F]+|\d+)$/.test(w)) {
716
+ const val = w.startsWith('-') ? -Number(w.slice(1)) : Number(w);
717
+ return Number.isFinite(val) ? { kind: 'const', value: val } : { kind: 'unmodelled', why: `unparsable word '${w}'` };
718
+ }
719
+ // A bare C identifier that is NOT a `.L` code label → the address of a named global. A `sym+N`
720
+ // offset, or a `.L` label (jump table / code address surviving to here), is unmodelled.
721
+ if (/^[A-Za-z_]\w*$/.test(w) && !w.startsWith('.L')) {
722
+ return { kind: 'gaddr', sym: w };
723
+ }
724
+ return { kind: 'unmodelled', why: `pool word '${w}' is a symbol offset or code label` };
725
+ }
726
+
727
+ // Recover an agbcc Thumb jump-table dispatch. Given a dispatch block `disp` ending in `mov pc, rV`
728
+ // and its unique bounds predecessor `bounds` ending in `cmp rX,#(N-1); bhi DEF`, verify the exact
729
+ // idiom and read the inline table — else return null (→ the indirect-jump loud-fail fires). The
730
+ // recovered switch REPLACES both blocks: `bounds` emits a `switch_br` (scrutinee rX; successors =
731
+ // case blocks + DEF).
732
+ //
733
+ // bounds: cmp rX,#(N-1); bhi DEF disp: lsl rY,rX,#2 ; ldr rP,=PTR ; add rA,rY,rP
734
+ // ; ldr rV,[rA] ; mov pc,rV
735
+ // PTR: .word TABLE TABLE: .word C0 … C_{N-1}
736
+ //
737
+ // Index IDENTITY-OR-DECLINE guard: the value feeding the table load must be EXACTLY the
738
+ // bounds-checked scrutinee scaled only by `<<2` — any other op (xor/neg/extra offset) → decline.
739
+ interface JumpTable {
740
+ scrutReg: string;
741
+ caseLabels: string[];
742
+ defaultLabel: string;
743
+ }
744
+ function recoverJumpTable(
745
+ bounds: AsmBlock,
746
+ disp: AsmBlock,
747
+ dataWords: Map<string, string[]>,
748
+ blockLabels: Set<string>,
749
+ ): JumpTable | null {
750
+ // bounds: last two instrs must be `cmp rX,#M` then `bhi DEF` (unsigned upper-bound guard).
751
+ const bi = bounds.instrs;
752
+ const bhi = bi[bi.length - 1],
753
+ cmp = bi[bi.length - 2];
754
+ if (!bhi || !cmp || bhi.mnemonic !== 'bhi' || cmp.mnemonic !== 'cmp') {
755
+ return null;
756
+ }
757
+ const scrutReg = cmp.ops[0];
758
+ const m = cmp.ops[1];
759
+ if (!m?.startsWith('#')) {
760
+ return null;
761
+ }
762
+ const n = imm(m) + 1; // cases 0..M → N = M+1
763
+ const defaultLabel = bhi.ops[0];
764
+
765
+ // disp: exactly the 5-op idiom, threading a single index register from `lsl rY,rX,#2`.
766
+ const d = disp.instrs;
767
+ if (d.length !== 5) {
768
+ return null;
769
+ }
770
+ const [lsl, ldrP, add, ldrV, movpc] = d;
771
+ if (lsl.mnemonic !== 'lsl' || lsl.ops[1] !== scrutReg || (lsl.ops[2] !== '#0x2' && lsl.ops[2] !== '#2')) {
772
+ return null;
773
+ }
774
+ const idxReg = lsl.ops[0]; // rY = rX << 2 (index*4, identity guard)
775
+ if (ldrP.mnemonic !== 'ldr') {
776
+ return null;
777
+ }
778
+ const ptrReg = ldrP.ops[0],
779
+ ptrLabel = ldrP.ops[1]; // rP = *(PTR literal)
780
+ if (add.mnemonic !== 'add' || add.ops[0] !== idxReg) {
781
+ return null;
782
+ }
783
+ // add rY, rY, rP (either operand order) — the address = table_base + index*4, nothing else.
784
+ const addSrcs = [add.ops[1], add.ops[2]];
785
+ if (!(addSrcs.includes(idxReg) && addSrcs.includes(ptrReg))) {
786
+ return null;
787
+ }
788
+ if (ldrV.mnemonic !== 'ldr') {
789
+ return null;
790
+ }
791
+ const { base } = parseAddr(ldrV.ops[1]); // rV = *(rY)
792
+ if (base !== idxReg || ldrV.ops[0] !== movpc.ops[1]) {
793
+ return null;
794
+ }
795
+ if (movpc.mnemonic !== 'mov' || movpc.ops[0] !== 'pc') {
796
+ return null;
797
+ }
798
+
799
+ // Read the table: the ldr loads a POINTER word (PTR: .word TABLE); the table is TABLE: .word C0…
800
+ const ptrWords = dataWords.get(ptrLabel);
801
+ if (!ptrWords || ptrWords.length !== 1) {
802
+ return null;
803
+ }
804
+ const caseLabels = dataWords.get(ptrWords[0]);
805
+ if (!caseLabels || caseLabels.length !== n) {
806
+ return null;
807
+ } // table length must equal the bound
808
+ // Every case target and the default must resolve to a real decoded block; a label that is an
809
+ // expression (`.L4+4`) or points outside the function would otherwise crash later — decline cleanly.
810
+ if (!blockLabels.has(defaultLabel) || caseLabels.some((l) => !blockLabels.has(l))) {
811
+ return null;
812
+ }
813
+ return { scrutReg, caseLabels, defaultLabel };
814
+ }
815
+
816
+ /** Lift decoded asm → an L1 Fn with block-argument SSA. `prototypes` supplies each callee's
817
+ * declared parameter count (from the project's headers); it is authoritative for recovering
818
+ * how many argument registers a `bl` passes (falling back to a heuristic when absent). */
819
+ export function lift(name: string, asm: string, target: TargetDescription, prototypes: Prototypes = {}): Fn {
820
+ assertInputFormat('thumb', 'gnu-as', asm);
821
+ const { blocks: rawBlocks, dataWords } = decode(name, asm);
822
+
823
+ // Regime B: recover agbcc jump tables. A dispatch block (`mov pc, rN`) plus its bounds
824
+ // predecessor (`cmp; bhi DEF`) collapse into a `switch_br` emitted from the BOUNDS block; the
825
+ // dispatch block is ELIDED from the CFG. A `mov pc` that is NOT a recognised table falls through
826
+ // to the loud-fail below.
827
+ const blockLabels = new Set(rawBlocks.map((b) => b.label));
828
+ // Any label referenced as a branch target (so we can tell if an elided dispatch block has a SECOND
829
+ // predecessor — a `b disp` from elsewhere — which would dangle after elision; decline if so).
830
+ const branchTargets = new Set<string>();
831
+ for (const b of rawBlocks) {
832
+ for (const ins of b.instrs) {
833
+ if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && ins.ops.length) {
834
+ branchTargets.add(ins.ops[ins.ops.length - 1]);
835
+ }
836
+ }
837
+ }
838
+ const tables = new Map<AsmBlock, JumpTable>(); // bounds block → recovered table
839
+ const elided = new Set<AsmBlock>(); // dispatch blocks removed from the CFG
840
+ rawBlocks.forEach((d, i) => {
841
+ const last = d.instrs[d.instrs.length - 1];
842
+ if (last && last.mnemonic === 'mov' && last.ops[0] === 'pc' && last.ops[1] !== 'lr') {
843
+ const bounds = rawBlocks[i - 1];
844
+ // The dispatch block must be reached ONLY by falling through from its bounds predecessor — a
845
+ // `b disp` target elsewhere would leave a dangling edge after elision, so decline (→ loud-fail).
846
+ const jt = bounds && !branchTargets.has(d.label) ? recoverJumpTable(bounds, d, dataWords, blockLabels) : null;
847
+ if (jt) {
848
+ tables.set(bounds, jt);
849
+ elided.add(d);
850
+ }
851
+ }
852
+ });
853
+ const asmBlocks = rawBlocks.filter((b) => !elided.has(b));
854
+
855
+ // TRUSTWORTHINESS: loud-fail on a control transfer this frontend cannot model, rather than
856
+ // silently dropping it. A computed/loaded PC write (`mov pc, rN`, `ldr pc, …`, `add/sub pc`,
857
+ // `ldmia rN!,{…,pc}` with rN≠sp) is a jump table / computed goto / register tail call — decode
858
+ // ends the block at it, but it has no static successor, so it must be a catchable "out of scope"
859
+ // signal, not a vanished branch. Mirrors MIPS `jr`/PPC `bctr`. (A RECOGNISED jump table's
860
+ // dispatch block is already elided above, so it is not scanned here.)
861
+ for (const ab of asmBlocks) {
862
+ for (const ins of ab.instrs) {
863
+ if (classifyXfer(ins) === 'indirect') {
864
+ throw new FrontendUnsupportedError(
865
+ `cannot lift '${name}': indirect/computed jump '${ins.mnemonic} ${ins.ops.join(', ')}' ` +
866
+ `— jump tables / computed gotos / register tail calls not supported`,
867
+ );
868
+ }
869
+ }
870
+ }
871
+
872
+ // --- CFG (successors per block) as label lists; fallthrough + branch targets (via classifyXfer) ---
873
+ const buildCfg = (blocks: AsmBlock[]) => {
874
+ const labelIndex = new Map<string, number>();
875
+ blocks.forEach((b, i) => labelIndex.set(b.label, i));
876
+ const succLabels: string[][] = blocks.map((b, i) => {
877
+ // A recovered jump-table BOUNDS block dispatches to its case blocks + default (the elided
878
+ // dispatch block's targets); its `bhi`/fall-through successors are replaced entirely.
879
+ const jt = tables.get(b);
880
+ if (jt) {
881
+ return [...jt.caseLabels, jt.defaultLabel];
882
+ }
883
+ const last = b.instrs[b.instrs.length - 1];
884
+ const fall = i + 1 < blocks.length ? blocks[i + 1].label : null;
885
+ const kind = last ? classifyXfer(last) : null;
886
+ if (kind === 'return') {
887
+ return [];
888
+ } // bx lr / pop {…,pc} / mov pc,lr
889
+ if (kind === 'uncond') {
890
+ return [last!.ops[0]];
891
+ } // unconditional
892
+ if (kind === 'cond') {
893
+ return [last!.ops[0], fall!];
894
+ } // taken, fallthrough
895
+ return fall ? [fall] : []; // fallthrough (or non-transfer last op / EMPTY synthetic block)
896
+ });
897
+ const preds: number[][] = blocks.map(() => []);
898
+ // A branch to a label that is not a code block (a data label, or a target outside the sliced
899
+ // function) cannot be modelled — fail loud, mirroring the MIPS/PPC non-block-boundary guards.
900
+ succLabels.forEach((ss, i) =>
901
+ ss.forEach((s) => {
902
+ const ti = labelIndex.get(s);
903
+ if (ti === undefined) {
904
+ throw new FrontendUnsupportedError(
905
+ `cannot lift '${name}': branch target '${s}' is not a code block in this function (a data label, or outside the sliced function)`,
906
+ );
907
+ }
908
+ preds[ti].push(i);
909
+ }),
910
+ );
911
+ return { labelIndex, succLabels, preds };
912
+ };
913
+
914
+ // A function whose ENTRY block is itself a loop header (some block branches back to it — the tight
915
+ // `strcpy`/`strlen`/`memset` shape where block 0 IS the loop) has no preheader to carry the
916
+ // incoming argument registers into the header's phis. Braun SSA would then build each loop-carried
917
+ // register's phi from the back-edge ALONE, dropping the entry value → a use-before-def on the
918
+ // header's first op. Insert a synthetic EMPTY preheader that falls through to the old entry: it
919
+ // becomes the true entry (its arg-register reads create the params), and the old header now has a
920
+ // forward predecessor supplying the entry operand of each phi. Guarded on `preds[0]` so ordinary
921
+ // functions (entry not a branch target) are untouched.
922
+ let { labelIndex, preds } = buildCfg(asmBlocks);
923
+ if (preds[0].length > 0) {
924
+ let ph = '.Lasmlift_preheader';
925
+ while (labelIndex.has(ph)) {
926
+ ph += '_';
927
+ }
928
+ asmBlocks.unshift({ label: ph, instrs: [] });
929
+ ({ labelIndex, preds } = buildCfg(asmBlocks));
930
+ }
931
+
932
+ // --- ISA-neutral SSA construction (shared Braun builder) ---
933
+ const ssa = makeSsaBuilder(name, asmBlocks.length, preds);
934
+ const { fn, irBlocks, readVar, writeVar, paramReg } = ssa;
935
+
936
+ const constVal = (n: number, b: number): Value => {
937
+ const v = mkValue(T.unk(32));
938
+ irBlocks[b].ops.push(mkOp('const', { results: [v], attrs: { value: n } }));
939
+ return v;
940
+ };
941
+ const reg = (s: string) => s.replace(/[[\]]/g, '');
942
+
943
+ // Reading sp as a DATA operand means an address-taken local (`add rD, sp, #N` = `&local`),
944
+ // an sp-relative spill slot (`ldr/str …, [sp, #N]`), or frame-pointer arithmetic — none
945
+ // modellable without a stack abstraction. sp is never WRITTEN (sp-dest ops are transparent
946
+ // frame bookkeeping), so Braun SSA would materialize it as a fabricated PHANTOM parameter that
947
+ // scrambles the signature. Fail LOUD instead, mirroring MIPS (`isStackPtr`) and PPC (`r1`).
948
+ const readData = (r: string, b: number): Value => {
949
+ if (r === 'sp' || r === 'r13') {
950
+ throw new FrontendUnsupportedError(
951
+ `cannot lift '${name}': stack pointer used as data (address-taken local / sp-relative slot / frame arithmetic) — local stack frames not supported`,
952
+ );
953
+ }
954
+ if (r === 'pc' || r === 'r15') {
955
+ // A pc-relative literal load is rewritten to a pool label before reaching here (decode's
956
+ // isPcRelLdr pass); a `pc`/`r15` base that survives to a data read is an unmodelled shape
957
+ // (`ldr [pc]` with no `#imm`, computed-pc arithmetic) — decline, never fabricate a param.
958
+ throw new FrontendUnsupportedError(`cannot lift '${name}': program counter used as a data base — not modelled`);
959
+ }
960
+ if (dataWords.has(r)) {
961
+ // The operand is a literal-pool / data LABEL, not a register — reading it as dataflow would
962
+ // fabricate a phantom parameter. Word-pool loads are resolved by poolRef upstream; anything
963
+ // else reaching here (a sub-word load off a pool label, a label used in arithmetic) declines.
964
+ throw new FrontendUnsupportedError(`cannot lift '${name}': data label '${r}' used as a register — not modelled`);
965
+ }
966
+ return readVar(r, b);
967
+ };
968
+
969
+ // Best-effort call arity via the shared helper (frontend/ssa.ts).
970
+ const fallbackArgcHere = (b: number): number => fallbackArgc(ssa, target.argRegs, b);
971
+
972
+ // --- fill each block in order, sealing blocks as their predecessors complete ---
973
+ const fillBlock = (ab: AsmBlock, bi: number) => {
974
+ const irb = irBlocks[bi];
975
+ let pendingCmp: { lhs: Value; rhs: Value } | null = null;
976
+
977
+ // TRUSTWORTHINESS GUARD (mirrors the MIPS/PPC frontends): an unmodelled instruction must not
978
+ // silently drop its destination register — emit an honest `opaque`: dead ⇒ it vanishes; live ⇒
979
+ // assertResolved fails LOUD (see frontend/opaque.ts for the policy). Push/pop and sp
980
+ // adjustments have no low-register data destination, so they fall through harmlessly;
981
+ // terminators are handled in the terminator section below.
982
+ const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
983
+ const emitOpaqueDest = (ins: { mnemonic: string; ops: string[] }) => {
984
+ // storeClass: unmodelled Thumb stores are str*/stm* — `stmia rN!, {…}`'s dest token `r0!`
985
+ // fails isReg, so without this it would be skipped as "no reg dest", silently deleting the
986
+ // memory writes AND the base writeback. push/pop stay transparent frame ops (they don't match).
987
+ // skipSafe: push/pop stay transparent frame ops (the deliberate policy);
988
+ // everything else with no register destination (swi, …) throws in opaqueDest.
989
+ const od = opaqueDest(ins.mnemonic, ins.ops, {
990
+ isReg: isThumbReg,
991
+ normalize: reg,
992
+ storeClass: /^(str|stm)/,
993
+ skipSafe: /^(push|pop|nop)$/,
994
+ context: name,
995
+ });
996
+ if (!od) {
997
+ return;
998
+ }
999
+ const operands = od.srcRegs.map((r) => readVar(r, bi));
1000
+ const res = mkValue(T.unk(32));
1001
+ // carry the mnemonic so annotate mode can name the gap (`ASMLIFT_ERROR("unmodelled 'rsb'")`)
1002
+ irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.mnemonic } }));
1003
+ writeVar(od.dst, bi, res);
1004
+ };
1005
+ // 2-operand ALU form `op rD, op2` (rD = rD ⟨op⟩ op2). `op2` is an immediate (`#N`) or a
1006
+ // register. A destination that is NOT a low data register (`add sp, #8` / `sub sp, #N` frame
1007
+ // adjustments) is transparent to dataflow — the frame is push/pop-based — so it falls through
1008
+ // harmlessly, matching the documented sp handling. A malformed operand (missing / non-register
1009
+ // non-immediate) degrades to a loud opaque rather than a crash or a silent data-dest drop.
1010
+ const emit2op = (opc: Opcode, dReg: string, op2: string | undefined, bi: number) => {
1011
+ if (!isThumbReg(reg(dReg))) {
1012
+ return;
1013
+ } // sp/pc frame adjustment: transparent
1014
+ if (op2 === undefined) {
1015
+ emitOpaqueDest({ mnemonic: opc, ops: [dReg] });
1016
+ return;
1017
+ }
1018
+ const rhs = op2.startsWith('#') ? constVal(imm(op2), bi) : readData(reg(op2), bi);
1019
+ const res = mkValue(T.unk(32));
1020
+ irb.ops.push(mkOp(opc, { operands: [readData(reg(dReg), bi), rhs], results: [res] }));
1021
+ writeVar(reg(dReg), bi, res);
1022
+ };
1023
+
1024
+ for (const ins of ab.instrs) {
1025
+ // Control transfers (branches, returns) are emitted in the terminator section below — skip them
1026
+ // here so a return-form PC write (`mov pc, lr`, `pop {…,pc}`) is not decoded as a data write to a
1027
+ // phantom `pc` register (a silent drop of the return). `cmp` is not a transfer, so it still runs.
1028
+ if (classifyXfer(ins)) {
1029
+ continue;
1030
+ }
1031
+ const [a, b, c] = ins.ops;
1032
+ switch (ins.mnemonic) {
1033
+ case 'mov':
1034
+ case 'movs': {
1035
+ const v = b?.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
1036
+ writeVar(reg(a), bi, v);
1037
+ break;
1038
+ }
1039
+ case 'add':
1040
+ case 'adds': {
1041
+ // `add rD, rS, #0` is agbcc's low-register copy idiom (Thumb `mov rD, rS` between
1042
+ // low regs isn't always available). Model it as a pure copy — same SSA value — not
1043
+ // an `x + 0` add. This keeps output clean and, crucially, makes a value copied to a
1044
+ // callee-saved register before a call read as still-live *after* the call, which is
1045
+ // how call-argument liveness tells a passed argument from a preserved one.
1046
+ if (c === '#0') {
1047
+ writeVar(reg(a), bi, readData(reg(b), bi));
1048
+ break;
1049
+ }
1050
+ // 2-operand form `add rD, op2` (rD = rD + op2): op2 in `b`, no third operand.
1051
+ // A malformed 1-operand `add` degrades to a loud opaque.
1052
+ if (c === undefined) {
1053
+ emit2op('add', a, b, bi);
1054
+ break;
1055
+ }
1056
+ const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1057
+ const res = mkValue(T.unk(32));
1058
+ irb.ops.push(mkOp('add', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1059
+ writeVar(reg(a), bi, res);
1060
+ break;
1061
+ }
1062
+ case 'sub':
1063
+ case 'subs': {
1064
+ if (c === undefined) {
1065
+ emit2op('sub', a, b, bi);
1066
+ break;
1067
+ } // `sub rD, op2` → rD = rD - op2
1068
+ const rhs = c?.startsWith('#') ? constVal(imm(c), bi) : readData(reg(c), bi);
1069
+ const res = mkValue(T.unk(32));
1070
+ irb.ops.push(mkOp('sub', { operands: [readData(reg(b), bi), rhs], results: [res] }));
1071
+ writeVar(reg(a), bi, res);
1072
+ break;
1073
+ }
1074
+ case 'lsr':
1075
+ case 'lsl':
1076
+ case 'asr':
1077
+ case 'lsrs':
1078
+ case 'lsls':
1079
+ case 'asrs': {
1080
+ const shiftMn = ins.mnemonic.replace(/s$/, ''); // pret spells the flag-setting forms lsls/lsrs/asrs
1081
+ const opc = shiftMn === 'lsr' ? 'shr_u' : shiftMn === 'asr' ? 'shr_s' : 'shl';
1082
+ // A missing SECOND operand is malformed — degrade to a loud opaque like emit2op does.
1083
+ if (b === undefined) {
1084
+ emitOpaqueDest(ins);
1085
+ break;
1086
+ }
1087
+ const res = mkValue(T.unk(32));
1088
+ if (c === undefined) {
1089
+ // 2-operand register form `lsl rD, rS` → rD = rD << rS
1090
+ irb.ops.push(mkOp(opc, { operands: [readData(reg(a), bi), readData(reg(b), bi)], results: [res] }));
1091
+ } else if (c.startsWith('#')) {
1092
+ // immediate form `lsl rD, rS, #n`
1093
+ irb.ops.push(mkOp(opc, { operands: [readData(reg(b), bi)], results: [res], attrs: { imm: imm(c) } }));
1094
+ } else {
1095
+ // register form `lsl rD, rS, rN` → rD = rS << rN
1096
+ irb.ops.push(mkOp(opc, { operands: [readData(reg(b), bi), readData(reg(c), bi)], results: [res] }));
1097
+ }
1098
+ writeVar(reg(a), bi, res);
1099
+ break;
1100
+ }
1101
+ case 'neg':
1102
+ case 'negs': {
1103
+ // `neg rD, rS` (and `rsb rD, rS, #0`) = arithmetic negation → -x
1104
+ const res = mkValue(T.unk(32));
1105
+ irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1106
+ writeVar(reg(a), bi, res);
1107
+ break;
1108
+ }
1109
+ case 'rsb':
1110
+ case 'rsbs': {
1111
+ // Reverse subtract. `rsb rD, rS, #0` is the negate idiom (0 - rS) → -x. Any other form
1112
+ // (`rsb rD, rS, #N`, N≠0 — not a Thumb-1 encoding, but be safe) is NOT modelled: degrade
1113
+ // to a loud `opaque` rather than silently leaving rD unwritten (a silent miscompile).
1114
+ if (c === '#0') {
1115
+ const res = mkValue(T.unk(32));
1116
+ irb.ops.push(mkOp('neg', { operands: [readData(reg(b), bi)], results: [res] }));
1117
+ writeVar(reg(a), bi, res);
1118
+ } else {
1119
+ emitOpaqueDest(ins);
1120
+ }
1121
+ break;
1122
+ }
1123
+ case 'mvn':
1124
+ case 'mvns': {
1125
+ // `mvn rD, rS` = bitwise NOT → ~x
1126
+ const res = mkValue(T.unk(32));
1127
+ irb.ops.push(mkOp('not', { operands: [readData(reg(b), bi)], results: [res] }));
1128
+ writeVar(reg(a), bi, res);
1129
+ break;
1130
+ }
1131
+ case 'bic':
1132
+ case 'bics': {
1133
+ // `bic rD, rM` (2-op) / `bic rD, rD, rM` (agbcc's redundant 3-op spelling) = rD & ~rM —
1134
+ // emitted verbatim by agbcc for the C idiom `x & ~y` (kleod's ReadKeyInput
1135
+ // key-transition mask), so the not+and pair recompiles to bic.
1136
+ if (b === undefined) {
1137
+ emitOpaqueDest(ins);
1138
+ break;
1139
+ }
1140
+ const [xr, mr] = c !== undefined ? [reg(b), reg(c)] : [reg(a), reg(b)];
1141
+ const inv = mkValue(T.unk(32));
1142
+ irb.ops.push(mkOp('not', { operands: [readData(mr, bi)], results: [inv] }));
1143
+ const res = mkValue(T.unk(32));
1144
+ irb.ops.push(mkOp('and', { operands: [readData(xr, bi), inv], results: [res] }));
1145
+ writeVar(reg(a), bi, res);
1146
+ break;
1147
+ }
1148
+ case 'ror':
1149
+ case 'rors': {
1150
+ // `ror rD, rS` (2-op) / `ror rD, rD, rS` (redundant 3-op) = rotate right → the rotr
1151
+ // op; the structurer spells the C rotate idiom, which agbcc compiles back to this ror.
1152
+ if (b === undefined) {
1153
+ emitOpaqueDest(ins);
1154
+ break;
1155
+ }
1156
+ const [xr, nr] = c !== undefined ? [reg(b), reg(c)] : [reg(a), reg(b)];
1157
+ const res = mkValue(T.unk(32));
1158
+ irb.ops.push(mkOp('rotr', { operands: [readData(xr, bi), readData(nr, bi)], results: [res] }));
1159
+ writeVar(reg(a), bi, res);
1160
+ break;
1161
+ }
1162
+ case 'ldmia':
1163
+ case 'stmia': {
1164
+ // Load/store-multiple with writeback: `ldmia rN!, {rA, rB…}` = one word access per
1165
+ // listed register at ascending offsets, then rN += 4×count. splitOperands is
1166
+ // brace-depth-aware, so the register list arrives as ONE token ('{rA, rB}'); the
1167
+ // rejoin below also tolerates a split list defensively. Thumb-1 LDMIA skips the
1168
+ // writeback when rN is itself in the list (the loaded value wins) — modelled; any
1169
+ // malformed shape degrades to the loud opaque.
1170
+ // `!` = writeback (`ldmia rN!, {…}`); its absence is the valid no-writeback form
1171
+ // (`ldmia rN, {…}` — same transfers, base unchanged). A missing register list is
1172
+ // malformed → loud opaque.
1173
+ const baseTok = a;
1174
+ const writeback = !!baseTok?.endsWith('!');
1175
+ if (baseTok === undefined || b === undefined || !b.startsWith('{')) {
1176
+ emitOpaqueDest(ins);
1177
+ break;
1178
+ }
1179
+ const baseReg = reg(writeback ? baseTok.slice(0, -1) : baseTok);
1180
+ const list = expandRegList(
1181
+ ins.ops
1182
+ .slice(1)
1183
+ .join(',')
1184
+ .replace(/[{}]/g, '')
1185
+ .split(',')
1186
+ .map((r) => r.trim())
1187
+ .filter(Boolean),
1188
+ );
1189
+ // An unexpandable range (alias endpoint, e.g. `r4-lr`) leaves a raw `-` token — the
1190
+ // exact transfer set is ambiguous, so degrade to the loud opaque rather than guess.
1191
+ if (list.some((r) => r.includes('-'))) {
1192
+ emitOpaqueDest(ins);
1193
+ break;
1194
+ }
1195
+ if (list.length === 0) {
1196
+ emitOpaqueDest(ins);
1197
+ break;
1198
+ }
1199
+ // SNAPSHOT the base ONCE: hardware performs every transfer from the ORIGINAL base, but
1200
+ // a base-in-list ldmia overwrites that register mid-list — re-reading it per iteration
1201
+ // loaded the siblings from the freshly-loaded value instead (silent wrong addresses,
1202
+ // adversarially reproduced). All accesses and the writeback read this snapshot.
1203
+ const base0 = readData(baseReg, bi);
1204
+ list.forEach((r, i) => {
1205
+ if (ins.mnemonic === 'ldmia') {
1206
+ const res = mkValue(T.unk(32));
1207
+ irb.ops.push(
1208
+ mkOp('load', { operands: [base0], results: [res], attrs: { off: 4 * i, signed: true, width: 4 } }),
1209
+ );
1210
+ writeVar(reg(r), bi, res);
1211
+ } else {
1212
+ irb.ops.push(mkOp('store', { operands: [base0, readData(reg(r), bi)], attrs: { off: 4 * i, width: 4 } }));
1213
+ }
1214
+ });
1215
+ // Writeback advances the base by 4×count — SUPPRESSED when there is no `!`, or (ldmia)
1216
+ // when the base is itself in the list (the loaded value wins, ARMv4T).
1217
+ const wroteBase = ins.mnemonic === 'ldmia' && list.some((r) => reg(r) === baseReg);
1218
+ if (writeback && !wroteBase) {
1219
+ const adv = mkValue(T.unk(32));
1220
+ irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
1221
+ writeVar(baseReg, bi, adv);
1222
+ }
1223
+ break;
1224
+ }
1225
+ case 'mul':
1226
+ case 'muls':
1227
+ case 'and':
1228
+ case 'ands':
1229
+ case 'orr':
1230
+ case 'orrs':
1231
+ case 'eor':
1232
+ case 'eors': {
1233
+ const opc = (
1234
+ {
1235
+ mul: 'mul',
1236
+ muls: 'mul',
1237
+ and: 'and',
1238
+ ands: 'and',
1239
+ orr: 'or',
1240
+ orrs: 'or',
1241
+ eor: 'xor',
1242
+ eors: 'xor',
1243
+ } as Record<string, Opcode>
1244
+ )[ins.mnemonic]!;
1245
+ // 3-operand (rD, rS, rM) or 2-operand (rD, rM) flag-setting form. A 1-operand form is
1246
+ // malformed — loud opaque, not a crash.
1247
+ if (b === undefined) {
1248
+ emitOpaqueDest(ins);
1249
+ break;
1250
+ }
1251
+ const [x, y] =
1252
+ c !== undefined
1253
+ ? [readData(reg(b), bi), readData(reg(c), bi)]
1254
+ : [readData(reg(a), bi), readData(reg(b), bi)];
1255
+ const res = mkValue(T.unk(32));
1256
+ irb.ops.push(mkOp(opc, { operands: [x, y], results: [res] }));
1257
+ writeVar(reg(a), bi, res);
1258
+ break;
1259
+ }
1260
+ case 'cmp': {
1261
+ if (a === undefined || b === undefined) {
1262
+ emitOpaqueDest(ins);
1263
+ break;
1264
+ }
1265
+ const rhs = b.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
1266
+ pendingCmp = { lhs: readData(reg(a), bi), rhs };
1267
+ break;
1268
+ }
1269
+ case 'ldr':
1270
+ case 'ldrb':
1271
+ case 'ldrh':
1272
+ case 'ldrsb':
1273
+ case 'ldrsh': {
1274
+ // A word load whose operand NAMES a literal pool is a pool reference, not a memory base:
1275
+ // a numeric word → `const`, a bare global → `gaddr` (structure.ts lowers a load/store
1276
+ // through it to `gSym`), anything else → loud decline. It must NEVER fall to the load
1277
+ // path below, which would materialise the pool label as a phantom pointer parameter.
1278
+ if (ins.mnemonic === 'ldr' && b !== undefined) {
1279
+ const pr = poolRef(b, dataWords);
1280
+ if (pr?.kind === 'const') {
1281
+ const res = mkValue(T.unk(32));
1282
+ irb.ops.push(mkOp('const', { results: [res], attrs: { value: pr.value } }));
1283
+ writeVar(reg(a), bi, res);
1284
+ break;
1285
+ }
1286
+ if (pr?.kind === 'gaddr') {
1287
+ const res = mkValue(T.unk(32));
1288
+ irb.ops.push(mkOp('gaddr', { results: [res], attrs: { sym: pr.sym } }));
1289
+ writeVar(reg(a), bi, res);
1290
+ break;
1291
+ }
1292
+ if (pr?.kind === 'unmodelled') {
1293
+ throw new FrontendUnsupportedError(
1294
+ `cannot lift '${name}': literal-pool load of ${pr.why} — not modelled`,
1295
+ );
1296
+ }
1297
+ }
1298
+ // rD, [base, #off] — a typed load. Width/signedness come from the mnemonic; the
1299
+ // base becomes a pointer to that element type during type recovery.
1300
+ if (a === undefined || b === undefined) {
1301
+ emitOpaqueDest(ins);
1302
+ break;
1303
+ }
1304
+ const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1305
+ const signed = ins.mnemonic === 'ldr' || /s/.test(ins.mnemonic.slice(3));
1306
+ const { base, off } = parseAddr(b);
1307
+ const res = mkValue(T.unk(32));
1308
+ irb.ops.push(mkOp('load', { operands: [readData(base, bi)], results: [res], attrs: { off, width, signed } }));
1309
+ writeVar(reg(a), bi, res);
1310
+ break;
1311
+ }
1312
+ case 'str':
1313
+ case 'strb':
1314
+ case 'strh': {
1315
+ // rS, [base, #off] — a typed store (a side-effecting statement, no result).
1316
+ if (a === undefined || b === undefined) {
1317
+ emitOpaqueDest(ins);
1318
+ break;
1319
+ }
1320
+ const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1321
+ const { base, off } = parseAddr(b);
1322
+ irb.ops.push(mkOp('store', { operands: [readData(base, bi), readData(reg(a), bi)], attrs: { off, width } }));
1323
+ break;
1324
+ }
1325
+ case 'bl':
1326
+ case 'blx': {
1327
+ // A call: read the argument registers (r0..), produce the return value in r0. The
1328
+ // callee's caller-saved clobber (r1..r3, lr) needs no modelling — agbcc has already
1329
+ // moved anything live across the call into a callee-saved register (a copy we alias).
1330
+ const targetSym = a;
1331
+ // Caller-supplied prototype wins; otherwise a known runtime helper (`__divsi3` &c.)
1332
+ // supplies its arity so its arguments are recovered; only then fall back to guessing.
1333
+ const argc =
1334
+ protoArity(prototypes[targetSym]) ?? protoArity(RUNTIME_HELPERS[targetSym]) ?? fallbackArgcHere(bi);
1335
+ const args: Value[] = [];
1336
+ for (let k = 0; k < argc; k++) {
1337
+ args.push(readVar(`r${k}`, bi));
1338
+ }
1339
+ const res = mkValue(T.unk(32));
1340
+ irb.ops.push(mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } }));
1341
+ writeVar('r0', bi, res);
1342
+ break;
1343
+ }
1344
+ default:
1345
+ // Control transfers are already skipped above; any other unmodelled op fails loud (opaque)
1346
+ // instead of silently dropping its destination.
1347
+ emitOpaqueDest(ins);
1348
+ break;
1349
+ }
1350
+ }
1351
+
1352
+ // terminator (via classifyXfer — the single source of truth shared with decode/succLabels)
1353
+ const last = ab.instrs[ab.instrs.length - 1];
1354
+ const kind = last ? classifyXfer(last) : null;
1355
+ const succ = (label: string): Successor => ({ block: irBlocks[labelIndex.get(label)!], args: [] });
1356
+ const jt = tables.get(ab);
1357
+ if (jt) {
1358
+ // Regime B: the bounds block dispatches a `switch_br` over the scrutinee — N case blocks (values
1359
+ // 0..N-1, dense) followed by the default block (last successor). The `cmp`/`bhi` are subsumed.
1360
+ pushSwitchBr(irb.ops, readVar(reg(jt.scrutReg), bi), [...jt.caseLabels.map(succ), succ(jt.defaultLabel)]);
1361
+ } else if (!last) {
1362
+ // an EMPTY block is only ever the synthetic entry preheader (decoded blocks are non-empty):
1363
+ // fall through to the real entry, whose loop-header phis take their entry operand from here.
1364
+ irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
1365
+ } else if (kind === 'return') {
1366
+ // bx lr / pop {…,pc} / mov pc,lr
1367
+ irb.ops.push(mkOp('ret', { operands: [readVar(target.returnReg, bi)] }));
1368
+ } else if (kind === 'uncond') {
1369
+ irb.ops.push(mkOp('br', { successors: [succ(last.ops[0])] }));
1370
+ } else if (kind === 'cond') {
1371
+ // `pendingCmp` is block-local; a `cmp` split from its branch by a label means the flags
1372
+ // cross a block boundary — not modelled. Decline loud.
1373
+ if (!pendingCmp) {
1374
+ throw new FrontendUnsupportedError(
1375
+ `cannot lift '${name}': conditional branch '${last.mnemonic}' has no reaching compare in its block`,
1376
+ );
1377
+ }
1378
+ const cond = mkValue(T.unk(32));
1379
+ irb.ops.push(mkOp(COND_OPCODE[last.mnemonic], { operands: [pendingCmp.lhs, pendingCmp.rhs], results: [cond] }));
1380
+ irb.ops.push(mkOp('cond_br', { operands: [cond], successors: [succ(last.ops[0]), succ(fallLabel(bi))] }));
1381
+ } else {
1382
+ // fallthrough (last instruction is a call / data op, no control transfer)
1383
+ irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
1384
+ }
1385
+ };
1386
+
1387
+ // The fall-through label after block `bi` — a LAST block needing one means control runs off
1388
+ // the end of the function (truncated/misparsed input): decline loud, never a TypeError.
1389
+ const fallLabel = (bi: number): string => {
1390
+ const nb = asmBlocks[bi + 1];
1391
+ if (!nb) {
1392
+ throw new FrontendUnsupportedError(
1393
+ `cannot lift '${name}': control falls off the end (block '${asmBlocks[bi].label}' has no terminator and no successor)`,
1394
+ );
1395
+ }
1396
+ return nb.label;
1397
+ };
1398
+ asmBlocks.forEach((ab, bi) => {
1399
+ fillBlock(ab, bi);
1400
+ ssa.markFilled(bi);
1401
+ });
1402
+
1403
+ ssa.finish();
1404
+
1405
+ // Order the entry block's parameters by ABI register (r0, r1, r2, …) so downstream
1406
+ // naming (`a0`, `a1`, …) matches the calling convention, not the read order. Safe only
1407
+ // for the true entry (no predecessors) — a loop header's params are phis whose position
1408
+ // is index-aligned with predecessor terminator args and must not be reordered.
1409
+ const entry = irBlocks[0];
1410
+ // non-ABI live-in ranks LAST (99) — deliberate Thumb tie-break; MIPS/PPC's is -1/first
1411
+ abiSortEntryParams(entry, preds[0].length > 0, (v) => {
1412
+ const m = /^r(\d+)$/.exec(paramReg.get(v) ?? '');
1413
+ return m ? +m[1] : 99;
1414
+ });
1415
+ return fn;
1416
+ }
1417
+
1418
+ /** The ARMv4T / Thumb (agbcc) frontend, registered for the `armv4t` target. */
1419
+ export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lift };