@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/backend/cfamily.ts +154 -5
- package/src/backend/cpp.ts +3 -1
- package/src/backend/pascal.ts +11 -0
- package/src/contracts.ts +37 -5
- package/src/declare.ts +251 -0
- package/src/frontend/frontend.ts +12 -2
- package/src/frontend/mips.ts +24 -23
- package/src/frontend/opaque.ts +39 -2
- package/src/frontend/ssa.ts +32 -53
- package/src/frontend/thumb.ts +420 -32
- package/src/ir/opcodes.ts +44 -0
- package/src/ir/simplify.ts +72 -0
- package/src/l3/argbase.ts +216 -0
- package/src/l3/ast.ts +126 -6
- package/src/l3/basecse.ts +3 -40
- package/src/l3/coalesce.ts +146 -0
- package/src/l3/dce.ts +2 -23
- package/src/l3/hoist.ts +65 -0
- package/src/l3/reindex.ts +7 -0
- package/src/l3/scopebase.ts +436 -0
- package/src/l3/symbol-refs.ts +61 -0
- package/src/l3/tailmerge.ts +120 -0
- package/src/l3/typing.ts +4 -0
- package/src/macros.ts +335 -0
- package/src/pattern/engine.ts +99 -6
- package/src/pipeline.ts +20 -6
- package/src/proto.ts +55 -0
- package/src/raise/divpow2.ts +226 -0
- package/src/raise/gvn.ts +141 -0
- package/src/raise/pre-recovery.ts +37 -3
- package/src/raise/recover.ts +24 -7
- package/src/raise/retsink.ts +36 -7
- package/src/raise/shortcircuit.ts +264 -22
- package/src/raise/structs.ts +12 -2
- package/src/rank.ts +370 -79
- package/src/structure/analysis.ts +42 -1
- package/src/structure/structure.ts +852 -67
- package/src/structure/switch-recover.ts +21 -3
- package/src/symbols.ts +541 -0
- package/src/target.ts +4 -2
- 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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|