@asmlift/core 0.2.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.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +154 -5
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +37 -5
  7. package/src/declare.ts +251 -0
  8. package/src/frontend/frontend.ts +12 -2
  9. package/src/frontend/mips.ts +24 -23
  10. package/src/frontend/opaque.ts +39 -2
  11. package/src/frontend/ssa.ts +32 -53
  12. package/src/frontend/thumb.ts +420 -32
  13. package/src/ir/opcodes.ts +44 -0
  14. package/src/ir/simplify.ts +72 -0
  15. package/src/l3/argbase.ts +216 -0
  16. package/src/l3/ast.ts +126 -6
  17. package/src/l3/basecse.ts +3 -40
  18. package/src/l3/coalesce.ts +146 -0
  19. package/src/l3/dce.ts +2 -23
  20. package/src/l3/hoist.ts +65 -0
  21. package/src/l3/reindex.ts +7 -0
  22. package/src/l3/scopebase.ts +436 -0
  23. package/src/l3/symbol-refs.ts +61 -0
  24. package/src/l3/tailmerge.ts +120 -0
  25. package/src/l3/typing.ts +4 -0
  26. package/src/macros.ts +335 -0
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +20 -6
  29. package/src/proto.ts +55 -0
  30. package/src/raise/divpow2.ts +226 -0
  31. package/src/raise/gvn.ts +141 -0
  32. package/src/raise/pre-recovery.ts +37 -3
  33. package/src/raise/recover.ts +24 -7
  34. package/src/raise/retsink.ts +36 -7
  35. package/src/raise/shortcircuit.ts +264 -22
  36. package/src/raise/structs.ts +12 -2
  37. package/src/rank.ts +370 -79
  38. package/src/structure/analysis.ts +42 -1
  39. package/src/structure/structure.ts +852 -67
  40. package/src/structure/switch-recover.ts +21 -3
  41. package/src/symbols.ts +541 -0
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +17 -2
@@ -19,7 +19,9 @@ import type { Opcode } from '../ir/opcodes';
19
19
  import { T } from '../ir/types';
20
20
  import { type Prototypes, protoArity } from '../proto';
21
21
  import { RUNTIME_HELPERS } from '../raise/softdiv';
22
+ import { type SymbolMap, lookupInterior, lookupSymbol } from '../symbols';
22
23
  import type { TargetDescription } from '../target';
24
+ import type { AsmData } from './asmdata';
23
25
  import { pushSwitchBr } from './emit';
24
26
  import { FrontendUnsupportedError } from './errors';
25
27
  import { assertInputFormat } from './format';
@@ -28,14 +30,74 @@ import { opaqueDest } from './opaque';
28
30
  import { abiSortEntryParams, fallbackArgc, makeSsaBuilder } from './ssa';
29
31
 
30
32
  interface Instr {
33
+ /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
34
+ * consumer matches one name. */
31
35
  mnemonic: string;
32
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;
33
41
  }
34
42
  interface AsmBlock {
35
43
  label: string;
36
44
  instrs: Instr[];
37
45
  }
38
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
+
39
101
  // Map a Thumb conditional-branch mnemonic to the icmp opcode for "branch taken". The signed forms
40
102
  // (`blt`/`ble`/`bgt`/`bge`) follow a signed `cmp`; the UNSIGNED forms carry the carry/borrow sense:
41
103
  // `bhi` = unsigned > (higher), `bls` = unsigned <= (lower-or-same), `bcc`/`blo` = unsigned <
@@ -117,6 +179,48 @@ const imm = (s: string) => parseInt(s.replace(/^#/, ''), s.includes('0x') ? 16 :
117
179
  // detection sees them, and any consumer that needs the exact list rejects the leftover `-` token
118
180
  // loudly rather than treating the fused range as one phantom register.
119
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
+ ]);
120
224
  const regNum = (r: string) => (r[0] === 'r' ? Number(r.slice(1)) : REG_NUM[r]);
