@asmlift/core 0.1.0 → 0.3.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/src/rank.ts CHANGED
@@ -12,17 +12,19 @@ import { cBackend } from './backend/c';
12
12
  import { assertDerefsTyped, assertResolved } from './contracts';
13
13
  import type { AsmData } from './frontend/asmdata';
14
14
  import { frontendFor } from './frontend/registry';
15
- import { Fn } from './ir/core';
15
+ import { Fn, type Value, defOpMap } from './ir/core';
16
16
  import { T } from './ir/types';
17
17
  import { verify } from './ir/verify';
18
18
  import type { LanguageBackend, SFn } from './l3/ast';
19
19
  import { registerishSpellings } from './l3/regspell';
20
20
  import { reindexWalks } from './l3/reindex';
21
+ import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
21
22
  import { RewritePattern } from './pattern/engine';
22
23
  import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
23
- import type { Prototypes } from './proto';
24
+ import { type Prototypes, prototypesFromSymbols } from './proto';
24
25
  import { runPreRecovery } from './raise/pre-recovery';
25
26
  import { recoverTypes } from './raise/recover';
27
+ import { type SymbolMap, symbolsByName } from './symbols';
26
28
  import { type TargetDescription, structureOptionsFor } from './target';
27
29
 
28
30
  /** The signedness of the entry parameters — the classic ambiguity asm cannot resolve.
@@ -51,25 +53,104 @@ function pinScalarParams(fn: Fn, signed: boolean, ptrIdx: Set<number>): void {
51
53
  });
52
54
  }
53
55
 
56
+ /** Bare-global ACCESS FACTS for name-only map symbols — the width/signedness authority the
57
+ * declaration synthesis (declare.ts) uses when the map has no shape. The map knows only the
58
+ * NAME (symtab-only projects: marioparty3); the candidate's own IR knows exactly how the cell
59
+ * is accessed, and the bare `gSym = v` / `x = gSym` spelling compiles to those bytes only
60
+ * under a decl of that exact width (`extern u16 g;` is `sh` where a guessed u32 is `sw`).
61
+ * Mirrors structure()'s scalar-global rule: a fact is recorded only for a symbol accessed
62
+ * EXCLUSIVELY at offset 0 with ONE width and ONE load signedness — anything else (interior
63
+ * offsets, address arithmetic, width or sign conflicts) records nothing, because those
64
+ * spellings go through `&gSym` casts where every object decl is address-identical. */
65
+ function bareGlobalAccessFacts(fn: Fn): Map<string, { width: number; signed: boolean }> {
66
+ const defs = defOpMap(fn);
67
+ const symOf = (v: Value): string | null => {
68
+ const d = defs.get(v);
69
+ return d?.opcode === 'gaddr' && d.attrs.code !== true ? (d.attrs.sym as string) : null;
70
+ };
71
+ const acc = new Map<string, { widths: Set<number>; signs: Set<boolean>; interior: boolean }>();
72
+ const get = (s: string) => acc.get(s) ?? acc.set(s, { widths: new Set(), signs: new Set(), interior: false }).get(s)!;
73
+ for (const b of fn.blocks) {
74
+ for (const op of b.ops) {
75
+ if (op.opcode === 'load' || op.opcode === 'store') {
76
+ const s = symOf(op.operands[0]);
77
+ if (s) {
78
+ const a = get(s);
79
+ if ((op.attrs.off as number) !== 0) {
80
+ a.interior = true;
81
+ } else {
82
+ a.widths.add(op.attrs.width as number);
83
+ if (op.opcode === 'load') {
84
+ a.signs.add(((op.attrs.signed as boolean) ?? false) && (op.attrs.width as number) < 4);
85
+ }
86
+ }
87
+ }
88
+ } else if (op.opcode === 'aload' || op.opcode === 'astore') {
89
+ const s = symOf(op.operands[0]);
90
+ if (s) {
91
+ get(s).interior = true;
92
+ }
93
+ } else {
94
+ // any other use of the address (arithmetic, a call arg, a comparison) is interior/escape
95
+ for (const o of op.operands) {
96
+ const s = symOf(o);
97
+ if (s) {
98
+ get(s).interior = true;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ const out = new Map<string, { width: number; signed: boolean }>();
105
+ for (const [s, a] of acc) {
106
+ if (!a.interior && a.widths.size === 1 && a.signs.size <= 1) {
107
+ out.set(s, { width: [...a.widths][0], signed: a.signs.has(true) });
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
54
113
  export interface EnumerateOptions {
55
114
  patterns?: RewritePattern[];
56
115
  backend?: LanguageBackend;
57
116
  prototypes?: Prototypes;
58
117
  asmData?: AsmData;
118
+ /** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
119
+ symbols?: SymbolMap;
59
120
  }
60
121
 
61
122
  /** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
62
123
  export interface Candidate {
63
124
  label: string;
64
125
  source: string;
126
+ /** the map-derived VALUE references this candidate's tree contains — what the scoring
127
+ * layer's declaration synthesis renders. DERIVED, never carried: computed once from the
128
+ * exact tree this candidate's source was emitted from, at the moment the candidate is
129
+ * finalized (l3/symbol-refs.ts — no pipeline stage caches refs, so they cannot go stale).
130
+ * Present on EVERY spelling variant that names mapped symbols — including '/raw-globals',
131
+ * whose tree still names pool/reloc-derived globals (it only drops the map's shaped
132
+ * SPELLINGS). Absent without a map — synthesis then has nothing to do. */
133
+ symbolRefs?: SymbolRef[];
65
134
  }
66
135
  /** A candidate paired with its score `S` (the injected scorer's result shape — must carry `.score`). */
67
136
  export interface Scored<S> extends Candidate {
68
137
  score: S;
69
138
  }
139
+ /** A candidate the scorer REFUSED — its C did not build. Recorded rather than discarded: a
140
+ * spelling that fails to compile is a defect in the emitter or in the facts it was given, and a
141
+ * scoring harness that shows only the surviving sibling reports a clean win over a hidden
142
+ * failure. */
143
+ export interface DroppedCandidate {
144
+ label: string;
145
+ /** the scorer's first error line (a compiler diagnostic, usually) */
146
+ error: string;
147
+ }
148
+
70
149
  export interface RankedResult<S> {
71
150
  best: Scored<S>; // lowest score
72
151
  candidates: Scored<S>[]; // sorted best (lowest) first
152
+ /** candidates whose scoreFn threw — empty when every spelling built */
153
+ dropped: DroppedCandidate[];
73
154
  }
74
155
 
75
156
  /** Emit the DISTINCT type/branch-sense candidate spellings for `name` — PURE, no scoring.
@@ -83,9 +164,14 @@ export function enumerateCandidates(
83
164
  opts: EnumerateOptions = {},
84
165
  ): Candidate[] {
85
166
  const backend = opts.backend ?? cBackend;
86
- const prototypes = opts.prototypes ?? {};
167
+ // Same merge as `decompile`: the project's DWARF signatures fill in what the caller did not
168
+ // state, so both the annotate pass and the ranked candidates reason about one prototype table.
169
+ const prototypes = prototypesFromSymbols(opts.symbols, opts.prototypes ?? {});
87
170
  const frontend = frontendFor(target);
88
- const baseOpts = structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false);
171
+ const baseOpts = {
172
+ ...structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false),
173
+ ...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
174
+ };
89
175
  // Branch-sense is a differ-ranked LEVER, the same class as param signedness: a divergent `if`
90
176
  // can be spelled with either sense (`if (c) A else B` vs `if (!c) B else A`), and which one the
91
177
  // source compiler emitted is genuinely ambiguous from asm. There is no safe global heuristic
@@ -101,75 +187,120 @@ export function enumerateCandidates(
101
187
  // so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
102
188
  // compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
103
189
  // the totality contract / return-sinking of the full spine are not run on it.)
104
- const probe = frontend.lift(name, asm, target, prototypes, opts.asmData);
190
+ const probe = frontend.lift(name, asm, target, prototypes, opts.asmData, opts.symbols);
105
191
  verify(probe);
106
192
  applyIdiomPatterns(probe, target, opts.patterns);
107
193
  runPreRecovery(probe, target, () => verify(probe));
108
194
  recoverTypes(probe);
109
195
  const ptrIdx = new Set<number>(probe.blocks[0].params.flatMap((p, i) => (NO_PIN_KINDS.has(p.type.kind) ? [i] : [])));
196
+ // Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
197
+ // from the probe: widths/offsets are lift-time facts, identical across every candidate.
198
+ const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
110
199
 
111
200
  const seen = new Set<string>();
112
201
  const out: Candidate[] = [];
113
- for (const cand of SIGN_CANDS) {
114
- const fn = frontend.lift(name, asm, target, prototypes, opts.asmData);
115
- verify(fn);
116
- applyIdiomPatterns(fn, target, opts.patterns);
117
- // The shared tower spine (pipeline.ts) the candidate's ONE difference from decompile() is the
118
- // signedness pin, injected between pre-recovery and recoverTypes via the beforeRecover hook.
119
- raiseRecovered(fn, target, { beforeRecover: () => pinScalarParams(fn, cand.signed, ptrIdx) });
120
- for (const s of senseCands) {
121
- // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
122
- // senses structure the same recovered function without re-lifting.
123
- const sfn = structureChecked(fn, { ...baseOpts, preserveDivergentBranchSense: s.sense });
124
- // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
125
- // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
126
- // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
127
- // when a loop re-spells, BOTH representations are emitted and the differ referees. The
128
- // re-spelling passes the same boundary contracts as the primary; one that fails them is
129
- // dropped here — never scored, never able to win.
130
- const spellings: { suffix: string; source: string }[] = [{ suffix: '', source: backend.emit(sfn) }];
131
- // Representation re-spellings each a lever on the same footing as signedness/branch sense,
132
- // each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
133
- // that declines by throwing Pascal loud-fails unspellable shapes — drops the candidate,
134
- // never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
135
- //
136
- // POLICY: re-spellings derive from the BASE spelling only levers do not compose
137
- // (an /indexed + /regcopy product is deferred until a row demands it). And a lever must
138
- // PRESERVE SEMANTICS by construction: the differ referees byte-exactness (a wrong candidate
139
- // can never fake a score-0 match), but on a NONMATCH row the best-scoring source is shown
140
- // to the user a semantically-wrong re-spelling there is plausible-but-wrong output, the
141
- // defect class this project exists to avoid. Hence each lever's decline-over-approximate
142
- // gates, adversarially audited.
143
- const respell = (suffix: string, alt: SFn): void => {
144
- try {
145
- assertResolved(alt);
146
- assertDerefsTyped(alt);
147
- spellings.push({ suffix, source: backend.emit(alt) });
148
- } catch {
149
- // contract-failing or unspellable re-spelling: drop it, keep the primary
202
+ // The SYMBOL-MAP spelling is itself a ranked LEVER on the same footing as signedness/branch
203
+ // sense: naming a global changes agbcc's codegen (the eager-load effect), and which side
204
+ // byte-wins is genuinely per-function — the dogfood's landed matches split between extern
205
+ // spellings and raw-address macros. So when a map is present the raw-global spelling is ALSO
206
+ // enumerated ('/raw-globals') and the differ referees; the dedup below collapses the pair
207
+ // wherever the map changed nothing, so this never scores worse than either side alone.
208
+ const symbolVariants: { suffix: string; symbols?: typeof opts.symbols }[] = opts.symbols
209
+ ? [
210
+ { suffix: '', symbols: opts.symbols },
211
+ { suffix: '/raw-globals', symbols: undefined },
212
+ ]
213
+ : [{ suffix: '' }];
214
+ for (const sv of symbolVariants) {
215
+ const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
216
+ for (const cand of SIGN_CANDS) {
217
+ const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
218
+ verify(fn);
219
+ applyIdiomPatterns(fn, target, opts.patterns);
220
+ // The shared tower spine (pipeline.ts) the candidate's ONE difference from decompile() is the
221
+ // signedness pin, injected between pre-recovery and recoverTypes via the beforeRecover hook.
222
+ raiseRecovered(fn, target, { beforeRecover: () => pinScalarParams(fn, cand.signed, ptrIdx) });
223
+ for (const s of senseCands) {
224
+ // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
225
+ // senses structure the same recovered function without re-lifting.
226
+ const sfn = structureChecked(fn, { ...svOpts, preserveDivergentBranchSense: s.sense });
227
+ // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
228
+ // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
229
+ // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
230
+ // when a loop re-spells, BOTH representations are emitted and the differ referees. The
231
+ // re-spelling passes the same boundary contracts as the primary; one that fails them is
232
+ // dropped here never scored, never able to win.
233
+ // Each spelling's symbol refs are DERIVED from its own final tree right where the
234
+ // spelling is emitted — the single point a candidate comes into existence. No pipeline
235
+ // stage carries refs (SFn has no such field), so a future l3 pass that rewrites the tree
236
+ // can never leave a stale ref behind: whatever tree reaches emit is the tree the refs
237
+ // describe, by construction. Collected against the FULL name-keyed map for EVERY
238
+ // spelling variant the '/raw-globals' sibling drops the map's shaped SPELLINGS, but
239
+ // its tree still NAMES pool/reloc-derived globals (ARM `.word gSym`, MIPS `%lo(gSym)`),
240
+ // and those references need declarations in the self-declared scoring world exactly
241
+ // like the named variant's (without them every raw sibling fails to compile there,
242
+ // and the eval-winning raw candidate becomes unreproducible outside project headers).
243
+ const refsOf = (tree: SFn): { symbolRefs?: SymbolRef[] } => {
244
+ const refs = baseOpts.symbols
245
+ ? collectSymbolRefs(tree.body, baseOpts.symbols, tree.name).map((r) => {
246
+ // name-only symbols carry the IR-derived access facts — the width authority
247
+ // for their synthesized declaration (shaped symbols keep the map's truth)
248
+ const access = r.info.shape === undefined ? accessFacts.get(r.name) : undefined;
249
+ return access ? { ...r, access } : r;
250
+ })
251
+ : [];
252
+ return refs.length ? { symbolRefs: refs } : {};
253
+ };
254
+ const spellings: { suffix: string; source: string; symbolRefs?: SymbolRef[] }[] = [
255
+ { suffix: '', source: backend.emit(sfn), ...refsOf(sfn) },
256
+ ];
257
+ // Representation re-spellings — each a lever on the same footing as signedness/branch sense,
258
+ // each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
259
+ // that declines by throwing — Pascal loud-fails unspellable shapes — drops the candidate,
260
+ // never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
261
+ //
262
+ // POLICY: re-spellings derive from the BASE spelling only — levers do not compose
263
+ // (an /indexed + /regcopy product is deferred until a row demands it). And a lever must
264
+ // PRESERVE SEMANTICS by construction: the differ referees byte-exactness (a wrong candidate
265
+ // can never fake a score-0 match), but on a NONMATCH row the best-scoring source is shown
266
+ // to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
267
+ // defect class this project exists to avoid. Hence each lever's decline-over-approximate
268
+ // gates, adversarially audited.
269
+ const respell = (suffix: string, alt: SFn): void => {
270
+ try {
271
+ assertResolved(alt);
272
+ assertDerefsTyped(alt);
273
+ spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
274
+ } catch {
275
+ // contract-failing or unspellable re-spelling: drop it, keep the primary
276
+ }
277
+ };
278
+ const indexed = reindexWalks(sfn);
279
+ if (indexed) {
280
+ respell('/indexed', indexed);
150
281
  }
151
- };
152
- const indexed = reindexWalks(sfn);
153
- if (indexed) {
154
- respell('/indexed', indexed);
155
- }
156
- // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
157
- // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
158
- // ambiguous, so both are ranked)
159
- const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
160
- registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
161
- for (const sp of spellings) {
162
- const source = sp.source;
163
- // Collapse a spelling that produced identical source (a function with no divergent `if`
164
- // structures the same either way): no point scoring a duplicate spelling. Deduping the
165
- // WHOLE emitted set (not just scored survivors) is equivalent — an identical source
166
- // scores identically, so it can never change `best` — and it keeps the candidate set to
167
- // the genuinely distinct spellings.
168
- if (seen.has(source)) {
169
- continue;
282
+ // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
283
+ // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
284
+ // ambiguous, so both are ranked)
285
+ const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
286
+ registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
287
+ for (const sp of spellings) {
288
+ const source = sp.source;
289
+ // Collapse a spelling that produced identical source (a function with no divergent `if`
290
+ // structures the same either way): no point scoring a duplicate spelling. Deduping the
291
+ // WHOLE emitted set (not just scored survivors) is equivalent an identical source
292
+ // scores identically, so it can never change `best` — and it keeps the candidate set to
293
+ // the genuinely distinct spellings.
294
+ if (seen.has(source)) {
295
+ continue;
296
+ }
297
+ seen.add(source);
298
+ out.push({
299
+ label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
300
+ source,
301
+ ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
302
+ });
170
303
  }
171
- seen.add(source);
172
- out.push({ label: `${cand.label}${s.suffix}${sp.suffix}`, source });
173
304
  }
174
305
  }
175
306
  }
@@ -185,24 +316,32 @@ export function enumerateCandidates(
185
316
  export function rankBy<S extends { score: number }>(
186
317
  candidates: Candidate[],
187
318
  symbol: string,
188
- scoreFn: (source: string, symbol: string) => S,
319
+ scoreFn: (source: string, symbol: string, candidate: Candidate) => S,
189
320
  ): RankedResult<S> {
190
- const results: Scored<S>[] = [];
191
- let lastScoreErr: unknown = null; // a candidate's C that failed to compile; only fatal if ALL do
192
- for (const c of candidates) {
321
+ const results: (Scored<S> & { order: number })[] = [];
322
+ const dropped: DroppedCandidate[] = []; // spellings that failed to build; only fatal if ALL do
323
+ let lastScoreErr: unknown = null;
324
+ candidates.forEach((c, order) => {
193
325
  try {
194
- results.push({ ...c, score: scoreFn(c.source, symbol) });
326
+ results.push({ ...c, order, score: scoreFn(c.source, symbol, c) });
195
327
  } catch (e) {
196
328
  lastScoreErr = e;
329
+ dropped.push({ label: c.label, error: firstLine(e) });
197
330
  }
198
- }
331
+ });
199
332
  if (results.length === 0) {
200
- const why =
201
- lastScoreErr instanceof Error
202
- ? lastScoreErr.message.split('\n')[0]
203
- : String(lastScoreErr ?? 'no candidate produced');
204
- throw new Error(`no scorable candidate for '${symbol}': ${why}`, { cause: lastScoreErr });
333
+ throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
205
334
  }
206
- results.sort((a, b) => a.score.score - b.score.score);
207
- return { best: results[0], candidates: results };
335
+ // Score first; ENUMERATION ORDER breaks a tie. That order is meaningful, not incidental:
336
+ // enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
337
+ // when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
338
+ // than a bare address. Spelled as an explicit comparator because relying on Array#sort's
339
+ // stability would make the preference an accident of two unrelated decisions.
340
+ results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
341
+ return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
342
+ }
343
+
344
+ /** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
345
+ function firstLine(e: unknown): string {
346
+ return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
208
347
  }