@asmlift/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
@@ -19,7 +19,7 @@
19
19
  // compares (`sltu`/`sltiu`) lower to `icmp_ult`; recover types their operands u32 so the backend
20
20
  // re-emits `sltu` (the operator is the same `<` — the signedness lives in the operand types).
21
21
  import { Fn, Op, Successor, Value, mkOp, mkValue } from '../ir/core';
22
- import type { Opcode } from '../ir/opcodes';
22
+ import { NEGATED_ICMP, type Opcode } from '../ir/opcodes';
23
23
  import { T } from '../ir/types';
24
24
  import type { Prototypes } from '../proto';
25
25
  import type { TargetDescription } from '../target';
@@ -31,7 +31,7 @@ import { assertInputFormat } from './format';
31
31
  import type { Frontend } from './frontend';
32
32
  import { opaqueDest } from './opaque';
33
33
  import { isSplatMips, parseSplatMips } from './splat';
34
- import { abiSortEntryParams } from './ssa';
34
+ import { abiSortEntryParams, stackSlotKey } from './ssa';
35
35
  import { makeSsaBuilder } from './ssa';
36
36
 
37
37
  type Instr = DisasmInstr;
@@ -47,19 +47,6 @@ const COND_Z: Record<string, Opcode> = {
47
47
  bgez: 'icmp_sge',
48
48
  };
49
49
  const COND_RR: Record<string, Opcode> = { beq: 'icmp_eq', bne: 'icmp_ne' };
50
- // Negated icmp opcode (for the `slt …; beqz` "branch when false" fold).
51
- const NEG_ICMP: Record<string, Opcode> = {
52
- icmp_slt: 'icmp_sge',
53
- icmp_sge: 'icmp_slt',
54
- icmp_sgt: 'icmp_sle',
55
- icmp_sle: 'icmp_sgt',
56
- icmp_ult: 'icmp_uge',
57
- icmp_uge: 'icmp_ult',
58
- icmp_ugt: 'icmp_ule',
59
- icmp_ule: 'icmp_ugt',
60
- icmp_eq: 'icmp_ne',
61
- icmp_ne: 'icmp_eq',
62
- };
63
50
 
64
51
  const isZero = (r: string) => r === 'zero' || r === '$0';
65
52
  // The stack pointer (`$29`). A `sw/lw` through it is not a store/load through a data pointer — it
@@ -67,7 +54,7 @@ const isZero = (r: string) => r === 'zero' || r === '$0';
67
54
  const isStackPtr = (r: string) => r === 'sp' || r === '$sp' || r === '$29';
68
55
  // SSA-variable name for the stack slot at a constant `sp`-offset. Distinct namespace from the
69
56
  // register names (which are alphabetic / `$N`), so it never collides with a real register var.
70
- const stackSlot = (off: number) => `sp@${off}`;
57
+ const stackSlot = stackSlotKey; // shared spelling: frontend/ssa.ts
71
58
  // Sub-word memory mnemonics (widths 1 and 2). Used by the `spSlotSafe` guard in `lift`: a sub-word
72
59
  // `sp`-relative access means the word stack-slot model is unsafe for that function.
73
60
  const SUBWORD_MEM = new Set(['lb', 'lbu', 'lh', 'lhu', 'sb', 'sh']);
@@ -604,6 +591,18 @@ export function lift(
604
591
  }
605
592
  return readVar(r, bi);
606
593
  };