121
225
  function expandRegList(tokens: string[]): string[] {
122
226
  const out: string[] = [];
@@ -171,10 +275,15 @@ function splitOperands(s: string): string[] {
171
275
  // Parse a Thumb memory addressing operand `[base]` or `[base, #off]` into base register +
172
276
  // constant byte offset. (Register-scaled indices like `[base, r1, lsl #2]` are not handled
173
277
  // yet — agbcc materialises those as explicit add/lsl before the load in the cases we target.)
174
- function parseAddr(operand: string): { base: string; off: number } {
278
+ function parseAddr(operand: string): { base: string; off: number; regOff?: string } {
175
279
  const inner = operand.replace(/[[\]]/g, '').trim();
176
280
  const parts = inner.split(',').map((s) => s.trim());
177
281
  const base = parts[0];
282
+ // `[rB, rX]` — REGISTER-offset addressing. Surfaced to the caller so load/store DECLINE
283
+ // loud: silently reading `[rB]` (the old behavior) dropped the index — a silent miscompile.
284
+ if (parts[1] !== undefined && !parts[1].startsWith('#')) {
285
+ return { base, off: 0, regOff: parts[1] };
286
+ }
178
287
  const off = parts[1]?.startsWith('#') ? imm(parts[1]) : 0;
179
288
  return { base, off };
180
289
  }
@@ -298,7 +407,14 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
298
407
  continue;
299
408
  }
300
409
  dataLabel = null; // a real instruction ends a data run
301
- 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
+ });
302
418
  }
303
419
  if (
304
420
  armLabels.has(name) ||
@@ -601,6 +717,41 @@ function decode(name: string, asm: string): { blocks: AsmBlock[]; dataWords: Map
601
717
  `cannot lift '${name}': block '${mixed.label}' interleaves raw data (.${subwordData.get(mixed.label)}) with instructions`,
602
718
  );
603
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
+ }
604
755
  let live = blocks.filter((b) => b.instrs.length > 0);
605
756
  // Alignment-pad NOPs a splitter emits around returns and literal pools: `lsls r0, r0, #0`
606
757
  // is the 0x0000 halfword, `mov r8, r8` is 0x46C0, plus a literal `nop`. A block made ONLY
@@ -724,6 +875,37 @@ function poolRef(operand: string, dataWords: Map<string, string[]>): PoolRef | n
724
875
  return { kind: 'unmodelled', why: `pool word '${w}' is a symbol offset or code label` };
725
876
  }
726
877
 
878
+ /** Does this function's literal pool name at least one EXTERNAL symbol?
879
+ *
880
+ * agbcc emits a pool word symbolically exactly when the source expression named a linker symbol,
881
+ * and numerically when it did not (`*(vu16 *)0x4000130`, an address-cast macro). So within one
882
+ * function, a numeric word sitting alongside a symbolic one is numeric *by the source's choice* —
883
+ * which is what lets {@link liftThumb} refuse to invent a name for it.
884
+ *
885
+ * The witness is required because that inference only holds for asm that KEPT its symbols. A
886
+ * linked-ROM disassembly resolves every relocation to a number, and there "numeric" says nothing
887
+ * about the source; vetoing on it would disable the map's naming for the users who need it most.
888
+ * A function whose pool names nothing external is therefore left alone (both spellings still
889
+ * enumerate, and the differ referees) rather than being read as evidence of anything.
890
+ *
891
+ * Labels DEFINED in this same asm — the jump-table pointer word, a pret-style `_08012358` pool
892
+ * label — are not external symbols and never witness: they survive disassembly whether or not
893
+ * relocations did. */
894
+ function poolNamesASymbol(dataWords: Map<string, string[]>, blockLabels: Set<string>): boolean {
895
+ for (const [, words] of dataWords) {
896
+ for (const raw of words) {
897
+ const w = raw.trim();
898
+ if (!/^[A-Za-z_]\w*$/.test(w) || w.startsWith('.L')) {
899
+ continue;
900
+ }
901
+ if (!dataWords.has(w) && !blockLabels.has(w)) {
902
+ return true;
903
+ }
904
+ }
905
+ }
906
+ return false;
907
+ }
908
+
727
909
  // Recover an agbcc Thumb jump-table dispatch. Given a dispatch block `disp` ending in `mov pc, rV`
