@asmlift/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/package.json +14 -0
  4. package/src/backend/c.ts +20 -0
  5. package/src/backend/cfamily.ts +352 -0
  6. package/src/backend/cpp.ts +145 -0
  7. package/src/backend/pascal.ts +279 -0
  8. package/src/contracts.ts +131 -0
  9. package/src/detect.ts +12 -0
  10. package/src/frontend/asmdata.ts +170 -0
  11. package/src/frontend/disasm.ts +102 -0
  12. package/src/frontend/emit.ts +57 -0
  13. package/src/frontend/errors.ts +14 -0
  14. package/src/frontend/format.ts +47 -0
  15. package/src/frontend/frontend.ts +22 -0
  16. package/src/frontend/mips.ts +875 -0
  17. package/src/frontend/opaque.ts +82 -0
  18. package/src/frontend/ppc.ts +990 -0
  19. package/src/frontend/registry.ts +34 -0
  20. package/src/frontend/ssa.ts +214 -0
  21. package/src/frontend/thumb.ts +1419 -0
  22. package/src/ir/core.ts +104 -0
  23. package/src/ir/opcodes.ts +143 -0
  24. package/src/ir/parse.ts +221 -0
  25. package/src/ir/print.ts +77 -0
  26. package/src/ir/types.ts +106 -0
  27. package/src/ir/verify.ts +221 -0
  28. package/src/l3/ast.ts +301 -0
  29. package/src/l3/basecse.ts +218 -0
  30. package/src/l3/dce.ts +256 -0
  31. package/src/l3/regspell.ts +331 -0
  32. package/src/l3/reindex.ts +447 -0
  33. package/src/l3/typing.ts +145 -0
  34. package/src/mangle.ts +135 -0
  35. package/src/pattern/engine.ts +392 -0
  36. package/src/pipeline.ts +272 -0
  37. package/src/proto.ts +42 -0
  38. package/src/raise/arrays.ts +84 -0
  39. package/src/raise/const.ts +52 -0
  40. package/src/raise/errors.ts +10 -0
  41. package/src/raise/magicdiv.ts +386 -0
  42. package/src/raise/pre-recovery.ts +71 -0
  43. package/src/raise/recover.ts +215 -0
  44. package/src/raise/retsink.ts +72 -0
  45. package/src/raise/shortcircuit.ts +207 -0
  46. package/src/raise/softdiv.ts +62 -0
  47. package/src/raise/struct-arrays.ts +257 -0
  48. package/src/raise/structs.ts +223 -0
  49. package/src/rank.ts +208 -0
  50. package/src/structure/analysis.ts +410 -0
  51. package/src/structure/hazards.ts +142 -0
  52. package/src/structure/loops.ts +169 -0
  53. package/src/structure/structure.ts +1726 -0
  54. package/src/structure/switch-recover.ts +410 -0
  55. package/src/target.ts +140 -0
  56. package/src/trace.ts +233 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Macabeus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @asmlift/core
