@asmlift/core 0.3.0 → 0.4.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.
@@ -3,11 +3,20 @@
3
3
  // CFG (predecessors per block) and, per block, emits ops through `readVar`/`writeVar`; this
4
4
  // module materialises block-argument phis at joins and back-edges.
5
5
  //
6
+ // `preds` is an EDGE list, not a block list: it carries one entry per CFG edge, so a `switch_br`
7
+ // with several case values reaching one block appears there several times. Both readings are
8
+ // needed and they are not interchangeable — phi wiring wants the distinct predecessor BLOCKS (one
9
+ // value each), while the args it appends belong to the EDGES (every one of them). `distinctPreds`
10
+ // names the first; `appendSuccessorArg` walks the second. (ir/core.ts `predecessors` and
11
+ // structure.ts `predecessorBlocks` have the same duality, and structure.ts already dedups ad hoc
12
+ // at its two join sites.)
13
+ //
6
14
  // Protocol: create the builder, then fill blocks in index order. For each block, emit its
7
15
  // computation via read/writeVar, push its terminator op last (successors referencing
8
16
  // `irBlocks`, args left empty — phi wiring appends them), then call `markFilled(b)`. When all
9
17
  // blocks are filled, call `finish()` to remove trivial phis.
10
- import { Block, Fn, Successor, Value, mkValue, replaceAllUsesWith } from '../ir/core';
18
+ import { Block, Fn, Value, mkValue } from '../ir/core';
19
+ import { simplifyTrivialPhis } from '../ir/simplify';
11
20
  import { T } from '../ir/types';
12
21
 
13
22
  export interface SsaBuilder {
@@ -27,6 +36,7 @@ export interface SsaBuilder {
27
36
  finish(): void;
28
37
  }
29
38
 
39
+ /** `preds` is per-EDGE (see the module header): one entry per CFG edge into each block. */
30
40
  export function makeSsaBuilder(name: string, blockCount: number, preds: number[][]): SsaBuilder {
31
41
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
32
42
  const fn: Fn = { name, blocks: irBlocks };
@@ -38,6 +48,9 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
38
48
  const phiBlock = new Map<Value, number>();
39
49
  const paramReg = new Map<Value, string>();
40
50
 
51
+ // `preds` lists an entry per CFG EDGE; these are the distinct predecessor BLOCKS.
52
+ const distinctPreds = (b: number): number[] => [...new Set(preds[b])];
53
+
41
54
  const writeVar = (reg: string, b: number, v: Value) => defs[b].set(reg, v);
42
55
  const readVar = (reg: string, b: number): Value => defs[b].get(reg) ?? readRecursive(reg, b);
43
56
 
@@ -55,7 +68,10 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
55
68
  incompletePhis[b].set(reg, phi);
56
69
  return phi;
57
70
  }
58
- const ps = preds[b];
71
+ // DISTINCT predecessor blocks: a switch_br reaching this block on several case values is one
72
+ // predecessor with several edges, and it supplies ONE value — counting the edges instead would
73
+ // manufacture a join (and a phi) where there is none.
74
+ const ps = distinctPreds(b);
59
75
  if (ps.length === 0) {
60
76
  // live-in with no predecessor: an incoming argument register → function parameter.
61
77
  const p = mkValue(T.unk(32));
@@ -75,16 +91,24 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
75
91
  return phi;
76
92
  };
77
93
  const addPhiOperands = (reg: string, b: number) => {
78
- for (const p of preds[b]) {
94
+ for (const p of distinctPreds(b)) {
79
95
  appendSuccessorArg(p, b, readVar(reg, p));
80
96
  }
81
97
  };
82
- // Append `arg` to predecessor p's terminator successor that targets block b.
98
+ // Append `arg` to EVERY successor edge of predecessor p that targets block b.
99
+ //
100
+ // A predecessor normally has one edge to a given successor, but a `switch_br` has as many as it
101
+ // has case values, and two cases sharing a body (`case 1: case 2:`) is ordinary C. Block args
102
+ // belong to the EDGE, so each of those edges needs its own copy: appending to just the first (a
103
+ // `find`) left the others short, while `preds` listing the block once per edge made the loop run
104
+ // k times and pile k copies onto that same first edge. Both halves of that — every edge, once per
105
+ // predecessor BLOCK — have to hold together, which is why they are fixed in one place.
83
106
  const appendSuccessorArg = (p: number, b: number, arg: Value) => {
84
107
  const term = irBlocks[p].ops[irBlocks[p].ops.length - 1];
85
- const s = term.successors.find((su) => su.block === irBlocks[b]);
86
- if (s) {
87
- s.args.push(arg);
108
+ for (const s of term.successors) {
109
+ if (s.block === irBlocks[b]) {
110
+ s.args.push(arg);
111
+ }
88
112
  }
89
113
  };
90
114
  const sealBlock = (b: number) => {
@@ -128,7 +152,7 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
128
152
  filled[b] = true;
129
153
  sealReadyBlocks();
130
154
  },
131
- finish: () => simplifyTrivialPhis(fn, phiBlock),
155
+ finish: () => simplifyTrivialPhis(fn, (p) => phiBlock.delete(p)),
132
156
  };
133
157
  }
134
158
 
@@ -167,48 +191,3 @@ export function abiSortEntryParams(
167
191
  }
168
192
  entry.params.sort((x, y) => rank(x) - rank(y));
169
193
  }