728
910
  // and its unique bounds predecessor `bounds` ending in `cmp rX,#(N-1); bhi DEF`, verify the exact
729
911
  // idiom and read the inline table — else return null (→ the indirect-jump loud-fail fires). The
@@ -746,21 +928,47 @@ function recoverJumpTable(
746
928
  disp: AsmBlock,
747
929
  dataWords: Map<string, string[]>,
748
930
  blockLabels: Set<string>,
931
+ longDefault?: string,
749
932
  ): JumpTable | null {
750
- // 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`.
751
943
  const bi = bounds.instrs;
752
- const bhi = bi[bi.length - 1],
944
+ const guard = bi[bi.length - 1],
753
945
  cmp = bi[bi.length - 2];
754
- if (!bhi || !cmp || bhi.mnemonic !== 'bhi' || cmp.mnemonic !== 'cmp') {
946
+ if (!guard || !cmp || cmp.mnemonic !== 'cmp') {
755
947
  return null;
756
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
+ }
757
963
  const scrutReg = cmp.ops[0];
758
964
  const m = cmp.ops[1];
759
965
  if (!m?.startsWith('#')) {
760
966
  return null;
761
967
  }
762
968
  const n = imm(m) + 1; // cases 0..M → N = M+1
763
- 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
+ }
764
972
 
765
973
  // disp: exactly the 5-op idiom, threading a single index register from `lsl rY,rX,#2`.
766
974
  const d = disp.instrs;
@@ -797,11 +1005,28 @@ function recoverJumpTable(
797
1005
  }
798
1006
 
799
1007
  // Read the table: the ldr loads a POINTER word (PTR: .word TABLE); the table is TABLE: .word C0…
800
- const ptrWords = dataWords.get(ptrLabel);
801
- if (!ptrWords || ptrWords.length !== 1) {
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) {
802
1023
  return null;
803
1024
  }
804
- 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());
805
1030
  if (!caseLabels || caseLabels.length !== n) {
806
1031
  return null;
807
1032
  } // table length must equal the bound
@@ -816,7 +1041,14 @@ function recoverJumpTable(
816
1041
  /** Lift decoded asm → an L1 Fn with block-argument SSA. `prototypes` supplies each callee's
817
1042
  * declared parameter count (from the project's headers); it is authoritative for recovering
818
1043
  * how many argument registers a `bl` passes (falling back to a heuristic when absent). */
819
- export function lift(name: string, asm: string, target: TargetDescription, prototypes: Prototypes = {}): Fn {
1044
+ export function lift(
1045
+ name: string,
1046
+ asm: string,
1047
+ target: TargetDescription,
1048
+ prototypes: Prototypes = {},
1049
+ _asmData?: AsmData,
1050
+ symbols?: SymbolMap,
1051
+ ): Fn {
820
1052
  assertInputFormat('thumb', 'gnu-as', asm);
821
1053
  const { blocks: rawBlocks, dataWords } = decode(name, asm);
822
1054
 
@@ -825,28 +1057,54 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
825
1057
  // dispatch block is ELIDED from the CFG. A `mov pc` that is NOT a recognised table falls through
826
1058
  // to the loud-fail below.
827
1059
  const blockLabels = new Set(rawBlocks.map((b) => b.label));
1060
+ // Whether THIS asm preserves symbol names in its literal pools — the witness the numeric-pool
1061
+ // naming veto needs (see poolNamesASymbol).
1062
+ const poolNamesSymbols = poolNamesASymbol(dataWords, blockLabels);
828
1063
  // Any label referenced as a branch target (so we can tell if an elided dispatch block has a SECOND
829
1064
  // predecessor — a `b disp` from elsewhere — which would dangle after elision; decline if so).
830
- 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>();
831
1068
  for (const b of rawBlocks) {
832
1069
  for (const ins of b.instrs) {
833
1070
  if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && ins.ops.length) {
834
- 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);
835
1073
  }
836
1074
  }
837
1075
  }
838
1076
  const tables = new Map<AsmBlock, JumpTable>(); // bounds block → recovered table
839
- 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
840
1078
  rawBlocks.forEach((d, i) => {
841
1079
  const last = d.instrs[d.instrs.length - 1];
842
- if (last && last.mnemonic === 'mov' && last.ops[0] === 'pc' && last.ops[1] !== 'lr') {
843
- const bounds = rawBlocks[i - 1];
844
- // The dispatch block must be reached ONLY by falling through from its bounds predecessor — a
845
- // `b disp` target elsewhere would leave a dangling edge after elision, so decline (→ loud-fail).
846
- const jt = bounds && !branchTargets.has(d.label) ? recoverJumpTable(bounds, d, dataWords, blockLabels) : null;
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);
1089
+ if (jt) {
1090
+ tables.set(prev, jt);
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]);
847
1104
  if (jt) {
848
- tables.set(bounds, jt);
1105
+ tables.set(boundsB, jt);
849
1106
  elided.add(d);
1107
+ elided.add(prev);
850
1108
  }
851
1109
  }
852
1110
  });
