@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/src/rank.ts CHANGED
@@ -12,17 +12,22 @@ 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
+ import { materializeArgBases } from './l3/argbase';
18
19
  import type { LanguageBackend, SFn } from './l3/ast';
20
+ import { coalesceCandidates } from './l3/coalesce';
19
21
  import { registerishSpellings } from './l3/regspell';
20
22
  import { reindexWalks } from './l3/reindex';
23
+ import { hoistScopedBases } from './l3/scopebase';
24
+ import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
21
25
  import { RewritePattern } from './pattern/engine';
22
26
  import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
23
- import type { Prototypes } from './proto';
27
+ import { type Prototypes, prototypesFromSymbols } from './proto';
24
28
  import { runPreRecovery } from './raise/pre-recovery';
25
29
  import { recoverTypes } from './raise/recover';
30
+ import { type SymbolMap, symbolsByName } from './symbols';
26
31
  import { type TargetDescription, structureOptionsFor } from './target';
27
32
 
28
33
  /** The signedness of the entry parameters — the classic ambiguity asm cannot resolve.
@@ -51,25 +56,122 @@ function pinScalarParams(fn: Fn, signed: boolean, ptrIdx: Set<number>): void {
51
56
  });
52
57
  }
53
58
 
59
+ /** Bare-global ACCESS FACTS for name-only map symbols — the width/signedness authority the
60
+ * declaration synthesis (declare.ts) uses when the map has no shape. The map knows only the
61
+ * NAME (symtab-only projects: marioparty3); the candidate's own IR knows exactly how the cell
62
+ * is accessed, and the bare `gSym = v` / `x = gSym` spelling compiles to those bytes only
63
+ * under a decl of that exact width (`extern u16 g;` is `sh` where a guessed u32 is `sw`).
64
+ * Mirrors structure()'s scalar-global rule: a fact is recorded only for a symbol accessed
65
+ * EXCLUSIVELY at offset 0 with ONE width and ONE load signedness — anything else (interior
66
+ * offsets, address arithmetic, width or sign conflicts) records nothing, because those
67
+ * spellings go through `&gSym` casts where every object decl is address-identical. */
68
+ function bareGlobalAccessFacts(fn: Fn): Map<string, { width: number; signed: boolean }> {
69
+ const defs = defOpMap(fn);
70
+ const symOf = (v: Value): string | null => {
71
+ const d = defs.get(v);
72
+ return d?.opcode === 'gaddr' && d.attrs.code !== true ? (d.attrs.sym as string) : null;
73
+ };
74
+ const acc = new Map<string, { widths: Set<number>; signs: Set<boolean>; interior: boolean }>();
75
+ const get = (s: string) => acc.get(s) ?? acc.set(s, { widths: new Set(), signs: new Set(), interior: false }).get(s)!;
76
+ for (const b of fn.blocks) {
77
+ for (const op of b.ops) {
78
+ if (op.opcode === 'load' || op.opcode === 'store') {
79
+ const s = symOf(op.operands[0]);
80
+ if (s) {
81
+ const a = get(s);
82
+ if ((op.attrs.off as number) !== 0) {
83
+ a.interior = true;
84
+ } else {
85
+ a.widths.add(op.attrs.width as number);
86
+ if (op.opcode === 'load') {
87
+ a.signs.add(((op.attrs.signed as boolean) ?? false) && (op.attrs.width as number) < 4);
88
+ }
89
+ }
90
+ }
91
+ } else if (op.opcode === 'aload' || op.opcode === 'astore') {
92
+ const s = symOf(op.operands[0]);
93
+ if (s) {
94
+ get(s).interior = true;
95
+ }
96
+ } else {
97
+ // any other use of the address (arithmetic, a call arg, a comparison) is interior/escape
98
+ for (const o of op.operands) {
99
+ const s = symOf(o);
100
+ if (s) {
101
+ get(s).interior = true;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ const out = new Map<string, { width: number; signed: boolean }>();
108
+ for (const [s, a] of acc) {
109
+ if (!a.interior && a.widths.size === 1 && a.signs.size <= 1) {
110
+ out.set(s, { width: [...a.widths][0], signed: a.signs.has(true) });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
54
116
  export interface EnumerateOptions {
55
117
  patterns?: RewritePattern[];
56
118
  backend?: LanguageBackend;
57
119
  prototypes?: Prototypes;
58
120
  asmData?: AsmData;
121
+ /** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
122
+ symbols?: SymbolMap;
123
+ /** Called when a re-spelling lever THROWS or fails a boundary contract, so the failure is visible
124
+ * instead of the candidate silently not existing. Enumeration continues either way — the primary
125
+ * spelling is unaffected — but a lever that never fires because it always throws is a defect, and
126
+ * without this it looks identical to a lever that correctly declined. */
127
+ onLeverError?: (label: string, error: string) => void;
59
128
  }
60
129
 
61
- /** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
130
+ /** One distinct candidate spelling a point in the axis cross (signedness × branch sense ×
131
+ * def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
132
+ * emitted to source. */
62
133
  export interface Candidate {
63
134
  label: string;
64
135
  source: string;
136
+ /** Which PREFERENCE GROUP this spelling belongs to — the symbol-variant index (0 = the map's own
137
+ * named spellings, 1 = their `/raw-globals` siblings). Enumeration emits the groups in
138
+ * preference order, and a lower group WINS a score tie: when both compile to the same bytes the
139
+ * reader should get `gCounter.field`, not a byte offset off a hoisted `(u8 *)` base.
140
+ *
141
+ * Carried structurally rather than left to enumeration order because the readability tie-break
142
+ * (compareScored) must compare only spellings that are genuinely alternatives of the same
143
+ * thing. Ranking a named spelling against a raw-address one on cast count is not a readability
144
+ * comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
145
+ * construction, trading named struct fields for anonymous byte offsets. */
146
+ group: number;
147
+ /** the map-derived VALUE references this candidate's tree contains — what the scoring
148
+ * layer's declaration synthesis renders. DERIVED, never carried: computed once from the
149
+ * exact tree this candidate's source was emitted from, at the moment the candidate is
150
+ * finalized (l3/symbol-refs.ts — no pipeline stage caches refs, so they cannot go stale).
151
+ * Present on EVERY spelling variant that names mapped symbols — including '/raw-globals',
152
+ * whose tree still names pool/reloc-derived globals (it only drops the map's shaped
153
+ * SPELLINGS). Absent without a map — synthesis then has nothing to do. */
154
+ symbolRefs?: SymbolRef[];
65
155
  }
66
156
  /** A candidate paired with its score `S` (the injected scorer's result shape — must carry `.score`). */
67
157
  export interface Scored<S> extends Candidate {
68
158
  score: S;
69
159
  }
160
+ /** A candidate the scorer REFUSED — its C did not build. Recorded rather than discarded: a
161
+ * spelling that fails to compile is a defect in the emitter or in the facts it was given, and a
162
+ * scoring harness that shows only the surviving sibling reports a clean win over a hidden
163
+ * failure. */
164
+ export interface DroppedCandidate {
165
+ label: string;
166
+ /** the scorer's first error line (a compiler diagnostic, usually) */
167
+ error: string;
168
+ }
169
+
70
170
  export interface RankedResult<S> {
71
171
  best: Scored<S>; // lowest score
72
172
  candidates: Scored<S>[]; // sorted best (lowest) first
173
+ /** candidates whose scoreFn threw — empty when every spelling built */
174
+ dropped: DroppedCandidate[];
73
175
  }
74
176
 
75
177
  /** Emit the DISTINCT type/branch-sense candidate spellings for `name` — PURE, no scoring.
@@ -83,9 +185,14 @@ export function enumerateCandidates(
83
185
  opts: EnumerateOptions = {},
84
186
  ): Candidate[] {
85
187
  const backend = opts.backend ?? cBackend;
86
- const prototypes = opts.prototypes ?? {};
188
+ // Same merge as `decompile`: the project's DWARF signatures fill in what the caller did not
189
+ // state, so both the annotate pass and the ranked candidates reason about one prototype table.
190
+ const prototypes = prototypesFromSymbols(opts.symbols, opts.prototypes ?? {});
87
191
  const frontend = frontendFor(target);
88
- const baseOpts = structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false);
192
+ const baseOpts = {
193
+ ...structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false),
194
+ ...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
195
+ };
89
196
  // Branch-sense is a differ-ranked LEVER, the same class as param signedness: a divergent `if`
90
197
  // can be spelled with either sense (`if (c) A else B` vs `if (!c) B else A`), and which one the
91
198
  // source compiler emitted is genuinely ambiguous from asm. There is no safe global heuristic
@@ -93,83 +200,215 @@ export function enumerateCandidates(
93
200
  // and let the differ referee. The default sense is always among them, so this never scores
94
201
  // worse; it only wins where the flip matches.
95
202
  const defSense = baseOpts.preserveDivergentBranchSense ?? true;
96
- const senseCands = [
97
- { suffix: '', sense: defSense },
98
- { suffix: '/flip-branch', sense: !defSense },
203
+ // `/defsite` def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
204
+ // structuring axis on the same footing as branch sense: where the asm materialized a merge
205
+ // constant is placement evidence, but whether the SOURCE spelled it there is genuinely
206
+ // ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
207
+ // (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
208
+ // below collapses every variant the anchoring left unchanged.
209
+ const baseSense = [
210
+ { suffix: '', sense: defSense, anchor: false, bitfields: true },
211
+ { suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
212
+ { suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
213
+ { suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
99
214
  ];
215
+ // `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
216
+ // The named read recompiles at the DECLARATION's access width; where that diverges from the
217
+ // asm's load width, the shifts are the spelling that matches — so both are emitted and the
218
+ // differ referees. Enumerated only when the map carries any bitfield member at all (checked
219
+ // below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
220
+ // every variant where no fold fired.
221
+ const mapHasBitfields =
222
+ opts.symbols !== undefined &&
223
+ [...opts.symbols.values()].some((infos) =>
224
+ infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
225
+ );
226
+ const senseCands = mapHasBitfields
227
+ ? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
228
+ : baseSense;
100
229
  // Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
101
230
  // so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
102
231
  // compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
103
232
  // 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);
233
+ const probe = frontend.lift(name, asm, target, prototypes, opts.asmData, opts.symbols);
105
234
  verify(probe);
106
235
  applyIdiomPatterns(probe, target, opts.patterns);
107
236
  runPreRecovery(probe, target, () => verify(probe));
108
237
  recoverTypes(probe);
109
238
  const ptrIdx = new Set<number>(probe.blocks[0].params.flatMap((p, i) => (NO_PIN_KINDS.has(p.type.kind) ? [i] : [])));
239
+ // Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
240
+ // from the probe: widths/offsets are lift-time facts, identical across every candidate.
241
+ const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
110
242
 
111
243
  const seen = new Set<string>();
112
244
  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 => {
245
+ // The SYMBOL-MAP spelling is itself a ranked LEVER on the same footing as signedness/branch
246
+ // sense: naming a global changes agbcc's codegen (the eager-load effect), and which side
247
+ // byte-wins is genuinely per-function — the dogfood's landed matches split between extern
248
+ // spellings and raw-address macros. So when a map is present the raw-global spelling is ALSO
249
+ // enumerated ('/raw-globals') and the differ referees; the dedup below collapses the pair
250
+ // wherever the map changed nothing, so this never scores worse than either side alone.
251
+ const symbolVariants: { suffix: string; symbols?: typeof opts.symbols }[] = opts.symbols
252
+ ? [
253
+ { suffix: '', symbols: opts.symbols },
254
+ { suffix: '/raw-globals', symbols: undefined },
255
+ ]
256
+ : [{ suffix: '' }];
257
+ for (const [svIndex, sv] of symbolVariants.entries()) {
258
+ const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
259
+ for (const cand of SIGN_CANDS) {
260
+ const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
261
+ verify(fn);
262
+ applyIdiomPatterns(fn, target, opts.patterns);
263
+ // The shared tower spine (pipeline.ts) the candidate's ONE difference from decompile() is the
264
+ // signedness pin, injected between pre-recovery and recoverTypes via the beforeRecover hook.
265
+ raiseRecovered(fn, target, { beforeRecover: () => pinScalarParams(fn, cand.signed, ptrIdx) });
266
+ for (const s of senseCands) {
267
+ // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
268
+ // senses structure the same recovered function without re-lifting.
269
+ let sfn: SFn;
144
270
  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
150
- }
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)) {
271
+ sfn = structureChecked(fn, {
272
+ ...svOpts,
273
+ preserveDivergentBranchSense: s.sense,
274
+ anchorConstCopies: s.anchor,
275
+ spellBitfieldMembers: s.bitfields,
276
+ });
277
+ } catch (e) {
278
+ if (!s.anchor && s.bitfields) {
279
+ throw e; // the base axes keep their behavior: a structuring failure aborts the row
280
+ }
281
+ // an anchored variant that fails structuring or its contracts is a dropped lever, never
282
+ // an aborted enumeration same rule as respell below
283
+ opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
169
284
  continue;
170
285
  }
171
- seen.add(source);
172
- out.push({ label: `${cand.label}${s.suffix}${sp.suffix}`, source });
286
+ // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
287
+ // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
288
+ // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
289
+ // when a loop re-spells, BOTH representations are emitted and the differ referees. The
290
+ // re-spelling passes the same boundary contracts as the primary; one that fails them is
291
+ // dropped here — never scored, never able to win.
292
+ // Each spelling's symbol refs are DERIVED from its own final tree right where the
293
+ // spelling is emitted — the single point a candidate comes into existence. No pipeline
294
+ // stage carries refs (SFn has no such field), so a future l3 pass that rewrites the tree
295
+ // can never leave a stale ref behind: whatever tree reaches emit is the tree the refs
296
+ // describe, by construction. Collected against the FULL name-keyed map for EVERY
297
+ // spelling variant — the '/raw-globals' sibling drops the map's shaped SPELLINGS, but
298
+ // its tree still NAMES pool/reloc-derived globals (ARM `.word gSym`, MIPS `%lo(gSym)`),
299
+ // and those references need declarations in the self-declared scoring world exactly
300
+ // like the named variant's (without them every raw sibling fails to compile there,
301
+ // and the eval-winning raw candidate becomes unreproducible outside project headers).
302
+ const refsOf = (tree: SFn): { symbolRefs?: SymbolRef[] } => {
303
+ const refs = baseOpts.symbols
304
+ ? collectSymbolRefs(tree.body, baseOpts.symbols, tree.name).map((r) => {
305
+ // name-only symbols carry the IR-derived access facts — the width authority
306
+ // for their synthesized declaration (shaped symbols keep the map's truth)
307
+ const access = r.info.shape === undefined ? accessFacts.get(r.name) : undefined;
308
+ return access ? { ...r, access } : r;
309
+ })
310
+ : [];
311
+ return refs.length ? { symbolRefs: refs } : {};
312
+ };
313
+ const spellings: { suffix: string; source: string; symbolRefs?: SymbolRef[] }[] = [
314
+ { suffix: '', source: backend.emit(sfn), ...refsOf(sfn) },
315
+ ];
316
+ // Representation re-spellings — each a lever on the same footing as signedness/branch sense,
317
+ // each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
318
+ // that declines by throwing — Pascal loud-fails unspellable shapes — drops the candidate,
319
+ // never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
320
+ //
321
+ // POLICY: re-spellings derive from the BASE spelling only — levers do not compose
322
+ // (an /indexed + /regcopy product is deferred until a row demands it). And a lever must
323
+ // PRESERVE SEMANTICS by construction: the differ referees byte-exactness (a wrong candidate
324
+ // can never fake a score-0 match), but on a NONMATCH row the best-scoring source is shown
325
+ // to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
326
+ // defect class this project exists to avoid. Hence each lever's decline-over-approximate
327
+ // gates, adversarially audited.
328
+ // Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
329
+ // from the pass itself — rather than from the contracts or the backend — would escape and
330
+ // abort the whole enumeration for this row, primary included: the one way a lever can cost
331
+ // a match. Making that structural rather than per-call-site means no lever can opt out.
332
+ const respell = (suffix: string, make: () => SFn | null | undefined): void => {
333
+ try {
334
+ const alt = make();
335
+ if (!alt) {
336
+ return; // the lever declined to fire — no candidate, not a duplicate of the primary
337
+ }
338
+ assertResolved(alt);
339
+ assertDerefsTyped(alt);
340
+ spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
341
+ } catch (e) {
342
+ // A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
343
+ // REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
344
+ // so without this a lever that fails here vanishes with no trace — indistinguishable
345
+ // from one that correctly declined, which is exactly the hidden failure
346
+ // DroppedCandidate exists to surface.
347
+ opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
348
+ }
349
+ };
350
+ // `/argbase` — name a call's argument bases before the call (l3/argbase.ts). A lever on the
351
+ // same footing as the others: the primary inline spelling stays in the list, so the differ
352
+ // referees and this can never cost a match.
353
+ respell('/argbase', () => materializeArgBases(sfn));
354
+ // `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
355
+ // (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
356
+ // carries: this one fires exactly where that placement would extend a live range the
357
+ // original never had.
358
+ // `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
359
+ // derivable from the tree — on the row this was built for the two legal merges score 18 and
360
+ // 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
361
+ // discards the winner. Every variant is emitted and the differ referees, exactly as
362
+ // `/regcopy` does for its allocator-ambiguous tail choice.
363
+ //
364
+ // POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
365
+ // levers do not compose. These are not a second lever composed onto the first: coalescing is
366
+ // enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
367
+ // just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
368
+ //
369
+ // EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
370
+ // runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
371
+ // the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
372
+ respell('/scopebase', () => hoistScopedBases(sfn));
373
+ const enumerate = (label: string, from: () => SFn | null | undefined): void => {
374
+ let variants: { merged: string; sfn: SFn }[] = [];
375
+ try {
376
+ const base = from();
377
+ variants = base ? coalesceCandidates(base) : [];
378
+ } catch (e) {
379
+ opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
380
+ return;
381
+ }
382
+ for (const c of variants) {
383
+ respell(`${label}-${c.merged}`, () => c.sfn);
384
+ }
385
+ };
386
+ enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
387
+ enumerate('/coalesce', () => sfn);
388
+ respell('/indexed', () => reindexWalks(sfn));
389
+ // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
390
+ // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
391
+ // ambiguous, so both are ranked)
392
+ const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
393
+ registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
394
+ for (const sp of spellings) {
395
+ const source = sp.source;
396
+ // Collapse a spelling that produced identical source (a function with no divergent `if`
397
+ // structures the same either way): no point scoring a duplicate spelling. Deduping the
398
+ // WHOLE emitted set (not just scored survivors) is equivalent — an identical source
399
+ // scores identically, so it can never change `best` — and it keeps the candidate set to
400
+ // the genuinely distinct spellings.
401
+ if (seen.has(source)) {
402
+ continue;
403
+ }
404
+ seen.add(source);
405
+ out.push({
406
+ label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
407
+ source,
408
+ group: svIndex,
409
+ ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
410
+ });
411
+ }
173
412
  }
174
413
  }
175
414
  }
@@ -185,24 +424,76 @@ export function enumerateCandidates(
185
424
  export function rankBy<S extends { score: number }>(
186
425
  candidates: Candidate[],
187
426
  symbol: string,
188
- scoreFn: (source: string, symbol: string) => S,
427
+ scoreFn: (source: string, symbol: string, candidate: Candidate) => S,
189
428
  ): 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) {
429
+ const results: (Scored<S> & { order: number })[] = [];
430
+ const dropped: DroppedCandidate[] = []; // spellings that failed to build; only fatal if ALL do
431
+ let lastScoreErr: unknown = null;
432
+ candidates.forEach((c, order) => {
193
433
  try {
194
- results.push({ ...c, score: scoreFn(c.source, symbol) });
434
+ results.push({ ...c, order, score: scoreFn(c.source, symbol, c) });
195
435
  } catch (e) {
196
436
  lastScoreErr = e;
437
+ dropped.push({ label: c.label, error: firstLine(e) });
197
438
  }
198
- }
439
+ });
199
440
  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 });
441
+ throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
205
442
  }
206
- results.sort((a, b) => a.score.score - b.score.score);
207
- return { best: results[0], candidates: results };
443
+ results.sort(compareScored);
444
+ return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
445
+ }
446
+
447
+ /** THE candidate ordering — score, then preference group, then readability, then enumeration
448
+ * order. Exported because there are TWO drivers over the same enumeration (this module's sync
449
+ * `rankBy` for the Node/objdiff scorer, and the webapp's async await-loop for the wasm one), and
450
+ * a per-driver copy would let the same input produce two different winners.
451
+ *
452
+ * SCORE dominates absolutely: the differ is the fitness function, and a tie means the axis that
453
+ * separates these two spellings did not change the bytes — so everything below only chooses what
454
+ * the READER sees, and can never cost a match.
455
+ *
456
+ * GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
457
+ *
458
+ * CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
459
+ * the C backend has to cast a shift operand back to the signedness the machine op needs, so
460
+ * pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
461
+ * for the same bytes as `s32 f(s32 a0) { return a0 >> a1; }`. Before the backend synthesized that
462
+ * cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
463
+ * silently install the noisier spelling.
464
+ *
465
+ * ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
466
+ * result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
467
+ * would make each preference an accident of two unrelated decisions. */
468
+ export function compareScored<S extends { score: number }>(
469
+ a: Candidate & { score: S; order: number },
470
+ b: Candidate & { score: S; order: number },
471
+ ): number {
472
+ return (
473
+ a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
474
+ );
475
+ }
476
+
477
+ /** Scalar casts in a candidate's rendered source — the readability tie-break above.
478
+ *
479
+ * A TEXT count over the emitted string, matching how the benchmark's own readability metric
480
+ * measures the same thing (apps/benchmark/src/eval/quality.ts) — the two must agree about what
481
+ * "cast noise" means, or ranking optimizes for something the report then scores differently.
482
+ *
483
+ * It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
484
+ * — those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
485
+ * ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
486
+ * arithmetic on a link-time address, which decomp projects write themselves. Counting it would
487
+ * penalize precisely the named spelling this ranking is supposed to prefer.
488
+ *
489
+ * Deterministic, and total on any string. */
490
+ function castCount(source: string): number {
491
+ const all = source.match(/\((?:u|s)(?:8|16|32)\)/g)?.length ?? 0;
492
+ const addr = source.match(/\((?:u|s)32\)\s*&/g)?.length ?? 0;
493
+ return all - addr;
494
+ }
495
+
496
+ /** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
497
+ function firstLine(e: unknown): string {
498
+ return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
208
499
  }
@@ -24,6 +24,12 @@ export interface StructureAnalysis {
24
24
  materialize: Set<Op>;
25
25
  /** cached forward reachability (successors-transitive, excluding the start block itself) */
26
26
  reachFrom: (b: Block) => Set<Block>;
27
+ /** where a value's expression ultimately renders — the anchored consumer it inlines into,
28
+ * transitively; null = several places / unresolvable (callers treat conservatively) */
29
+ emitPos: (op: Op) => { blk: Block; idx: number } | null;
30
+ /** may an op `isWrite` accepts execute between `def` and a statement at `render`, on any
31
+ * def-avoiding path — the fold-ordering gate (see the closure's comment) */
32
+ memWriteBetween: (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean) => boolean;
27
33
  }
28
34
 
29
35
  export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
@@ -406,5 +412,40 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
406
412
  }
407
413
  }
408
414
  }
409
- return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom };
415
+ // The def→render path discipline, exported for the bitfield fold's ordering gate
416
+ // (structure.ts): may an op `isWrite` accepts execute between `def` and a statement at
417
+ // `render`, on any def-avoiding path? Same cycle-aware rules as the materialize decisions
418
+ // above — the def block's tail, the render block's head, and every between-block on a path;
419
+ // a path re-crossing the def is the next dynamic instance and does not count.
420
+ const memWriteBetween = (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean): boolean => {
421
+ const b = opBlock.get(def)!;
422
+ const oi = opIndex.get(def)!;
423
+ const wDirty = (list: Op[], from: number, to: number): boolean => {
424
+ for (let k = from; k < to; k++) {
425
+ if (isWrite(list[k])) {
426
+ return true;
427
+ }
428
+ }
429
+ return false;
430
+ };
431
+ if (render.blk === b && oi < render.idx) {
432
+ return wDirty(b.ops, oi + 1, render.idx);
433
+ }
434
+ if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
435
+ return true;
436
+ }
437
+ for (const x of reachAvoiding(b, b)) {
438
+ if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
439
+ continue; // acyclic render block: head checked
440
+ }
441
+ if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
442
+ continue; // not on a def→render path
443
+ }
444
+ if (wDirty(x.ops, 0, x.ops.length)) {
445
+ return true;
446
+ }
447
+ }
448
+ return false;
449
+ };
450
+ return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween };
410
451
  }