@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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ Input is **text**, following what each target's toolchain produces:
38
38
  | Option | Meaning |
39
39
  | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
40
  | `backend` | `cBackend` (default) or `pascalBackend` — values from `@asmlift/core/backend/*`. C++ is `cppBackend(spec)`, a per-function factory: it takes a `CppFnSpec` (class/method name, explicit param types, class field layouts — what a project's headers supply) and covers free and non-virtual member functions with word-sized fields; virtual dispatch, references, ctors/dtors decline |
41
- | `patterns` | Idiom rewrite patterns. Omitted = `DEFAULT_IDIOM_PATTERNS` (each gated per compiler); `[]` = none |
41
+ | `patterns` | Idiom rewrite patterns. Omitted = `DEFAULT_IDIOM_PATTERNS` (self-selects per target: most are compiler-gated, the boolean-negation folds are universal); `[]` = none |
42
42
  | `prototypes` | Callee arities + void-ness, as a real project takes them from headers — drives call-argument recovery |
43
43
  | `asmData` | Optional `objdump -s -r -t` side-table; required to recover MIPS/PPC jump-table switches |
44
44
  | `onGap` | `"strict"` (default): throw on any gap. `"annotate"`: emit best-effort source with `ASMLIFT_ERROR` markers; every gap is also returned in the structured `diagnostics` array (empty ⇔ gap-free) |
@@ -86,7 +86,7 @@ injected via hooks, never copied. `verify()` runs after every IR-mutating pass;
86
86
  | `pattern/engine.ts` | Idiom layer: **rewrite patterns as data** + greedy driver + DCE; `patternApplies` gates on Target capabilities |
87
87
  | `raise/*.ts` | The pre-recovery recognizers, in ONE ordered list (`pre-recovery.ts`): const materialize → magic division (`magicdiv.ts`, Hacker's Delight inverse) → soft division → array legalize → struct-array → struct-pointer → short-circuit; plus `recover.ts` (L1→L2 type recovery), `retsink.ts` (return-sinking), `errors.ts` (`RaiseUnsupportedError`) |
88
88
  | `structure/*.ts` | L2→L3 in four modules: `loops.ts` (natural-loop discovery), `analysis.ts` (use registry, liveness, C4 materialization), `switch-recover.ts` (Regime-A comparison-tree recovery), `structure.ts` (SSA-destruction coalescing with interference checks + emission: if/while/do-while/for/switch, break/early-return) |
89
- | `l3/*.ts` | `ast.ts`: language-**neutral** structured AST, the one traversal vocabulary (`exprChildren` etc.), and the `LanguageBackend` seam. Post-structure passes `dce.ts` + `basecse.ts`, the differ-ranked re-spelling levers `regspell.ts` + `reindex.ts`, and `typing.ts` (the rendered-expression C type the backends and contracts share) |
89
+ | `l3/*.ts` | `ast.ts`: language-**neutral** structured AST, the one traversal vocabulary (`exprChildren` etc.), and the `LanguageBackend` seam. Post-structure passes `dce.ts` + `basecse.ts`, the shared hoist mechanism `hoist.ts`, the differ-ranked re-spelling levers `regspell.ts` + `reindex.ts` + `argbase.ts` + `scopebase.ts`, and `typing.ts` (the rendered-expression C type the backends and contracts share) |
90
90
  | `backend/{c,cpp,cfamily,pascal}.ts` | Three backends: C and C++ (CodeWarrior mangling via `mangle.ts`) over the shared `cfamily.ts` substrate, and Pascal (`:=`, `div`, tail-position returns; unspellable constructs throw) |
91
91
  | `pipeline.ts` | `decompile()` + the shared tower spine + annotate-mode stubs/diagnostics |
92
92
  | `trace.ts` | `decompileTraced` — the traced tower (per-stage IR dumps + pattern before/after events), browser-pure; @asmlift/cli's `report.ts` enriches it with objdiff scores/candidates, the playground's Pipeline tab renders it directly |
@@ -104,7 +104,9 @@ Recovered today: straight-line, if/else diamonds, natural loops (`while` / `do-w
104
104
  properly nested, in-body `break`/early-`return`), comparison-tree and jump-table switches,
105
105
  direct calls, constant-offset and variable-index memory (`*p`, `p[n]`, `a[i]`, struct fields),
106
106
  magic-number and soft division, short-circuit booleans, width casts. Still DECLINED (loud, never
107
- wrong code): **local stack frames** (address-taken locals / sp-as-data / live spills),
107
+ wrong code): **local stack frames** (address-taken locals / sp-as-data; MIPS models word `sp`
108
+ slots and PPC elides callee-saved save slots, so a spill/reload pair is modelled on those two —
109
+ anything the narrow models cannot honour declines),
108
110
  **cross-block condition flags** on PPC (a `cmpw` whose branch lands in another block — the
109
111
  capability gap behind the mwcc switch stubs), computed tail calls, PIC/`gp`/SDA global access,
110
112
  switch fall-through, multi-latch/irreducible loops, floats, and 64-bit memory ops. Prototypes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asmlift/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Match decompile an assembly function to C or Pascal",
@@ -9,6 +9,78 @@ import { IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
9
9
  import { BinOp, Expr, SFn, Stmt, dotBase } from '../l3/ast';
10
10
  import { type VarTypes, declaredTypes, derefStrideOk, exprCType } from '../l3/typing';
11
11
 
12
+ /**
13
+ * The C SIGNEDNESS a rendered integer expression actually has — `true`/`false`, or `undefined`
14
+ * when it is not determinable here. The deliberate complement to l3/typing's `exprCType`, which is
15
+ * pointer-ness-accurate and reports every integer as `s32` by contract; this models the two C
16
+ * rules that contract omits, integer PROMOTION and the usual arithmetic CONVERSIONS.
17
+ *
18
+ * It lives HERE, in the C-family backend, because it is a model of C's own rules with no meaning
19
+ * for another language — the same reason the cast it feeds is synthesized here rather than in the
20
+ * tower. `exprCType` stays in l3/ because Pascal consults it too.
21
+ *
22
+ * It exists for one question, and the question is byte-load-bearing: C spells both `>>>` and `>>`
23
+ * as `>>` and chooses between them from the left operand's type. A logical shift rendered over a
24
+ * signed expression recompiles to `asr` where the target has `lsr`, and evaluates to a different
25
+ * value. The C-family backend casts the operand whenever this returns anything but the signedness
26
+ * the operator needs, so `undefined` is the safe answer in every case the model does not cover — a
27
+ * redundant cast is codegen-identical, a missing one is a miscompile.
28
+ *
29
+ * Anything narrower than 32 bits promotes to `int` and is therefore SIGNED, whatever it was
30
+ * declared. Pointers, calls and markers are `undefined`.
31
+ */
32
+ function renderedIntSignedness(e: Expr, varType: VarTypes): boolean | undefined {
33
+ const rec = (x: Expr): boolean | undefined => renderedIntSignedness(x, varType);
34
+ // an lvalue-ish leaf: its C type is a declaration / an explicit cast / a carried access width
35
+ const promoted = (t: IrType | undefined): boolean | undefined =>
36
+ t?.kind !== 'int' ? undefined : t.width < 32 ? true : t.width === 32 ? t.signed : undefined;
37
+ switch (e.k) {
38
+ case 'var':
39
+ case 'cast':
40
+ case 'index':
41
+ case 'field':
42
+ return promoted(exprCType(e, varType));
43
+ // A decimal literal is `int` when it fits in one; C89 gives a larger one an unsigned type,
44
+ // which is not the same operand — so it is left undetermined rather than assumed. INT_MIN is
45
+ // in that larger class despite fitting: the backend prints it as `-2147483648`, which C lexes
46
+ // as unary minus applied to `2147483648` — a constant too big for `int`, hence unsigned long.
47
+ case 'const':
48
+ return e.value > -2147483648 && e.value <= 2147483647 ? true : undefined;
49
+ // `-x` / `~x` carry the PROMOTED type of the operand; `!x` is `int`.
50
+ case 'un':
51
+ return e.op === '!' ? true : rec(e.e);
52
+ case 'bin': {
53
+ // Shifts take the type of the LEFT operand alone — the right is promoted independently.
54
+ if (e.op === '<<' || e.op === '>>' || e.op === '>>>') {
55
+ return rec(e.l);
56
+ }
57
+ // Comparisons and the logical connectives yield `int`.
58
+ if (['<', '<=', '>', '>=', '==', '!=', '&&', '||'].includes(e.op)) {
59
+ return true;
60
+ }
61
+ // Usual arithmetic conversions over the remaining binary operators: at equal rank, unsigned
62
+ // wins. Either side unknown leaves the result unknown — EXCEPT when the known side is
63
+ // unsigned, which already decides it.
64
+ //
65
+ // That exception is the one place this returns a DEFINITE answer from an unknown operand,
66
+ // and it is sound only because every integer here is rank `int`: at UNEQUAL rank C converts
67
+ // to the wider type first, so `unsigned int & long long` is SIGNED. Core has no 64-bit
68
+ // integer type at all (the decomp typedef vocabulary stops at 32 — see contracts.ts
69
+ // SCALAR_WIDTHS), so the unequal-rank case cannot arise. Adding one would invalidate this.
70
+ const l = rec(e.l);
71
+ const r = rec(e.r);
72
+ if (l === false || r === false) {
73
+ return false;
74
+ }
75
+ return l === true && r === true ? true : undefined;
76
+ }
77
+ case 'call':
78
+ case 'marker':
79
+ case 'addr':
80
+ return undefined;
81
+ }
82
+ }
83
+
12
84
  // C operator precedence (lower binds tighter). Used to emit MINIMAL parentheses. Shared: C++ has
13
85
  // the same precedence for these operators.
14
86
  const PREC: Record<BinOp, number> = {
@@ -19,6 +91,7 @@ const PREC: Record<BinOp, number> = {
19
91
  '-': 4,
20
92
  '<<': 5,
21
93
  '>>': 5,
94
+ '>>>': 5, // spells as C's `>>` (see printExpr's shift rule) — same precedence
22
95
  '<': 6,
23
96
  '<=': 6,
24
97
  '>': 6,
@@ -69,6 +142,8 @@ export interface StructFieldDecl {
69
142
  name: string;
70
143
  type: IrType;
71
144
  volatile?: boolean;
145
+ /** bitfield width — spells `u32 name : n;` (the map-layout synthesis is the only producer) */
146
+ bits?: number;
72
147
  }
73
148
 
74
149
  /** THE struct-declaration spelling — every `struct N { ... };` asmlift prints comes from here,
@@ -76,7 +151,9 @@ export interface StructFieldDecl {
76
151
  * drift apart. One line, fields in caller order (the type is self-describing: padding is the
77
152
  * caller's discipline, already present as real fields). */
78
153
  export function renderStructDecl(name: string, fields: StructFieldDecl[]): string {
79
- return `struct ${name} { ${fields.map((f) => `${f.volatile ? 'volatile ' : ''}${cDeclare(f.type, f.name)};`).join(' ')} };`;
154
+ const one = (f: StructFieldDecl) =>
155
+ `${f.volatile ? 'volatile ' : ''}${cDeclare(f.type, f.name)}${f.bits !== undefined ? ` : ${f.bits}` : ''};`;
156
+ return `struct ${name} { ${fields.map(one).join(' ')} };`;
80
157
  }
81
158
 
82
159
  // A LEAF hook lets a C-family backend override how a `var` or `index` node spells WITHOUT
@@ -103,6 +180,28 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
103
180
  derefStrideOk(exprCType(ix.base, vt), ix.width)
104
181
  ? ix.base
105
182
  : { k: 'cast', to: T.ptr(scalarTypeForAccess(ix.width, ix.signed)), e: ix.base };
183
+ // C-FAMILY SHIFT LEGALIZATION, the same discipline one operator over. The tower keeps the two
184
+ // right shifts apart (`>>>` logical, `>>` arithmetic); C spells BOTH `>>` and picks between them
185
+ // from the LEFT OPERAND'S TYPE. So the operand must be made to carry the choice, or an `shr_u`
186
+ // recompiles to `asr` where the target has `lsr` AND evaluates differently —
187
+ // `*(u8 *)&g << 30 >> 30` promotes to `int`, so a 2-bit field holding 2 comes out -1.
188
+ //
189
+ // (engine.ts's zext fold covers the same hazard for widths C can NAME, by folding the whole
190
+ // shift pair to a cast op. Every other extract width — every bitfield read — lands here.)
191
+ //
192
+ // The cast is added unless the operand PROVABLY renders with the signedness the op needs:
193
+ // renderedIntSignedness answers `undefined` wherever its model does not reach, and a redundant
194
+ // cast is codegen-identical while a missing one is a miscompile. An existing 32-bit integer cast
195
+ // is REPLACED rather than wrapped — `(u32)(s32)&g` and `(u32)&g` are the same bytes, and the
196
+ // arithmetic rules upstream do emit that inner cast (intifyAddr).
197
+ const shiftOperand = (e0: Extract<Expr, { k: 'bin' }>): Expr => {
198
+ const wantSigned = e0.op === '>>';
199
+ if (renderedIntSignedness(e0.l, vt) === wantSigned) {
200
+ return e0.l;
201
+ }
202
+ const inner = e0.l.k === 'cast' && e0.l.to.kind === 'int' && e0.l.to.width === 32 ? e0.l.e : e0.l;
203
+ return { k: 'cast', to: T.int(32, wantSigned), e: inner };
204
+ };
106
205
  switch (e.k) {
107
206
  case 'var':
108
207
  return e.name;
@@ -123,6 +222,21 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
123
222
  // binds tighter than any prefix operator, so a cast/unary/deref base is printed at prec 1
124
223
  // and parenthesizes itself: `((u8 *)p)[1]`). The postfix form needs no outer parentheses.
125
224
  const base = legalized(e);
225
+ // Leading constant subscripts (a multidimensional array global's bare spelling) keep the
226
+ // postfix form whatever `idx` is: `g[0][0]` is the element, `*g[0]` would be its ROW.
227
+ //
228
+ // `lead` implies the base already strides the access width — its only producer registers a
229
+ // matching element type for the global (structure.ts bareArrayLead + noteGlobal). Nothing
230
+ // else enforced that, and the failure would be quiet-ish: legalization would wrap the base,
231
+ // spelling `((u16 *)g)[0][i]`, which subscripts a `u16` twice. Check it rather than assume.
232
+ if (e.lead && e.lead.length > 0) {
233
+ if (base !== e.base) {
234
+ throw new Error(
235
+ `c backend: a multidimensional array access needs a base that strides ${e.width} bytes as spelled`,
236
+ );
237
+ }
238
+ return `${rec(base, 1)}${e.lead.map((l) => `[${l}]`).join('')}[${rec(e.idx, 99)}]`;
239
+ }
126
240
  if (e.idx.k === 'const' && e.idx.value === 0) {
127
241
  const s = `*${rec(base, 2)}`;
128
242
  return parentPrec < 2 ? `(${s})` : s;
@@ -141,6 +255,13 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
141
255
  // first (the C++ member-access rewrite).
142
256
  const ix = dotBase(e);
143
257
  if (ix) {
258
+ // This path spells the index node from parts, so a `lead` would be DROPPED — an element
259
+ // access silently becoming a row's. Unreachable today (arrayAccess's lead branch requires
260
+ // `fieldOff === undefined`, which is exclusive with the dot form), but this is a
261
+ // text-returning path with no other guard, so it refuses rather than assumes.
262
+ if (ix.lead && ix.lead.length > 0) {
263
+ throw new Error(`c backend: a multidimensional array element has no struct-field spelling yet`);
264
+ }
144
265
  const hooked = leaf?.(ix, rec);
145
266
  const baseTxt = hooked ?? `${rec(ix.base, 1)}[${rec(ix.idx, 99)}]`;
146
267
  return `${baseTxt}.${e.name}`;
@@ -173,7 +294,9 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
173
294
  }
174
295
  case 'bin': {
175
296
  const p = PREC[e.op];
176
- const s = `${rec(e.l, p)} ${e.op} ${rec(e.r, p - 1)}`;
297
+ // Both right shifts spell C's `>>`; `shiftOperand` supplies the operand cast that says which.
298
+ const shift = e.op === '>>' || e.op === '>>>';
299
+ const s = `${rec(shift ? shiftOperand(e) : e.l, p)} ${shift ? '>>' : e.op} ${rec(e.r, p - 1)}`;
177
300
  return p > parentPrec ? `(${s})` : s;
178
301
  }
179
302
  }
@@ -90,7 +90,9 @@ export function cppBackend(spec: CppFnSpec): LanguageBackend {
90
90
  // class pointer, and print an unscaled `this[2]` that C++ strides by sizeof(class).
91
91
  // Correct bytes over idiomatic spelling, never the reverse.
92
92
  const leaf: LeafHook = (e: Expr) => {
93
- if (e.k === 'index' && e.base.k === 'var' && e.idx.k === 'const') {
93
+ // `lead` (a multidimensional array global) is not a receiver access and must not be
94
+ // rewritten to one — the hook returns text, so a dropped subscript would be silent.
95
+ if (e.k === 'index' && e.base.k === 'var' && e.idx.k === 'const' && !e.lead?.length) {
94
96
  const r = recv.get(e.base.name);
95
97
  if (r) {
96
98
  if (e.width === 4) {
@@ -35,6 +35,10 @@ const BIT_FN: Partial<Record<BinOp, string>> = {
35
35
  '|': 'bitor',
36
36
  '^': 'bitxor',
37
37
  '<<': 'lshift',
38
+ // `rshift` over this backend's signed `Integer` reproduces IDO's `sra` — verified byte-exact
39
+ // against upas (pascal-ido.test.ts `asr2`). `>>>`, the LOGICAL shift, has no verified spelling
40
+ // here and is therefore absent: it reaches the loud decline below rather than borrowing this
41
+ // one, which would emit an arithmetic shift where the machine did a logical one.
38
42
  '>>': 'rshift',
39
43
  };
40
44
 
@@ -83,6 +87,13 @@ function makePrinter(vt: VarTypes) {
83
87
  // any plausible `^Integer`-shaped callee agrees with the machine width; a sub-word access
84
88
  // through an unknowable base would DISCARD the node's width (upas checks types, not
85
89
  // machine widths), so it declines like a definite mismatch.
90
+ // Leading constant subscripts (a multidimensional array global) have no IDO Pascal
91
+ // spelling yet, and dropping them would read a ROW's address as an element — decline
92
+ // LOUD, like the address-of case above. Unreachable today (the symbol map is agbcc-only),
93
+ // but silence here would be the wrong kind of unreachable.
94
+ if (e.lead && e.lead.length > 0) {
95
+ throw new Error(`pascal backend: a multidimensional array access has no IDO Pascal spelling yet`);
96
+ }
86
97
  const bt = exprCType(e.base, vt);
87
98
  if ((bt !== undefined && !derefStrideOk(bt, e.width)) || (bt === undefined && e.width !== 4)) {
88
99
  throw new Error(
package/src/contracts.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // wrong C.
6
6
  import type { Fn, Value } from './ir/core';
7
7
  import { type IrType, typeToString } from './ir/types';
8
- import type { Expr, SFn, Stmt } from './l3/ast';
8
+ import type { BinOp, Expr, SFn, Stmt } from './l3/ast';
9
9
  import { exprChildren, fieldSpellsDot, stmtChildren, stmtExprs } from './l3/ast';
10
10
  import { declaredTypes, exprCType } from './l3/typing';
11
11
 
@@ -71,8 +71,21 @@ export function assertDerefsTyped(sfn: SFn): void {
71
71
  const vt = declaredTypes(sfn);
72
72
  const ctype = (e: Expr): IrType | undefined => exprCType(e, vt);
73
73
  const bad: string[] = [];
74
+ // A `void` function must not RETURN A VALUE. Holds by construction today — returnType() answers
75
+ // void only when every `ret` is operand-less — but `retType` has two producers (the recovered
76
+ // type and the prototype's `returnsVoid`) and the value-suppression lives in a third place
77
+ // (structure.ts's return lowering), so a regressing edit to any of them prints `return expr;`
78
+ // inside a void function. That is ill-formed C the candidate compiler only rejects two stages
79
+ // later, with a diagnostic pointing at the symptom rather than the pass. Cheap to state here.
80
+ if (sfn.retType.kind === 'void') {
81
+ const valued = (stmts: Stmt[]): boolean =>
82
+ stmts.some((s) => (s.k === 'return' && s.value !== undefined) || valued(stmtChildren(s)));
83
+ if (valued(sfn.body)) {
84
+ bad.push(`function '${sfn.name}' is typed void but a return carries a value`);
85
+ }
86
+ }
74
87
  // Ops C rejects outright on a pointer operand (the additive ops and &&/|| are legal C).
75
- const NO_PTR_OPS = new Set(['&', '|', '^', '<<', '>>', '*', '/', '%']);
88
+ const NO_PTR_OPS = new Set<BinOp>(['&', '|', '^', '<<', '>>', '>>>', '*', '/', '%']);
76
89
  // The comparison operators — where a bare `&SYM` operand is SIGN-ambiguous, not ill-formed.
77
90
  const CMP_OPS = new Set(['<', '<=', '>', '>=', '==', '!=']);
78
91
  // 1/2/4 only: the decomp typedef vocabulary (C_TYPEDEFS) has no 64-bit scalar, so a width-8
package/src/declare.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  ENUM_IS_SIGNED,
38
38
  type SymbolInfo,
39
39
  type SymbolStructField,
40
+ arrayInnerExtents,
40
41
  declaredFields,
41
42
  pointeeFields,
42
43
  symbolFieldType,
@@ -75,23 +76,41 @@ function structDecl(tag: string, layout: SymbolStructField[] | undefined, size:
75
76
  return null;
76
77
  }
77
78
  const fields: StructFieldDecl[] = [];
78
- let cursor = 0;
79
+ // The cursor is in BITS (declaredFields' own discipline) so bitfield members seat exactly.
80
+ // Gaps pad as the u8 arrays they always were when both ends are byte-aligned, and as named
81
+ // `u32 asmlift_pad_N : k` bitfields otherwise — split at 32-bit unit boundaries, matching the
82
+ // no-straddle allocation rule declaredFields verified each kept member against. For a
83
+ // bitfield-free layout every gap is byte-aligned, so the emitted text is unchanged.
84
+ let bitCursor = 0;
79
85
  let pad = 0;
80
- for (const m of members) {
81
- if (m.offset > cursor) {
86
+ const padTo = (lo: number): void => {
87
+ while (bitCursor < lo) {
82
88
  // asmlift_-prefixed so a REAL member named pad_N (a decomp-header idiom) never collides
83
- fields.push({ name: `asmlift_pad_${pad++}`, type: T.array(T.u(8), m.offset - cursor) });
89
+ const name = `asmlift_pad_${pad++}`;
90
+ if (bitCursor % 8 === 0 && lo % 8 === 0) {
91
+ fields.push({ name, type: T.array(T.u(8), (lo - bitCursor) / 8) });
92
+ bitCursor = lo;
93
+ } else {
94
+ const k = Math.min(lo - bitCursor, 32 - (bitCursor % 32));
95
+ fields.push({ name, type: T.u(32), bits: k });
96
+ bitCursor += k;
97
+ }
84
98
  }
99
+ };
100
+ for (const m of members) {
101
+ const bits = m.bitWidth !== undefined;
102
+ const lo = m.offset * 8 + (bits ? m.bitOffset! : 0);
103
+ padTo(lo);
85
104
  fields.push({
86
105
  name: m.name,
87
106
  type: fieldType(m),
88
107
  ...(m.volatile ? { volatile: true } : {}),
108
+ ...(bits ? { bits: m.bitWidth } : {}),
89
109
  });
90
- cursor = m.offset + m.size;
110
+ bitCursor = bits ? lo + m.bitWidth! : (m.offset + m.size) * 8;
91
111
  }
92
- if (size !== undefined && size > cursor) {
93
- // tail padding to the declared size
94
- fields.push({ name: `asmlift_pad_${pad}`, type: T.array(T.u(8), size - cursor) });
112
+ if (size !== undefined) {
113
+ padTo(size * 8); // tail padding to the declared size
95
114
  }
96
115
  return renderStructDecl(tag, fields);
97
116
  }
@@ -139,7 +158,14 @@ export function renderDeclarations(refs: SymbolRef[]): string {
139
158
  // A non-1/2/4 element width is never bare-indexed by core (only &gSym cast forms), so
140
159
  // an unsized u8[] decl is codegen-identical for every spelling core emits.
141
160
  const elem = info.elemSize !== undefined ? intType(info.elemSize, info.elemSigned ?? false) : null;
142
- lines.push(`extern ${quals(info)}${elem ?? 'u8'} ${name}[];`);
161
+ // The RANK must be reproduced, or the declaration disagrees with the access core spells:
162
+ // a `gSym[0][i]` needs a 2-D declaration to be an element rather than a type error. The
163
+ // OUTERMOST extent is always left unsized — it is the one C lets a declaration omit, and
164
+ // omitting it keeps this decl compatible with the project's real one whatever its size
165
+ // (the same reason the rank-1 form has always been `[]`). Inner extents are load-bearing:
166
+ // they are what scales each leading subscript, so they are spelled exactly.
167
+ const rank = (arrayInnerExtents(info) ?? []).map((d) => `[${d}]`).join('');
168
+ lines.push(`extern ${quals(info)}${elem ?? 'u8'} ${name}[]${rank};`);
143
169
  break;
144
170
  }
145
171
  case 'struct': {
@@ -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';
@@ -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
@@ -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) => {
@@ -1053,7 +1050,7 @@ function condValue(
1053
1050
  ops: Op[],
1054
1051
  read: (r: string) => Value,
1055
1052
  constVal: (n: number) => Value,
1056
- cmpDef: Map<string, { value: Value; opcode: string; lhs: Value; rhs: Value }>,
1053
+ cmpDef: Map<Value, { opcode: string; lhs: Value; rhs: Value }>,
1057
1054
  ): Value {
1058
1055
  const mk = (opc: Opcode, l: Value, r: Value): Value => {
1059
1056
  const v = mkValue(T.unk(32));
@@ -1064,15 +1061,19 @@ function condValue(
1064
1061
  return mk(COND_RR[br.mnemonic], read(br.ops[0]), read(br.ops[1]));
1065
1062
  }
1066
1063
  // *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);
1064
+ // Resolve the register to its reaching VALUE first: that is the fold's key (so a redefinition
1065
+ // between the compare and the branch misses instead of folding stale), and it is also what
1066
+ // routes the operand through `read`'s loud guards (sp / `%hi` / `gp` used as data) on every
1067
+ // path — the fold used to bypass them by never reading the register at all.
1068
+ const rsv = read(br.ops[0]);
1069
+ const folded = cmpDef.get(rsv);
1069
1070
  if (folded && br.mnemonic === 'bnez') {
1070
- return folded.value;
1071
+ return rsv;
1071
1072
  } // branch when slt is true
1072
1073
  if (folded && br.mnemonic === 'beqz') {
1073
- return mk(NEG_ICMP[folded.opcode], folded.lhs, folded.rhs);
1074
+ return mk(NEGATED_ICMP[folded.opcode], folded.lhs, folded.rhs);
1074
1075
  } // …when false
1075
- return mk(COND_Z[br.mnemonic], read(rs), constVal(0));
1076
+ return mk(COND_Z[br.mnemonic], rsv, constVal(0));
1076
1077
  }
1077
1078
 
1078
1079
  /** 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 {
@@ -27,6 +58,11 @@ export interface OpaquePolicy {
27
58
  storeClass?: RegExp;
28
59
  /** attribution for thrown declines: the function being lifted (optionally "+ site"). */
29
60
  context?: string;
61
+ /** How to SPELL the mnemonic in messages, when that differs from the name used to classify it.
62
+ * A frontend that normalises legacy spellings (Thumb `ldsh` -> `ldrsh`) classifies on the
63
+ * canonical name but must report the one the input file actually contains — otherwise a decline
64
+ * names an instruction the reader cannot find in their own .s. Defaults to `mnemonic`. */
65
+ display?: string;
30
66
  /** Mnemonics PROVABLY effect-free — or deliberately transparent (Thumb push/pop frame ops) —
31
67
  * in this ISA: the ONLY unmodelled no-destination instructions that may be skipped. Any other
32
68
  * no-destination unmodelled instruction THROWS: a side-effect-only instruction (swi, syscall,
@@ -54,13 +90,14 @@ export interface OpaqueDest {
54
90
  * reaches structuring as the sentinel `?` and trips `assertResolved` — the loud failure the
55
91
  * contract requires, instead of a stale/absent value surfacing as confidently-wrong source. */
56
92
  export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy): OpaqueDest | null {
93
+ const shown = policy.display ?? mnemonic;
57
94
  if (policy.storeClass?.test(mnemonic)) {
58
95
  // `context` names the function (and, where the ISA has addresses, the site) — this message
59
96
  // lands verbatim in annotate-mode stub headers, where an un-attributed decline is
60
97
  // unactionable in a multi-function run.
61
98
  const where = policy.context ? `cannot lift '${policy.context}': ` : '';
62
99
  throw new FrontendUnsupportedError(
63
- `${where}unmodelled store-class instruction '${mnemonic}' — a memory write cannot be skipped or degraded to a register opaque`,
100
+ `${where}unmodelled store-class instruction '${shown}' — a memory write cannot be skipped or degraded to a register opaque`,
64
101
  );
65
102
  }
66
103
  const norm = policy.normalize ?? ((s) => s);
@@ -71,7 +108,7 @@ export function opaqueDest(mnemonic: string, ops: string[], policy: OpaquePolicy
71
108
  } // explicitly transparent for this ISA
72
109
  const where = policy.context ? `cannot lift '${policy.context}': ` : '';
73
110
  throw new FrontendUnsupportedError(
74
- `${where}unmodelled effect instruction '${mnemonic}' — no register destination to degrade, and skipping it would silently delete its effect`,
111
+ `${where}unmodelled effect instruction '${shown}' — no register destination to degrade, and skipping it would silently delete its effect`,
75
112
  );
76
113
  }
77
114
  if (policy.isZero?.(dst)) {