@@ -980,7 +1238,7 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
980
1238
  // adjustments have no low-register data destination, so they fall through harmlessly;
981
1239
  // terminators are handled in the terminator section below.
982
1240
  const isThumbReg = (s: string | undefined): s is string => /^r\d+$/.test(s ?? '');
983
- const emitOpaqueDest = (ins: { mnemonic: string; ops: string[] }) => {
1241
+ const emitOpaqueDest = (ins: { mnemonic: string; ops: string[]; asWritten?: string }) => {
984
1242
  // storeClass: unmodelled Thumb stores are str*/stm* — `stmia rN!, {…}`'s dest token `r0!`
985
1243
  // fails isReg, so without this it would be skipped as "no reg dest", silently deleting the
986
1244
  // memory writes AND the base writeback. push/pop stay transparent frame ops (they don't match).
@@ -992,6 +1250,7 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
992
1250
  storeClass: /^(str|stm)/,
993
1251
  skipSafe: /^(push|pop|nop)$/,
994
1252
  context: name,
1253
+ display: ins.asWritten,
995
1254
  });
996
1255
  if (!od) {
997
1256
  return;
@@ -999,7 +1258,7 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
999
1258
  const operands = od.srcRegs.map((r) => readVar(r, bi));
1000
1259
  const res = mkValue(T.unk(32));
1001
1260
  // carry the mnemonic so annotate mode can name the gap (`ASMLIFT_ERROR("unmodelled 'rsb'")`)
1002
- irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.mnemonic } }));
1261
+ irb.ops.push(mkOp('opaque', { operands, results: [res], attrs: { mnemonic: ins.asWritten ?? ins.mnemonic } }));
1003
1262
  writeVar(od.dst, bi, res);
1004
1263
  };
1005
1264
  // 2-operand ALU form `op rD, op2` (rD = rD ⟨op⟩ op2). `op2` is an immediate (`#N`) or a
@@ -1028,6 +1287,22 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1028
1287
  if (classifyXfer(ins)) {
1029
1288
  continue;
1030
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
+ }
1031
1306
  const [a, b, c] = ins.ops;
