@asmlift/core 0.6.0 → 0.8.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 (88) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +39 -11
  4. package/src/backend/pascal.ts +2 -2
  5. package/src/codegen-flags.ts +640 -0
  6. package/src/contracts.ts +60 -11
  7. package/src/frontend/disasm.ts +141 -11
  8. package/src/frontend/high-half.ts +149 -0
  9. package/src/frontend/mips.ts +458 -209
  10. package/src/frontend/ppc.ts +332 -67
  11. package/src/frontend/reloc-symbol.ts +109 -0
  12. package/src/frontend/splat.ts +56 -18
  13. package/src/frontend/ssa.ts +127 -30
  14. package/src/frontend/stackargs.ts +420 -0
  15. package/src/frontend/thumb.ts +209 -232
  16. package/src/ir/alias.ts +24 -0
  17. package/src/ir/core.ts +70 -3
  18. package/src/ir/opcodes.ts +52 -7
  19. package/src/ir/parse.ts +7 -1
  20. package/src/ir/simplify.ts +1 -1
  21. package/src/l3/address.ts +2 -2
  22. package/src/l3/advance.ts +373 -0
  23. package/src/l3/argbase.ts +6 -6
  24. package/src/l3/argcopy.ts +269 -0
  25. package/src/l3/ast.ts +110 -22
  26. package/src/l3/basecse.ts +50 -30
  27. package/src/l3/coalesce.ts +118 -61
  28. package/src/l3/gates.ts +75 -1
  29. package/src/l3/hoist.ts +1 -1
  30. package/src/l3/homesplit.ts +13 -13
  31. package/src/l3/initfirst.ts +3 -3
  32. package/src/l3/inlinebase.ts +16 -16
  33. package/src/l3/mentions.ts +68 -5
  34. package/src/l3/mulfirst.ts +3 -3
  35. package/src/l3/nearbase.ts +4 -4
  36. package/src/l3/offmember.ts +5 -5
  37. package/src/l3/parkfirst.ts +6 -6
  38. package/src/l3/pollguard.ts +3 -3
  39. package/src/l3/ptrfield.ts +4 -4
  40. package/src/l3/regspell.ts +8 -8
  41. package/src/l3/reindex.ts +22 -17
  42. package/src/l3/scopebase.ts +32 -29
  43. package/src/l3/sinkinit.ts +7 -7
  44. package/src/l3/slotorder.ts +3 -3
  45. package/src/l3/storage.ts +1 -1
  46. package/src/l3/tailmerge.ts +2 -2
  47. package/src/l3/tailret.ts +70 -0
  48. package/src/l3/typing.ts +3 -3
  49. package/src/l3/unmerge.ts +483 -59
  50. package/src/l3/unreduce.ts +15 -14
  51. package/src/l3/volatileptr.ts +11 -11
  52. package/src/l3/volatileval.ts +11 -11
  53. package/src/l3/volstore.ts +16 -16
  54. package/src/l3/zerosub.ts +6 -6
  55. package/src/mangle.ts +49 -0
  56. package/src/pattern/engine.ts +132 -17
  57. package/src/pipeline.ts +39 -16
  58. package/src/proto.ts +2 -2
  59. package/src/raise/const.ts +203 -3
  60. package/src/raise/divpow2.ts +2 -2
  61. package/src/raise/extscale.ts +345 -0
  62. package/src/raise/globalshape.ts +32 -12
  63. package/src/raise/gvn.ts +2 -2
  64. package/src/raise/magicdiv.ts +2 -2
  65. package/src/raise/memberarrays.ts +4 -4
  66. package/src/raise/narrowlocal.ts +18 -2
  67. package/src/raise/paramwidth.ts +133 -3
  68. package/src/raise/pre-recovery.ts +100 -25
  69. package/src/raise/retsink.ts +389 -19
  70. package/src/raise/shortcircuit.ts +595 -34
  71. package/src/raise/structs.ts +4 -4
  72. package/src/raise/tailsink.ts +141 -0
  73. package/src/rank-declare.ts +21 -13
  74. package/src/{rank-axes.ts → rank-variations.ts} +319 -189
  75. package/src/rank.ts +1176 -805
  76. package/src/structure/analysis.ts +87 -90
  77. package/src/structure/bitfields.ts +130 -30
  78. package/src/structure/globalaccess.ts +30 -4
  79. package/src/structure/namecoalesce.ts +32 -13
  80. package/src/structure/retspell.ts +95 -0
  81. package/src/structure/structure.ts +1425 -201
  82. package/src/structure/switch-recover.ts +101 -8
  83. package/src/symbols.ts +127 -6
  84. package/src/target.ts +374 -44
  85. package/src/trace.ts +28 -19
  86. package/src/variation-definitions.ts +1590 -0
  87. package/src/variation-gates.ts +92 -0
  88. package/src/variation-tokens.ts +356 -0
@@ -24,11 +24,19 @@ import { T } from '../ir/types';
24
24
  import type { Prototypes } from '../proto';
25
25
  import type { TargetDescription } from '../target';
26
26
  import { type AsmData, readJumpTable, textRelocAt } from './asmdata';
27
- import { type DisasmInstr, parseImm, parseMem, parseDisasm as parseSharedDisasm, sliceSymbol } from './disasm';
27
+ import {
28
+ type DisasmInstr,
29
+ parseImm,
30
+ parseMem,
31
+ parseDisasm as parseSharedDisasm,
32
+ sliceSymbol,
33
+ symbolStart,
34
+ } from './disasm';
28
35
  import { mkEmitKit, pushSwitchBr } from './emit';
29
36
  import { FrontendUnsupportedError } from './errors';
30
37
  import { assertInputFormat } from './format';
31
38
  import type { Frontend } from './frontend';
39
+ import { makeHighHalves } from './high-half';
32
40
  import { opaqueDest } from './opaque';
33
41
  import { isSplatMips, parseSplatMips } from './splat';
34
42
  import { abiSortEntryParams, stackSlotKey } from './ssa';
@@ -47,6 +55,23 @@ const COND_Z: Record<string, Opcode> = {
47
55
  bgez: 'icmp_sge',
48
56
  };
49
57
  const COND_RR: Record<string, Opcode> = { beq: 'icmp_eq', bne: 'icmp_ne' };
58
+ // BRANCH-LIKELY → the ordinary branch it tests the same way. The difference is the delay slot:
59
+ // a likely branch NULLIFIES its slot when the branch is not taken, so the slot is conditional code
60
+ // (see `toBlocks`, which gives it its own block on the taken edge). `beqzl`/`bnezl` are objdump's
61
+ // printing of `beql`/`bnel` against `$0`; both spellings appear in the corpus.
62
+ const LIKELY_BASE: Record<string, string> = {
63
+ beql: 'beq',
64
+ bnel: 'bne',
65
+ beqzl: 'beqz',
66
+ bnezl: 'bnez',
67
+ blezl: 'blez',
68
+ bgtzl: 'bgtz',
69
+ bltzl: 'bltz',
70
+ bgezl: 'bgez',
71
+ };
72
+ // A coprocessor-1 branch tests an FP condition code (`fcc`) that `c.cond.s/d` sets. Neither the
73
+ // code nor the compare is modelled, so these refuse whether or not they are also likely.
74
+ const isFpCondBranch = (m: string) => m.startsWith('bc1');
50
75
 
51
76
  const isZero = (r: string) => r === 'zero' || r === '$0';
52
77
  // The stack pointer (`$29`). A `sw/lw` through it is not a store/load through a data pointer — it
@@ -69,110 +94,105 @@ const isReturn = (ins: Instr) => ins.mnemonic === 'jr';
69
94
  const isUncond = (ins: Instr) => ins.mnemonic === 'b' || ins.mnemonic === 'j';
70
95
  const isCond = (ins: Instr) => ins.mnemonic in COND_Z || ins.mnemonic in COND_RR;
71
96
  const isXfer = (ins: Instr) => isReturn(ins) || isUncond(ins) || isCond(ins);
97
+ // ANY instruction that redirects control, modelled or not: on MIPS that is the whole `b*` space
98
+ // (minus the `break` trap) plus `j*`. `isXfer` is the subset this frontend models; this is the
99
+ // superset the refusals and the delay-slot guards have to reason about.
100
+ const isControlTransfer = (ins: Instr) =>
101
+ (ins.mnemonic[0] === 'b' && ins.mnemonic !== 'break') || ins.mnemonic[0] === 'j';
72
102
 