170
-
171
- // Remove block-parameters that are really trivial phis: those whose incoming operands (across
172
- // every predecessor edge, ignoring self-references from a back-edge) are all the same single
173
- // value. Such a parameter carries no join information — a loop-invariant register or a value
174
- // defined before the join — so it is replaced by that value and the corresponding argument
175
- // dropped from each predecessor's terminator. Iterated to fixpoint because removing one phi
176
- // can make another trivial.
177
- function simplifyTrivialPhis(fn: Fn, phiBlock: Map<Value, number>): void {
178
- const edgesTo = (b: Block): Successor[] => {
179
- const out: Successor[] = [];
180
- for (const pb of fn.blocks) {
181
- for (const op of pb.ops) {
182
- for (const s of op.successors) {
183
- if (s.block === b) {
184
- out.push(s);
185
- }
186
- }
187
- }
188
- }
189
- return out;
190
- };
191
- let changed = true;
192
- while (changed) {
193
- changed = false;
194
- for (const b of fn.blocks) {
195
- const incoming = edgesTo(b);
196
- for (let i = b.params.length - 1; i >= 0; i--) {
197
- const param = b.params[i];
198
- const operands = incoming.map((s) => s.args[i]);
199
- const distinct = [...new Set(operands.filter((v) => v !== param))];
200
- if (distinct.length !== 1) {
201
- continue;
202
- } // a genuine join (or unreachable) — keep it
203
- const v = distinct[0];
204
- replaceAllUsesWith(fn, param, v);
205
- b.params.splice(i, 1);
206
- for (const s of incoming) {
207
- s.args.splice(i, 1);
208
- }
209
- phiBlock.delete(param);
210
- changed = true;
211
- }
212
- }
213
- }
214
- }
@@ -30,14 +30,74 @@ import { opaqueDest } from './opaque';
30
30
  import { abiSortEntryParams, fallbackArgc, makeSsaBuilder } from './ssa';
31
31
 
32
32
  interface Instr {
33
+ /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
34
+ * consumer matches one name. */
33
35
  mnemonic: string;
34
36
  ops: string[];
37
+ /** the spelling the input file actually used, present only when normalisation changed it.
38
+ * Messages must use this: a decline naming `ldrsh` for a file containing `ldsh` sends the
39
+ * reader looking for an instruction that is not there. */
40
+ asWritten?: string;
35
41
  }
36
42
  interface AsmBlock {
37
43
  label: string;
38
44
  instrs: Instr[];
39
45
  }
40
46
 
47
+ // Alternative mnemonic spellings, normalised at the single point where an instruction enters the
48
+ // IR. Each maps to a name the decode switch below already handles.
49
+ //
50
+ // These are not "similar" instructions — each pair is ONE instruction with two accepted spellings.
51
+ // The ARM7TDMI Technical Reference Manual (ARM DDI 0029G) gives a single encoding for each:
52
+ // Figure 1-6 "Thumb instruction set formats" lists Format 08 "Load and store sign-extended byte and
53
+ // halfword" (0101 H S 1 Ro Rb Rd) and Format 15 "Multiple load and store" (1100 L Rb Rlist), and
54
+ // Table 1-7 "Thumb instruction set summary" spells them `LDRSH Rd, [Rb, Ro]`, `LDRSB Rd, [Rb, Ro]`,
55
+ // `LDMIA Rb!, <reglist>` and `STMIA Rb!, <reglist>`. Older ARM7TDMI documentation used LDSH/LDSB,
56
+ // which is where the short spellings come from; the stack-suffix forms (FD, EA) are the same
57
+ // instructions named after the stack discipline they implement.
58
+ //
59
+ // Confirmed with this project's own toolchain — same encoding, and gba-kit executes them with the
60
+ // same architectural effect (sign extension, transfers, base writeback):
61
+ //
62
+ // ldsh / ldrsh 885e / 885e ldm / ldmia / ldmfd 01c9 / 01c9 / 01c9
63
+ // ldsb / ldrsb 8856 / 8856 stm / stmia / stmea 01c1 / 01c1 / 01c1
64
+ //
65
+ // Measured on the Klonoa: Empire of Dreams disassembly (luvdis, 469 .s files): `ldsh` 292 and
66
+ // `ldsb` 180 against `ldrsh` 0 and `ldrsb` 12 — the same tool emits both spellings for the signed
67
+ // byte load — and `ldm` 12 / `stm` 34 against `ldmia` 0 / `stmia` 0. The UAL names this frontend
68
+ // cased for the multiple forms never appear in that corpus at all.
69
+ //
70
+ // These are PURE SYNONYMS — identical operands — which is why they belong in a table here rather
71
+ // than in decode arms like MIPS's `move` or PPC's `slwi`. That distinction, and why there is no
72
+ // shared alias helper across the three frontends, is written up once in ./opaque.ts.
73
+ //
74
+ // The LOAD aliases matter more than the store ones, and the asymmetry is worth knowing: an
75
+ // unrecognised `stm*` matches `opaquePolicy.storeClass` and fails LOUD, but an unrecognised `ldm*`
76
+ // does not — it reaches opaqueDest, which takes ops[0] (the BASE) as the destination, so the opaque
77
+ // is dead, DCE removes it, and the load silently vanishes. Measured: `ldmfd r1, {r0}; bx lr` lifted
78
+ // to `return a0;` where the answer is `return *a0;`. Every load spelling ARMv4T Thumb accepts is
79
+ // therefore listed. (`ldmed`/`ldmea` are NOT: they mean IB/DB, which Thumb-1 does not have, and
80
+ // `as` rejects them — so they cannot appear.)
81
+ //
82
+ // `stmfd` is deliberately absent, and the asymmetry is real rather than an oversight: `stmfd` is
83
+ // `stmdb`, and ARMv4T Thumb has neither — `as` rejects both with "selected processor does not
84
+ // support ... in Thumb mode". There is nothing to normalise it TO.
85
+ //
86
+ // Null-prototype so that an inherited key (`constructor`, `toString`) cannot be mistaken for an
87
+ // entry. Unreachable from real assembly, but the lookup should not depend on that.
88
+ const LEGACY_MNEMONICS: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
89
+ ldsh: 'ldrsh',
90
+ ldsb: 'ldrsb',
91
+ ldm: 'ldmia',
92
+ ldmfd: 'ldmia',
93
+ stm: 'stmia',
94
+ stmea: 'stmia',
95
+ });
96
+
97
+ function canonicalMnemonic(mn: string): string {
98
+ return LEGACY_MNEMONICS[mn] ?? mn;
99
+ }
100
+
41
101
  // Map a Thumb conditional-branch mnemonic to the icmp opcode for "branch taken". The signed forms
