@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
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.2.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,
@@ -47,14 +120,42 @@ export function cType(t: IrType): string {
47
120
  }
48
121
 
49
122
  /** Declare a name of a given type, C declarator rules: an array puts its length AFTER the name
50
- * (`u8 _pad[4]`), everything else is the prefix `cType name`. */
123
+ * (`u8 _pad[4]`), a pointer binds its `*` to the declarator (`void *p`), everything else is
124
+ * the prefix `cType name`. */
51
125
  function cDeclare(t: IrType, name: string): string {
52
126
  if (t.kind === 'array') {
53
127
  return `${cType(t.elem)} ${name}[${t.count}]`;
54
128
  }
129
+ if (t.kind === 'ptr') {
130
+ return `${cType(t.to)} *${name}`;
131
+ }
55
132
  return `${cType(t)} ${name}`;
56
133
  }
57
134
 
135
+ /** One field of a rendered struct declaration — the minimal input shape shared by the two
136
+ * producers of `struct N { ... };` text: the backend's recovered structs (SFn.structs, whose
137
+ * StructType fields already carry pads as real `u8[N]` members) and the cli's map-layout
138
+ * declaration synthesis (declare.ts, which seats fields at exact offsets by interleaving pad
139
+ * fields itself). `volatile` is the MMIO member idiom (`volatile u16 gain;`) — only the
140
+ * map-derived synthesis sets it today; recovered structs never do. */
141
+ export interface StructFieldDecl {
142
+ name: string;
143
+ type: IrType;
144
+ volatile?: boolean;
145
+ /** bitfield width — spells `u32 name : n;` (the map-layout synthesis is the only producer) */
146
+ bits?: number;
147
+ }
148
+
149
+ /** THE struct-declaration spelling — every `struct N { ... };` asmlift prints comes from here,
150
+ * so the backend's recovered-struct decls and the scoring layer's synthesized decls cannot
151
+ * drift apart. One line, fields in caller order (the type is self-describing: padding is the
152
+ * caller's discipline, already present as real fields). */
153
+ export function renderStructDecl(name: string, fields: StructFieldDecl[]): string {
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(' ')} };`;
157
+ }
158
+
58
159
  // A LEAF hook lets a C-family backend override how a `var` or `index` node spells WITHOUT
59
160
  // re-implementing precedence, parenthesization, or statement structure. It returns the
60
161
  // replacement text, or null to fall through to the default C spelling — how the C++ backend
@@ -79,6 +180,28 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
79
180
  derefStrideOk(exprCType(ix.base, vt), ix.width)
80
181
  ? ix.base
81
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
+ };
82
205
  switch (e.k) {
83
206
  case 'var':
84
207
  return e.name;
@@ -99,6 +222,21 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
99
222
  // binds tighter than any prefix operator, so a cast/unary/deref base is printed at prec 1
100
223
  // and parenthesizes itself: `((u8 *)p)[1]`). The postfix form needs no outer parentheses.
101
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
+ }
102
240
  if (e.idx.k === 'const' && e.idx.value === 0) {
103
241
  const s = `*${rec(base, 2)}`;
104
242
  return parentPrec < 2 ? `(${s})` : s;
@@ -117,10 +255,21 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
117
255
  // first (the C++ member-access rewrite).
118
256
  const ix = dotBase(e);
119
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
+ }
120
265
  const hooked = leaf?.(ix, rec);
121
266
  const baseTxt = hooked ?? `${rec(ix.base, 1)}[${rec(ix.idx, 99)}]`;
122
267
  return `${baseTxt}.${e.name}`;
123
268
  }
269
+ // explicit dot: a struct-VALUE global's field (`gSym.field`, symbol-map layout spelling)
270
+ if (e.dot) {
271
+ return `${rec(e.base, 1)}.${e.name}`;
272
+ }
124
273
  return `${rec(e.base, 1)}->${e.name}`;
125
274
  }
126
275
  case 'un': {
@@ -145,7 +294,9 @@ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook):
145
294
  }
146
295
  case 'bin': {
147
296
  const p = PREC[e.op];
148
- 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)}`;
149
300
  return p > parentPrec ? `(${s})` : s;
150
301
  }
151
302
  }
