@asmlift/core 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asmlift/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Match decompile an assembly function to C or Pascal",
@@ -388,7 +388,10 @@ function printStmt(s: Stmt, indent: string, vt: VarTypes, leaf?: LeafHook): stri
388
388
  out.push(`${bi}break;`);
389
389
  }
390
390
  }
391
- if (s.default) {
391
+ // `?.length`, not just presence: a label with no statement under it is not valid C89, and an
392
+ // L3 pass (dce, reindex) may empty a default that arrived with statements — the structurer's
393
+ // own "don't attach an empty default" rule cannot see that.
394
+ if (s.default?.length) {
392
395
  out.push(`${ci}default:`);
393
396
  for (const t of s.default) {
394
397
  out.push(...printStmt(t, bi, vt, leaf));
@@ -470,7 +473,7 @@ function cFamilyBody(fn0: SFn, leaf?: LeafHook): string[] {
470
473
  const vt: VarTypes = declaredTypes(fn);
471
474
  const lines: string[] = [];
472
475
  for (const l of fn.locals) {
473
- lines.push(` ${cType(l.type)} ${l.name};`);
476
+ lines.push(` ${l.volatile ? 'volatile ' : ''}${cType(l.type)} ${l.name};`);
474
477
  }
475
478
  for (const s of fn.body) {
476
479
  lines.push(...printStmt(s, ' ', vt, leaf));
package/src/contracts.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  // decompileRanked / decompileWithReport).
4
4
  // A pass that regresses fails AT its boundary with a diagnostic, not three stages later as
5
5
  // wrong C.
6
- import type { Fn, Value } from './ir/core';
6
+ import { type Block, type Fn, type Value, successorsOf } from './ir/core';
7
7
  import { type IrType, typeToString } from './ir/types';
8
8
  import type { BinOp, Expr, SFn, Stmt } from './l3/ast';
9
- import { exprChildren, fieldSpellsDot, stmtChildren, stmtExprs } from './l3/ast';
9
+ import { exprChildren, fieldSpellsDot, gapReasonFor, stmtChildren, stmtExprs } from './l3/ast';
10
10
  import { declaredTypes, exprCType } from './l3/typing';
11
11
 
12
12
  export class ContractError extends Error {
@@ -58,6 +58,170 @@ export function assertResolved(sfn: SFn): void {
58
58
  }
59
59
  }
60
60
 
61
+ // ── effects: executed once, never dropped ──────────────────────────────────────────────────
62
+ //
63
+ // The three contracts around this one are about TYPING and SPELLABILITY. Nothing checked the
64
+ // property the structurer's materialization model exists to preserve: a call in the asm must run
65
+ // exactly as often in the emitted source. Its two failure modes are the two that hurt most —
66
+ // asmlift's first rule is that a loud failure beats a silently wrong answer, and both of these are
67
+ // silent:
68
+ //
69
+ // • DROPPED — a call the asm makes has no counterpart in the tree at all;
70
+ // • RE-RUN — inlining a call's value at more than one render position (or a structuring copy
71
+ // that duplicates a region onto a single path) makes one call execute twice. The round that
72
+ // recovered switch fall-through hit exactly this shape, and only an adversarial reviewer
73
+ // caught it.
74
+ //
75
+ // Deliberately narrow, so it never declines a function that is fine:
76
+ //
77
+ // • CALLS only. Loads legitimately re-render (that is the whole point of the inline-at-use
78
+ // model, and the alias gate governs it); stores are checked by neither direction here because
79
+ // the readability DCE pass is allowed to drop a provably dead one.
80
+ // • PER PATH, not per tree. Structuring may legitimately emit one block twice — two exclusive
81
+ // switch arms sharing a body, a duplicated return merge — and each path still executes it
82
+ // once. So the duplication rule compares the maximum over syntactic root-to-leaf paths (a
83
+ // branch takes the max of its arms, a loop body counts once, a fall-through arm chains into
84
+ // the next) against the IR's static count.
85
+ // • Names the IR does not have are ignored, and only calls carrying a target symbol are counted
86
+ // (every frontend that emits `call` today stamps one).
87
+ type CallCounts = Map<string, number>;
88
+
89
+ /** per-key combine of two count maps (`sum` for sequence, `max` for exclusive alternatives) */
90
+ function combine(a: CallCounts, b: CallCounts, f: (x: number, y: number) => number): CallCounts {
91
+ const out = new Map(a);
92
+ for (const [k, v] of b) {
93
+ out.set(k, f(out.get(k) ?? 0, v));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** every `call` expression under `e`, counted by target name */
99
+ function callsInExpr(e: Expr, into: CallCounts): void {
100
+ if (e.k === 'call') {
101
+ into.set(e.fn, (into.get(e.fn) ?? 0) + 1);
102
+ }
103
+ exprChildren(e).forEach((c) => callsInExpr(c, into));
104
+ }
105
+
106
+ /** `total` = every occurrence in the tree; `path` = the most any single syntactic path executes */
107
+ function countCalls(stmts: Stmt[]): { total: CallCounts; path: CallCounts } {
108
+ let total: CallCounts = new Map();
109
+ let path: CallCounts = new Map();
110
+ const add = (r: { total: CallCounts; path: CallCounts }, pathF: (x: number, y: number) => number) => {
111
+ total = combine(total, r.total, (x, y) => x + y);
112
+ path = combine(path, r.path, pathF);
113
+ };
114
+ for (const s of stmts) {
115
+ const own: CallCounts = new Map();
116
+ stmtExprs(s).forEach((e) => callsInExpr(e, own));
117
+ add({ total: own, path: own }, (x, y) => x + y);
118
+ if (s.k === 'if') {
119
+ const t = countCalls(s.then);
120
+ const e = countCalls(s.else);
121
+ // exclusive arms: the path count is whichever arm runs, the total counts both
122
+ add(
123
+ { total: combine(t.total, e.total, (x, y) => x + y), path: combine(t.path, e.path, Math.max) },
124
+ (x, y) => x + y,
125
+ );
126
+ } else if (s.k === 'switch') {
127
+ const arms = s.cases.map((c) => countCalls(c.body));
128
+ const dflt = countCalls(s.default ?? []);
129
+ // A fall-through arm continues into the NEXT one emitted (the last into `default`), so a
130
+ // path through arm i runs the chain starting at i — the shape the fall-through round's
131
+ // CRITICAL took. Built from the end; `chain[i]` is that arm's per-path count.
132
+ const chain: CallCounts[] = new Array(arms.length);
133
+ for (let i = arms.length - 1; i >= 0; i--) {
134
+ const next = i + 1 < arms.length ? chain[i + 1] : dflt.path;
135
+ chain[i] = s.cases[i].fallsThrough ? combine(arms[i].path, next, (x, y) => x + y) : arms[i].path;
136
+ }
137
+ const armTotal = arms.reduce((acc, a) => combine(acc, a.total, (x, y) => x + y), dflt.total);
138
+ const armPath = chain.reduce((acc, c) => combine(acc, c, Math.max), dflt.path);
139
+ add({ total: armTotal, path: armPath }, (x, y) => x + y);
140
+ } else {
141
+ // Sequenced children (a loop body, a `for`'s init/inc): counted ONCE — a loop's dynamic trip
142
+ // count is not a syntactic occurrence, and the IR side is static too.
143
+ for (const c of stmtChildren(s)) {
144
+ add(countCalls([c]), (x, y) => x + y);
145
+ }
146
+ }
147
+ }
148
+ return { total, path };
149
+ }
150
+
151
+ /**
152
+ * Post structuring: every call the asm makes is emitted, and none is emitted more times than the
153
+ * asm makes it on any one path. See the note above for what this deliberately does not cover.
154
+ */
155
+ export function assertEffectsPreserved(fn: Fn, sfn: SFn): void {
156
+ // Reachable blocks only: an unreachable block's call is legitimately never emitted.
157
+ const seen = new Set<Block>([fn.blocks[0]]);
158
+ for (const stack = [fn.blocks[0]]; stack.length;) {
159
+ for (const s of successorsOf(stack.pop()!)) {
160
+ if (!seen.has(s)) {
161
+ seen.add(s);
162
+ stack.push(s);
163
+ }
164
+ }
165
+ }
166
+ const irCalls: CallCounts = new Map();
167
+ // Unmodelled instructions, by the mnemonic the frontend stamped. Same "never dropped" property as
168
+ // a call, and it needs its own tally because an `opaque` carries no `target`.
169
+ const irOpaques = new Set<string>();
170
+ for (const b of seen) {
171
+ for (const op of b.ops) {
172
+ if (op.opcode === 'call' && typeof op.attrs.target === 'string') {
173
+ const t = op.attrs.target;
174
+ irCalls.set(t, (irCalls.get(t) ?? 0) + 1);
175
+ } else if (op.opcode === 'opaque') {
176
+ irOpaques.add(gapReasonFor(op.attrs.mnemonic));
177
+ }
178
+ }
179
+ }
180
+ // DROPPED only, not the RE-RUN half: a gap rendered twice is a diagnostic printed twice, which
181
+ // costs nothing because nothing recompiles it, and structuring legitimately duplicates a shared
182
+ // arm — so a per-path count here would fire on correct output.
183
+ //
184
+ // Bites only in ANNOTATE mode (under `strict` the gap is the `?` sentinel and structure() has
185
+ // already thrown), which is where it is needed: that is the CLI and benchmark default, and the
186
+ // only mode with no other backstop against a silently dropped opaque.
187
+ if (irOpaques.size) {
188
+ const emitted = new Set<string>();
189
+ const we = (e: Expr): void => {
190
+ if (e.k === 'marker') {
191
+ emitted.add(e.reason);
192
+ }
193
+ exprChildren(e).forEach(we);
194
+ };
195
+ const ws = (s: Stmt): void => {
196
+ stmtExprs(s).forEach(we);
197
+ stmtChildren(s).forEach(ws);
198
+ };
199
+ sfn.body.forEach(ws);
200
+ for (const reason of irOpaques) {
201
+ if (!emitted.has(reason)) {
202
+ throw new ContractError(
203
+ `structuring dropped the ${reason} in '${sfn.name}' — an instruction asmlift could not model left no trace`,
204
+ );
205
+ }
206
+ }
207
+ }
208
+ if (!irCalls.size) {
209
+ return;
210
+ }
211
+ const { total, path } = countCalls(sfn.body);
212
+ for (const [name, n] of irCalls) {
213
+ if (!(total.get(name) ?? 0)) {
214
+ throw new ContractError(`structuring dropped the call to '${name}' in '${sfn.name}' — its effect is lost`);
215
+ }
216
+ const p = path.get(name) ?? 0;
217
+ if (p > n) {
218
+ throw new ContractError(
219
+ `structuring emitted ${p} calls to '${name}' on one path in '${sfn.name}', where the asm makes ${n}`,
220
+ );
221
+ }
222
+ }
223
+ }
224
+
61
225
  /** Post structuring: the AST's memory accesses and operators must be SPELLABLE — a `field`
62
226
  * node's base a pointer-to-struct (`->`) or a struct value (`.`, an array element) carrying
63
227
  * that field; no pointer operand under an operator C rejects; and every SCALAR `index` node's
@@ -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;
@@ -54,7 +54,7 @@ const isZero = (r: string) => r === 'zero' || r === '$0';
54
54
  const isStackPtr = (r: string) => r === 'sp' || r === '$sp' || r === '$29';
55
55
  // SSA-variable name for the stack slot at a constant `sp`-offset. Distinct namespace from the
56
56
  // register names (which are alphabetic / `$N`), so it never collides with a real register var.
57
- const stackSlot = (off: number) => `sp@${off}`;
57
+ const stackSlot = stackSlotKey; // shared spelling: frontend/ssa.ts
58
58
  // Sub-word memory mnemonics (widths 1 and 2). Used by the `spSlotSafe` guard in `lift`: a sub-word
59
59
  // `sp`-relative access means the word stack-slot model is unsafe for that function.
60
60
  const SUBWORD_MEM = new Set(['lb', 'lbu', 'lh', 'lhu', 'sb', 'sh']);
@@ -866,8 +866,8 @@ export function lift(
866
866
  }
867
867
  };
868
868
  // TRUSTWORTHINESS GUARD (mirrors the PPC frontend): an unmodelled instruction must not silently
869
- // drop its destination register — emit an honest `opaque`: dead it vanishes; live ⇒
870
- // 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).
871
871
  const emitOpaqueDest = (ins: Instr) => {
872
872
  // A `%hi`/`%lo` operand on an instruction NOT modelled as a global consumer — an FP load/store
873
873
  // (`lwc1`/`ldc1`), or any unmodelled op — reaches here (the modelled consumers handle their own
@@ -889,8 +889,8 @@ export function lift(
889
889
  const od = opaqueDest(ins.mnemonic, ins.ops, {
890
890
  isReg: isMipsReg,
891
891
  isZero,
892
- storeClass: /^(sb|sh|sw|swl|swr|sc|sd|sdl|sdr|swc1|sdc1)$/,
893
- skipSafe: /^(nop|ssnop|break)$/,
892
+ storeClass: /^(sb|sh|sw|swl|swr|sc|sd|sdl|sdr|swc1|sdc1)$/i,
893
+ skipSafe: /^(nop|ssnop|break)$/i,
894
894
  context: `${name} @0x${ins.addr.toString(16)}`,
895
895
  });
896
896
  if (!od) {
@@ -938,6 +938,13 @@ export function lift(
938
938
  // incoming STACK-PASSED argument (5th+ param, O32) or an uninitialised local — neither modelled.
939
939
  // Without this, readVar would FABRICATE a phantom entry parameter for the slot, silently emitting a
940
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.
941
948
  if (!ssa.hasReachingDef(stackSlot(off), bi)) {
942
949
  throw new FrontendUnsupportedError(
943
950
  `cannot lift '${name}': load from stack slot sp@${off} that was never stored ` +
@@ -46,8 +46,9 @@ export interface OpaquePolicy {
46
46
  /** token cleanup before classification — e.g. Thumb strips `[`/`]` off a memory operand.
47
47
  * Default: identity. */
48
48
  normalize?: (s: string) => string;
49
- /** true iff the register is hardwired-zero (MIPS `$zero`/`$0`): writing it is a no-op, so it is
50
- * 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. */
51
52
  isZero?: (r: string) => boolean;
52
53
  /** Mnemonics that WRITE MEMORY in this ISA: the "no register destination ⇒ safe to fall
53
54
  * through" premise is FALSE for stores — skipping one silently deletes the write (Thumb
@@ -63,10 +64,10 @@ export interface OpaquePolicy {
63
64
  * canonical name but must report the one the input file actually contains — otherwise a decline
64
65
  * names an instruction the reader cannot find in their own .s. Defaults to `mnemonic`. */
65
66
  display?: string;
66
- /** Mnemonics PROVABLY effect-free — or deliberately transparent (Thumb push/pop frame ops) —
67
- * in this ISA: the ONLY unmodelled no-destination instructions that may be skipped. Any other
68
- * no-destination unmodelled instruction THROWS: a side-effect-only instruction (swi, syscall,
69
- * sync, cache…) skipped silently is a deleted effect — a silent miscompile. Default: none. */
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. */
70
71
  skipSafe?: RegExp;
71
72
  }
72
73
 
@@ -79,16 +80,28 @@ export interface OpaqueDest {
79
80
 
80
81
  /** Decide the opaque destination + register source operands for an unmodelled instruction
81
82
  * `mnemonic` with operand list `ops` (operands in objdump order — destination first). Returns
82
- * `null` only when the instruction is provably skippable: a write to a hardwired-zero register,
83
- * or a policy.skipSafe mnemonic. An unmodelled STORE-CLASS instruction (policy.storeClass)
84
- * throws loud (a skipped memory write is a silent miscompile) and so does any OTHER
85
- * no-destination instruction not in skipSafe: with no register to degrade to a live-`?`
86
- * 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).
87
87
  *
88
88
  * When non-null, the caller MUST emit an `opaque` op that writes `dst` and consumes `srcRegs`
89
- * (read through the frontend's own SSA): a DEAD opaque is DCE'd away harmlessly, while a LIVE one
90
- * reaches structuring as the sentinel `?` and trips `assertResolved`the loud failure the
91
- * 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. */
92
105
  export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy): OpaqueDest | null {
93
106
  const shown = policy.display ?? mnemonic;
94
107
  if (policy.storeClass?.test(mnemonic)) {
@@ -102,7 +115,10 @@ export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy
102
115
  }
103
116
  const norm = policy.normalize ?? ((s) => s);
104
117
  const dst = norm(ops[0] ?? '');
105
- 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)) {
106
122
  if (policy.skipSafe?.test(mnemonic)) {
107
123
  return null;
108
124
  } // explicitly transparent for this ISA
@@ -111,9 +127,6 @@ export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy
111
127
  `${where}unmodelled effect instruction '${shown}' — no register destination to degrade, and skipping it would silently delete its effect`,
112
128
  );
113
129
  }
114
- if (policy.isZero?.(dst)) {
115
- return null;
116
- } // writes hardwired zero → a genuine no-op
117
130
  const srcRegs = ops.slice(1).map(norm).filter(policy.isReg);
118
131
  return { dst, srcRegs };
119
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