42
102
  // (`blt`/`ble`/`bgt`/`bge`) follow a signed `cmp`; the UNSIGNED forms carry the carry/borrow sense:
43
103
  // `bhi` = unsigned > (higher), `bls` = unsigned <= (lower-or-same), `bcc`/`blo` = unsigned <
@@ -119,6 +179,48 @@ const imm = (s: string) => parseInt(s.replace(/^#/, ''), s.includes('0x') ? 16 :
119
179
  // detection sees them, and any consumer that needs the exact list rejects the leftover `-` token
120
180
  // loudly rather than treating the fused range as one phantom register.
121
181
  const REG_NUM: Record<string, number> = { sp: 13, lr: 14, pc: 15 };
182
+
183
+ // Thumb-1 data-processing mnemonics that write the condition flags when their destination is a LOW
184
+ // register — which is all of them on this ISA, `s`-suffix or not (the assembler picks the encoding).
185
+ // Used to invalidate a pending compare: see the decode loop. `cmp`/`cmn`/`tst` are absent on purpose
186
+ // — they set flags but define no register, and `cmp` is the very instruction that seeds the pending
187
+ // compare. Loads, stores, push/pop, `bl` and the high-register forms leave the flags alone.
188
+ const FLAG_SETTING = new Set([
189
+ 'mov',
190
+ 'movs',
191
+ 'add',
192
+ 'adds',
193
+ 'sub',
194
+ 'subs',
195
+ 'lsl',
196
+ 'lsls',
197
+ 'lsr',
198
+ 'lsrs',
199
+ 'asr',
200
+ 'asrs',
201
+ 'neg',
202
+ 'negs',
203
+ 'rsb',
204
+ 'rsbs',
205
+ 'mvn',
206
+ 'mvns',
207
+ 'bic',
208
+ 'bics',
209
+ 'ror',
210
+ 'rors',
211
+ 'mul',
212
+ 'muls',
213
+ 'and',
214
+ 'ands',
215
+ 'orr',
216
+ 'orrs',
217
+ 'eor',
218
+ 'eors',
219
+ 'adc',
220
+ 'adcs',
221
+ 'sbc',
222
+ 'sbcs',
223
+ ]);
122
224
  const regNum = (r: string) => (r[0] === 'r' ? Number(r.slice(1)) : REG_NUM[r]);
123
225
  function expandRegList(tokens: string[]): string[] {
124
226
  const out: string[] = [];
@@ -305,7 +407,14 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
305
407
  continue;
306
408
  }
307
409
  dataLabel = null; // a real instruction ends a data run
308
- flat.push({ instr: { mnemonic: m[1], ops: m[2] ? splitOperands(m[2]) : [] } });
410
+ const canon = canonicalMnemonic(m[1]);
411
+ flat.push({
412
+ instr: {
413
+ mnemonic: canon,
414
+ ops: m[2] ? splitOperands(m[2]) : [],
415
+ ...(canon === m[1] ? {} : { asWritten: m[1] }),
416
+ },
417
+ });
309
418
  }
310
419
  if (
311
420
  armLabels.has(name) ||
@@ -608,6 +717,41 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
608
717
  `cannot lift '${name}': block '${mixed.label}' interleaves raw data (.${subwordData.get(mixed.label)}) with instructions`,
609
718
  );
610
719
  }