@@ -333,9 +484,7 @@ function cFamilyBody(fn0: SFn, leaf?: LeafHook): string[] {
333
484
  * raise/structs.ts (unaccessed leading/interior gaps) interleave them where natural C alignment
334
485
  * does not already cover the offset. This just declares each field in order. */
335
486
  function structDecls(fn: SFn): string[] {
336
- return (fn.structs ?? []).map(
337
- (s) => `struct ${s.name} { ${s.fields.map((f) => cDeclare(f.type, f.name) + ';').join(' ')} };`,
338
- );
487
+ return (fn.structs ?? []).map((s) => renderStructDecl(s.name, s.fields));
339
488
  }
340
489
 
341
490
  /** Assemble a full C-family function from a caller-supplied signature line and the shared body. */
@@ -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,23 @@ 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>(['&', '|', '^', '<<', '>>', '>>>', '*', '/', '%']);
89
+ // The comparison operators — where a bare `&SYM` operand is SIGN-ambiguous, not ill-formed.
90
+ const CMP_OPS = new Set(['<', '<=', '>', '>=', '==', '!=']);
76
91
  // 1/2/4 only: the decomp typedef vocabulary (C_TYPEDEFS) has no 64-bit scalar, so a width-8
77
92
  // access would print as the nonexistent `(s64 *)` — exactly the three-stages-later failure
78
93
  // this rule pre-empts. (If f64 loads ever land they are floats, not a scalar width here.)
@@ -99,15 +114,32 @@ export function assertDerefsTyped(sfn: SFn): void {
99
114
  }
100
115
  // A bare global ADDRESS `&SYM` under `+`/`-` is an ESCAPING interior pointer: C scales the byte
101
116
  // offset by sizeof(SYM), which is unknown for a header-typed global, so `&SYM + N` is byte-
102
- // inexact. A load/store base folds the offset byte-correctly (globalOf turns `&SYM + N` into an
103
- // `index`/`field` node whose base is a bare `addr`, never an `addr` under a `bin`), so an `addr`
104
- // reaching a `+`/`-` operand here escaped to a value contextflag it rather than emit wrong bytes.
117
+ // inexact. Nothing emits this shape anymore: a load/store base folds byte-correctly (globalOf
118
+ // turns `&SYM + N` into an `index`/`field` node whose base is a bare `addr`), and the additive
119
+ // lowering intifies every other `addr` operand to `(u32)&SYM` (structure.ts intifyAddrthe
120
+ // cast types int, so it never lands here). A bare `addr` reaching a `+`/`-` operand is therefore
121
+ // a lowering REGRESSION — flag it rather than emit wrong bytes.
105
122
  if (e.k === 'bin' && (e.op === '+' || e.op === '-')) {
106
123
  const addrSide = e.l.k === 'addr' ? e.l : e.r.k === 'addr' ? e.r : undefined;
107
124
  if (addrSide) {
108
125
  bad.push(`interior pointer arithmetic on the global address '&${addrSide.name}'`);
109
126
  }
110
127
  }
128
+ // A bare global address `&SYM` as a COMPARISON operand is the same unspelled escape under a
129
+ // different operator — and worse than ill-formed: the compare's SIGNEDNESS is spelled by the
130
+ // operand TYPES (the structurer maps icmp_ult and icmp_slt to the same '<'), and `&SYM`'s C
131
+ // type is the project's own declaration, unknowable here — so the emitted compare can flip
132
+ // signedness against the asm's, silently. The cmp lowering intifies it signedness-aware
133
+ // (`(u32)`/`(s32)&SYM` — structure.ts intifyAddrCmp; the cast types int, so it never lands
134
+ // here). A bare `addr` reaching a comparison operand is therefore a lowering REGRESSION —
135
+ // flag it rather than emit sign-ambiguous C.
136
+ if (e.k === 'bin' && CMP_OPS.has(e.op)) {
137
+ for (const side of [e.l, e.r]) {
138
+ if (side.k === 'addr') {
139
+ bad.push(`bare global address '&${side.name}' as a comparison operand`);
140
+ }
141
+ }
142
+ }
111
143
  // `!p` is legal C (pointer truthiness); `-p`/`~p` are not.
112
144
  if (e.k === 'un' && e.op !== '!' && ctype(e.e)?.kind === 'ptr') {
113
145
  bad.push(`pointer operand under unary '${e.op}'`);
package/src/declare.ts ADDED
@@ -0,0 +1,251 @@
1
+ // asmlift — declaration SYNTHESIS for self-declaring candidates
2
+ // (research/self-declaring-candidates-2026-07-26.md).
3
+ //
4
+ // A scored candidate that names map-derived symbols must compile WITHOUT the project's headers:
5
+ // this module renders the declaration block for exactly the symbols the candidate's tree
6
+ // references in a value context (Candidate.symbolRefs — derived from the candidate's final
7
+ // tree at enumeration, l3/symbol-refs.ts). It is a SCORING-LAYER
8
+ // concern only — backends never print declarations (a project user compiles asmlift output
9
+ // against their own headers, where a second declaration would collide).
10
+ //
11
+ // LIVES IN CORE (browser-pure, no Node imports) because BOTH scorers prepend it: the cli's
12
+ // Node/objdiff path (which re-exports this module unchanged) and the webapp's wasm scorer
13
+ // (score-wasm.ts) — one renderer, so the two scoring worlds cannot drift.
14
+ //
15
+ // Fidelity rules (each empirically verified against agbcc — see the research doc):
16
+ // • struct decls are rebuilt from the map layout with explicit `u8 pad[]` gap fields — the
17
+ // padded synthesis is byte-identical to the real header declaration;
18
+ // • member/scalar SIGNEDNESS drives u8/s8/u16/s16/u32/s32 — an s8 field read is
19
+ // ldrb+lsl+asr where u8 is ldrb alone, so a guessed signedness is a wrong-bytes decl;
20
+ // • `volatile` is load-bearing (a non-volatile MMIO decl lets the compiler fold/reorder
21
+ // accesses), `const` is the ROM-table spelling;
22
+ // • code symbols get `void Name(void);` ONLY when value-referenced — call targets are never
23
+ // in `symbolRefs` (core excludes them: prototyping a called symbol is C89 poison);
24
+ // • nothing guesses, with TWO documented exceptions: a SHAPED symbol without the facts to
25
+ // declare faithfully is SKIPPED — the candidate then fails to compile LOUDLY and is
26
+ // dropped by rankBy. Exception one is the 4-byte signless-non-pointer cell (see
27
+ // ENUM_IS_SIGNED): spelled s32 on the C89 enum=int rule. For a true enum that is the header
28
+ // truth; the residual mis-spell class (a 4-byte nested-struct member word-read via a dot
29
+ // field) can only LOSE score — the target bytes derive from the truth decls, so a
30
+ // divergent compile can never false-match. Exception two is the NAME-ONLY data symbol
31
+ // (`extern u32 name;` — see the default case): required to reproduce symtab-only map
32
+ // rows outside project headers, justified by the same only-loses-score argument.
33
+ import { type StructFieldDecl, renderStructDecl } from './backend/cfamily';
34
+ import { T } from './ir/types';
35
+ import type { SymbolRef } from './l3/symbol-refs';
36
+ import {
37
+ ENUM_IS_SIGNED,
38
+ type SymbolInfo,
39
+ type SymbolStructField,
40
+ arrayInnerExtents,
41
+ declaredFields,
42
+ pointeeFields,
43
+ symbolFieldType,
44
+ } from './symbols';
45
+
46
+ /** The u8/s8/u16/s16/u32/s32 spelling for a 1/2/4-byte cell, or null (no faithful narrow type). */
47
+ function intType(size: number, signed: boolean): string | null {
48
+ const base = size === 1 ? '8' : size === 2 ? '16' : size === 4 ? '32' : null;
49
+ return base === null ? null : `${signed ? 's' : 'u'}${base}`;
50
+ }
51
+
52
+ /** `volatile const ` qualifier prefix (either may be absent). */
53
+ function quals(info: SymbolInfo): string {
54
+ return `${info.volatile ? 'volatile ' : ''}${info.const ? 'const ' : ''}`;
55
+ }
56
+
57
+ /** One struct field's type, seated at its exact offset by the caller's pad discipline — THE shared
58
+ * map-field typing (symbols.ts `symbolFieldType`, which core's own legalization env also reads,
59
+ * so the declaration and the type the emitter reasoned against cannot drift). Member volatility
60
+ * is kept on the field decl here (`vu16 field;` — dropping it lets the compiler fold repeated
61
+ * reads), being a decl-only fact. */
62
+ const fieldType = symbolFieldType;
63
+
64
+ /** The padded `struct Tag { ... };` declaration for a layout: fields seated at exact offsets,
65
+ * gaps as explicit u8 pad arrays, rendered by THE shared struct renderer (core
66
+ * backend/cfamily.ts renderStructDecl — the same spelling the backend's recovered-struct
67
+ * decls use, so the two cannot drift). Returns null when the layout cannot be reproduced
68
+ * faithfully (an unsized member). */
69
+ function structDecl(tag: string, layout: SymbolStructField[] | undefined, size: number | undefined): string | null {
70
+ // THE shared spellability predicate (symbols.ts): which members exist, and whether the layout
71
+ // can be reproduced at all. Core's access rules gate on the SAME call, so a member this
72
+ // declaration omits — an unsizable layout declined whole, a union alias dropped for its first
73
+ // view — is a member no emitted expression can name.
74
+ const members = declaredFields(layout);
75
+ if (members === null) {
76
+ return null;
77
+ }
78
+ const fields: StructFieldDecl[] = [];
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;
85
+ let pad = 0;
86
+ const padTo = (lo: number): void => {
87
+ while (bitCursor < lo) {
88
+ // asmlift_-prefixed so a REAL member named pad_N (a decomp-header idiom) never collides
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
+ }
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);
104
+ fields.push({
105
+ name: m.name,
106
+ type: fieldType(m),
107
+ ...(m.volatile ? { volatile: true } : {}),
108
+ ...(bits ? { bits: m.bitWidth } : {}),
109
+ });
110
+ bitCursor = bits ? lo + m.bitWidth! : (m.offset + m.size) * 8;
111
+ }
112
+ if (size !== undefined) {
113
+ padTo(size * 8); // tail padding to the declared size
114
+ }
115
+ return renderStructDecl(tag, fields);
116
+ }
117
+
118
+ /**
119
+ * Render the declaration block for a candidate's recorded symbol references. Deterministic
120
+ * (refs arrive name-sorted from core; struct decls dedupe by tag). The block is prepended by
121
+ * the candidate compiler AFTER the typedef prelude — it spells types as u8/s16/… — and only in
122
+ * the self-declared world (the probe in compile-command.ts arbitrates; in the headers world
123
+ * both prelude and declarations are dropped, headers own everything).
124
+ */
125
+ export function renderDeclarations(refs: SymbolRef[]): string {
126
+ const lines: string[] = [];
127
+ const declaredTags = new Set<string>();
128
+ for (const { name, info, access } of refs) {
129
+ // An address-cast macro declares itself: the header's own body, verbatim. It must NOT become
130
+ // an `extern` — that is the whole point of the fact (an extern emits a relocated pool word
131
+ // where the macro emits the numeric one the target shows).
132
+ if (info.macroBody !== undefined) {
133
+ lines.push(`#define ${name} ${info.macroBody}`);
134
+ continue;
135
+ }
136
+ if (info.kind === 'code') {
137
+ // value-referenced code symbol ((u32)Func): any prototype makes the name visible, and
138
+ // the address is arity-independent. Call targets never reach this module (core excludes
139
+ // them from symbolRefs — see collectSymbolRefs).
140
+ lines.push(`void ${name}(void);`);
141
+ continue;
142
+ }
143
+ switch (info.shape) {
144
+ case 'scalar': {
145
+ // Signedness default: absent + 4 bytes is the enum idiom (int ⇒ s32); absent + narrow
146
+ // has no honest spelling — skip (loud, see module note).
147
+ const t =
148
+ info.size !== undefined
149
+ ? intType(info.size, info.signed ?? (info.size === 4 ? ENUM_IS_SIGNED : false))
150
+ : null;
151
+ if (t !== null && (info.signed !== undefined || info.size === 4)) {
152
+ lines.push(`extern ${quals(info)}${t} ${name};`);
153
+ }
154
+ break;
155
+ }
156
+ case 'array': {
157
+ // Element type mirrors core's bare `gSym[i]` env typing exactly (elemSigned ?? false).
158
+ // A non-1/2/4 element width is never bare-indexed by core (only &gSym cast forms), so
159
+ // an unsized u8[] decl is codegen-identical for every spelling core emits.
160
+ const elem = info.elemSize !== undefined ? intType(info.elemSize, info.elemSigned ?? false) : null;
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};`);
169
+ break;
170
+ }
171
+ case 'struct': {
172
+ // With a layout: the padded struct decl + a typed extern (the `gSym.field` spelling
173
+ // compiles against it). Without one, every core spelling is &gSym-based (a struct
174
+ // global never spells bare), so an unsized u8[] extern is codegen-identical.
175
+ const tag = info.structName ?? `Asmlift_${name}`;
176
+ const decl = structDecl(tag, info.layout, info.size);
177
+ if (decl !== null) {
178
+ if (!declaredTags.has(tag)) {
179
+ declaredTags.add(tag);
180
+ lines.push(decl);
181
+ }
182
+ lines.push(`extern ${quals(info)}struct ${tag} ${name};`);
183
+ } else {
184
+ lines.push(`extern ${quals(info)}u8 ${name}[];`);
185
+ }
186
+ break;
187
+ }
188
+ case 'pointer': {
189
+ // With a POINTEE layout the emitter may spell an interior as `gPtr->member`, which only
190
+ // compiles against a pointer to that struct — so the pointee is declared here (the same
191
+ // padded synthesis a struct global gets) and the extern is typed. The declared pointee
192
+ // never changes bytes: the cell is 4 bytes whatever it addresses, and core's own lowering
193
+ // makes every arithmetic stride EXPLICIT (`(u8 *)gPtr + K` / `(u32)gPtr`), so no emitted
194
+ // expression is scaled by this type.
195
+ // Without one, pointee fidelity is unnecessary — load/store/compare of the cell are
196
+ // identical for any object-pointer type, and the output then never derefs through the
197
+ // decl's pointee.
198
+ // THE shared gate (symbols.ts pointeeFields): the typed extern is emitted on exactly
199
+ // the condition under which core may spell `gPtr->member`, so the two cannot disagree.
200
+ const tag = info.pointee?.structName;
201
+ const decl =
202
+ pointeeFields(info.pointee) !== null ? structDecl(tag!, info.pointee!.layout, info.pointee!.size) : null;
203
+ if (decl !== null && !declaredTags.has(tag!)) {
204
+ declaredTags.add(tag!);
205
+ lines.push(decl);
206
+ }
207
+ // The POINTEE's own qualifiers bind to the pointed-at type (`volatile struct S *g`); the
208
+ // cell's bind to the VARIABLE (`struct S *volatile g`). They are independent declarations
209
+ // of two different objects, and the synthesis reproduces each on its own side of the `*`.
210
+ const pointeeQuals = `${info.pointee?.volatile ? 'volatile ' : ''}${info.pointee?.const ? 'const ' : ''}`;
211
+ const pointeeType = decl !== null ? `${pointeeQuals}struct ${tag} *` : `${pointeeQuals}void *`;
212
+ lines.push(`extern ${pointeeType}${info.volatile ? 'volatile ' : ''}${info.const ? 'const ' : ''}${name};`);
213
+ break;
214
+ }
215
+ default: {
216
+ // Name-only (no sidecar shape) — the second documented exception (see the module note;
217
+ // the first is the 4-byte signless enum cell). Skipping here was the original rule, but
218
+ // it made every named-spelling row of a symtab-only map project (marioparty3: names
219
+ // with no DWARF shapes) unreproducible in the self-declared world — the benchmark
220
+ // compiled those candidates inside the project headers, which declare the symbol.
221
+ // The width authority is the candidate's OWN IR (ref.access, rank.ts
222
+ // bareGlobalAccessFacts): a bare `name = v` / `x = name` compiles to the access the
223
+ // tree performed only under a decl of that exact width (`extern u16 g;` is `sh` where
224
+ // a guessed u32 is `sw`). Without a bare off-0 access fact, every core spelling goes
225
+ // through `&name` casts, where any object decl is address-identical — u32 is the
226
+ // fallback cell. A divergent decl can only LOSE score — the target bytes derive from
227
+ // the truth decls, so a mis-declared compile can never false-match (same argument as
228
+ // enumIsSigned).
229
+ const t = access ? intType(access.width, access.signed) : null;
230
+ lines.push(`extern ${quals(info)}${t ?? 'u32'} ${name};`);
231
+ break;
232
+ }
233
+ }
234
+ }
235
+ return lines.length ? lines.join('\n') + '\n' : '';
236
+ }
237
+
238
+ /** The object-like `#define`s out of a rendered declaration block.
239
+ *
240
+ * Address-cast macro defines are the one part of a synthesized block that must survive into the
241
+ * HEADERS world too. Everything else there is owned by the injected headers (a duplicate typedef
242
+ * or struct definition is a C89 hard error), but a duplicate `#define` with an identical body is
243
+ * legal — and a PREPROCESSED project context has no macros left at all, so dropping these turns a
244
+ * macro-named candidate into an `undeclared identifier` rather than a spelling choice. */
245
+ export function macroDefinesOf(declarations: string | undefined): string {
246
+ if (!declarations) {
247
+ return '';
248
+ }
249
+ const lines = declarations.split('\n').filter((l) => l.startsWith('#define '));
250
+ return lines.length ? lines.join('\n') + '\n' : '';
251
+ }