2
+
3
+ The asmlift decompile pipeline as a pure library: one function's **assembly text in, C / C++ /
4
+ Pascal source out**, built to recompile **byte-identical** to the original object — the
5
+ generator role m2c plays in the console-decompilation workflow. Three ISA frontends
6
+ (ARMv4T/Thumb, MIPS, PowerPC), three language backends over one neutral AST; C and Pascal are
7
+ drop-in backends, C++ is a deliberately scoped per-function factory (see the `backend` option).
8
+
9
+ The package is **browser-pure by enforced contract**: zero dependencies, no Node or DOM APIs —
10
+ it bundles unchanged into the playground webapp (`apps/web` in the repo). The contract is gated
11
+ twice: `test/browser-safe.test.ts` (import scanning) and a dedicated `tsc -p packages/core`
12
+ project with `types: []`.
13
+
14
+ The operative invariant everywhere: **loud decline > silent miscompile**. Where the pipeline
15
+ cannot be byte-faithful it throws a typed error (strict mode) or emits an `ASMLIFT_ERROR`-marked
16
+ stub (`onGap: "annotate"`) — never plausible wrong code.
17
+
18
+ > Not yet published to npm. Inside this repo it resolves via the pnpm workspace.
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { decompile } from '@asmlift/core/pipeline';
24
+ import { MIPS_IDO } from '@asmlift/core/target';
25
+
26
+ const asm = `...output of: mips-linux-gnu-objdump -d --no-show-raw-insn fn.o ...`;
27
+ const { source, diagnostics } = decompile('my_func', asm, MIPS_IDO);
28
+ console.log(source); // s32 my_func(s32 a0) { ... }
29
+ ```
30
+
31
+ Input is **text**: GBA `.s` assembly for the ARM target — both agbcc compiler output and
32
+ pret-style project splits (`asm/nonmatchings/`: `thumb_func_start` macros, `_08xxxxxx` labels,
33
+ `.4byte` literal pools) — and `objdump -d --no-show-raw-insn`
34
+ output for MIPS/PPC — the format follows what each target's toolchain actually produces. The
35
+ frontends classify the input first (`frontend/format.ts`): text that positively matches the
36
+ _other_ format declines at the boundary naming both, instead of failing confusingly mid-decode.
37
+ Multi-function input is sliced to the named symbol; an absent symbol declines with the list of
38
+ symbols present. (`@asmlift/cli` additionally accepts ELF **object files** and runs the right
39
+ objdump for you.)
40
+
41
+ ### `decompile(name, asm, target, opts?)`
42
+
43
+ | Option | Meaning |
44
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `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 |
46
+ | `patterns` | Idiom rewrite patterns. Omitted = `DEFAULT_IDIOM_PATTERNS` (each gated per compiler); `[]` = none |
47
+ | `prototypes` | Callee arities + void-ness, as a real project takes them from headers — drives call-argument recovery |
48
+ | `asmData` | Optional `objdump -s -r -t` side-table; required to recover MIPS/PPC jump-table switches |
49
+ | `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) |
50
+
51
+ Targets: `ARMV4T_AGBCC`, `MIPS_IDO`, `MIPS_GCC`, `PPC_MWCC` (`@asmlift/core/target`).
52
+
53
+ ### Other entry points
54
+
55
+ - `decompileTraced` (`@asmlift/core/trace`) — same tower, returns a `TraceReport`: per-stage IR
56
+ dumps + pattern before/after events. This is what the playground's Pipeline tab renders.
57
+ - `detectName` (`@asmlift/core/detect`) — best-effort symbol detection for pasted asm.
58
+ - Typed decline errors: `FrontendUnsupportedError`, `RaiseUnsupportedError`, `StructureError`,
59
+ `ContractError`, `VerifyError` — a principled decline is distinguishable from a bug.
60
+
61
+ Everything under `src/` is importable as `@asmlift/core/<path>` (e.g.
62
+ `@asmlift/core/pattern/engine`); `@asmlift/core` alone resolves to the pipeline.
63
+
64
+ ## Architecture
65
+
66
+ Three ISA frontends (ARMv4T/Thumb, MIPS, PowerPC), four compilers (agbcc, IDO, KMC GCC,
67
+ CodeWarrior), three language backends over one neutral AST — all scored across the package seam
68
+ by [`@asmlift/cli`](../cli/README.md) with the community `objdiff` engine (in-process, pinned
69
+ `objdiff-wasm`; asmlift never hand-rolls a diff).
70
+
71
+ ### The pipeline (`decompile()` in `pipeline.ts`)
72
+
73
+ ```
74
+ asm ─▶ lift ─▶ idiom fold ─▶ pre-recovery ─▶ type recovery ─▶ retsink ─▶ structure ─▶ emit
75
+ (L1) (patterns) (recognizers) (L2) (L3) (C/C++/Pascal)
76
+ └────▶ ranked candidates ─▶ objdiff ─▶ score (@asmlift/cli rank.ts)
77
+ ```
78
+
79
+ The stage sequence is ONE shared spine (`applyIdiomPatterns` / `raiseRecovered` /
80
+ `structureChecked`, exported by `pipeline.ts`) that `decompile()`, `decompileTraced` (trace.ts),
81
+ and @asmlift/cli's `decompileRanked`/`decompileWithReport` all run — per-caller differences are
82
+ injected via hooks, never copied. `verify()` runs after every IR-mutating pass;
83
+ `assertTypesRecovered` / `assertResolved` (contracts.ts) gate the L2/L3 boundaries.
84
+
85
+ ### Modules
86
+
87
+ | Module | What it is |
88
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | `ir/{types,core,opcodes,print,parse,verify}.ts` | MLIR-lite substrate: CFG of blocks + typed **block-arguments**, the typed opcode registry (`Opcode`, the one `effects` table DCE and hoist guards derive from), printer + parser (round-trip for L1/scalar types — see the domain note in parse.ts), verifier (arity/attrs/terminators/SSA dominance, located errors) |
90
+ | `frontend/{thumb,mips,ppc}.ts` | ISA frontends: decode → CFG → L1 with **Braun-2013 block-arg SSA** (`ssa.ts`), incl. loops, calls (signature-driven arity), memory, jump tables. Shared scaffolding: `disasm.ts` (objdump parsing), `emit.ts` (per-block emitter kit + `switch_br`), `opaque.ts` (the unmodelled-op → loud-`opaque` contract), `errors.ts` (`FrontendUnsupportedError`; PPC's subclass), `registry.ts`, `asmdata.ts` (Regime-B jump-table side-table) |
91
+ | `pattern/engine.ts` | Idiom layer: **rewrite patterns as data** + greedy driver + DCE; `patternApplies` gates on Target capabilities |
92
+ | `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`) |
93
+ | `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) |
94
+ | `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) |
95
+ | `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) |
96
+ | `pipeline.ts` | `decompile()` + the shared tower spine + annotate-mode stubs/diagnostics |
97
+ | `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 |
98
+ | `rank.ts` | Pure candidate enumeration + `rankBy` (an injected score function ranks). @asmlift/cli's differ ranks through `rankBy`; the playground's wasm scorer consumes the same enumeration with its own async loop |
99
+ | `target.ts` | `TargetDescription` (ABI + capabilities + compilerBehaviors as data — no `arch ==` in shared code); toolchain paths live in `@asmlift/toolchains` |
100
+ | `contracts.ts`, `proto.ts`, `mangle.ts` | Boundary contracts; prototype tables; the CodeWarrior mangler |
101
+
102
+ Scoring and ranking live across the package seam in [`@asmlift/cli`](../cli/README.md):
103
+ `score.ts` + `objdiff.ts` (toolchain compiles → in-process pinned `objdiff-wasm`, fail-closed)
104
+ and `rank.ts` (ranked type candidates re-ranked by the differ).
105
+
106
+ ### Honest coverage gaps
107
+
108
+ Recovered today: straight-line, if/else diamonds, natural loops (`while` / `do-while` / `for`,
109
+ properly nested, in-body `break`/early-`return`), comparison-tree and jump-table switches,
110
+ direct calls, constant-offset and variable-index memory (`*p`, `p[n]`, `a[i]`, struct fields),
111
+ magic-number and soft division, short-circuit booleans, width casts. Still DECLINED (loud, never
112
+ wrong code): **local stack frames** (address-taken locals / sp-as-data / live spills),
113
+ **cross-block condition flags** on PPC (a `cmpw` whose branch lands in another block — the
114
+ capability gap behind the mwcc switch stubs), computed tail calls, PIC/`gp`/SDA global access,
115
+ switch fall-through, multi-latch/irreducible loops, floats, and 64-bit memory ops. Prototypes
116
+ (callee arities, void-ness) come from a caller-supplied map, as a real project takes them from
117
+ headers.
118
+
119
+ ## Tests
120
+
121
+ The toolchain-free half of the test story lives in `test/`: every suite there runs with no
122
+ compiler installed, on any machine and in hosted CI. The toolchain-bound matching suites live in
123
+ [`@asmlift/cli`](../cli/CONTRIBUTION.md#tests) (Docker-gated suites skip WITH a warning — see
124
+ `../cli/test/matching/docker-gate.ts`). The CI gate is `pnpm run test:offline`, whose directory
125
+ list in the root package.json is SELF-VERIFYING — `offline-list.test.ts` derives the offline set from each
126
+ suite's imports and fails on drift.
127
+
128
+ Landmarks (not exhaustive — suites are named for what they pin):
129
+
130
+ - `roundtrip` / `verify` / `determinism` / `pattern` — the IR substrate.
131
+ - `m1`–`m5` (in `../cli/test/matching/`) — one milestone thesis each.
132
+ - `../cli/test/matching/regression.test.ts` — data-driven over `matching/fixtures.ts`; the guard
133
+ that keeps every already-matching function matching. How to add a fixture — and when a
134
+ matching test is the right tool at all, vs a benchmark row — is under
135
+ [`@asmlift/cli` › Tests](../cli/CONTRIBUTION.md#tests).
136
+ - `contract-invariant` / `contracts` — the loud-fail contract, mutation-proven.
137
+ - `structure-guard` / `structure-soundness` / `audit-regression` — the adversarial-audit repro
138
+ locks.
139
+
140
+ **`test/corpus/` is load-bearing beyond this suite.** The committed disassembly fixtures in
141
+ `test/corpus/` are ALSO imported (via Vite `?raw`) by the playground's example gallery —
142
+ `apps/web/src/pages/playground/examples.ts`. Renaming or pruning a corpus file breaks the `apps/web` build
143
+ (CI-gated on every push), so treat these files as a public fixture surface, not suite-private
144
+ scratch.
145
+
146
+ ## More
147
+
148
+ - the root [`README.md`](../../README.md) — monorepo layout, benchmark, webapps.
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@asmlift/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Match decompile an assembly function to C or Pascal",
7
+ "exports": {
8
+ ".": "./src/pipeline.ts",
9
+ "./*": "./src/*.ts"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ]
14
+ }
@@ -0,0 +1,20 @@
1
+ // asmlift — the C language backend. Consumes the language-NEUTRAL L3 AST and owns ALL C spelling.
2
+ // Text is produced ONLY by a precedence-aware printer over the typed AST — never by string-
3
+ // concatenating over IR ops.
4
+ //
5
+ // Expression/statement/type spelling lives in backend/cfamily.ts, shared with the C++ backend;
6
+ // this backend owns only the C SIGNATURE line. The Pascal backend implements the same
7
+ // LanguageBackend interface over the same L3 with its OWN spelling.
8
+ import { LanguageBackend, SFn } from '../l3/ast';
9
+ import { cComment, cType, emitCFamily } from './cfamily';
10
+
11
+ export { cComment }; // re-export: the shared spelling lives in cfamily.ts
12
+
13
+ export const cBackend: LanguageBackend = {
14
+ id: 'c',
15
+ emit(fn: SFn): string {
16
+ const params = fn.params.map((p) => `${cType(p.type)} ${p.name}`).join(', ') || 'void';
17
+ return emitCFamily(`${cType(fn.retType)} ${fn.name}(${params})`, fn);
18
+ },
19
+ comment: cComment,
20
+ };
@@ -0,0 +1,352 @@
1
+ // asmlift — the SHARED C-family spelling. The L3 AST is language-neutral for the C-family:
2
+ // expression/statement nodes are neutral, and TYPE SPELLING is a backend decision (`cType`
3
+ // here, `pasType` in the Pascal backend). The empirical fact grounding the sharing: a
4
+ // CodeWarrior member function's BODY is byte-identical to the same C with `this` as an
5
+ // explicit pointer — so the C++ backend reuses this body spelling VERBATIM and owns only its
6
+ // DIVERGENT surface (the mangled/scoped signature, references, `this`). c.ts and cpp.ts
7
+ // consume exactly the exported seam: `emitCFamily` + `cType` + `LeafHook`.
8
+ import { IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
9
+ import { BinOp, Expr, SFn, Stmt, dotBase } from '../l3/ast';
10
+ import { type VarTypes, declaredTypes, derefStrideOk, exprCType } from '../l3/typing';
11
+
12
+ // C operator precedence (lower binds tighter). Used to emit MINIMAL parentheses. Shared: C++ has
13
+ // the same precedence for these operators.
14
+ const PREC: Record<BinOp, number> = {
15
+ '*': 3,
16
+ '/': 3,
17
+ '%': 3,
18
+ '+': 4,
19
+ '-': 4,
20
+ '<<': 5,
21
+ '>>': 5,
22
+ '<': 6,
23
+ '<=': 6,
24
+ '>': 6,
25
+ '>=': 6,
26
+ '==': 7,
27
+ '!=': 7,
28
+ '&': 8,
29
+ '^': 9,
30
+ '|': 10,
31
+ '&&': 11,
32
+ '||': 12,
33
+ };
34
+
35
+ /** Spell a recovered type in the decomp C-family typedef vocabulary (`s32`/`u32`/`u8`/`T *`). */
36
+ export function cType(t: IrType): string {
37
+ if (t.kind === 'ptr') {
38
+ return `${cType(t.to)} *`;
39
+ }
40
+ if (t.kind === 'struct') {
41
+ return `struct ${t.name}`;
42
+ }
43
+ if (t.kind === 'array') {
44
+ return `${cType(t.elem)}[${t.count}]`;
45
+ } // ill-formed as a prefix; use cDeclare
46
+ return typeToString(t); // s32 / u32 / u8 / unk32 (treated as s32 upstream)
47
+ }
48
+
49
+ /** 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`. */
51
+ function cDeclare(t: IrType, name: string): string {
52
+ if (t.kind === 'array') {
53
+ return `${cType(t.elem)} ${name}[${t.count}]`;
54
+ }
55
+ return `${cType(t)} ${name}`;
56
+ }
57
+
58
+ // A LEAF hook lets a C-family backend override how a `var` or `index` node spells WITHOUT
59
+ // re-implementing precedence, parenthesization, or statement structure. It returns the
60
+ // replacement text, or null to fall through to the default C spelling — how the C++ backend
61
+ // renders member access (`this->x` as bare `x`, `o->x`) over the exact same printer the C
62
+ // backend uses. The default (no hook) is byte-identical C.
63
+ export type LeafHook = (e: Expr, rec: (e: Expr, p: number) => string) => string | null;
64
+
65
+ function printExpr(e: Expr, parentPrec: number, vt: VarTypes, leaf?: LeafHook): string {
66
+ const rec = (x: Expr, p: number) => printExpr(x, p, vt, leaf);
67
+ if (leaf) {
68
+ const s = leaf(e, rec);
69
+ if (s !== null) {
70
+ return s;
71
+ }
72
+ }
73
+ // C-FAMILY LEGALIZATION (owned here, per the width-carrying `index` node contract in l3/ast.ts):
74
+ // a deref whose base does not render as a pointer/array STRIDING the access width is spelled
75
+ // through the honest reinterpret cast at that width — the machine semantics of the access.
76
+ // Materialized as a synthetic cast node so the spelling (text, precedence, parens) is exactly
77
+ // that of a tree-level cast.
78
+ const legalized = (ix: Extract<Expr, { k: 'index' }>): Expr =>
79
+ derefStrideOk(exprCType(ix.base, vt), ix.width)
80
+ ? ix.base
81
+ : { k: 'cast', to: T.ptr(scalarTypeForAccess(ix.width, ix.signed)), e: ix.base };
82
+ switch (e.k) {
83
+ case 'var':
84
+ return e.name;
85
+ case 'const':
86
+ return String(e.value);
87
+ case 'addr': {
88
+ // `&gSym` — the address of a named global. A prefix operator; parenthesizes under a POSTFIX
89
+ // parent like the other prefix forms.
90
+ const g = `&${e.name}`;
91
+ return parentPrec < 2 ? `(${g})` : g;
92
+ }
93
+ case 'call':
94
+ return `${e.fn}(${e.args.map((a) => rec(a, 99)).join(', ')})`;
95
+ case 'index': {
96
+ // `*base` for the zero offset (a PREFIX operator, so a prefix-shaped base like a cast needs
97
+ // no parens: `*(u8 *)p` — but the whole form must self-parenthesize under a POSTFIX parent:
98
+ // `(*p)[1]`, never `*p[1]` which C groups as `*(p[1])`), `base[idx]` otherwise (POSTFIX —
99
+ // binds tighter than any prefix operator, so a cast/unary/deref base is printed at prec 1
100
+ // and parenthesizes itself: `((u8 *)p)[1]`). The postfix form needs no outer parentheses.
101
+ const base = legalized(e);
102
+ if (e.idx.k === 'const' && e.idx.value === 0) {
103
+ const s = `*${rec(base, 2)}`;
104
+ return parentPrec < 2 ? `(${s})` : s;
105
+ }
106
+ return `${rec(base, 1)}[${rec(e.idx, 99)}]`;
107
+ }
108
+ case 'field': {
109
+ // `base->name`, or `base.name` when the base is itself an array element (a struct VALUE, not
110
+ // a pointer) — an array-of-struct access `arr[i].field` (fieldSpellsDot, the shared rule).
111
+ // Postfix (base at prec 1, exactly like `[]` above), no outer parens.
112
+ //
113
+ // The dot form's base index node is printed WITHOUT legalization: a struct-array element's
114
+ // base is legalized at the TREE level (arrayAccess casts to the recovered struct pointer;
115
+ // carrying the struct identity ON the node — like width — is the named follow-up), and the
116
+ // width-cast legalization above must not double-wrap it. The leaf hook still sees the base
117
+ // first (the C++ member-access rewrite).
118
+ const ix = dotBase(e);
119
+ if (ix) {
120
+ const hooked = leaf?.(ix, rec);
121
+ const baseTxt = hooked ?? `${rec(ix.base, 1)}[${rec(ix.idx, 99)}]`;
122
+ return `${baseTxt}.${e.name}`;
123
+ }
124
+ return `${rec(e.base, 1)}->${e.name}`;
125
+ }
126
+ case 'un': {
127
+ // A prefix operator: parenthesize under a POSTFIX parent (`(-a)[1]`), and parenthesize a
128
+ // same-op nested `-` (`-(-a)`, never `--a` — C lexes that as predecrement).
129
+ const inner = rec(e.e, 2);
130
+ const s = `${e.op}${e.op === '-' && inner.startsWith('-') ? `(${inner})` : inner}`;
131
+ return parentPrec < 2 ? `(${s})` : s;
132
+ }
133
+ // A gap marker spells as a call to the UNDEFINED macro ASMLIFT_ERROR("reason", args…) — the
134
+ // m2c M2C_ERROR discipline: the function is complete and readable, but a compile fails until
135
+ // the user consciously defines the macro. Postfix/call-shaped, so no outer parens needed.
136
+ case 'marker':
137
+ return `ASMLIFT_ERROR(${[JSON.stringify(e.reason), ...e.args.map((a) => rec(a, 99))].join(', ')})`;
138
+ // A C cast binds as a prefix operator (like unary), tighter than any binary op: the operand is
139
+ // printed at prec 2, so `(u8)a & 1` needs no parens but `(u8)(a & 1)` gets them from the inner
140
+ // op. Under a POSTFIX parent ([]/->) the cast itself must parenthesize — `((struct S *)p)->f`,
141
+ // NOT `(struct S *)p->f` (which C parses as a cast OF the member access).
142
+ case 'cast': {
143
+ const s = `(${cType(e.to)})${rec(e.e, 2)}`;
144
+ return parentPrec < 2 ? `(${s})` : s;
145
+ }
146
+ case 'bin': {
147
+ const p = PREC[e.op];
148
+ const s = `${rec(e.l, p)} ${e.op} ${rec(e.r, p - 1)}`;
149
+ return p > parentPrec ? `(${s})` : s;
150
+ }
151
+ }
152
+ }
153
+
154
+ function printStmt(s: Stmt, indent: string, vt: VarTypes, leaf?: LeafHook): string[] {
155
+ const pe = (e: Expr, p: number) => printExpr(e, p, vt, leaf);
156
+ switch (s.k) {
157
+ case 'assign':
158
+ return [`${indent}${s.name} = ${pe(s.value, 99)};`];
159
+ case 'store':
160
+ // The lvalue is a full Expr (`index` or `field`), so the leaf hook spells a member write
161
+ // (`this->x = …`) exactly as it spells a member read.
162
+ return [`${indent}${pe(s.lval, 2)} = ${pe(s.value, 99)};`];
163
+ case 'exprstmt':
164
+ return [`${indent}${pe(s.value, 99)};`];
165
+ case 'return':
166
+ return [`${indent}return${s.value ? ' ' + pe(s.value, 99) : ''};`];
167
+ case 'if': {
168
+ const cond = pe(s.cond, 99);
169
+ if (s.then.length === 1 && s.else.length === 0 && s.then[0].k !== 'if') {
170
+ // Inline `if (c) stmt;` ONLY when the statement prints as a single line — a multi-line
171
+ // then (a do-while/while/for/switch) taking just `[0]` here silently truncated the body.
172
+ const inner = printStmt(s.then[0], '', vt, leaf);
173
+ if (inner.length === 1) {
174
+ return [`${indent}if (${cond}) ${inner[0].trim()}`];
175
+ }
176
+ }
177
+ const out = [`${indent}if (${cond}) {`];
178
+ for (const t of s.then) {
179
+ out.push(...printStmt(t, indent + ' ', vt, leaf));
180
+ }
181
+ if (s.else.length) {
182
+ out.push(`${indent}} else {`);
183
+ for (const e of s.else) {
184
+ out.push(...printStmt(e, indent + ' ', vt, leaf));
185
+ }
186
+ }
187
+ out.push(`${indent}}`);
188
+ return out;
189
+ }
190
+ case 'while': {
191
+ const out = [`${indent}while (${pe(s.cond, 99)}) {`];
192
+ for (const t of s.body) {
193
+ out.push(...printStmt(t, indent + ' ', vt, leaf));
194
+ }
195
+ out.push(`${indent}}`);
196
+ return out;
197
+ }
198
+ case 'dowhile': {
199
+ const out = [`${indent}do {`];
200
+ for (const t of s.body) {
201
+ out.push(...printStmt(t, indent + ' ', vt, leaf));
202
+ }
203
+ out.push(`${indent}} while (${pe(s.cond, 99)});`);
204
+ return out;
205
+ }
206
+ case 'for': {
207
+ // `for (init; cond; inc) { body }`. PRECONDITION (guaranteed by the sole producer, structure.ts
208
+ // `recognizeForLoops`): init/inc are each a SINGLE-LINE `assign` statement. `clause` renders one
209
+ // and strips its trailing `;` so it sits inside the header (`i = 0; c; i = i + 1`). A multi-line
210
+ // statement (an `if`/nested loop) would render mangled — but the recognizer never builds one here.
211
+ const clause = (st: Stmt) => printStmt(st, '', vt, leaf).join(' ').replace(/;\s*$/, '').trim();
212
+ const out = [`${indent}for (${clause(s.init)}; ${pe(s.cond, 99)}; ${clause(s.inc)}) {`];
213
+ for (const t of s.body) {
214
+ out.push(...printStmt(t, indent + ' ', vt, leaf));
215
+ }
216
+ out.push(`${indent}}`);
217
+ return out;
218
+ }
219
+ case 'break':
220
+ return [`${indent}break;`];
221
+ case 'continue':
222
+ return [`${indent}continue;`];
223
+ case 'switch': {
224
+ const out = [`${indent}switch (${pe(s.scrutinee, 99)}) {`];
225
+ const ci = indent + ' '; // case-label indent
226
+ const bi = indent + ' '; // case-body indent
227
+ for (const c of s.cases) {
228
+ for (const v of c.values) {
229
+ out.push(`${ci}case ${v}:`);
230
+ }
231
+ for (const t of c.body) {
232
+ out.push(...printStmt(t, bi, vt, leaf));
233
+ }
234
+ // A case whose body ends in `return`/`break` (a terminated arm) needs no `break;`; only an
235
+ // open non-fall-through arm gets one. `fallsThrough` omits it so control drops to the next case.
236
+ if (!c.fallsThrough && !endsTerminated(c.body)) {
237
+ out.push(`${bi}break;`);
238
+ }
239
+ }
240
+ if (s.default) {
241
+ out.push(`${ci}default:`);
242
+ for (const t of s.default) {
243
+ out.push(...printStmt(t, bi, vt, leaf));
244
+ }
245
+ }
246
+ out.push(`${indent}}`);
247
+ return out;
248
+ }
249
+ }
250
+ }
251
+
252
+ // Does a statement list end in a control-flow terminator (so a trailing `break;` would be dead)?
253
+ function endsTerminated(body: Stmt[]): boolean {
254
+ const last = body[body.length - 1];
255
+ return (
256
+ !!last &&
257
+ (last.k === 'return' ||
258
+ last.k === 'break' ||
259
+ last.k === 'continue' ||
260
+ (last.k === 'if' && endsTerminated(last.then) && last.else.length > 0 && endsTerminated(last.else)))
261
+ );
262
+ }
263
+
264
+ /** The body of a C-family function: local declarations + statements, one string per line. The
265
+ * SIGNATURE (return type + name + params, plus any C++ scope/`this`/mangling) is the caller's —
266
+ * that is the language-divergent part each backend owns. */
267
+ // C-FAMILY WRITE LEGALIZATION (the assign-side sibling of the deref legalization in printExpr):
268
+ // a value whose rendered C type is definitely NON-pointer written into a pointer-declared slot
269
+ // (`v2 = a1 + v0` with `v2: u8 *`; `return a0 + v0` from a ptr-returning fn; `*pp = intexpr`
270
+ // through a pointer-element slot) is an ERROR on mwcc (gcc merely warns) — the honest spelling
271
+ // is the reinterpret cast to the DECLARED type, exactly what the machine's register move does.
272
+ // Unknowable renderings (calls) are left alone: their C type comes from prototypes outside this
273
+ // function. (A rebuilding transform with per-kind semantics — its own switch, per the l3/ast.ts
274
+ // traversal-vocabulary exemption.)
275
+ function legalizePointerWrites(fn: SFn): SFn {
276
+ const vt = declaredTypes(fn);
277
+ const castTo = (t: IrType | undefined, e: Expr): Expr => {
278
+ if (t?.kind !== 'ptr') {
279
+ return e;
280
+ }
281
+ const ct = exprCType(e, vt);
282
+ return ct && ct.kind !== 'ptr' && ct.kind !== 'array' ? { k: 'cast', to: t, e } : e;
283
+ };
284
+ const fix = (s: Stmt): Stmt => {
285
+ switch (s.k) {
286
+ case 'assign':
287
+ return { ...s, value: castTo(vt(s.name), s.value) };
288
+ case 'store': {
289
+ // the slot's type is the lvalue's C element type (a pointer-element slot needs the cast)
290
+ const slot = exprCType(s.lval, vt);
291
+ return { ...s, value: castTo(slot, s.value) };
292
+ }
293
+ case 'return':
294
+ return s.value ? { ...s, value: castTo(fn.retType, s.value) } : s;
295
+ case 'if':
296
+ return { ...s, then: s.then.map(fix), else: s.else.map(fix) };
297
+ case 'while':
298
+ case 'dowhile':
299
+ return { ...s, body: s.body.map(fix) };
300
+ case 'for':
301
+ return { ...s, init: fix(s.init), inc: fix(s.inc), body: s.body.map(fix) };
302
+ case 'switch':
303
+ return {
304
+ ...s,
305
+ cases: s.cases.map((c) => ({ ...c, body: c.body.map(fix) })),
306
+ default: s.default ? s.default.map(fix) : undefined,
307
+ };
308
+ default:
309
+ return s;
310
+ }
311
+ };
312
+ return { ...fn, body: fn.body.map(fix) };
313
+ }
314
+
315
+ function cFamilyBody(fn0: SFn, leaf?: LeafHook): string[] {
316
+ const fn = legalizePointerWrites(fn0);
317
+ // The legalization env: every printed var's declared type, from the SAME params/locals the
318
+ // emitted declarations come from — so the printer judges exactly the C the reader will see.
319
+ const vt: VarTypes = declaredTypes(fn);
320
+ const lines: string[] = [];
321
+ for (const l of fn.locals) {
322
+ lines.push(` ${cType(l.type)} ${l.name};`);
323
+ }
324
+ for (const s of fn.body) {
325
+ lines.push(...printStmt(s, ' ', vt, leaf));
326
+ }
327
+ return lines;
328
+ }
329
+
330
+ /** Struct declarations this function references, one `struct N { ... };` per recovered aggregate.
331
+ * The struct type is SELF-DESCRIBING: any padding needed to seat fields at their exact offsets is
332
+ * already present as real `u8[N]` pad fields — both raise/struct-arrays.ts (element strides) and
333
+ * raise/structs.ts (unaccessed leading/interior gaps) interleave them where natural C alignment
334
+ * does not already cover the offset. This just declares each field in order. */
335
+ 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
+ );
339
+ }
340
+
341
+ /** Assemble a full C-family function from a caller-supplied signature line and the shared body. */
342
+ export function emitCFamily(signature: string, fn: SFn, leaf?: LeafHook): string {
343
+ const decls = structDecls(fn);
344
+ const preamble = decls.length ? decls.join('\n') + '\n' : '';
345
+ return preamble + [`${signature} {`, ...cFamilyBody(fn, leaf), '}'].join('\n') + '\n';
346
+ }
347
+
348
+ // One C-family comment spelling, shared by the C and C++ backends. A `*` followed by `/`
349
+ // inside the text would terminate the comment early — split it.
350
+ export function cComment(text: string): string {
351
+ return `/* ${text.replace(/\*\//g, '* /')} */`;
352
+ }