720
+ // Two labels on the same instruction (`.LCB80:` immediately followed by `.L7:`) make the first
721
+ // an ALIAS of the second, not a block of its own — agbcc emits exactly that when a long-jump
722
+ // helper label lands on an existing one. The empty block is dropped just below, so a branch
723
+ // naming the alias would afterwards resolve to nothing and decline as a dangling target. Point
724
+ // those branches at the block the label actually names, before anything reads the CFG.
725
+ // A label naming DATA is emphatically NOT an alias, and this is the guard the whole pass turns
726
+ // on. Decode pushes an empty block for a literal-pool / jump-table label too, so aliasing them
727
+ // blindly would silently retarget `beq .Lpool` at whatever code happens to follow the pool —
728
+ // marker-free, plausible, wrong C where the frontend used to decline. Every agbcc pool is a
729
+ // label on data, so that is the common case, not an exotic one. A data label therefore neither
730
+ // aliases nor is aliased THROUGH: scanning past one for a later code block would silently jump
731
+ // over the data.
732
+ const isDataLabel = (l: string) => dataWords.has(l) || subwordData.has(l);
733
+ const aliasOf = new Map<string, string>();
734
+ for (let i = 0; i < blocks.length; i++) {
735
+ if (blocks[i].instrs.length > 0 || isDataLabel(blocks[i].label)) {
736
+ continue;
737
+ }
738
+ let j = i + 1;
739
+ while (j < blocks.length && blocks[j].instrs.length === 0 && !isDataLabel(blocks[j].label)) {
740
+ j++;
741
+ }
742
+ const next = blocks[j];
743
+ if (next && next.instrs.length > 0) {
744
+ aliasOf.set(blocks[i].label, next.label);
745
+ } // otherwise a trailing or data-fronted label: left dangling so a branch to it still declines
746
+ }
747
+ for (const b of aliasOf.size ? blocks : []) {
748
+ for (const ins of b.instrs) {
749
+ const k = ins.ops.length - 1;
750
+ if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && k >= 0) {
751
+ ins.ops[k] = aliasOf.get(ins.ops[k]) ?? ins.ops[k];
752
+ }
753
+ }
754
+ }
611
755
  let live = blocks.filter((b) => b.instrs.length > 0);
612
756
  // Alignment-pad NOPs a splitter emits around returns and literal pools: `lsls r0, r0, #0`
613
757
  // is the 0x0000 halfword, `mov r8, r8` is 0x46C0, plus a literal `nop`. A block made ONLY