73
103
  // Shared objdump scaffolding (frontend/disasm.ts): parseImm/parseMem/parseDisasm. MIPS needs no
74
104
  // reloc or hint-suffix handling; register-scaled indices are materialised by IDO as explicit
75
105
  // `sll`+`addu` before the access, so no `base+index` addressing form appears in parseMem input.
76
- const parseDisasm = (disasm: string): Instr[] => parseSharedDisasm(disasm);
106
+ // A zero word IS an instruction here — `sll zero,zero,0`, which objdump prints as `nop` — so the
107
+ // runs of them objdump elides as `...` (GCC's `mflo` hazard pads) come back as the nops they are.
108
+ const parseDisasm = (disasm: string): Instr[] => parseSharedDisasm(disasm, { zeroWord: 'nop' });
77
109
 
78
- // A MIPS `%hi`/`%lo` relocation operand the assembler's HI16/LO16 split that materialises the
79
- // address of a named global: `%hi(SYM)`, `%lo(SYM)`, `%hi(SYM + N)`, or the memory form
80
- // `%lo(SYM + N)(base)`. Splat spells global access this way and the Splat parser preserves it
81
- // verbatim (frontend/splat.ts); the objdump dialect hides the symbol in a relocation, which
82
- // `applyMipsGlobalRelocs` rewrites into the same `%hi`/`%lo` operands. Either way `lift` folds a
83
- // `lui %hi` + its consuming `%lo` into a single `gaddr(SYM)` (the op the Thumb frontend also emits
84
- // for a pool-loaded global), carrying the addend as the access offset. Returns null for a
85
- // non-`%hi/%lo` operand.
86
- function parseReloc(kind: 'hi' | 'lo', operand: string): { sym: string; addend: number; base?: string } | null {
87
- const m = operand.match(
88
- new RegExp(String.raw`^%${kind}\(\s*([A-Za-z_.$][\w.$]*)\s*(?:\+\s*(0x[0-9a-fA-F]+|\d+))?\s*\)(?:\((\w+)\))?$`),
89
- );
90
- if (!m) {
91
- return null;
92
- }
93
- return { sym: m[1], addend: m[2] ? parseImm(m[2]) : 0, base: m[3] };
94
- }
95
-
96
- // Bridge objdump global-access relocations into the `%hi`/`%lo` operands `parseReloc` reads, so the
97
- // gaddr recognition recovers named globals from an object file the same way it does from Splat text.
98
- // In objdump a global load shows `lui rX,0x0` with the symbol ONLY in the `R_MIPS_HI16`/`LO16`
99
- // reloc records without this the base decodes as address 0 and the access reads `*(T *)0`. Using
100
- // asmData, rewrite each `lui`'s immediate to `%hi(SYM)` and its paired consumer's operand to
101
- // `%lo(SYM[+N])`. Mirrors the harness's disasmToM2c rewrite, so asmlift and m2c recover the same
102
- // symbols. NAMED object symbols only — a reloc against a `.rodata`/`.data` SECTION is a jump-table
103
- // base (Regime B) or section-relative data, left untouched.
104
- function applyMipsGlobalRelocs(instrs: Instr[], ad: AsmData): void {
110
+ // Bridge an object file's `.text` relocation records onto the instructions they fill, so the
111
+ // high/low fold reads ONE carrier (`ins.reloc`) whichever dialect the input arrived in — the same
112
+ // carrier frontend/ppc.ts reads, folded by the same shared invariant (frontend/high-half.ts). The
113
+ // Splat dialect spells `%hi`/`%lo` in the operand text and writes its own records
114
+ // (frontend/splat.ts); objdump hides the symbol in the relocation table, which is what this reads.
115
+ //
116
+ // EVERY HI16/LO16 IS CARRIED, WHATEVER IT NAMES, and that includes a SECTION symbol (`.data`,
117
+ // `.rodata` — a file-static array, a string literal, any anonymous datum). A record left off the
118
+ // carrier leaves its pair raw, and raw is the `lui`'s link-time placeholder the literal 0 in a
119
+ // relocatable object — standing in for the address, which `*(u8 *)(0 + i)` renders and the compiler
120
+ // accepts. Only a carrier that holds every record can put the fold's refusals in front of them.
121
+ //
122
+ // WHETHER THE NAME CAN BE WRITTEN DOWN is a different question, and this frontend does NOT adopt
123
+ // frontend/ppc.ts's naming-policy refusal for it. A section name is never a C identifier, so a
124
+ // recovered `.data` access is already LOUD twice over downstream: rank-declare.ts refuses the
125
+ // declaration and REPORTS the name to the caller, and the candidate's own source does not compile
126
+ // — strictly more than a frontend refusal would say, and what the mapless-decls tests are built
127
+ // around. The rest of the policy has nothing to refuse here, measured rather than assumed: of the
128
+ // 20,262 distinct symbols a `.text` HI16/LO16 names across the three N64 checkouts,
129
+ // `classifyRelocSymbol` answers `plain` for 20,245, `section-local` for 4 and `cpp-mangled` for 13
130
+ // and all 13 are ORDINARY C NAMES (`game_GameFrame__1F`, `ovl__0078CB80_VRAM`) that the policy's
131
+ // `__<digit>` class-scope marker misreads. Adopting it would refuse 13 names C spells perfectly
132
+ // well and buy nothing: the kind it exists for, a mwcc vtable that would otherwise COMPILE, has no
133
+ // inhabitant on this ISA.
134
+ //
135
+ // A recovered JUMP TABLE's dispatch is untouched: Regime B reads the table through `asmdata.ts` and
136
+ // prunes the dispatch block, so its `lui %hi(.rodata)` never reaches `decode`.
137
+ //
138
+ // `R_MIPS_GOT16`/`R_MIPS_CALL16`/`R_MIPS_GPREL16` are PIC/small-data access, already refused by the
139
+ // `gp`-as-data guard; widening this carrier to them would trade those messages for worse ones
140
+ // without recovering an address.
141
+ //
142
+ // THE ADDEND IS NOT ON THE RECORD. MIPS objects are REL: the relocation has no addend field and the
143
+ // address's low bits live in the two instruction immediates, so a record that DOES carry an addend
144
+ // is not the format assumed here, and refuses.
145
+ function attachMipsRelocs(name: string, instrs: Instr[], ad: AsmData): void {
105
146
  const byAddr = new Map(instrs.map((ins) => [ins.addr, ins]));
106
- const his: { addr: number; sym: string }[] = [];
107
- const los = new Map<number, string>(); // LO16 instruction addr → symbol
108
147
  for (const r of ad.relocs) {
109
- if (r.section !== '.text' || r.sym.startsWith('.')) {
110
- continue; // section-symbol relocs are jump tables / anonymous data — not named globals
111
- }
112
- if (r.type === 'R_MIPS_HI16') {
113
- his.push({ addr: r.offset, sym: r.sym });
114
- } else if (r.type === 'R_MIPS_LO16') {
115
- los.set(r.offset, r.sym);
116
- }
117
- }
118
- if (his.length === 0) {
119
- return;
120
- }
121
- his.sort((a, b) => a.addr - b.addr);
122
- const loAddrs = [...los.keys()].sort((a, b) => a - b);
123
- const consumed = new Set<number>();
124
- for (const hi of his) {
125
- const lui = byAddr.get(hi.addr);
126
- if (!lui || lui.mnemonic !== 'lui') {
148
+ if (r.section !== '.text' || (r.type !== 'R_MIPS_HI16' && r.type !== 'R_MIPS_LO16')) {
127
149
  continue;
128
150
  }
129
- // Pair with the first not-yet-consumed same-symbol LO16 after the lui (GCC emits the pair with
130
- // the base register threaded, so a 1:1 by-symbol-and-order match is the observed shape).
131
- const loAddr = loAddrs.find((a) => a > hi.addr && !consumed.has(a) && los.get(a) === hi.sym);
132
- const lo = loAddr !== undefined ? byAddr.get(loAddr) : undefined;
133
- if (!lo) {
151
+ const ins = byAddr.get(r.offset);
152
+ if (!ins) {
153
+ // A `.text` record describes the whole section, so most belong to the object's OTHER
154
+ // functions and are not this slice's business. One that lands INSIDE the slice and still
155
+ // matches no instruction means the relocation table and this disassembly disagree about
156
+ // addressing, and the half it describes would stay raw — so refuse rather than skip it.
157
+ if (r.offset >= instrs[0].addr && r.offset <= instrs[instrs.length - 1].addr) {
158
+ throw new FrontendUnsupportedError(
159
+ `cannot lift '${name}': relocation '${r.type} ${r.sym}' at 0x${r.offset.toString(16)} falls inside the ` +
160
+ `function (0x${instrs[0].addr.toString(16)}..0x${instrs[instrs.length - 1].addr.toString(16)}) but on no ` +
161
+ `instruction — the relocation table and this disassembly disagree about addressing`,
162
+ );
163
+ }
134
164
  continue;
135
165
  }
136
- // The addend N rides in the instruction fields, not the reloc record: `(HI16 imm << 16) + the
137
- // LO16 instruction's signed immediate`. HI16 imm is 0 in a relocatable object.
138
- const rw = rewriteLoReloc(lo, hi.sym, parseImm(lui.ops[1] ?? '0') << 16);
139
- if (rw === null) {
140
- continue; // an unmodelled consumer (FP load, …) — leave the pair raw; it declines downstream
141
- }
142
- lui.ops[1] = rw.n === 0 ? `%hi(${hi.sym})` : `%hi(${hi.sym} + 0x${rw.n.toString(16)})`;
143
- consumed.add(loAddr!);
144
- }
145
- }
146
-
147
- // Rewrite a LO16 consumer's operand to `%lo(SYM[+N])`, returning the addend N, or null when the
148
- // instruction is not a modelled global consumer (leave it raw). `hiBase` is the HI16 imm << 16.
149
- function rewriteLoReloc(lo: Instr, sym: string, hiBase: number): { n: number } | null {
150
- const macro = (n: number) => (n === 0 ? `%lo(${sym})` : `%lo(${sym} + 0x${n.toString(16)})`);
151
- if (lo.mnemonic === 'addiu' || lo.mnemonic === 'addi') {
152
- const n = hiBase + parseImm(lo.ops[2] ?? '0');
153
- if (n < 0) {
154
- return null; // a negative interior offset — unusual; leave raw
166
+ if (r.addend !== 0) {
167
+ throw new FrontendUnsupportedError(
168
+ `cannot lift '${name}': relocation '${r.type} ${r.sym}' at 0x${r.offset.toString(16)} carries an addend ` +
169
+ `(0x${r.addend.toString(16)}), but MIPS relocations are REL and hold the addend in the instruction ` +
170
+ `fields this object is not the assumed format`,
171
+ );
155
172
  }
156
- lo.ops[2] = macro(n);
157
- return { n };
158
- }
159
- if (/^(lw|lh|lhu|lb|lbu|sw|sh|sb)$/.test(lo.mnemonic)) {
160
- const mem = parseMem(lo.ops[lo.ops.length - 1] ?? '');
161
- const n = hiBase + mem.off;
162
- if (n < 0) {
163
- return null;
173
+ // At most one relocation per instruction — the same invariant disasm.ts and frontend/splat.ts
174
+ // enforce on their own inputs: the carrier is one field, so keeping the last would leave one
175
+ // symbol standing for the other's operand.
176
+ if (ins.reloc) {
177
+ throw new FrontendUnsupportedError(
178
+ `cannot lift '${name}': two relocations on one instruction ('${ins.mnemonic}' at ` +
179
+ `0x${ins.addr.toString(16)}): '${ins.reloc.type} ${ins.reloc.sym}' and '${r.type} ${r.sym}'`,
180
+ );
164
181
  }
165
- lo.ops[lo.ops.length - 1] = `${macro(n)}(${mem.base})`;
166
- return { n };
182
+ ins.reloc = { type: r.type, sym: r.sym, addend: 0 };
167
183
  }
168
- return null;
169
184
  }
170
185
 
171
186
  interface MipsBlock {
172
187
  startAddr: number;
173
188
  body: Instr[]; // computation instructions (excludes the branch and its delay slot)
174
189
  branch: Instr | null; // terminating control transfer, or null for a pure fall-through
175
- delay: Instr | null; // delay-slot instruction (executes before the transfer)
190
+ // The word at `branch + 4`, which this block runs after its branch. NULL for a branch-LIKELY:
191
+ // its slot is NULLIFIED when the branch is not taken, so it does not run in this block at all —
192
+ // it is its own block at `branch + 4`, reached only by the taken edge, which then jumps on to
193
+ // the branch's target.
194
+ delay: Instr | null;
195
+ delayAnnulled: boolean;
176
196
  }
177
197
 
178
198
  // Mnemonics whose destination is `ops[0]` (a plain register write) — the subset the jump-table
@@ -348,29 +368,135 @@ function recoverMipsJumpTables(instrs: Instr[], ad: AsmData): Map<number, MipsJT
348
368
  return out;
349
369
  }
350
370
 
371
+ // BRANCH-LIKELY → the ordinary branch that tests the same way, in place, returning the addresses
372
+ // rewritten. The mnemonic is all that differs in the COMPARISON; what differs in the FLOW is the
373
+ // delay slot, which `toBlocks` then places on the taken edge alone.
374
+ //
375
+ // Refuses, by name, every shape that placement cannot model. A nullified slot read as an ordinary
376
+ // always-executed one is not a cosmetic error: `absi` would return `-x` for every `x >= 0`, and a
377
+ // store in such a slot would be performed on a path that never performs it. That is C which
378
+ // compiles and is wrong — strictly worse than the decline it would replace.
379
+ function normaliseBranchLikely(name: string, instrs: Instr[], startAddr: number): Set<number> {
380
+ const likely = instrs.filter((ins) => ins.mnemonic in LIKELY_BASE);
381
+ if (likely.length === 0) {
382
+ return new Set();
383
+ }
384
+ const refusal = (ins: Instr, why: string) =>
385
+ new FrontendUnsupportedError(
386
+ `cannot lift '${name}': branch-likely '${ins.mnemonic}' at 0x${ins.addr.toString(16)} — ${why}`,
387
+ );
388
+ // Every address this function branches to, the likely branches' own targets included: one that
389
+ // jumps to its OWN slot is the same hazard as some other branch landing there. A recovered jump
390
+ // table's arms are not in here — they do not exist until `recoverMipsJumpTables` has run, so
391
+ // `lift` checks them against the slot addresses this returns.
392
+ const targets = new Set(instrs.map((ins) => ins.target).filter((t): t is number => t !== undefined));
393
+ // BY ADDRESS, never by array position: reading a branch's array NEIGHBOUR as its delay slot puts
394
+ // an arm the function always runs onto the taken edge of a branch that never guarded it, which is
395
+ // C that compiles and is wrong. The two readings agree only while the list has a word per
396
+ // address, which is what `parseDisasm` guarantees by refusing a listing it cannot account for.
397
+ const at = new Map(instrs.map((ins) => [ins.addr, ins]));
398
+ const hex = (a: number) => `0x${a.toString(16)}`;
399
+ const rewritten = new Set<number>();
400
+ for (const br of likely) {
401
+ const slot = at.get(br.addr + 4);
402
+ const fallThrough = at.get(br.addr + 8);
403
+ const prev = at.get(br.addr - 4);
404
+ if (br.target === undefined) {
405
+ throw refusal(br, 'the branch target is not a resolved address');
406
+ }
407
+ if (slot === undefined) {
408
+ throw refusal(br, `the disassembly has no instruction at ${hex(br.addr + 4)} to be its delay slot`);
409
+ }
410
+ if (fallThrough === undefined) {
411
+ throw refusal(br, `the disassembly has no instruction at ${hex(br.addr + 8)} for the not-taken edge to land on`);
412
+ }
413
+ if (prev === undefined && br.addr !== startAddr) {
414
+ // WHAT PRECEDES IT DECIDES WHETHER IT MAY BE PLACED AT ALL, so an unreadable predecessor is
415
+ // not a detail to shrug at: a likely branch sitting in some transfer's delay slot has no
416
+ // block of its own to put a slot in. The function's own first word is the one address with
417
+ // nothing before it, and that is read off the objdump HEADER — not off the first line
418
+ // parsed, which is the same thing only when the listing spells that word.
419
+ throw refusal(br, `the disassembly has no instruction at ${hex(br.addr - 4)}, so what precedes it is unknown`);
420
+ }
421
+ if (prev !== undefined && isControlTransfer(prev)) {
422
+ throw refusal(br, `it sits in the delay slot of '${prev.mnemonic}', so its own slot has no block to live in`);
423
+ }
424
+ if (isControlTransfer(slot)) {
425
+ throw refusal(
426
+ br,
427
+ `the delay slot is itself a control transfer ('${slot.mnemonic}'), which the ISA leaves undefined`,
428
+ );
429
+ }
430
+ // The slot's block is entered ONLY by this branch's taken edge. Anything else arriving there
431
+ // would run the slot with no branch conditioning it, and then take the branch's target.
432
+ if (targets.has(slot.addr)) {
433
+ throw refusal(br, 'another branch targets the delay slot, which would run it unconditioned');
434
+ }
435
+ br.mnemonic = LIKELY_BASE[br.mnemonic];
436
+ rewritten.add(br.addr);
437
+ }
438
+ return rewritten;
439
+ }
440
+
441
+ // EVERY control transfer this frontend models has a delay slot — the word at `branch + 4`, which
442
+ // runs after it — and every conditional branch's not-taken edge lands on the word at `branch + 8`.
443
+ // `toBlocks` places both, so a listing that does not spell them refuses here rather than there:
444
+ // with the word missing, the slot would be taken from whatever came next and the not-taken edge
445
+ // would silently lose its successor. A branch-LIKELY asks the same two questions in its own words,
446
+ // before the rewrite, because for it they decide conditional execution rather than placement.
447
+ function checkDelaySlots(name: string, instrs: Instr[]): void {
448
+ const at = new Set(instrs.map((ins) => ins.addr));
449
+ const hex = (a: number) => `0x${a.toString(16)}`;
450
+ for (const ins of instrs) {
451
+ if (!isXfer(ins)) {
452
+ continue;
453
+ }
454
+ if (!at.has(ins.addr + 4)) {
455
+ throw new FrontendUnsupportedError(
456
+ `cannot lift '${name}': '${ins.mnemonic}' at ${hex(ins.addr)} — the disassembly has no ` +
457
+ `instruction at ${hex(ins.addr + 4)} to be its delay slot`,
458
+ );
459
+ }
460
+ if (isCond(ins) && !at.has(ins.addr + 8)) {
461
+ throw new FrontendUnsupportedError(
462
+ `cannot lift '${name}': '${ins.mnemonic}' at ${hex(ins.addr)} — the disassembly has no ` +
463
+ `instruction at ${hex(ins.addr + 8)} for the not-taken edge to land on`,
464
+ );
465
+ }
466
+ }
467
+ }
468
+
351
469
  // Split the instruction stream into basic blocks. Delay slots are consumed into their
352
470
  // branching block; leaders are the entry, every branch target, and each conditional branch's
353
- // fall-through. Unreachable trailing blocks (padding `nop`s) are dropped.
471
+ // fall-through. A branch-LIKELY's slot is not consumed — it becomes its own block (see
472
+ // `normaliseBranchLikely`). Unreachable trailing blocks (padding `nop`s) are dropped.
354
473
  function toBlocks(
355
474
  instrs: Instr[],
356
475
  jts: Map<number, MipsJT>,
476
+ likelyAddrs: Set<number>,
357
477
  ): { blocks: MipsBlock[]; succAddrs: Map<MipsBlock, number[]> } {
478
+ // BY ADDRESS, never by array position — the placement rule is the same one `normaliseBranchLikely`
479
+ // states, and `checkDelaySlots` has already refused every transfer whose words are not there.
480
+ const at = new Map(instrs.map((ins) => [ins.addr, ins]));
358
481
  const consumed = new Set<number>();
359
- instrs.forEach((ins, i) => {
360
- if (isXfer(ins)) {
361
- consumed.add(i + 1);
482
+ for (const ins of instrs) {
483
+ if (isXfer(ins) && !likelyAddrs.has(ins.addr)) {
484
+ consumed.add(ins.addr + 4);
362
485
  }
363
- });
486
+ }
364
487
 
365
488
  const leaders = new Set<number>(instrs.length ? [instrs[0].addr] : []);
366
- instrs.forEach((ins, i) => {
489
+ for (const ins of instrs) {
367
490
  if ((isCond(ins) || isUncond(ins)) && ins.target !== undefined) {
368
491
  leaders.add(ins.target);
369
492
  }
370
- if (isCond(ins) && instrs[i + 2]) {
371
- leaders.add(instrs[i + 2].addr);
372
- } // fall-through
373
- });
493
+ if (isCond(ins)) {
494
+ leaders.add(ins.addr + 8);
495
+ } // fall-through: past the branch and its slot
496
+ if (likelyAddrs.has(ins.addr)) {
497
+ leaders.add(ins.addr + 4);
498
+ } // the nullified slot's own block
499
+ }
374
500
  // A recovered jump table makes its case + default targets leaders (the bounds block's `switch_br`
375
501
  // successors); the dispatch block then becomes unreachable and is pruned below.
376
502
  for (const jt of jts.values()) {
@@ -381,19 +507,25 @@ function toBlocks(
381
507
  }
382
508
 
383
509
  const blocks: MipsBlock[] = [];
510
+ // Where a nullified slot's block goes once the slot has run: its branch's target.
511
+ const slotGoto = new Map<number, number>();
384
512
  let cur: MipsBlock | null = null;
385
- for (let i = 0; i < instrs.length; i++) {
386
- if (consumed.has(i)) {
513
+ for (const ins of instrs) {
514
+ if (consumed.has(ins.addr)) {
387
515
  continue;
388
516
  } // delay slot: handled with its branch
389
- const ins = instrs[i];
390
517
  if (cur === null || leaders.has(ins.addr)) {
391
- cur = { startAddr: ins.addr, body: [], branch: null, delay: null };
518
+ cur = { startAddr: ins.addr, body: [], branch: null, delay: null, delayAnnulled: false };
392
519
  blocks.push(cur);
393
520
  }
394
521
  if (isXfer(ins)) {
395
522
  cur.branch = ins;
396
- cur.delay = instrs[i + 1] ?? null;
523
+ if (likelyAddrs.has(ins.addr)) {
524
+ cur.delayAnnulled = true;
525
+ slotGoto.set(ins.addr + 4, ins.target!);
526
+ } else {
527
+ cur.delay = at.get(ins.addr + 4) ?? null;
528
+ }
397
529
  cur = null;
398
530
  } else {
399
531
  cur.body.push(ins);
@@ -403,6 +535,11 @@ function toBlocks(
403
535
  // Successor addresses per block (before reachability pruning).
404
536
  const succAddrs = new Map<MipsBlock, number[]>();
405
537
  for (const b of blocks) {
538
+ const goto = slotGoto.get(b.startAddr);
539
+ if (goto !== undefined) {
540
+ succAddrs.set(b, [goto]);
541
+ continue;
542
+ } // a nullified slot's block: run the slot, then take the branch
406
543
  const br = b.branch;
407
544
  const jt = br ? jts.get(br.addr) : undefined;
408
545
  if (jt) {
@@ -416,8 +553,11 @@ function toBlocks(
416
553
  succAddrs.set(b, []);
417
554
  } else if (isUncond(br)) {
418
555
  succAddrs.set(b, br.target !== undefined ? [br.target] : []);
556
+ } else if (b.delayAnnulled) {
557
+ // branch-likely: TAKEN runs the nullified slot's block, NOT-TAKEN skips straight past it
558
+ succAddrs.set(b, [br.addr + 4, br.addr + 8]);
419
559
  } else {
420
- const fall = (b.delay ? b.delay.addr : br.addr) + 4; // instruction after the delay slot
560
+ const fall = br.addr + 8; // past the branch and its delay slot
421
561
  succAddrs.set(b, br.target !== undefined ? [br.target, fall] : [fall]);
422
562
  }
423
563
  }
@@ -459,20 +599,60 @@ export function lift(
459
599
  assertInputFormat('mips', 'objdump', asm);
460
600
  }
461
601
  // ONE function only — an absent symbol declines loud (either dialect's slicer enforces this).
462
- const instrs = splat ? parseSplatMips(asm, name) : parseDisasm(sliceSymbol(asm, name));
602
+ const sliced = splat ? asm : sliceSymbol(asm, name);
603
+ const instrs = splat ? parseSplatMips(asm, name) : parseDisasm(sliced);
463
604
  if (instrs.length === 0) {
464
605
  throw new FrontendUnsupportedError(`cannot lift '${name}': no instructions found in the input text`);
465
606
  }
607
+ // Headerless input — a raw fragment, and every Splat listing — says where the function starts
608
+ // only by its first line.
609
+ const startAddr = (splat ? undefined : symbolStart(sliced)) ?? instrs[0].addr;
610
+ // An FP condition-code branch is a DIFFERENT gap from a branch-likely, and saying so is what lets
611
+ // each be worked on alone: `bc1fl` is both, and the condition code blocks it either way. Asked
612
+ // FIRST, before the likely rewrite, so that a function carrying both reports the FP gap rather
613
+ // than whatever the likely placement happens to say about the other branch.
614
+ for (const ins of instrs) {
615
+ if (isFpCondBranch(ins.mnemonic)) {
616
+ throw new FrontendUnsupportedError(
617
+ `cannot lift '${name}': floating-point condition-code branch '${ins.mnemonic}' at 0x${ins.addr.toString(16)} ` +
618
+ `— the FP condition code is not modelled`,
619
+ );
620
+ }
621
+ }
622
+ // BRANCH-LIKELY is rewritten to its ordinary branch FIRST, so everything downstream — jump-table
623
+ // recovery's block-boundary walk included — sees one branch vocabulary. What stays special is the
624
+ // slot, and `toBlocks` is where that is placed.
625
+ const likelyAddrs = normaliseBranchLikely(name, instrs, startAddr);
466
626
  // Regime B: recover jump tables from the `jr`-dispatch idiom + the AsmData table. A recovered
467
627
  // dispatch's `jr` is subsumed into a `switch_br` (emitted from its bounds block), so it is
468
628
  // exempted from the loud-fail below; an UNrecovered `jr <non-ra>` still fails loud.
469
629
  const jts = asmData ? recoverMipsJumpTables(instrs, asmData) : new Map<number, MipsJT>();
470
630
  const recoveredJr = new Set([...jts.values()].map((j) => j.jrAddr));
471
- // Bridge global-access relocations into `%hi`/`%lo` operands (objdump dialect only Splat text
472
- // already carries them). Runs AFTER jump-table recovery so its raw `.rodata` table-base relocs are
473
- // read pristine; global rewrites target NAMED symbols and never touch a jump-table base.
631
+ // THE TWO WAYS A RECOVERED TABLE MEETS AN ANNULLED SLOT, neither of which `normaliseBranchLikely`
632
+ // can see: the table does not exist while it runs. (1) The dispatch's bounds branch emits a
633
+ // `switch_br`, which runs its delay slot unconditionally a nullified slot is not that. (2) An
634
+ // arm of the table lands ON a nullified slot, which would run the slot with no branch
635
+ // conditioning it and then jump to that branch's target: `case 1:` would take the taken arm's
636
+ // value where the hardware runs the slot and falls on through. Neither idiom has ever been seen
637
+ // in the corpus; refuse rather than answer either one wrong.
638
+ const jtArms = new Set([...jts.values()].flatMap((jt) => [...jt.caseAddrs, jt.defaultAddr]));
639
+ for (const addr of likelyAddrs) {
640
+ if (jts.has(addr)) {
641
+ throw new FrontendUnsupportedError(
642
+ `cannot lift '${name}': branch-likely at 0x${addr.toString(16)} — a recovered switch's bounds branch cannot annul its delay slot`,
643
+ );
644
+ }
645
+ if (jtArms.has(addr + 4)) {
646
+ throw new FrontendUnsupportedError(
647
+ `cannot lift '${name}': branch-likely at 0x${addr.toString(16)} — a recovered switch arm lands on its delay slot, which would run it unconditioned`,
648
+ );
649
+ }
650
+ }
651
+ // Carry the object's relocations on the instructions they fill (objdump dialect only — Splat
652
+ // text spells the halves and writes its own records). Runs AFTER jump-table recovery, so that
653
+ // reads `.text` relocs pristine and a recovered table base is decided before any of this.
474
654
  if (!splat && asmData) {
475
- applyMipsGlobalRelocs(instrs, asmData);
655
+ attachMipsRelocs(name, instrs, asmData);
476
656
  }
477
657
  // TRUSTWORTHINESS: fail LOUD on a control transfer this frontend cannot model — the `opaque`
478
658
  // path cannot catch these (implicit or no register destination). `jal`/`jalr` clobber `v0`
@@ -492,18 +672,16 @@ export function lift(
492
672
  }
493
673
  // CATCH-ALL (mirrors the PPC denylist): an unmodelled control-transfer mnemonic would otherwise
494
674
  // fall through to `emitOpaqueDest` and have its BRANCH silently dropped (no register dest for
495
- // the opaque guard to catch). Bites the branch-LIKELY forms (`beql`/`bnel`/`b*zl`, which annul
496
- // the delay slot when not taken) and coprocessor branches (`bc1t`/`bc1f`…). `break` is a trap,
497
- // not a branch.
498
- const isBranchish = (ins.mnemonic[0] === 'b' && ins.mnemonic !== 'break') || ins.mnemonic[0] === 'j';
499
- if (isBranchish && !isXfer(ins) && ins.mnemonic !== 'jal' && ins.mnemonic !== 'jalr') {
675
+ // the opaque guard to catch). `break` is a trap, not a branch.
676
+ if (isControlTransfer(ins) && !isXfer(ins) && ins.mnemonic !== 'jal' && ins.mnemonic !== 'jalr') {
500
677
  throw new FrontendUnsupportedError(
501
678
  `cannot lift '${name}': unmodelled control transfer '${ins.mnemonic}' at 0x${ins.addr.toString(16)} ` +
502
- `— branch-likely / coprocessor branch not supported`,
679
+ `— not a modelled branch form`,
503
680
  );
504
681
  }
505
682
  }
506
- const { blocks, succAddrs } = toBlocks(instrs, jts);
683
+ checkDelaySlots(name, instrs);
684
+ const { blocks, succAddrs } = toBlocks(instrs, jts, likelyAddrs);
507
685
  const idxOf = new Map(blocks.map((b, i) => [b.startAddr, i]));
508
686
 
509
687
  // CFG predecessors by block index.
@@ -533,6 +711,20 @@ export function lift(
533
711
  const RET = target.returnReg;
534
712
  const ARG_REGS = target.argRegs;
535
713
 
714
+ // The pending `%hi` halves of this function's global addresses, keyed by the SSA VALUE each `lui`
715
+ // defines (frontend/high-half.ts holds the invariant and why a register-keyed map cannot answer
716
+ // the question; frontend/ppc.ts folds the same one). FUNCTION-scoped, because a value is: a pair
717
+ // split across blocks folds when SSA says the half reaches, and refuses when what arrives is the
718
+ // block parameter standing for a merge.
719
+ const highHalves = makeHighHalves({
720
+ hi: '%hi',
721
+ hiArticle: 'a',
722
+ lo: '%lo',
723
+ fail: (message) => {
724
+ throw new FrontendUnsupportedError(message);
725
+ },
726
+ });
727
+
536
728
  // SOUNDNESS GUARD. The word stack-slot model (emitLoad/emitStore) is safe ONLY when every
537
729
  // sp-relative access in the function is word-width. If a SUB-WORD sp access aliases a word slot
538
730
  // (`sw a0,4(sp)` then `lbu v0,4(sp)`), routing the word store to an SSA slot while the sub-word
@@ -556,10 +748,6 @@ export function lift(
556
748
 
557
749
  const fillBlock = (b: MipsBlock, bi: number) => {
558
750
  const ops = irBlocks[bi].ops;
559
- // Pending `lui rX, %hi(SYM)` relocations awaiting their consuming `%lo` (a load/store base or an
560
- // `addiu`). Block-local: the pair is emitted adjacently, so a `%lo` with no matching in-scope
561
- // `%hi` (a cross-block or gp-relative access) declines LOUD rather than fabricating a base.
562
- const hiReloc = new Map<string, { sym: string; addend: number }>();
563
751
  // Materialise the address of a named global — the same `gaddr` op the Thumb frontend emits; the
564
752
  // structurer lowers a load/store through it to `SYM` (scalar) or `((T *)&SYM)[i]` (aggregate).
565
753
  const emitGaddr = (sym: string): Value => {
@@ -571,16 +759,6 @@ export function lift(
571
759
  if (isZero(r)) {
572
760
  return constVal(0);
573
761
  }
574
- // A `%hi(SYM)` register read as DATA before its `%lo` completes the address is a split hi/lo
575
- // relocation (the high half used alone) this frontend does not model — decline rather than
576
- // treat the partial address as a value. The legit consumers (load/store/addiu `%lo`) validate
577
- // `hiReloc` directly and never route the base through `read`, so this fires only on misuse.
578
- const hr = hiReloc.get(r);
579
- if (hr) {
580
- throw new FrontendUnsupportedError(
581
- `cannot lift '${name}': %hi(${hr.sym}) register used as data before a matching %lo — split hi/lo relocation not modelled`,
582
- );
583
- }
584
762
  // Reading `sp` as a DATA operand means frame-pointer arithmetic or an address-taken local
585
763
  // (`addiu a0,sp,8` = `&local`) — not modellable without a stack abstraction. Fabricating a
586
764
  // value for `sp` invents a PHANTOM leading parameter that shifts every real argument — a
@@ -600,7 +778,9 @@ export function lift(
600
778
  `cannot lift '${name}': gp used as data (PIC / small-data global access) — not supported`,
601
779
  );
602
780
  }
603
- return readVar(r, bi);
781
+ // A register holding the high half of an address is NOT a value. The legitimate consumers
782
+ // (`addiu %lo`, a `%lo` load/store base) reach the half through `foldLoHalf` instead.
783
+ return highHalves.guardRead(name, r, readVar(r, bi));
604
784
  };
605
785
  // `slt`-family results, so a following `beqz`/`bnez` can fold into one compare.
606
786
  //
@@ -615,10 +795,6 @@ export function lift(
615
795
  // rD,%hi(SYM)` deliberately reassigns rD's meaning without it).
616
796
  const cmpDef = new Map<Value, { opcode: string; lhs: Value; rhs: Value }>();
617
797
  const write = (r: string, v: Value) => {
618
- // Writing a register clears any pending `%hi` it held — the high-half address is gone once the
619
- // register is reassigned (e.g. `lw rHi, %lo(SYM)(rHi)` reuses the base as the load dest). A
620
- // `%hi` NOT overwritten persists across multiple `%lo` uses (the read-modify-write idiom).
621
- hiReloc.delete(r);
622
798
  if (!isZero(r)) {
623
799
  writeVar(r, bi, v);
624
800
  }
@@ -643,8 +819,30 @@ export function lift(
643
819
  cmpDef.set(v, { opcode: opc, lhs, rhs });
644
820
  };
645
821
 
822
+ // Where a refusal about ONE INSTRUCTION starts, spelled as frontend/ppc.ts spells it — the
823
+ // artifact's decline text is read row by row across both ISAs.
824
+ const site = (ins: Instr) => `cannot lift '${name}': '${ins.mnemonic}' at 0x${ins.addr.toString(16)}`;
825
+ // Set by every case entitled to fold a relocation, cleared per instruction, and answered by
826
+ // `relocPlaceholder`.
827
+ let relocTaken = false;
828
+ // EVERY immediate this frontend turns into a value goes through here. objdump prints them as
829
+ // numbers, so a non-numeric one means the operand is not an immediate at all — a relocation
830
+ // spelling the reader failed to resolve, a label — and bare `parseImm` answers NaN, which
831
+ // `constVal` renders as the literal 0. That is the relocation fold's own failure one level
832
+ // down, so it refuses instead.
833
+ const imm = (ins: Instr, text: string | undefined): number => {
834
+ const v = parseImm(text ?? '');
835
+ if (!Number.isFinite(v)) {
836
+ throw new FrontendUnsupportedError(
837
+ `${site(ins)} has the non-numeric ` +
838
+ `immediate '${text ?? ''}' where a number belongs — this frontend will not read it as one`,
839
+ );
840
+ }
841
+ return v;
842
+ };
646
843
  const decode = (ins: Instr) => {
647
844
  const [d, s, t] = ins.ops;
845
+ relocTaken = false;
648
846
  // The GOT/small-data base register `gp` is set up by IDO's PIC prologue (`lui gp; addiu gp,gp,lo;
649
847
  // addu gp,gp,t9`, an `_gp_disp` HI16/LO16 pair). It is a RELOCATION base, never program data — and
650
848
  // reading `t9` for the `addu` would fabricate a phantom leading parameter (like the sp/r1 guards).
@@ -660,30 +858,44 @@ export function lift(
660
858
  write(d, read(s));
661
859
  break; // pseudo: addu/or rD,rS,zero
662
860
  case 'li':
663
- write(d, constVal(parseImm(s)));
861
+ write(d, constVal(imm(ins, s)));
664
862
  break; // pseudo: load immediate
665
863
  // `lui rD, hi` loads the 16-bit immediate into the UPPER half (mirrors PPC `lis`). Alone it
666
864
  // is the high half of a 32-bit literal; the following `ori`/`addiu` supplies the low half
667
865
  // and raise/const.ts folds the const/const pair into one 32-bit const — the form that
668
866
  // recompiles to this exact `lui;ori`.
669
867
  case 'lui':
670
- // `lui rD, %hi(SYM)` is the high half of a global's address record it, pending the `%lo`
671
- // that completes it (below), instead of materialising a bogus numeric const. rD's SSA value
672
- // is deliberately NOT written: the high half is meaningless alone, so the `gaddr` is emitted
673
- // at the consuming `%lo`. A read of rD as data before that is caught by the `read` guard;
674
- // an UNconsumed `%hi` (no `%lo`) is a dead `lui` whose rD is never read — the residual case
675
- // (an unconsumed `%hi` reg read via a `readVar` bypass) does not occur in compiler output.
676
- if (s.startsWith('%')) {
677
- const hi = parseReloc('hi', s);
678
- if (!hi) {
679
- throw new FrontendUnsupportedError(`cannot lift '${name}': unsupported relocation immediate '${s}'`);
680
- }
681
- if (!isZero(d)) {
682
- hiReloc.set(d, { sym: hi.sym, addend: hi.addend });
683
- }
868
+ // `lui rD, %hi(SYM)` is the HIGH HALF of a global's address, so rD is defined as a
869
+ // PLACEHOLDER value rather than a number (frontend/high-half.ts) and the `gaddr` itself
870
+ // is emitted at the `%lo` that completes the address. Being REL, this half contributes
871
+ // `hi_imm << 16` to the addend.
872
+ if (ins.reloc?.type === 'R_MIPS_HI16') {
873
+ relocTaken = true;
874
+ const hi = mkValue(T.unk(32));
875
+ highHalves.record(hi, {
876
+ sym: ins.reloc.sym,
877
+ addend: imm(ins, s ?? '0') << 16,
878
+ addr: ins.addr,
879
+ mnemonic: ins.mnemonic,
880
+ });
881
+ write(d, hi);
684
882
  break;
685
883
  }
686
- write(d, constVal((parseImm(s) << 16) >> 0));
884
+ // `lui rD, 0x0` WITHOUT a relocation is not code any compiler wrote: `lui` of zero
885
+ // writes zero, which `move rD,zero` says in one instruction. What does print it is a
886
+ // relocatable object's UNRELOCATED high half, whose symbol lives in a record this lift
887
+ // was never handed — the side table is optional, and a caller may simply not pass one.
888
+ // Every refusal above covers a relocation that ARRIVED and was dropped; this covers the
889
+ // one that never arrived, so `*(T *)0` is not the answer either way. A non-zero `lui` is
890
+ // a genuine absolute address or literal high half (a linked dump, `lui;ori`).
891
+ if (imm(ins, s) === 0) {
892
+ throw new FrontendUnsupportedError(
893
+ `${site(ins)} loads the high half 0x0 with no relocation on it — that is an ` +
894
+ `unrelocated placeholder, not the value; the object's R_MIPS_HI16/LO16 records were not supplied ` +
895
+ `with this disassembly (pass the object's \`objdump -s -r -t\` side table)`,
896
+ );
897
+ }
898
+ write(d, constVal((imm(ins, s) << 16) >> 0));
687
899
  break;
688
900
  case 'addiu':
689
901
  case 'addi':
@@ -693,29 +905,22 @@ export function lift(
693
905
  if (isStackPtr(d)) {
694
906
  break;
695
907
  }
696
- // `addiu rD, rHi, %lo(SYM)` completes a global's address materialised by a `lui %hi(SYM)`:
697
- // rD = &SYM (+ addend for a byte offset into the global). Emits the shared `gaddr` op.
698
- if (t.startsWith('%')) {
699
- const lo = parseReloc('lo', t);
700
- const hr = lo ? hiReloc.get(s) : undefined;
701
- if (!lo || !hr || hr.sym !== lo.sym || hr.addend !== lo.addend) {
702
- throw new FrontendUnsupportedError(
703
- `cannot lift '${name}': %lo relocation '${t}' with no matching in-scope %hi — split/cross-block hi/lo not modelled`,
704
- );
705
- }
706
- // `&SYM` (addend 0), or `&SYM + N` for a byte offset into the global. The `add` tree is
908
+ // `addiu rD, rHi, %lo(SYM)` completes a global's address begun by a `lui %hi(SYM)`:
909
+ // rD = &SYM (+ a byte offset into it). Emits the shared `gaddr` op.
910
+ if (ins.reloc?.type === 'R_MIPS_LO16') {
911
+ const { base: g, off } = foldLoHalf(ins, s, imm(ins, t));
912
+ // `&SYM` (offset 0), or `&SYM + N` for a byte offset into the global. The `add` tree is
707
913
  // folded byte-correctly by memAccess when this address is a load/store base; if it
708
914
  // instead ESCAPES as a value, `assertDerefsTyped` declines it (the byte offset would
709
915
  // element-scale in C) — see the interior-global-pointer guard there.
710
- const g = emitGaddr(lo.sym);
711
- lo.addend !== 0 ? emitBin('add', d, g, constVal(lo.addend)) : write(d, g);
916
+ off !== 0 ? emitBin('add', d, g, constVal(off)) : write(d, g);
712
917
  break;
713
918
  }
714
919
  if (isZero(s)) {
715
- write(d, constVal(parseImm(t)));
920
+ write(d, constVal(imm(ins, t)));
716
921
  break;
717
922
  } // li idiom
718
- emitBin('add', d, read(s), constVal(parseImm(t)));
923
+ emitBin('add', d, read(s), constVal(imm(ins, t)));
719
924
  break;
720
925
  case 'addu':
721
926
  case 'add':
@@ -775,19 +980,19 @@ export function lift(
775
980
  emitBin('and', d, read(s), read(t));
776
981
  break;
777
982
  case 'andi':
778
- emitBin('and', d, read(s), constVal(parseImm(t)));
983
+ emitBin('and', d, read(s), constVal(imm(ins, t)));
779
984
  break;
780
985
  case 'or':
781
986
  isZero(t) ? write(d, read(s)) : emitBin('or', d, read(s), read(t));
782
987
  break;
783
988
  case 'ori':
784
- emitBin('or', d, read(s), constVal(parseImm(t)));
989
+ emitBin('or', d, read(s), constVal(imm(ins, t)));
785
990
  break;
786
991
  case 'xor':
787
992
  emitBin('xor', d, read(s), read(t));
788
993
  break;
789
994
  case 'xori':
790
- emitBin('xor', d, read(s), constVal(parseImm(t)));
995
+ emitBin('xor', d, read(s), constVal(imm(ins, t)));
791
996
  break;
792
997
  // `nor rD, x, zero` / `nor rD, zero, x` = ~x (GCC emits the zero in EITHER operand — e.g.
793
998
  // its branchless `x<0?0:x` uses `nor v0,zero,a0`; IDO tends to put zero second).
@@ -807,13 +1012,13 @@ export function lift(
807
1012
  break;
808
1013
  }
809
1014
  case 'sll':
810
- emitShImm('shl', d, read(s), t);
1015
+ kit.shImm('shl', d, read(s), imm(ins, t));
811
1016
  break;
812
1017
  case 'srl':
813
- emitShImm('shr_u', d, read(s), t);
1018
+ kit.shImm('shr_u', d, read(s), imm(ins, t));
814
1019
  break;
815
1020
  case 'sra':
816
- emitShImm('shr_s', d, read(s), t);
1021
+ kit.shImm('shr_s', d, read(s), imm(ins, t));
817
1022
  break;
818
1023
  // Variable shift `<op>v rD, rT, rS` = rD = rT <shift> rS: VALUE is rT (=s), AMOUNT is rS
819
1024
  // (=t) — value-then-amount, unlike `slt rD,rS,rT`.
@@ -837,58 +1042,71 @@ export function lift(
837
1042
  emitCmp('icmp_slt', d, read(s), read(t));
838
1043
  break;
839
1044
  case 'slti':
840
- emitCmp('icmp_slt', d, read(s), constVal(parseImm(t)));
1045
+ emitCmp('icmp_slt', d, read(s), constVal(imm(ins, t)));
841
1046
  break;
842
1047
  case 'sltu':
843
1048
  emitCmp('icmp_ult', d, read(s), read(t));
844
1049
  break;
845
1050
  case 'sltiu':
846
- emitCmp('icmp_ult', d, read(s), constVal(parseImm(t)));
1051
+ emitCmp('icmp_ult', d, read(s), constVal(imm(ins, t)));
847
1052
  break;
848
1053
  // typed memory: `off(base)` addressing. Width/signedness come from the mnemonic; the
849
1054
  // base is typed a pointer-to-element during recovery, mirroring the Thumb frontend.
850
1055
  case 'lw':
851
- emitLoad(d, s, 4, true);
1056
+ emitLoad(ins, d, s, 4, true);
852
1057
  break;
853
1058
  case 'lh':
854
- emitLoad(d, s, 2, true);
1059
+ emitLoad(ins, d, s, 2, true);
855
1060
  break;
856
1061
  case 'lhu':
857
- emitLoad(d, s, 2, false);
1062
+ emitLoad(ins, d, s, 2, false);
858
1063
  break;
859
1064
  case 'lb':
860
- emitLoad(d, s, 1, true);
1065
+ emitLoad(ins, d, s, 1, true);
861
1066
  break;
862
1067
  case 'lbu':
863
- emitLoad(d, s, 1, false);
1068
+ emitLoad(ins, d, s, 1, false);
864
1069
  break;
865
1070
  case 'sw':
866
- emitStore(d, s, 4);
1071
+ emitStore(ins, d, s, 4);
867
1072
  break; // d = source reg, s = off(base)
868
1073
  case 'sh':
869
- emitStore(d, s, 2);
1074
+ emitStore(ins, d, s, 2);
870
1075
  break;
871
1076
  case 'sb':
872
- emitStore(d, s, 1);
1077
+ emitStore(ins, d, s, 1);
873
1078
  break;
874
1079
  default:
875
1080
  emitOpaqueDest(ins);
876
1081
  break; // unmodelled: an honest opaque, never a silent drop
877
1082
  }
1083
+ // THE CHOKE POINT (mirrors the PPC frontend). Every case above either folded the relocation
1084
+ // or threw, so one still sitting here was DROPPED. An unmodelled `%lo` consumer (`lwc1`,
1085
+ // `swc1`, `ori`) lands here, which is what keeps "not modelled" from becoming "not emitted".
1086
+ if (ins.reloc && !relocTaken) {
1087
+ relocPlaceholder(ins);
1088
+ }
1089
+ };
1090
+ // A relocation no case folded. The printed immediate is a link-time placeholder — the literal 0
1091
+ // in a relocatable object — so finishing the lift would put that 0 where the symbol belongs.
1092
+ const relocPlaceholder = (ins: Instr): never => {
1093
+ const r = ins.reloc!;
1094
+ throw new FrontendUnsupportedError(
1095
+ `${site(ins)} carries '${r.type}' against ` +
1096
+ `'${r.sym}' but is not a modelled consumer of it — the printed immediate is a link-time ` +
1097
+ `placeholder, not the value`,
1098
+ );
878
1099
  };
879
1100
  // TRUSTWORTHINESS GUARD (mirrors the PPC frontend): an unmodelled instruction must not silently
880
1101
  // drop its destination register — emit an honest `opaque`, which fails LOUD at assertResolved
881
1102
  // whether or not anything reads that register (see frontend/opaque.ts for the policy).
882
1103
  const emitOpaqueDest = (ins: Instr) => {
883
- // A `%hi`/`%lo` operand on an instruction NOT modelled as a global consumer an FP load/store
884
- // (`lwc1`/`ldc1`), or any unmodelled opreaches here (the modelled consumers handle their own
885
- // `%hi`/`%lo` and return before the default case). Dropping it to an opaque would silently
886
- // delete the global access (its base is not a bare register the opaque srcReg scan can see), so
887
- // decline LOUD rather than lose it.
888
- if (ins.ops.some((o) => o.startsWith('%'))) {
889
- throw new FrontendUnsupportedError(
890
- `cannot lift '${name}': unmodelled instruction '${ins.mnemonic}' with a %hi/%lo global operand — not modelled`,
891
- );
1104
+ // THE RELOCATION FIRST. `opaqueDest` would refuse an unmodelled `%lo` consumer for the
1105
+ // lesser reason "no register destination" or, for a form that HAS one, degrade it to an
1106
+ // opaque and lose the global access with it. Naming the relocation says which capability is
1107
+ // missing.
1108
+ if (ins.reloc) {
1109
+ relocPlaceholder(ins);
892
1110
  }
893
1111
  // storeClass: unmodelled MIPS stores — incl. the unaligned pair swl/swr and the FPU stores,
894
1112
  // whose FIRST token is a register (a SOURCE, not a dest) that would otherwise fabricate an
@@ -913,25 +1131,37 @@ export function lift(
913
1131
  write(od.dst, res);
914
1132
  };
915
1133
  const emitUn = kit.un;
916
- const emitShImm = (opc: Opcode, d: string, x: Value, sa: string) => kit.shImm(opc, d, x, parseImm(sa));
917
- // Resolve a `%lo(SYM + N)(rHi)` memory operand to a global address: validate it pairs with an
918
- // in-scope `%hi`, emit the `gaddr`, and return it as the base with the addend as the access
919
- // offset. A non-`%lo` operand returns null (the caller falls through to the normal off(base)).
920
- const globalBase = (mem: string): { base: Value; off: number } | null => {
921
- if (!mem.startsWith('%')) {
922
- return null;
923
- }
924
- const lo = parseReloc('lo', mem);
925
- const hr = lo && lo.base ? hiReloc.get(lo.base) : undefined;
926
- if (!lo || !lo.base || !hr || hr.sym !== lo.sym || hr.addend !== lo.addend) {
1134
+ // `%lo` completes the address a `lui %hi` began. The pairing asks SSA through `readVar`, not
1135
+ // `read`, which refuses a half what `rHi` holds HERE, so a pair separated by unrelated
1136
+ // instructions folds while a register reused between the halves does not, and a half that
1137
+ // arrives only through a merge or a loop header comes back as the block parameter standing for
1138
+ // the join, which is not a half and refuses.
1139
+ const foldLoHalf = (ins: Instr, rHi: string, loImm: number): { base: Value; off: number } => {
1140
+ relocTaken = true;
1141
+ const lo = ins.reloc!;
1142
+ const hi = highHalves.pair(site(ins), rHi, readVar(rHi, bi), lo.sym);
1143
+ // The addend is split across the two instruction immediates because MIPS is REL.
1144
+ const off = hi.addend + loImm;
1145
+ if (off < 0) {
927
1146
  throw new FrontendUnsupportedError(
928
- `cannot lift '${name}': %lo access '${mem}' with no matching in-scope %hi — split/cross-block hi/lo not modelled`,
1147
+ `${site(ins)} completes '${lo.sym}' at ` +
1148
+ `byte offset ${off} — an address BELOW the symbol (an index-biased array base) is not yet modelled`,
929
1149
  );
930
1150
  }
931
- return { base: emitGaddr(lo.sym), off: lo.addend };
1151
+ return { base: emitGaddr(lo.sym), off };
932
1152
  };
933
- const emitLoad = (d: string, mem: string, width: number, signed: boolean) => {
934
- const g = globalBase(mem);
1153
+ // A displacement memory operand whose base is a `%hi` half: the global's address plus the
1154
+ // access offset. A non-`%lo` instruction returns null (the caller falls through to the ordinary
1155
+ // `off(base)` path).
1156
+ const globalBase = (ins: Instr, mem: string): { base: Value; off: number } | null => {
1157
+ if (ins.reloc?.type !== 'R_MIPS_LO16') {
1158
+ return null;
1159
+ }
1160
+ const { off, base } = parseMem(mem);
1161
+ return foldLoHalf(ins, base, off);
1162
+ };
1163
+ const emitLoad = (ins: Instr, d: string, mem: string, width: number, signed: boolean) => {
1164
+ const g = globalBase(ins, mem);
935
1165
  if (g) {
936
1166
  const res = mkValue(T.unk(32));
937
1167
  ops.push(mkOp('load', { operands: [g.base], results: [res], attrs: { off: g.off, width, signed } }));
@@ -969,8 +1199,8 @@ export function lift(
969
1199
  ops.push(mkOp('load', { operands: [read(base)], results: [res], attrs: { off, width, signed } }));
970
1200
  write(d, res);
971
1201
  };
972
- const emitStore = (srcReg: string, mem: string, width: number) => {
973
- const g = globalBase(mem);
1202
+ const emitStore = (ins: Instr, srcReg: string, mem: string, width: number) => {
1203
+ const g = globalBase(ins, mem);
974
1204
  if (g) {
975
1205
  ops.push(mkOp('store', { operands: [g.base, read(srcReg)], attrs: { off: g.off, width } }));
976
1206
  return;
@@ -1022,11 +1252,17 @@ export function lift(
1022
1252
 
1023
1253
  if (br && isCond(br)) {
1024
1254
  const cond = condValue(br, ops, read, constVal, cmpDef);
1255
+ // A NULLIFIED slot is not decoded here: it is its own block on the taken edge, and the
1256
+ // not-taken edge skips past it (toBlocks). Every pass after this one sees ordinary
1257
+ // conditional execution.
1258
+ if (b.delayAnnulled) {
1259
+ ops.push(mkOp('cond_br', { operands: [cond], successors: [succ(br.addr + 4), succ(br.addr + 8)] }));
1260
+ return;
1261
+ }
1025
1262
  if (b.delay) {
1026
1263
  decode(b.delay);
1027
1264
  }
1028
- const fall = (b.delay ? b.delay.addr : br.addr) + 4;
1029
- ops.push(mkOp('cond_br', { operands: [cond], successors: [succ(br.target!), succ(fall)] }));
1265
+ ops.push(mkOp('cond_br', { operands: [cond], successors: [succ(br.target!), succ(br.addr + 8)] }));
1030
1266
  return;
1031
1267
  }
1032
1268
  if (b.delay) {
@@ -1034,9 +1270,20 @@ export function lift(
1034
1270
  } // unconditional / return: delay slot just executes first
1035
1271
 
1036
1272
  if (!br || isReturn(br)) {
1037
- const retOps = ssa.hasReachingDef(RET, bi) ? [readVar(RET, bi)] : [];
1273
+ // A HIGH HALF IN v0 IS NOT A RETURN VALUE. `v0` is both MIPS's return register and an
1274
+ // ordinary caller-saved scratch, so a void function can end with a half still live in it
1275
+ // (`lui v0,%hi(g); lw v1,%lo(g)(v0); … ; sw v1,%lo(g)(v0)`). Counting that def hands the
1276
+ // placeholder to the `ret` op, and the whole function then declines at `assertNoneEscaped` —
1277
+ // loud, but for a merge that never happened. Rejecting it gives the honest void return.
1278
+ // PowerPC reads its return register through the guard, so the refusal it would get is already
1279
+ // the right one; it passes this same predicate to its call-arity count.
1280
+ const retOps = ssa.hasReachingDef(RET, bi, (v) => !highHalves.has(v)) ? [readVar(RET, bi)] : [];
1038
1281
  if (!br) {
1039
- ops.push(mkOp('br', { successors: [succ(succAddrs.get(b)![0])] }));
1282
+ // A nullified slot's block leaves by its branch's TARGET, which need not be the next
1283
+ // address, so `fallthrough` is claimed only where control really does fall through.
1284
+ const to = succAddrs.get(b)![0];
1285
+ const next = (b.body[b.body.length - 1]?.addr ?? b.startAddr) + 4;
1286
+ ops.push(mkOp('br', { attrs: to === next ? { fallthrough: true } : {}, successors: [succ(to)] }));
1040
1287
  } // fall-through
1041
1288
  else {
1042
1289
  ops.push(mkOp('ret', { operands: retOps }));
@@ -1051,7 +1298,9 @@ export function lift(
1051
1298
  fillBlock(b, bi);
1052
1299
  ssa.markFilled(bi);
1053
1300
  });
1301
+ highHalves.assertAllConsumed(name);
1054
1302
  ssa.finish();
1303
+ highHalves.assertNoneEscaped(name, irBlocks);
1055
1304
 
1056
1305
  // ABI-ordered entry parameters (a0, a1, …) — a callee-saved copy can read a later argument
1057
1306
  // register first. Only the true entry (no predecessors) is sorted; a loop header's phis are