1032
1307
  switch (ins.mnemonic) {
1033
1308
  case 'mov':
@@ -1167,9 +1442,18 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1167
1442
  // rejoin below also tolerates a split list defensively. Thumb-1 LDMIA skips the
1168
1443
  // writeback when rN is itself in the list (the loaded value wins) — modelled; any
1169
1444
  // malformed shape degrades to the loud opaque.
1170
- // `!` = writeback (`ldmia rN!, {…}`); its absence is the valid no-writeback form
1171
- // (`ldmia rN, {…}` — same transfers, base unchanged). A missing register list is
1172
- // malformed loud opaque.
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.
1173
1457
  const baseTok = a;
1174
1458
  const writeback = !!baseTok?.endsWith('!');
1175
1459
  if (baseTok === undefined || b === undefined || !b.startsWith('{')) {
@@ -1196,6 +1480,30 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1196
1480
  emitOpaqueDest(ins);
1197
1481
  break;
1198
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
+ }
1199
1507
  // SNAPSHOT the base ONCE: hardware performs every transfer from the ORIGINAL base, but
1200
1508
  // a base-in-list ldmia overwrites that register mid-list — re-reading it per iteration
1201
1509
  // loaded the siblings from the freshly-loaded value instead (silent wrong addresses,
@@ -1212,10 +1520,12 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1212
1520
  irb.ops.push(mkOp('store', { operands: [base0, readData(reg(r), bi)], attrs: { off: 4 * i, width: 4 } }));
1213
1521
  }
1214
1522
  });
1215
- // Writeback advances the base by 4×count SUPPRESSED when there is no `!`, or (ldmia)
1216
- // when the base is itself in the list (the loaded value wins, ARMv4T).
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.
1217
1527
  const wroteBase = ins.mnemonic === 'ldmia' && list.some((r) => reg(r) === baseReg);
