@asmlift/core 0.4.0 → 0.5.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/package.json +1 -1
- package/src/backend/cfamily.ts +5 -2
- package/src/contracts.ts +166 -2
- package/src/frontend/mips.ts +13 -6
- package/src/frontend/opaque.ts +31 -18
- package/src/frontend/ppc.ts +18 -7
- package/src/frontend/ssa.ts +249 -5
- package/src/frontend/thumb.ts +1082 -72
- package/src/ir/alias.ts +75 -0
- package/src/ir/opcodes.ts +24 -14
- package/src/l3/argbase.ts +6 -1
- package/src/l3/ast.ts +9 -1
- package/src/l3/basecse.ts +57 -24
- package/src/l3/coalesce.ts +107 -38
- package/src/l3/dce.ts +31 -18
- package/src/l3/gates.ts +67 -0
- package/src/l3/scopebase.ts +11 -7
- package/src/l3/tailmerge.ts +8 -4
- package/src/pipeline.ts +60 -4
- package/src/raise/divpow2.ts +2 -1
- package/src/raise/gvn.ts +16 -6
- package/src/raise/pre-recovery.ts +4 -2
- package/src/raise/retsink.ts +5 -4
- package/src/raise/shortcircuit.ts +3 -5
- package/src/raise/struct-arrays.ts +2 -1
- package/src/raise/structs.ts +29 -1
- package/src/rank.ts +26 -2
- package/src/structure/analysis.ts +168 -123
- package/src/structure/structure.ts +228 -63
- package/src/structure/switch-recover.ts +96 -27
package/src/l3/gates.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// A pass's admission rules as DATA, so "does every sound gate have a test that fails without it?"
|
|
2
|
+
// is a query instead of an audit.
|
|
3
|
+
//
|
|
4
|
+
// Because the table is a value, a test can drop one entry and re-run the pass: the real predicate,
|
|
5
|
+
// on real input, with no test-only branch in the shipped path. That makes `sound` cost something to
|
|
6
|
+
// declare — see `gateTableDefects` and the contract test that pairs with it.
|
|
7
|
+
//
|
|
8
|
+
// `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
|
|
9
|
+
// which has room; duplicating it here is how a table stops paying for itself.
|
|
10
|
+
export interface Gate<Ctx> {
|
|
11
|
+
/** stable, kebab-case; appears in test names and in the contract report */
|
|
12
|
+
readonly id: string;
|
|
13
|
+
/** one line: the reason the rule exists */
|
|
14
|
+
readonly why: string;
|
|
15
|
+
/** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
|
|
16
|
+
* heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
|
|
17
|
+
readonly sound: boolean;
|
|
18
|
+
/** the test that fails when this gate is removed — required for a sound gate */
|
|
19
|
+
readonly guardedBy?: string;
|
|
20
|
+
/** true ⇒ REJECT this candidate */
|
|
21
|
+
readonly rejects: (c: Ctx) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The id of the first gate that rejects `c`, or null when every gate admits it. FIRST, not all:
|
|
25
|
+
* one decisive rule is what makes a refusal attributable, and it keeps the cost the same as the
|
|
26
|
+
* `||` chain this replaces — evaluation still short-circuits. */
|
|
27
|
+
export function firstRejection<Ctx>(gates: readonly Gate<Ctx>[], c: Ctx): string | null {
|
|
28
|
+
for (const g of gates) {
|
|
29
|
+
if (g.rejects(c)) {
|
|
30
|
+
return g.id;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A gate table with one entry removed — the ablation, as a value. Throws on an unknown id: a
|
|
37
|
+
* typo'd ablation that silently tests nothing is the failure this file exists to prevent. */
|
|
38
|
+
export function without<Ctx>(gates: readonly Gate<Ctx>[], id: string): readonly Gate<Ctx>[] {
|
|
39
|
+
if (!gates.some((g) => g.id === id)) {
|
|
40
|
+
throw new Error(`no gate '${id}' to ablate (have: ${gates.map((g) => g.id).join(', ')})`);
|
|
41
|
+
}
|
|
42
|
+
return gates.filter((g) => g.id !== id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Structural defects in a gate table — the part checkable without running the pass. Returns
|
|
46
|
+
* findings rather than throwing, so core stays free of a test-framework import. */
|
|
47
|
+
export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
for (const g of gates) {
|
|
51
|
+
if (seen.has(g.id)) {
|
|
52
|
+
out.push(`duplicate gate id '${g.id}'`);
|
|
53
|
+
}
|
|
54
|
+
seen.add(g.id);
|
|
55
|
+
if (!/^[a-z][a-z0-9-]*$/.test(g.id)) {
|
|
56
|
+
out.push(`gate id '${g.id}' is not kebab-case`);
|
|
57
|
+
}
|
|
58
|
+
if (g.why.trim().length < 12) {
|
|
59
|
+
out.push(`gate '${g.id}' has no usable \`why\``);
|
|
60
|
+
}
|
|
61
|
+
// the one rule that costs something to declare
|
|
62
|
+
if (g.sound && !g.guardedBy?.trim()) {
|
|
63
|
+
out.push(`gate '${g.id}' is marked sound but names no guard`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
package/src/l3/scopebase.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// L3 re-spelling lever: hoist a reused global base into a pointer local at the INNERMOST scope
|
|
2
2
|
// that contains all of its uses.
|
|
3
3
|
//
|
|
4
|
+
// The lever earns its place: returning `null` from `hoistScopedBases` costs
|
|
5
|
+
// kleod:UpdateHUDCounterDisplay its match, so the benchmark's zero-lost gate guards this file.
|
|
6
|
+
//
|
|
4
7
|
// `l3/basecse.ts` already hoists a reused leaf base — but always to the FUNCTION TOP, and only for
|
|
5
8
|
// an `addr`/`const` base. Both limits are load-bearing here, and each costs a real row:
|
|
6
9
|
//
|
|
7
10
|
// PLACEMENT. A base used only inside one `if` arm, hoisted to the function top, is live across
|
|
8
11
|
// everything before that arm — a live range the original never had, which is the register-pressure
|
|
9
|
-
// failure basecse's own loop gate exists for.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// failure basecse's own loop gate exists for. That argument is why the lever is scope-aware; it is
|
|
13
|
+
// NOT a claim about what the lever achieves, and no committed measurement separates the two
|
|
14
|
+
// placements (the one that did edited a reference source by hand and cannot be re-run). On
|
|
15
|
+
// kleod:UpdateHUDCounterDisplay the primary path declines outright (a later pass retired the phi
|
|
16
|
+
// it keyed on, so the base's uses span the function body), and the cluster fallback below is what
|
|
17
|
+
// recovers it.
|
|
15
18
|
// basecse's header already names the gap — "a loop-body base is left
|
|
16
19
|
// inline for a future scope-aware hoist" — and this is that hoist.
|
|
17
20
|
//
|
|
@@ -143,7 +146,8 @@ function collect(
|
|
|
143
146
|
// CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
|
|
144
147
|
// (its `stmtChildren('for')` is `[init, inc, …body]`, recursed with `nested`), this pass counts
|
|
145
148
|
// it at the enclosing cadence, which is the truthful reading — it runs once. Recorded because
|
|
146
|
-
// the divergence is real and an extraction has to pick one
|
|
149
|
+
// the divergence is real and an extraction has to pick one; both readings are pinned in
|
|
150
|
+
// test/addr-placement.test.ts so the pick is deliberate rather than whichever survives.
|
|
147
151
|
stmtExprs(s).forEach((e) => visit(e, isLoop));
|
|
148
152
|
if (s.k === 'for') {
|
|
149
153
|
// `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
|
package/src/l3/tailmerge.ts
CHANGED
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
//
|
|
16
16
|
// if (c) { } else { g[594] = g[659]; } v4 = 1; → if (!c) { g[594] = g[659]; } v4 = 1;
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// THE BENCHMARK DOES NOT GUARD THIS PASS. It fires on two of its 743 rows and both match with the
|
|
19
|
+
// merge and without it, so no score moves if this file breaks — and none moves if the pipeline
|
|
20
|
+
// simply stops calling it. `test/tailmerge.test.ts` covers that gap explicitly, end to end, on one
|
|
21
|
+
// of those two functions; the rest of that file calls this pass directly and cannot see an unwiring.
|
|
22
|
+
//
|
|
23
|
+
// Placement is not a matter of taste: peeling the same statements ABOVE the `if` is a THIRD option,
|
|
24
|
+
// unsound for its own reason (it crosses the condition as well as both arms). The soundness argument
|
|
25
|
+
// above covers only below-vs-in-arms.
|
|
22
26
|
//
|
|
23
27
|
// KNOWN INTERACTIONS, both byte-level rather than soundness. This pass is unconditional like
|
|
24
28
|
// `dce.ts` and `basecse.ts` rather than a differ-refereed lever, and the argument those files each
|
package/src/pipeline.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// asmlift — the library entry point. `decompile(name, asm, target)` runs the raising tower and
|
|
2
2
|
// returns structured results: the source, the per-level IR dumps, and diagnostics.
|
|
3
3
|
import { cBackend } from './backend/c';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ContractError,
|
|
6
|
+
assertDerefsTyped,
|
|
7
|
+
assertEffectsPreserved,
|
|
8
|
+
assertResolved,
|
|
9
|
+
assertTypesRecovered,
|
|
10
|
+
} from './contracts';
|
|
5
11
|
import type { AsmData } from './frontend/asmdata';
|
|
6
12
|
import { FrontendUnsupportedError } from './frontend/errors';
|
|
7
13
|
import { frontendFor } from './frontend/registry';
|
|
8
|
-
import type
|
|
14
|
+
import { type Block, type Fn, successorsOf } from './ir/core';
|
|
9
15
|
import { print } from './ir/print';
|
|
10
16
|
import { T } from './ir/types';
|
|
11
17
|
import { VerifyError, verify } from './ir/verify';
|
|
12
|
-
import { Expr, LanguageBackend, SFn, Stmt, exprChildren, stmtChildren, stmtExprs } from './l3/ast';
|
|
18
|
+
import { Expr, LanguageBackend, SFn, Stmt, exprChildren, gapReasonFor, stmtChildren, stmtExprs } from './l3/ast';
|
|
13
19
|
import { hoistReusedGlobalBases } from './l3/basecse';
|
|
14
20
|
import { eliminateDeadStores } from './l3/dce';
|
|
15
21
|
import { mergeCommonTails } from './l3/tailmerge';
|
|
@@ -189,9 +195,55 @@ export function raiseRecovered(fn: Fn, target: TargetDescription, hooks: RaiseHo
|
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
/** Run `body`; if it declines, name the unmodelled instructions the function carries.
|
|
199
|
+
*
|
|
200
|
+
* An `opaque` degrades its own value AND makes its block impure, so a shape recognizer refuses:
|
|
201
|
+
* `headerPure` rejects a header holding one, and the loop declines with "unrecovered back-edge …".
|
|
202
|
+
* True and useless — the shape is fine, an instruction is missing — and the benchmark classifies
|
|
203
|
+
* declines by that text, so the round is filed as a loop-capability gap and the improvement loop
|
|
204
|
+
* builds the wrong thing.
|
|
205
|
+
*
|
|
206
|
+
* Only ADDS attribution: never converts a decline into a success, never fires without an
|
|
207
|
+
* unmodelled instruction, reachable blocks only (one in dead code did not cause the refusal). */
|
|
208
|
+
function attributeOpaques<T>(fn: Fn, body: () => T): T {
|
|
209
|
+
try {
|
|
210
|
+
return body();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// Attribution is a nicety, so it must not be able to throw: a crash here would replace a
|
|
213
|
+
// DESIGNED loud failure with an incidental one, which contract-invariant.test.ts rejects by name.
|
|
214
|
+
if (!(e instanceof StructureError) || !fn.blocks[0]) {
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
const seen = new Set<Block>([fn.blocks[0]]);
|
|
218
|
+
for (const stack = [fn.blocks[0]]; stack.length;) {
|
|
219
|
+
for (const s of successorsOf(stack.pop()!)) {
|
|
220
|
+
if (!seen.has(s)) {
|
|
221
|
+
seen.add(s);
|
|
222
|
+
stack.push(s);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const names = new Set<string>();
|
|
227
|
+
for (const b of seen) {
|
|
228
|
+
for (const op of b.ops) {
|
|
229
|
+
if (op.opcode === 'opaque') {
|
|
230
|
+
names.add(typeof op.attrs.mnemonic === 'string' ? op.attrs.mnemonic : '?');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!names.size || /unmodelled instruction/.test(e.message)) {
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
237
|
+
// Through `gapReasonFor`, so the classifier sees its canonical text — a hand-written variant
|
|
238
|
+
// misses the mnemonic-anchored classes and every attributed decline lands in the generic bucket.
|
|
239
|
+
const list = [...names].sort().map(gapReasonFor).join(', ');
|
|
240
|
+
throw new StructureError(`${e.message} — and the function carries ${list}, which is the more likely cause`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
192
244
|
/** Stage 4 — structure + its boundary contracts, always as a pair. */
|
|
193
245
|
export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]): SFn {
|
|
194
|
-
const raw = structure(fn, opts);
|
|
246
|
+
const raw = attributeOpaques(fn, () => structure(fn, opts));
|
|
195
247
|
// BOTH boundary contracts run on the pre-DCE tree: the readability pass must never be able to
|
|
196
248
|
// hide a structuring defect by dropping the dead statement that carries it. assertResolved
|
|
197
249
|
// catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
|
|
@@ -199,6 +251,7 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
|
|
|
199
251
|
// removes statements/flips branches over an already-validated tree.
|
|
200
252
|
assertResolved(raw);
|
|
201
253
|
assertDerefsTyped(raw);
|
|
254
|
+
assertEffectsPreserved(fn, raw);
|
|
202
255
|
// Then the readability/quality rewrites: merge a statement common to every arm of an if,
|
|
203
256
|
// drop dead stores (whose empty-then peephole flips the arm the merge empties), then hoist a
|
|
204
257
|
// reused aggregate-global
|
|
@@ -206,6 +259,9 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
|
|
|
206
259
|
// local's initializer, so re-validate deref typing on the rewritten tree.
|
|
207
260
|
const sfn = hoistReusedGlobalBases(eliminateDeadStores(mergeCommonTails(raw)));
|
|
208
261
|
assertDerefsTyped(sfn);
|
|
262
|
+
// Re-checked after the readability rewrites for the same reason deref typing is: a pass that
|
|
263
|
+
// merges arms or drops statements must not be able to lose or duplicate a call.
|
|
264
|
+
assertEffectsPreserved(fn, sfn);
|
|
209
265
|
return sfn;
|
|
210
266
|
}
|
|
211
267
|
|
package/src/raise/divpow2.ts
CHANGED
|
@@ -103,7 +103,8 @@ export function recognizeDivPow2(fn: Fn): boolean {
|
|
|
103
103
|
for (const bias of preds.get(m)!) {
|
|
104
104
|
// The BIAS arm: sole predecessor is the head, and it does nothing but bias (and possibly
|
|
105
105
|
// shift). The whole block is DELETED, not hoisted, so anything else in it would be silently
|
|
106
|
-
// dropped — a store, a call or
|
|
106
|
+
// dropped — a store, a call or an opaque there would simply stop happening (an opaque
|
|
107
|
+
// whether or not its result is read: liveness says nothing about what the instruction did).
|
|
107
108
|
const bt = term(bias);
|
|
108
109
|
if (bt.opcode !== 'br' || bt.successors[0]?.block !== m || bias === fn.blocks[0]) {
|
|
109
110
|
continue;
|
package/src/raise/gvn.ts
CHANGED
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
// analysis.ts, whose materialize-into-a-local rule covers `const`, `call` and the memory reads, NOT
|
|
40
40
|
// address ops), so the address is re-spelled at each access exactly as the original source did.
|
|
41
41
|
// Hoisting therefore does not create the long live range that hoisting a LOADED value would.
|
|
42
|
+
// That is a promise ANOTHER module keeps, so `test/addr-placement.test.ts` holds it to it: let
|
|
43
|
+
// analysis.ts materialize an address op and the entry hoist becomes a function-top local — the one
|
|
44
|
+
// this pass exists to delete, reintroduced one level up.
|
|
42
45
|
//
|
|
43
46
|
// SCOPE, deliberately narrow: `code: true` symbols (a promoted function pointer, spelled `(u32)Name`
|
|
44
47
|
// rather than `&Name`) are numbered separately from data ones, because the attr is part of what the
|
|
@@ -47,15 +50,22 @@
|
|
|
47
50
|
// THE WIN IS CONTINGENT ON THE SYMBOL MAP, which is worth knowing before relying on it. With a map
|
|
48
51
|
// supplying an array's rank the accesses render as `gSym[0][i]`, a `var` base that
|
|
49
52
|
// `l3/basecse.ts`'s `isHoistableBase` cannot see, so nothing re-creates the local this pass
|
|
50
|
-
// deleted. WITHOUT
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
53
|
+
// deleted. WITHOUT one the same accesses spell as `addr`, basecse sees the reuse, and it hoists a
|
|
54
|
+
// function-top `p0 = (u16 *)&gBgTilemapBufs` — the same local, one level up. Both arms are pinned
|
|
55
|
+
// in `test/addr-placement.test.ts`; before that they rested on a run nobody could repeat.
|
|
56
|
+
//
|
|
57
|
+
// FOUR modules now answer "is this address a local?" with independent policies — here: never;
|
|
58
|
+
// basecse: at the function top, when reused 2+ times; l3/scopebase.ts: at the innermost scope
|
|
59
|
+
// holding the uses; l3/argbase.ts: immediately before a call whose arguments share it. Reconciling
|
|
60
|
+
// them is recorded debt, and the same test pins the two places they actively disagree, because a
|
|
61
|
+
// consolidation has to PICK rather than discover them: a `for`'s init (basecse reads it at loop
|
|
62
|
+
// cadence and refuses, scopebase at the enclosing one and hoists) and a global name shadowed by a
|
|
63
|
+
// local (scopebase must refuse — it re-spells the base as `&g` — while argbase may fire, because it
|
|
64
|
+
// keeps the base expression verbatim).
|
|
55
65
|
import { Block, Fn, Op, Value, mkOp, replaceAllUsesWith } from '../ir/core';
|
|
56
66
|
|
|
57
67
|
/** Ops whose result depends on `attrs` alone — no operands, no memory, no control flow. */
|
|
58
|
-
const NUMBERABLE = new Set(['gaddr']);
|
|
68
|
+
const NUMBERABLE = new Set(['gaddr', 'laddr']); // laddr: same argument — operand-free, pure, attr-keyed
|
|
59
69
|
|
|
60
70
|
/** The value-number key: the opcode plus every attribute, in a stable order. */
|
|
61
71
|
function keyOf(op: Op): string {
|
|
@@ -47,8 +47,10 @@ export const PRE_RECOVERY_PASSES: PreRecoveryPass[] = [
|
|
|
47
47
|
// Numbering alone is not enough and not safe to ship alone: collapsing the duplicates leaves a
|
|
48
48
|
// block param whose edges now all carry one value, and the structurer still destroys THAT into
|
|
49
49
|
// a local (it only reuses a name a carrier already has, and an inlined `gaddr` has none).
|
|
50
|
-
//
|
|
51
|
-
//
|
|
50
|
+
// Numbering alone costs kleod:UpdateHUDCounterDisplay its match, so the pair is the atomic
|
|
51
|
+
// unit, expressed as a body rather than a sum of two unrelated counts. It is NOT monotone,
|
|
52
|
+
// which is worth knowing before tuning either half: dropping the cleanup IMPROVES
|
|
53
|
+
// kleod:ConfigureEntityBehavior and kleod:CountCollectedGems, neither of them near matching.
|
|
52
54
|
run: (fn) => {
|
|
53
55
|
const n = numberPureValues(fn);
|
|
54
56
|
return n + simplifyTrivialPhis(fn);
|
package/src/raise/retsink.ts
CHANGED
|
@@ -68,10 +68,11 @@ export function sinkReturns(fn: Fn): boolean {
|
|
|
68
68
|
// i.e. BEFORE this one, so on `ifand`/`and3` shape (b) is the only one that ever fires.
|
|
69
69
|
//
|
|
70
70
|
// The connective ALONE is not enough, and the extra requirement is BOTH arms being real
|
|
71
|
-
// blocks (≥2 `br` preds). `return a || b` (synthetic:lor) also ends up as a
|
|
72
|
-
// `logic_or`, but it is a value-merge: one edge runs from the head STRAIGHT
|
|
73
|
-
// so the merge has a single `br` pred. Sinking it replaces the merge
|
|
74
|
-
// byte-matches
|
|
71
|
+
// blocks (≥2 `br` preds). `return a || b` (synthetic:lor:agbcc) also ends up as a
|
|
72
|
+
// `cond_br` on a `logic_or`, but it is a value-merge: one edge runs from the head STRAIGHT
|
|
73
|
+
// into the merge, so the merge has a single `br` pred. Sinking it replaces the merge
|
|
74
|
+
// variable that byte-matches: dropping the `brPreds.length >= 2` half of this gate costs
|
|
75
|
+
// that row its match. A two-armed diamond is what
|
|
75
76
|
// distinguishes `if (a && b) return X; return Y;` from every value-merge.
|
|
76
77
|
//
|
|
77
78
|
// A simple single-condition select is excluded by both arms of the gate for the same reason:
|
|
@@ -319,11 +319,9 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
|
319
319
|
// ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
|
|
320
320
|
// see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
|
|
321
321
|
// short circuit.
|
|
322
|
-
// HOIST_UNSAFE_OPS
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
|
|
326
|
-
// an observed bug.
|
|
322
|
+
// HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
|
|
323
|
+
// out of the arm that guards it is the reordering this refuses. Loud either way today — a
|
|
324
|
+
// decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
|
|
327
325
|
const body = g.ops.slice(0, -1);
|
|
328
326
|
if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
329
327
|
continue;
|
|
@@ -87,7 +87,8 @@ function withPadding(dataFields: StructField[], stride: number): StructField[] {
|
|
|
87
87
|
* rematerializes the same element address (several `add(base, i*stride)` ops for one logical
|
|
88
88
|
* array), and recovering them one-by-one would let the first claim the base and force its
|
|
89
89
|
* twins to decline — a mixed spelling that is worse than either pure form (found live on
|
|
90
|
-
* pokeemerald:
|
|
90
|
+
* pokeemerald:GetGenderFromSpeciesAndPersonality, whose address is materialized twice). A base
|
|
91
|
+
* whose element pointers
|
|
91
92
|
* disagree on stride declines entirely: two strides over one base is a reinterpreted view or
|
|
92
93
|
* a 2D layout, genuinely ambiguous — decline over guess. */
|
|
93
94
|
export function recognizeStructArrays(fn: Fn): number {
|
package/src/raise/structs.ts
CHANGED
|
@@ -178,6 +178,17 @@ export function recognizeStructs(fn: Fn): number {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Which values are the address of a NAMED global (`gaddr`)? Consulted only when synthesis
|
|
182
|
+
// DECLINES: see the catch below.
|
|
183
|
+
const namedGlobal = new Set<Value>();
|
|
184
|
+
for (const b of fn.blocks) {
|
|
185
|
+
for (const op of b.ops as Op[]) {
|
|
186
|
+
if (op.opcode === 'gaddr') {
|
|
187
|
+
namedGlobal.add(op.results[0]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
181
192
|
let count = 0;
|
|
182
193
|
for (const base of order) {
|
|
183
194
|
if (arrayBases.has(base)) {
|
|
@@ -186,11 +197,28 @@ export function recognizeStructs(fn: Fn): number {
|
|
|
186
197
|
if (base.type.kind !== 'unknown') {
|
|
187
198
|
continue;
|
|
188
199
|
} // already typed (not a bare recovery target)
|
|
200
|
+
|
|
189
201
|
const accesses = accessesOf.get(base)!;
|
|
190
202
|
if (isArray(accesses)) {
|
|
191
203
|
continue;
|
|
192
204
|
} // uniform stride / single aligned access → array
|
|
193
|
-
|
|
205
|
+
try {
|
|
206
|
+
base.type = T.ptr(buildStruct(`Struct${count}`, accesses));
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// A NAMED global whose accesses synthesis cannot reconcile is not a reason to decline the
|
|
209
|
+
// function: its declaration belongs to the project's own headers, and its constant-offset
|
|
210
|
+
// accesses render at L3 through the symbol context (member spelling when the map knows the
|
|
211
|
+
// layout, the honest cast spelling when it does not). The inhabitant is agbcc FUSING two
|
|
212
|
+
// adjacent u8 compares into one ldrh — `s.level == 8 && s.world == 6` reads offset 12 at
|
|
213
|
+
// widths 1 AND 2, which is not a union, just two spellings of declared bytes. An ANONYMOUS
|
|
214
|
+
// base (a loaded pointer, a parameter) has no other source of truth, so for it the decline
|
|
215
|
+
// stands exactly as before — this catch narrows nothing for the shapes that already worked,
|
|
216
|
+
// because a base synthesis succeeds on takes the same path it always took.
|
|
217
|
+
if (e instanceof RaiseUnsupportedError && namedGlobal.has(base)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
194
222
|
count++;
|
|
195
223
|
}
|
|
196
224
|
return count;
|
package/src/rank.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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 { globalCellOf } from './ir/alias';
|
|
15
16
|
import { Fn, type Value, defOpMap } from './ir/core';
|
|
16
17
|
import { T } from './ir/types';
|
|
17
18
|
import { verify } from './ir/verify';
|
|
@@ -223,7 +224,7 @@ export function enumerateCandidates(
|
|
|
223
224
|
[...opts.symbols.values()].some((infos) =>
|
|
224
225
|
infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
|
|
225
226
|
);
|
|
226
|
-
const
|
|
227
|
+
const bitfieldCands = mapHasBitfields
|
|
227
228
|
? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
|
|
228
229
|
: baseSense;
|
|
229
230
|
// Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
|
|
@@ -239,6 +240,28 @@ export function enumerateCandidates(
|
|
|
239
240
|
// Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
|
|
240
241
|
// from the probe: widths/offsets are lift-time facts, identical across every candidate.
|
|
241
242
|
const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
|
|
243
|
+
// `/reread-globals` — the VALUE-HOME axis (structure/analysis.ts AnalyzeOptions). Whether the
|
|
244
|
+
// source read a global once into a variable or re-read it at each use is not derivable from asm:
|
|
245
|
+
// the compiler CSEs the second spelling back into one load, and the round-5 dogfood watched agbcc
|
|
246
|
+
// land on both sides inside a single function (its highest-cost defect, 25 of 27 points on one
|
|
247
|
+
// klonoa function and 35/50 both ways on another). So both spellings are emitted and the differ
|
|
248
|
+
// referees — the same footing as signedness and branch sense, and never a default: the cached
|
|
249
|
+
// spelling stays the primary, so this can only ever ADD a winner.
|
|
250
|
+
//
|
|
251
|
+
// Gated on the function having a load that resolves to a named global at all — the only thing the
|
|
252
|
+
// axis can change. The dedup below collapses the pair wherever it changed nothing.
|
|
253
|
+
const probeDefs = defOpMap(probe);
|
|
254
|
+
const readsANamedGlobal = probe.blocks.some((b) =>
|
|
255
|
+
b.ops.some(
|
|
256
|
+
(op) => op.opcode === 'load' && globalCellOf(probeDefs, op.operands[0], op.attrs.off as number) !== null,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
const senseCands = readsANamedGlobal
|
|
260
|
+
? [
|
|
261
|
+
...bitfieldCands.map((s) => ({ ...s, reread: false })),
|
|
262
|
+
...bitfieldCands.map((s) => ({ ...s, suffix: `${s.suffix}/reread-globals`, reread: true })),
|
|
263
|
+
]
|
|
264
|
+
: bitfieldCands.map((s) => ({ ...s, reread: false }));
|
|
242
265
|
|
|
243
266
|
const seen = new Set<string>();
|
|
244
267
|
const out: Candidate[] = [];
|
|
@@ -273,9 +296,10 @@ export function enumerateCandidates(
|
|
|
273
296
|
preserveDivergentBranchSense: s.sense,
|
|
274
297
|
anchorConstCopies: s.anchor,
|
|
275
298
|
spellBitfieldMembers: s.bitfields,
|
|
299
|
+
rereadGlobals: s.reread,
|
|
276
300
|
});
|
|
277
301
|
} catch (e) {
|
|
278
|
-
if (!s.anchor && s.bitfields) {
|
|
302
|
+
if (!s.anchor && s.bitfields && !s.reread) {
|
|
279
303
|
throw e; // the base axes keep their behavior: a structuring failure aborts the row
|
|
280
304
|
}
|
|
281
305
|
// an anchored variant that fails structuring or its contracts is a dropped lever, never
|