@@ -784,21 +928,47 @@ function recoverJumpTable(
784
928
  disp: AsmBlock,
785
929
  dataWords: Map<string, string[]>,
786
930
  blockLabels: Set<string>,
931
+ longDefault?: string,
787
932
  ): JumpTable | null {
788
- // bounds: last two instrs must be `cmp rX,#M` then `bhi DEF` (unsigned upper-bound guard).
933
+ // bounds: last two instrs are `cmp rX,#M` then the out-of-range guard, in one of two spellings.
934
+ //
935
+ // direct cmp rX,#M ; bhi DEF → fall through to the dispatch
936
+ // long jump cmp rX,#M ; bls DISP ; b DEF → branch TO the dispatch, long-branch the default
937
+ //
938
+ // The second is what agbcc emits whenever the default is out of a conditional branch's reach —
939
+ // Thumb-1 `B<cond>` carries a signed 8-bit HALFWORD offset, so ±256 BYTES, about 128
940
+ // instructions — which on a real switch it usually is: five of the six benchmark
941
+ // functions with a table use it, and only the sixth uses the direct form. `longDefault` is the
942
+ // target of that trailing `b`, read by the caller from the block after `bounds`.
789
943
  const bi = bounds.instrs;
790
- const bhi = bi[bi.length - 1],
944
+ const guard = bi[bi.length - 1],
791
945
  cmp = bi[bi.length - 2];
792
- if (!bhi || !cmp || bhi.mnemonic !== 'bhi' || cmp.mnemonic !== 'cmp') {
946
+ if (!guard || !cmp || cmp.mnemonic !== 'cmp') {
793
947
  return null;
794
948
  }
949
+ let defaultLabel: string;
950
+ if (longDefault === undefined) {
951
+ if (guard.mnemonic !== 'bhi') {
952
+ return null;
953
+ }
954
+ defaultLabel = guard.ops[0];
955
+ } else {
956
+ // The `bls` must name THIS dispatch block, or the guard belongs to some other branch and the
957
+ // `b` we picked up is not its default.
958
+ if (guard.mnemonic !== 'bls' || guard.ops[0] !== disp.label) {
959
+ return null;
960
+ }
961
+ defaultLabel = longDefault;
962
+ }
795
963
  const scrutReg = cmp.ops[0];
796
964
  const m = cmp.ops[1];
797
965
  if (!m?.startsWith('#')) {
798
966
  return null;
799
967
  }
800
968
  const n = imm(m) + 1; // cases 0..M → N = M+1
801
- const defaultLabel = bhi.ops[0];
969
+ if (n < 1) {
970
+ return null; // a bound that admits no case at all is not a dispatch — fail closed
971
+ }
802
972
 
803
973
  // disp: exactly the 5-op idiom, threading a single index register from `lsl rY,rX,#2`.
804
974
  const d = disp.instrs;
@@ -835,11 +1005,28 @@ function recoverJumpTable(
835
1005
  }
836
1006
 
837
1007
  // Read the table: the ldr loads a POINTER word (PTR: .word TABLE); the table is TABLE: .word C0…
838
- const ptrWords = dataWords.get(ptrLabel);
839
- if (!ptrWords || ptrWords.length !== 1) {
1008
+ // Note the case labels are matched against `blockLabels` as WRITTEN: the adjacent-label aliasing in
1009
+ // `decode` rewrites branch operands, not `.word` entries, so a table naming an aliased label would
1010
+ // decline here rather than dispatch anywhere. Loud, and no corpus instance — left as a known edge
1011
+ // rather than fixed speculatively.
1012
+ //
1013
+ // The pointer word is addressed the same way every other pool load in this frontend is —
1014
+ // `LABEL[+N]`, selecting word N/4 — because a literal pool is a POOL: agbcc packs the dispatch
1015
+ // pointer in beside whatever else the function needed, and which slot it lands in is an artifact
1016
+ // of emission order. Reading only a bare label whose pool held exactly ONE word declined six real
1017
+ // benchmark functions whose table pointer merely sat later in the pool. Same fix m2c made in
1018
+ // `a7c5c2d`, and the same shared POOL_LABEL grammar the const/gaddr resolvers use, so the three
1019
+ // cannot disagree about what `.L21+0x4` addresses.
1020
+ const pm = ptrLabel.match(POOL_LABEL);
1021
+ const ptrWords = pm ? dataWords.get(pm[1]) : undefined;
1022
+ if (!pm || !ptrWords) {
840
1023
  return null;
841
1024
  }
842
- const caseLabels = dataWords.get(ptrWords[0]);
1025
+ const ptrOff = pm[2] ? Number(pm[2]) : 0;
1026
+ if (ptrOff % 4 !== 0 || ptrOff / 4 >= ptrWords.length) {
1027
+ return null; // misaligned or past the end of the pool — not a word this pool holds
1028
+ }
1029
+ const caseLabels = dataWords.get(ptrWords[ptrOff / 4].trim());
843
1030
  if (!caseLabels || caseLabels.length !== n) {
844
1031
  return null;
845
1032
  } // table length must equal the bound
@@ -875,26 +1062,49 @@ export function lift(
875
1062
  const poolNamesSymbols = poolNamesASymbol(dataWords, blockLabels);
876
1063
  // Any label referenced as a branch target (so we can tell if an elided dispatch block has a SECOND
877
1064
  // predecessor — a `b disp` from elsewhere — which would dangle after elision; decline if so).
878
- const branchTargets = new Set<string>();
1065
+ // How many branches name each label — not just whether any does, because the long-jump bounds
1066
+ // form legitimately branches to its own dispatch block exactly once.
1067
+ const branchRefs = new Map<string, number>();
879
1068
  for (const b of rawBlocks) {
880
1069
  for (const ins of b.instrs) {
881
1070
  if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && ins.ops.length) {
882
- branchTargets.add(ins.ops[ins.ops.length - 1]);
1071
+ const t = ins.ops[ins.ops.length - 1];
1072
+ branchRefs.set(t, (branchRefs.get(t) ?? 0) + 1);
883
1073
  }
884
1074
  }
885
1075
  }
886
1076
  const tables = new Map<AsmBlock, JumpTable>(); // bounds block → recovered table
887
- const elided = new Set<AsmBlock>(); // dispatch blocks removed from the CFG
1077
+ const elided = new Set<AsmBlock>(); // dispatch (and long-jump default) blocks removed from the CFG
888
1078
  rawBlocks.forEach((d, i) => {
889
1079
  const last = d.instrs[d.instrs.length - 1];
890
- if (last && last.mnemonic === 'mov' && last.ops[0] === 'pc' && last.ops[1] !== 'lr') {
891
- const bounds = rawBlocks[i - 1];
892
- // The dispatch block must be reached ONLY by falling through from its bounds predecessor — a
893
- // `b disp` target elsewhere would leave a dangling edge after elision, so decline (→ loud-fail).
894
- const jt = bounds && !branchTargets.has(d.label) ? recoverJumpTable(bounds, d, dataWords, blockLabels) : null;
1080
+ if (!last || last.mnemonic !== 'mov' || last.ops[0] !== 'pc' || last.ops[1] === 'lr') {
1081
+ return;
1082
+ }
1083
+ const refs = branchRefs.get(d.label) ?? 0;
1084
+ const prev = rawBlocks[i - 1];
1085
+ // Direct form: the dispatch is reached ONLY by falling through from its bounds predecessor. A
1086
+ // `b disp` from anywhere else would leave a dangling edge after elision, so decline (→ loud-fail).
1087
+ if (prev && refs === 0) {
1088
+ const jt = recoverJumpTable(prev, d, dataWords, blockLabels);
895
1089
  if (jt) {
896
- tables.set(bounds, jt);
1090
+ tables.set(prev, jt);
897
1091
  elided.add(d);
1092
+ return;
1093
+ }
1094
+ }
1095
+ // Long-jump form: `bounds` (cmp; bls DISP), then a lone `b DEF` block, then the dispatch. The
1096
+ // dispatch is entered by exactly that one `bls` and nothing else, and the `b DEF` block —
1097
+ // synthetically labelled, so unnameable and unreachable once the bounds block dispatches — is
1098
+ // elided WITH it. Leaving it would make it a parameterless predecessor of the default block,
1099
+ // and wiring a phi through it fabricates an entry parameter (the phantom-param miscompile).
1100
+ const boundsB = rawBlocks[i - 2];
1101
+ const prevNamed = prev ? (branchRefs.get(prev.label) ?? 0) > 0 : false;
1102
+ if (refs === 1 && prev && boundsB && !prevNamed && prev.instrs.length === 1 && prev.instrs[0].mnemonic === 'b') {
1103
+ const jt = recoverJumpTable(boundsB, d, dataWords, blockLabels, prev.instrs[0].ops[0]);
1104
+ if (jt) {
1105
+ tables.set(boundsB, jt);
1106
+ elided.add(d);
1107
+ elided.add(prev);
898
1108
  }
899
1109
  }
900
1110
  });
@@ -1028,7 +1238,7 @@ export function lift(
1028
1238
  // adjustments have no low-register data destination, so they fall through harmlessly;
1029
1239
  // terminators are handled in the terminator section below.
1030
1240
  const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
1031
- const emitOpaqueDest = (ins: { mnemonic: string; ops: string[] }) => {
1241
+ const emitOpaqueDest = (ins: { mnemonic: string; ops: string[]; asWritten?: string }) => {
1032
1242
  // storeClass: unmodelled Thumb stores are str*/stm* — `stmia rN!, {…}`'s dest token `r0!`
1033
1243
  // fails isReg, so without this it would be skipped as "no reg dest", silently deleting the
1034
1244
  // memory writes AND the base writeback. push/pop stay transparent frame ops (they don't match).
@@ -1040,6 +1250,7 @@ export function lift(
1040
1250
  storeClass: /^(str|stm)/,
1041
1251
  skipSafe: /^(push|pop|nop)$/,
1042
1252
  context: name,
1253
+ display: ins.asWritten,
1043
1254
  });
1044
1255
  if (!od) {
1045
1256
  return;
@@ -1047,7 +1258,7 @@ export function lift(
1047
1258
  const operands = od.srcRegs.map((r) => readVar(r, bi));
1048
1259
  const res = mkValue(T.unk(32));
1049
1260
  // carry the mnemonic so annotate mode can name the gap (`ASMLIFT_ERROR("unmodelled 'rsb'")`)
1050
- irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.mnemonic } }));
1261
+ irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.asWritten ?? ins.mnemonic } }));
1051
1262
  writeVar(od.dst, bi, res);