1218
- if (writeback && !wroteBase) {
1528
+ if (!wroteBase) {
1219
1529
  const adv = mkValue(T.unk(32));
1220
1530
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
1221
1531
  writeVar(baseReg, bi, adv);
@@ -1278,6 +1588,54 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1278
1588
  if (ins.mnemonic === 'ldr' && b !== undefined) {
1279
1589
  const pr = poolRef(b, dataWords);
1280
1590
  if (pr?.kind === 'const') {
1591
+ // Numeric-pool PROMOTION (symbols.ts): a pool-loaded word whose value the
1592
+ // project's symbol map knows becomes the NAMED global's address — the same
1593
+ // `gaddr` the symbol-pool path emits, so everything downstream is the existing
1594
+ // named-global machinery. Only pool-loaded words promote (an address built by
1595
+ // arithmetic never reaches here); a promoted `code` symbol carries `code: true`
1596
+ // so the structurer spells it `(u32)Name`, not `&Name`.
1597
+ //
1598
+ // VETOED when this asm's pool names other symbols (poolNamesASymbol): agbcc would
1599
+ // have emitted THIS word symbolically too had the source named it, so promoting it
1600
+ // spells a name the source did not use.
1601
+ //
1602
+ // …but the veto is really about RELOCATION, not about naming. An `extern` name makes
1603
+ // the compiler emit a relocated pool word, which contradicts the numeric word the
1604
+ // target shows. An address-cast MACRO expands to that same numeric literal, so it is
1605
+ // COMPATIBLE with the evidence by construction and is never vetoed — indeed it is
1606
+ // the spelling the numeric word is evidence FOR (klonoa's true source reaches these
1607
+ // cells through exactly such macros). Nothing is guessed in either case: a vetoed
1608
+ // word stays the raw constant the target says it is.
1609
+ const found = symbols ? lookupSymbol(symbols, pr.value) : null;
1610
+ const si = found && (!poolNamesSymbols || found.macroBody !== undefined) ? found : null;
1611
+ if (si) {
1612
+ const res = mkValue(T.unk(32));
1613
+ irb.ops.push(
1614
+ mkOp('gaddr', {
1615
+ results: [res],
1616
+ attrs: { sym: si.name, ...(si.kind === 'code' ? { code: true } : {}) },
1617
+ }),
1618
+ );
1619
+ writeVar(reg(a), bi, res);
1620
+ break;
1621
+ }
1622
+ // INTERIOR attribution: a value strictly inside a sized data symbol becomes
1623
+ // `gaddr sym + offset` — the `&gSym + K` tree structure.ts already lowers (and,
1624
+ // with a struct layout, spells as the named field). Sized symbols only; an
1625
+ // unattributed address stays a raw const — nothing guesses.
1626
+ // Interior attribution is always an `&gSym + K` spelling — extern-shaped, hence
1627
+ // relocated — so the veto applies to it without the macro exemption above.
1628
+ const interior = symbols && !poolNamesSymbols ? lookupInterior(symbols, pr.value) : null;
1629
+ if (interior) {
1630
+ const g = mkValue(T.unk(32));
1631
+ const k = mkValue(T.unk(32));
1632
+ const res = mkValue(T.unk(32));
1633
+ irb.ops.push(mkOp('gaddr', { results: [g], attrs: { sym: interior.info.name } }));
1634
+ irb.ops.push(mkOp('const', { results: [k], attrs: { value: interior.offset } }));
1635
+ irb.ops.push(mkOp('add', { operands: [g, k], results: [res] }));
1636
+ writeVar(reg(a), bi, res);
1637
+ break;
1638
+ }
1281
1639
  const res = mkValue(T.unk(32));
1282
1640
  irb.ops.push(mkOp('const', { results: [res], attrs: { value: pr.value } }));
1283
1641
  writeVar(reg(a), bi, res);
@@ -1303,9 +1661,19 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1303
1661
  }
1304
1662
  const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1305
1663
  const signed = ins.mnemonic === 'ldr' || /s/.test(ins.mnemonic.slice(3));
1306
- const { base, off } = parseAddr(b);
1664
+ const { base, off, regOff } = parseAddr(b);
1665
+ // `[rB, rX]` register-offset: lower EXACTLY as `rB + rX` then a load at offset 0 —
1666
+ // the same address arithmetic the encoding performs. (parseAddr used to silently
1667
+ // read `[rB]`, dropping the index — a silent miscompile; ldrsh exists ONLY in this
1668
+ // form in Thumb-1, so every ldrsh went through here.)
1669
+ let baseVal = readData(base, bi);
1670
+ if (regOff !== undefined) {
1671
+ const sum = mkValue(T.unk(32));
1672
+ irb.ops.push(mkOp('add', { operands: [baseVal, readData(regOff, bi)], results: [sum] }));
1673
+ baseVal = sum;
1674
+ }
1307
1675
  const res = mkValue(T.unk(32));
1308
- irb.ops.push(mkOp('load', { operands: [readData(base, bi)], results: [res], attrs: { off, width, signed } }));
1676
+ irb.ops.push(mkOp('load', { operands: [baseVal], results: [res], attrs: { off, width, signed } }));
1309
1677
  writeVar(reg(a), bi, res);
1310
1678
  break;
1311
1679
  }
@@ -1318,8 +1686,15 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1318
1686
  break;
1319
1687
  }
1320
1688
  const width = /b/.test(ins.mnemonic) ? 1 : /h/.test(ins.mnemonic) ? 2 : 4;
1321
- const { base, off } = parseAddr(b);
1322
- irb.ops.push(mkOp('store', { operands: [readData(base, bi), readData(reg(a), bi)], attrs: { off, width } }));
1689
+ const { base, off, regOff } = parseAddr(b);
1690
+ let storeBase = readData(base, bi);
1691
+ if (regOff !== undefined) {
1692
+ // register-offset store: same exact `rB + rX` lowering as the load path above
1693
+ const sum = mkValue(T.unk(32));
1694
+ irb.ops.push(mkOp('add', { operands: [storeBase, readData(regOff, bi)], results: [sum] }));
1695
+ storeBase = sum;
1696
+ }
1697
+ irb.ops.push(mkOp('store', { operands: [storeBase, readData(reg(a), bi)], attrs: { off, width } }));
1323
1698
  break;
1324
1699
  }
1325
1700
  case 'bl':
@@ -1364,7 +1739,20 @@ export function lift(name: string, asm: string, target: TargetDescription, proto
1364
1739
  irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
1365
1740
  } else if (kind === 'return') {
1366
1741
  // bx lr / pop {…,pc} / mov pc,lr
1367
- 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)] }));
1368
1756
  } else if (kind === 'uncond') {
1369
1757
  irb.ops.push(mkOp('br', { successors: [succ(last.ops[0])] }));
1370
1758
  } else if (kind === 'cond') {