594
+ // `slt`-family results, so a following `beqz`/`bnez` can fold into one compare.
595
+ //
596
+ // Keyed by the SSA VALUE the compare produced, never by its register. Keying by register is the
597
+ // bug: `slt v0,a0,a1; xori v0,v0,1; beqz v0,L` (a materialised `a0 >= a1` that a branch then
598
+ // tests — IDO's spelling, and a live benchmark row) redefines v0, and a register-keyed record
599
+ // folds the branch against the DEAD `slt`, silently emitting the INVERTED condition. A value
600
+ // cannot go stale that way: `condValue` resolves the branch's register to whatever value reaches
601
+ // it and looks THAT up, so a redefinition simply misses and the honest `icmp_eq(rX, 0)` is
602
+ // emitted. It also needs no invalidation discipline to be maintained by every future writer —
603
+ // which matters, because `write` is NOT the only path that redefines a register (`lui
604
+ // rD,%hi(SYM)` deliberately reassigns rD's meaning without it).
605
+ const cmpDef = new Map<Value, { opcode: string; lhs: Value; rhs: Value }>();
607
606
  const write = (r: string, v: Value) => {
608
607
  // Writing a register clears any pending `%hi` it held — the high-half address is gone once the
609
608
  // register is reassigned (e.g. `lw rHi, %lo(SYM)(rHi)` reuses the base as the load dest). A
@@ -626,13 +625,11 @@ export function lift(
626
625
  // DELIBERATELY (unlike divState): a cross-block `mult`/`mflo` pair has no observed inhabitant,
627
626
  // and the miss degrades to a LOUD opaque, never silence.
628
627
  let mulState: { rs: Value; rt: Value; signed: boolean } | null = null;
629
- // `slt`-family results, so a following `beqz`/`bnez` can fold into one compare.
630
- const cmpDef = new Map<string, { value: Value; opcode: string; lhs: Value; rhs: Value }>();
631
628
  const emitCmp = (opc: Opcode, d: string, lhs: Value, rhs: Value) => {
632
629
  const v = mkValue(T.unk(32));
633
630
  ops.push(mkOp(opc, { operands: [lhs, rhs], results: [v] }));
634
631
  write(d, v);
635
- cmpDef.set(d, { value: v, opcode: opc, lhs, rhs });
632
+ cmpDef.set(v, { opcode: opc, lhs, rhs });
636
633
  };
637
634
 
638
635
  const decode = (ins: Instr) => {
@@ -869,8 +866,8 @@ export function lift(
869
866
  }
870
867
  };
871
868
  // TRUSTWORTHINESS GUARD (mirrors the PPC frontend): an unmodelled instruction must not silently
872
- // drop its destination register — emit an honest `opaque`: dead it vanishes; live ⇒
873
- // assertResolved fails LOUD (see frontend/opaque.ts for the policy).
869
+ // drop its destination register — emit an honest `opaque`, which fails LOUD at assertResolved
870
+ // whether or not anything reads that register (see frontend/opaque.ts for the policy).
874
871
  const emitOpaqueDest = (ins: Instr) => {
875
872
  // A `%hi`/`%lo` operand on an instruction NOT modelled as a global consumer — an FP load/store
876
873
  // (`lwc1`/`ldc1`), or any unmodelled op — reaches here (the modelled consumers handle their own
@@ -892,8 +889,8 @@ export function lift(
892
889
  const od = opaqueDest(ins.mnemonic, ins.ops, {
893
890
  isReg: isMipsReg,
894
891
  isZero,
895
- storeClass: /^(sb|sh|sw|swl|swr|sc|sd|sdl|sdr|swc1|sdc1)$/,
896
- skipSafe: /^(nop|ssnop|break)$/,
892
+ storeClass: /^(sb|sh|sw|swl|swr|sc|sd|sdl|sdr|swc1|sdc1)$/i,
893
+ skipSafe: /^(nop|ssnop|break)$/i,
897
894
  context: `${name} @0x${ins.addr.toString(16)}`,
898
895
  });
899
896
  if (!od) {
@@ -941,6 +938,13 @@ export function lift(
941
938
  // incoming STACK-PASSED argument (5th+ param, O32) or an uninitialised local — neither modelled.
942
939
  // Without this, readVar would FABRICATE a phantom entry parameter for the slot, silently emitting a
943
940
  // function of wrong arity that returns the wrong argument. Loud-fail instead of miscompiling.
941
+ //
942
+ // Those two cases are SEPARABLE, and the Thumb frontend now separates them (frontend/thumb.ts,
943
+ // incomingArgIndex): a slot at or above the callee's own frame cannot have been written by this
944
+ // function, so it is an incoming argument; below the frame top it is a local. Doing the same here
945
+ // needs O32's own frame rule — the 16-byte home area means a stack argument is NOT simply "above
946
+ // the frame", so the Thumb arithmetic does not carry over — plus ssa.ensureParam for the register
947
+ // half. Until then this stays one loud decline for both.
944
948
  if (!ssa.hasReachingDef(stackSlot(off), bi)) {
945
949
  throw new FrontendUnsupportedError(
946
950
  `cannot lift '${name}': load from stack slot sp@${off} that was never stored ` +
@@ -1053,7 +1057,7 @@ function condValue(
1053
1057
  ops: Op[],
1054
1058
  read: (r: string) => Value,
1055
1059
  constVal: (n: number) => Value,
1056
- cmpDef: Map<string, { value: Value; opcode: string; lhs: Value; rhs: Value }>,
1060
+ cmpDef: Map<Value, { opcode: string; lhs: Value; rhs: Value }>,
1057
1061
  ): Value {
1058
1062
  const mk = (opc: Opcode, l: Value, r: Value): Value => {
1059
1063
  const v = mkValue(T.unk(32));
@@ -1064,15 +1068,19 @@ function condValue(
1064
1068
  return mk(COND_RR[br.mnemonic], read(br.ops[0]), read(br.ops[1]));
1065
1069
  }
1066
1070
  // *z forms compare a register against zero — except beqz/bnez may fold a preceding `slt`.
1067
- const rs = br.ops[0];
1068
- const folded = cmpDef.get(rs);
1071
+ // Resolve the register to its reaching VALUE first: that is the fold's key (so a redefinition
1072
+ // between the compare and the branch misses instead of folding stale), and it is also what
1073
+ // routes the operand through `read`'s loud guards (sp / `%hi` / `gp` used as data) on every
1074
+ // path — the fold used to bypass them by never reading the register at all.
1075
+ const rsv = read(br.ops[0]);
1076
+ const folded = cmpDef.get(rsv);
1069
1077
  if (folded && br.mnemonic === 'bnez') {
1070
- return folded.value;
1078
+ return rsv;
1071
1079
  } // branch when slt is true
1072
1080
  if (folded && br.mnemonic === 'beqz') {
1073
- return mk(NEG_ICMP[folded.opcode], folded.lhs, folded.rhs);
1081
+ return mk(NEGATED_ICMP[folded.opcode], folded.lhs, folded.rhs);
1074
1082
  } // …when false
1075
- return mk(COND_Z[br.mnemonic], read(rs), constVal(0));
1083
+ return mk(COND_Z[br.mnemonic], rsv, constVal(0));
1076
1084
  }
1077
1085
 
1078
1086
  /** The MIPS-II / IDO frontend, registered for the `mips` target. */
@@ -7,6 +7,37 @@
7
7
  // This module owns only the POLICY (which token is the destination, which are register sources).
8
8
  // The frontend still owns the SSA plumbing (how to `read` a source and `write`/emit the result),
9
9
  // because that is block-local state the policy must not touch.
10
+ //
11
+ // ─── A NOTE ON ALTERNATIVE MNEMONIC SPELLINGS ──────────────────────────────────────────────────
12
+ //
13
+ // Every ISA here accepts more than one spelling for some instructions, and the frontends handle
14
+ // that in two DIFFERENT ways on purpose. Which one is right is decided by the operands, not by
15
+ // taste:
16
+ //
17
+ // * PURE SYNONYM — same operands, same semantics, different name. Normalise it in a name→name
18
+ // table at the parse site, so every consumer of the mnemonic sees one name. Thumb does this
19
+ // for `ldsh`/`ldrsh`, `ldsb`/`ldrsb`, `ldm`/`ldmfd`/`ldmia`, `stm`/`stmea`/`stmia`, which
20
+ // ARM DDI 0029G Figure 1-6 gives a single encoding apiece.
21
+ //
22
+ // The table is the right shape THERE because the mnemonic is read by more than the decode
23
+ // switch — Thumb's `classifyXfer` matches it to tell a return from an indirect jump, the
24
+ // `storeClass` below matches it to decide whether an unmodelled op may be skipped, and the
25
+ // instruction-size walk tests it for `bl`. An alias arm on one `case` fixes one of those.
26
+ //
27
+ // * EXTENDED MNEMONIC / PSEUDO-INSTRUCTION — different operand GRAMMAR, so no rename can
28
+ // express it. Give it its own decode arm. MIPS `move rD,rS` (2 operands) is `addu rD,rS,zero`
29
+ // (3); PPC `mr rD,rS` is `or rD,rS,rS`; PPC `slwi rD,rS,n` (3) is `rlwinm rD,rS,n,mb,me` (5,
30
+ // with mask fields computed from n). Routing these through a table would be a category error.
31
+ //
32
+ // There is deliberately NO shared alias helper. As of writing, MIPS and PPC have no pure-synonym
33
+ // gap at all — every `unmodelled instruction` decline they produce across the whole benchmark is
34
+ // a genuinely unmodelled opcode (`lwc1`, `fmuls`, `fctiwz`, `subfe`, …), not a spelling — so such
35
+ // a helper would have exactly one caller. The bar for extracting one is the bar this module itself
36
+ // met: several frontends hand-copying the same policy AND observed drift between the copies.
37
+ //
38
+ // One thing that IS shared, and should stay shared: `display` below. A frontend that normalises
39
+ // spellings must still REPORT the one its input actually used, or a decline sends the reader
40
+ // looking for an instruction their disassembly does not contain.
10
41
  import { FrontendUnsupportedError } from './errors';
11
42
 
12
43
  export interface OpaquePolicy {
@@ -15,8 +46,9 @@ export interface OpaquePolicy {
15
46
  /** token cleanup before classification — e.g. Thumb strips `[`/`]` off a memory operand.
16
47
  * Default: identity. */
17
48
  normalize?: (s: string) => string;
18
- /** true iff the register is hardwired-zero (MIPS `$zero`/`$0`): writing it is a no-op, so it is
19
- * NOT a real destination and the instruction is safe to skip. Default: nothing is zero. */
49
+ /** true iff the register is hardwired-zero (MIPS `$zero`/`$0`). It is then not a real
50
+ * destination so there is nothing to degrade and the instruction is REFUSED, not skipped
51
+ * (`teq zero, zero` is a trap). Default: nothing is zero. */
20
52
  isZero?: (r: string) => boolean;
21
53
  /** Mnemonics that WRITE MEMORY in this ISA: the "no register destination ⇒ safe to fall
22
54
  * through" premise is FALSE for stores — skipping one silently deletes the write (Thumb
@@ -27,10 +59,15 @@ export interface OpaquePolicy {
27
59
  storeClass?: RegExp;
28
60
  /** attribution for thrown declines: the function being lifted (optionally "+ site"). */
29
61
  context?: string;
30
- /** Mnemonics PROVABLY effect-free or deliberately transparent (Thumb push/pop frame ops)
31
- * in this ISA: the ONLY unmodelled no-destination instructions that may be skipped. Any other
32
- * no-destination unmodelled instruction THROWS: a side-effect-only instruction (swi, syscall,
33
- * sync, cache…) skipped silently is a deleted effect a silent miscompile. Default: none. */
62
+ /** How to SPELL the mnemonic in messages, when that differs from the name used to classify it.
63
+ * A frontend that normalises legacy spellings (Thumb `ldsh` -> `ldrsh`) classifies on the
64
+ * canonical name but must report the one the input file actually contains — otherwise a decline
65
+ * names an instruction the reader cannot find in their own .s. Defaults to `mnemonic`. */
66
+ display?: string;
67
+ /** Mnemonics PROVABLY effect-free — or deliberately transparent (Thumb push/pop frame ops) — in
68
+ * this ISA: the only unmodelled instructions that may be skipped at all. Anything else with no
69
+ * degradable destination THROWS, because a side-effect-only instruction (swi, syscall, sync,
70
+ * cache…) skipped silently is a deleted effect. Default: none. */
34
71
  skipSafe?: RegExp;
35
72
  }
36
73
 
@@ -43,40 +80,53 @@ export interface OpaqueDest {
43
80
 
44
81
  /** Decide the opaque destination + register source operands for an unmodelled instruction
45
82
  * `mnemonic` with operand list `ops` (operands in objdump order — destination first). Returns
46
- * `null` only when the instruction is provably skippable: a write to a hardwired-zero register,
47
- * or a policy.skipSafe mnemonic. An unmodelled STORE-CLASS instruction (policy.storeClass)
48
- * throws loud (a skipped memory write is a silent miscompile) and so does any OTHER
49
- * no-destination instruction not in skipSafe: with no register to degrade to a live-`?`
50
- * sentinel, skipping would silently delete a side effect (swi/syscall/sync/cache).
83
+ * `null` only for a policy.skipSafe mnemonic. An unmodelled STORE-CLASS instruction
84
+ * (policy.storeClass) throws loud (a skipped memory write is a silent miscompile), and so does any
85
+ * instruction with no degradable destination: with no register to carry the live-`?` sentinel,
86
+ * skipping would silently delete a side effect (swi/syscall/sync/cache).
51
87
  *
52
88
  * When non-null, the caller MUST emit an `opaque` op that writes `dst` and consumes `srcRegs`
53
- * (read through the frontend's own SSA): a DEAD opaque is DCE'd away harmlessly, while a LIVE one
54
- * reaches structuring as the sentinel `?` and trips `assertResolved`the loud failure the
55
- * contract requires, instead of a stale/absent value surfacing as confidently-wrong source. */
89
+ * (read through the frontend's own SSA). It reaches structuring as the sentinel `?` and trips
90
+ * `assertResolved` the loud failure the contract requiresWHETHER OR NOT anything reads `dst`;
91
+ * `opaque` carries `effects: true` (ir/opcodes.ts) for the same reason `call` does.
92
+ *
93
+ * Two properties of `ops[0]` read as permission to skip and are not. Both describe the
94
+ * DESTINATION, while the risk is everything else the instruction does:
95
+ * - nobody reads it — a dead register says nothing about a memory or system effect;
96
+ * - it is hardwired zero — MIPS `teq zero, zero` is a conditional TRAP.
97
+ * So `skipSafe`, a short per-ISA list of provably transparent mnemonics, is the only way an
98
+ * unmodelled instruction leaves no trace.
99
+ *
100
+ * `storeClass` is not load-bearing for soundness — a missed store degrades loudly like anything
101
+ * else — but it throws naming the memory write instead of an unresolvable value, and it catches
102
+ * the shape where `ops[0]` is a SOURCE (MIPS `swl rt, off(base)`) before a `dst` is fabricated
103
+ * from it. It and `skipSafe` match case-INSENSITIVELY: mnemonic case is a property of the
104
+ * disassembler, not of the instruction. */
56
105
  export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy): OpaqueDest | null {
106
+ const shown = policy.display ?? mnemonic;
57
107
  if (policy.storeClass?.test(mnemonic)) {
58
108
  // `context` names the function (and, where the ISA has addresses, the site) — this message
59
109
  // lands verbatim in annotate-mode stub headers, where an un-attributed decline is
60
110
  // unactionable in a multi-function run.
61
111
  const where = policy.context ? `cannot lift '${policy.context}': ` : '';
62
112
  throw new FrontendUnsupportedError(
63
- `${where}unmodelled store-class instruction '${mnemonic}' — a memory write cannot be skipped or degraded to a register opaque`,
113
+ `${where}unmodelled store-class instruction '${shown}' — a memory write cannot be skipped or degraded to a register opaque`,
64
114
  );
65
115
  }
66
116
  const norm = policy.normalize ?? ((s) => s);
67
117
  const dst = norm(ops[0] ?? '');
68
- if (!policy.isReg(dst)) {
118
+ // No DEGRADABLE destination: `ops[0]` is not a register, or it is the hardwired zero. Same answer
119
+ // for both — a zero write is a genuine no-op only for a MODELLED instruction, and by construction
120
+ // nothing modelled reaches here.
121
+ if (!policy.isReg(dst) || policy.isZero?.(dst)) {
69
122
  if (policy.skipSafe?.test(mnemonic)) {
70
123
  return null;
71
124
  } // explicitly transparent for this ISA
72
125
  const where = policy.context ? `cannot lift '${policy.context}': ` : '';
73
126
  throw new FrontendUnsupportedError(
74
- `${where}unmodelled effect instruction '${mnemonic}' — no register destination to degrade, and skipping it would silently delete its effect`,
127
+ `${where}unmodelled effect instruction '${shown}' — no register destination to degrade, and skipping it would silently delete its effect`,
75
128
  );
76
129
  }
77
- if (policy.isZero?.(dst)) {
78
- return null;
79
- } // writes hardwired zero → a genuine no-op
80
130
  const srcRegs = ops.slice(1).map(norm).filter(policy.isReg);
81
131
  return { dst, srcRegs };
82
132
  }
@@ -24,7 +24,7 @@
24
24
  // the result against 0; that implicit compare is wired so a following `beq`/`bne` fuses.
25
25
  //
26
26
  // TRUSTWORTHINESS: an unmodelled instruction with a register destination emits an `opaque` value
27
- // (dead vanishes, live fails LOUD downstream); an unmodelled CONTROL TRANSFER throws
27
+ // that fails LOUD downstream read or not; an unmodelled CONTROL TRANSFER throws
28
28
  // PpcUnsupportedError in `lift`. Never plausible-but-wrong C.
29
29
  //
30
30
  // Scope: straight-line + `if`/diamond integer functions (incl. the conditional-return idiom),
@@ -480,15 +480,15 @@ export function lift(
480
480
  const constVal = kit.cnst;
481
481
  const emit = kit.emit;
482
482
  // TRUSTWORTHINESS GUARD: an unmodelled instruction must not silently drop its destination —
483
- // emit an honest `opaque` instead: dead DCE'd; live assertResolved fails LOUD (see
484
- // frontend/opaque.ts for the policy).
483
+ // emit an honest `opaque` instead, which fails LOUD at assertResolved whether or not anything
484
+ // reads that register (see frontend/opaque.ts for the policy).
485
485
  const emitOpaqueDest = (ins: Instr) => {
486
486
  // storeClass: every PPC store mnemonic is st* — an unmodelled one (`stwbrx`, `sthbrx`, …)
487
487
  // must throw, never skip (its first token is the SOURCE register).
488
488
  const od = opaqueDest(ins.mnemonic, ins.ops, {
489
489
  isReg,
490
- storeClass: /^st/,
491
- skipSafe: /^nop$/,
490
+ storeClass: /^st/i,
491
+ skipSafe: /^nop$/i,
492
492
  context: `${name} @0x${ins.addr.toString(16)}`,
493
493
  });
494
494
  if (!od) {
@@ -567,12 +567,23 @@ export function lift(
567
567
  // (anything live across the call has already been moved to a callee-saved register).
568
568
  case 'bl': {
569
569
  const sym = ins.sym ?? 'func';
570
- const argc = protoArity(prototypes[sym]) ?? fallbackArgc(bi);
570
+ const declared = protoArity(prototypes[sym]);
571
+ const argc = declared ?? fallbackArgc(bi);
571
572
  const args: Value[] = [];
572
573
  for (let k = 0; k < argc; k++) {
573
574
  args.push(read(ARG_REGS[k]));
574
575
  }
575
- emit('call', RET, args, { target: sym });
576
+ // Pushed with `tmp` rather than `emit` so the result register is written AFTER the clobber
577
+ // is recorded — the order matters: r3.. are volatile under the EABI, so a GUESSED arity
578
+ // that counted a register set up before an intervening call passes an argument the caller
579
+ // never set up (`finish()` cuts those back — frontend/ssa.ts), while the call's OWN result
580
+ // must stay fresh for the next call (`bar(foo())`).
581
+ const res = kit.tmp('call', args, { target: sym });
582
+ if (declared === undefined) {
583
+ ssa.recordGuessedCall(ops[ops.length - 1], bi, ARG_REGS);
584
+ }
585
+ ssa.noteCall(bi);
586
+ write(RET, res);
576
587
  break;
577
588
  }
578
589
  // Stack-frame + link-register bookkeeping. `stwu r1,-N(r1)` / `addi r1,r1,N` adjust the frame