1052
1263
  };
1053
1264
  // 2-operand ALU form `op rD, op2` (rD = rD ⟨op⟩ op2). `op2` is an immediate (`#N`) or a
@@ -1076,6 +1287,22 @@ export function lift(
1076
1287
  if (classifyXfer(ins)) {
1077
1288
  continue;
1078
1289
  }
1290
+ // A Thumb-1 data-processing instruction on LOW registers writes the condition flags whether or
1291
+ // not the mnemonic carries the `s` (agbcc spells `adds r0,r0,r3` as `add r0,r0,r3`, and the
1292
+ // assembler picks the flag-setting encoding) — so an instruction between a `cmp` and its branch
1293
+ // REPLACES the flags the branch will test. Folding the earlier `cmp` in anyway would emit a
1294
+ // condition on the wrong operands: silently wrong C with no marker. Drop the pending compare
1295
+ // and let the terminator's existing "no reaching compare in its block" decline fire — the loud
1296
+ // answer, since modelling arithmetic flags is a capability asmlift does not have.
1297
+ //
1298
+ // The HIGH-register forms (`mov rD,rH`, `add rD,rH`) do NOT set flags and stay transparent,
1299
+ // which is what keeps agbcc's callee-saved shuffling from tripping this. Measured free: across
1300
+ // every agbcc row in the benchmark, no conditional-branch block has ANY instruction between its
1301
+ // compare and the branch — compilers keep the pair adjacent. The inhabitant this guards is
1302
+ // hand-written asm in the playground, where there is no oracle to catch a lie.
1303
+ if (FLAG_SETTING.has(ins.mnemonic) && /^r[0-7]$/.test(reg(ins.ops[0] ?? ''))) {
1304
+ pendingCmp = null;
1305
+ }
1079
1306
  const [a, b, c] = ins.ops;
1080
1307
  switch (ins.mnemonic) {
1081
1308
  case 'mov':
@@ -1215,9 +1442,18 @@ export function lift(
1215
1442
  // rejoin below also tolerates a split list defensively. Thumb-1 LDMIA skips the
1216
1443
  // writeback when rN is itself in the list (the loaded value wins) — modelled; any
1217
1444
  // malformed shape degrades to the loud opaque.
1218
- // `!` = writeback (`ldmia rN!, {…}`); its absence is the valid no-writeback form
1219
- // (`ldmia rN, {…}` — same transfers, base unchanged). A missing register list is
1220
- // malformed loud opaque.
1445
+ // There is NO no-writeback form in Thumb-1, so the `!` is decoration and must not drive
1446
+ // the model. Four sources agree:
1447
+ // * ARM DDI 0029G Table 1-7 gives the canonical syntax as `LDMIA Rb!, <reglist>` and
1448
+ // `STMIA Rb!, <reglist>` — the `!` is part of the mnemonic, not an option, and
1449
+ // Figure 1-6 Format 15 has no bit that could encode its absence;
1450
+ // * GNU as assembles `ldm r1,{r0}` and `ldm r1!,{r0}` to the same halfword, 0xc901,
1451
+ // and warns "this instruction will write back the base register";
1452
+ // * gba-kit executes both with the base advanced by 4;
1453
+ // * GBATEK, THUMB.15: "Both STM and LDM are incrementing the Base Register".
1454
+ // An earlier version of this comment called the `!`-less spelling "the valid
1455
+ // no-writeback form — same transfers, base unchanged", which is false, and the code
1456
+ // below acted on it. A missing register list is malformed → loud opaque.
1221
1457
  const baseTok = a;
1222
1458
  const writeback = !!baseTok?.endsWith('!');
1223
1459
  if (baseTok === undefined || b === undefined || !b.startsWith('{')) {
@@ -1244,6 +1480,30 @@ export function lift(
1244
1480
  emitOpaqueDest(ins);
1245
1481
  break;
1246
1482
  }
1483
+ // An STM whose base is in its own list, but is not the LOWEST entry, stores a value this
1484
+ // frontend must not guess — because the available references DISAGREE about what it is.
1485
+ //
1486
+ // ARM: UNPREDICTABLE, "the stored value cannot be relied upon".
1487
+ // GNU as: warns "value stored for rN is UNKNOWN".
1488
+ // GBATEK: version-specific — "Store OLD base if Rb is FIRST entry in Rlist,
1489
+ // otherwise store NEW base (STM/ARMv4), always store OLD base (STM/ARMv5)".
1490
+ // mGBA: stores the OLD base unconditionally, on an ARMv4T core — its STM_LOOP
1491
+ // reads gprs[i] during the loop and the writeback runs after it.
1492
+ //
1493
+ // So GBATEK's ARMv4 rule and the reference emulator's behaviour do not agree, and no
1494
+ // hardware test result was found either way. This frontend used to emit the old base,
1495
+ // i.e. it silently picked one side of that disagreement. Declining is the contract:
1496
+ // where the architecture declines to define a value, so do we.
1497
+ //
1498
+ // (One site in the Klonoa corpus, in unreachable code after a `pop`/`bx`, and it already
1499
+ // declines for an unrelated pc-relative-pool reason — so this costs nothing today.)
1500
+ if (ins.mnemonic === 'stmia' && list.some((r) => reg(r) === baseReg) && reg(list[0]) !== baseReg) {
1501
+ throw new FrontendUnsupportedError(
1502
+ `cannot lift '${name}': stm with the base register in its own list, not as the lowest ` +
1503
+ `entry — the value stored for that register is UNPREDICTABLE and differs between ` +
1504
+ `ARMv4 (new base) and ARMv5 (old base)`,
1505
+ );
1506
+ }
1247
1507
  // SNAPSHOT the base ONCE: hardware performs every transfer from the ORIGINAL base, but
1248
1508
  // a base-in-list ldmia overwrites that register mid-list — re-reading it per iteration
1249
1509
  // loaded the siblings from the freshly-loaded value instead (silent wrong addresses,
@@ -1260,10 +1520,12 @@ export function lift(
1260
1520
  irb.ops.push(mkOp('store', { operands: [base0, readData(reg(r), bi)], attrs: { off: 4 * i, width: 4 } }));
1261
1521
  }
1262
1522
  });
1263
- // Writeback advances the base by 4×count SUPPRESSED when there is no `!`, or (ldmia)
1264
- // when the base is itself in the list (the loaded value wins, ARMv4T).
1523
+ // Writeback advances the base by 4×count. It is suppressed ONLY for an ldmia whose base
1524
+ // is in its own list the loaded value wins. GBATEK, THUMB.15: "no writeback
1525
+ // (LDM/ARMv4/ARMv5; at this point, THUMB opcodes work different than ARM opcodes)".
1526
+ // The `!` is NOT what decides it: see above, there is no encoding without writeback.
1265
1527
  const wroteBase = ins.mnemonic === 'ldmia' && list.some((r) => reg(r) === baseReg);
1266
- if (writeback && !wroteBase) {
1528
+ if (!wroteBase) {
1267
1529
  const adv = mkValue(T.unk(32));
1268
1530
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
1269
1531
  writeVar(baseReg, bi, adv);
@@ -1477,7 +1739,20 @@ export function lift(
1477
1739
  irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
1478
1740
  } else if (kind === 'return') {
1479
1741
  // bx lr / pop {…,pc} / mov pc,lr
1480
- irb.ops.push(mkOp('ret', { operands: [readVar(target.returnReg, bi)] }));
1742
+ //
1743
+ // A `bx rN` BRANCHES THROUGH rN, so at that instruction rN holds the RETURN ADDRESS. When rN
1744
+ // is the return-VALUE register the two uses collide, and the address wins by definition —
1745
+ // whatever value was in r0 is gone, so the function cannot be returning one. agbcc spells an
1746
+ // interworking return that way (`push {lr}` … `pop {r0}; bx r0`), and reading r0 as a value
1747
+ // there invents a return the machine provably cannot make: a phantom `return`, a non-`void`
1748
+ // signature that would contradict the project's own prototype, and a live range that keeps
1749
+ // otherwise-dead computation alive.
1750
+ //
1751
+ // The other return forms are untouched, because none of them writes the return register:
1752
+ // `bx lr` and `bx r1`/`bx r2` branch through a different one, and `pop {…,pc}` / `mov pc,lr`
1753
+ // load PC directly. Only the register actually branched through is disqualified.
1754
+ const viaReturnReg = last.mnemonic === 'bx' && last.ops[0] === target.returnReg;
1755
+ irb.ops.push(mkOp('ret', { operands: viaReturnReg ? [] : [readVar(target.returnReg, bi)] }));
1481
1756
  } else if (kind === 'uncond') {
1482
1757
  irb.ops.push(mkOp('br', { successors: [succ(last.ops[0])] }));
1483
1758
  } else if (kind === 'cond') {
package/src/ir/opcodes.ts CHANGED
@@ -130,11 +130,55 @@ export function opSig(opcode: string): OpSig | undefined {
130
130
  return (OPCODES as Record<string, OpSig | undefined>)[opcode];
131
131
  }
132
132
 
133
+ /** The comparison whose result is the logical NEGATION of each `icmp_*` — `!(a < b)` is `a >= b`.
134
+ *
135
+ * Unlike EFFECTFUL_OPS/HOIST_UNSAFE_OPS below, this is AUTHORED data seated beside the registry,
136
+ * not a view derived from it: nothing in `OPCODES` states which comparison opposes which. What is
137
+ * derived is its SYMMETRY — the five involutive pairs are expanded both ways, so `neg(neg(c)) === c`
138
+ * holds by construction (a hand-written map is one typo away from breaking it, and the symptom is a
139
+ * plainly inverted condition in the emitted C). Completeness against the icmp family is the part
140
+ * construction cannot give, so a test asserts it (test/pattern.test.ts) — an eleventh comparison
141
+ * added to `OPCODES` would otherwise degrade three consumers three different ways.
142
+ *
143
+ * It lives here for the reason HOIST_UNSAFE_OPS does: every consumer that has to say "the opposite
144
+ * of this compare" reads THIS one — the MIPS frontend's `slt …; beqz` branch-when-false fold, the
145
+ * short-circuit recognizer's diamond negation, and the idiom layer's `cmp ^ 1` fold — so they
146
+ * cannot drift apart the way inline copies did. Two adjacent facts worth knowing: raise/
147
+ * shortcircuit.ts derives its `BOOL_OPS` from these keys (asserting negatable-icmp == boolean-op,
148
+ * true today), and l3/ast.ts `NEGATE_REL` is the SAME relation over the neutral L3 operator
149
+ * vocabulary — deliberately separate, because signedness lives in the operand types there, so the
150
+ * two tables are not candidates for further consolidation. */
151
+ const ICMP_NEGATION_PAIRS: readonly (readonly [Opcode, Opcode])[] = [
152
+ ['icmp_eq', 'icmp_ne'],
153
+ ['icmp_slt', 'icmp_sge'],
154
+ ['icmp_sgt', 'icmp_sle'],
155
+ ['icmp_ult', 'icmp_uge'],
156
+ ['icmp_ugt', 'icmp_ule'],
157
+ ];
158
+ export const NEGATED_ICMP: Readonly<Record<string, Opcode>> = Object.fromEntries(
159
+ ICMP_NEGATION_PAIRS.flatMap(([a, b]) => [
160
+ [a, b],
161
+ [b, a],
162
+ ]),
163
+ );
164
+
133
165
  /** Ops with an observable side effect — the derived view raise/shortcircuit.ts consumes. */
134
166
  export const EFFECTFUL_OPS: ReadonlySet<string> = new Set(
135
167
  (Object.keys(OPCODES) as Opcode[]).filter((k) => (OPCODES[k] as OpSig).effects),
136
168
  );
137
169
 
170
+ /** Ops that may not be REORDERED across other code — `EFFECTFUL_OPS` plus `opaque`.
171
+ *
172
+ * `effects` is overloaded on two axes, and `opaque` is exactly the op that separates them: a dead
173
+ * `opaque` MUST stay deletable (`isDceSafe` below says so deliberately — giving it `effects: true`
174
+ * would strand dead opaques after every pattern rewrite, and they would surface as ASMLIFT_ERROR
175
+ * gaps in functions that emit cleanly today), while a LIVE one is an instruction asmlift could not
176
+ * model and must not be moved past anything. So "deletable when dead" and "movable when live" are
177
+ * different questions and get different views, both derived here rather than re-spelled per
178
+ * consumer — structure/analysis.ts and structure/structure.ts each carry their own inline copy of
179
+ * this membership, which is how the two models drifted apart in the first place. */
180
+ export const HOIST_UNSAFE_OPS: ReadonlySet<string> = new Set([...EFFECTFUL_OPS, 'opaque']);
181
+
138
182
  /** May a dead result of this opcode be deleted? Registered, no observable effects, not control
139
183
  * flow. Deliberately includes `opaque` — a dead opaque vanishing is designed behavior. */
140
184
  export function isDceSafe(opcode: string): boolean {