Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 39 additions & 23 deletions apps/benchmark/results/results.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"meta": {
"generatedAt": "2026-08-02T22:33:22.408Z",
"generatedAt": "2026-08-03T13:41:49.747Z",
"toolchains": [
"ido7.1",
"agbcc",
Expand All @@ -14,7 +14,7 @@
"real": 192
},
"asmlift": {
"commit": "2716c392e5fcaf4171e2aa6caf3ef8c1939f9377",
"commit": "4cc1eb3eef09dc1d8f427bde8142bc6a0a7f745a",
"dirty": false
},
"m2c": {
Expand Down Expand Up @@ -2719,11 +2719,11 @@
"droppedCandidates": [
{
"label": "unsigned/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-oVaj7h/c.c:1077: invalid operands to binary <<"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-6EBcDE/c.c:1077: invalid operands to binary <<"
},
{
"label": "unsigned/scopebase/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-iGhmun/c.c:1078: invalid operands to binary <<"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-f8MaMA/c.c:1078: invalid operands to binary <<"
}
],
"symbolsUsed": [
Expand Down Expand Up @@ -3351,7 +3351,7 @@
"droppedCandidates": [
{
"label": "unsigned",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-21DgJj/c.c:1076: `gBgDataPtrs' undeclared (first use in this function)"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-8vSdHZ/c.c:1076: `gBgDataPtrs' undeclared (first use in this function)"
}
],
"symbolsUsed": [],
Expand Down Expand Up @@ -4337,7 +4337,7 @@
"droppedCandidates": [
{
"label": "unsigned",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-FEuOIT/c.c:1077: too many arguments to function `thunk_sub_080002A0'"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-yt7sUA/c.c:1077: too many arguments to function `thunk_sub_080002A0'"
}
],
"symbolsUsed": [],
Expand Down Expand Up @@ -4918,15 +4918,31 @@
"asmlift": {
"decompiler": "asmlift",
"symbolMap": true,
"candidateLabel": "unsigned",
"candidateLabel": "unsigned/scopebase-coalesce-v2-v4",
"droppedCandidates": [
{
"label": "unsigned/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-PAa2js/c.c:1085: incompatible types in assignment"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-f8dhOV/c.c:1085: incompatible types in assignment"
},
{
"label": "unsigned/coalesce-v2-v3/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-CMyrpb/c.c:1084: incompatible types in assignment"
},
{
"label": "unsigned/coalesce-v2-v4/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-KzxpAS/c.c:1084: incompatible types in assignment"
},
{
"label": "signed/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-KCEBna/c.c:1085: incompatible types in assignment"
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-bWSRXp/c.c:1085: incompatible types in assignment"
},
{
"label": "signed/coalesce-v2-v3/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-4MxiRD/c.c:1084: incompatible types in assignment"
},
{
"label": "signed/coalesce-v2-v4/raw-globals",
"error": "agbcc failed: /var/folders/q_/6tsqtbsd2ks6l381b5yc8fvh0000gn/T/bench-cand-UOG64w/c.c:1084: incompatible types in assignment"
}
],
"symbolsUsed": [
Expand All @@ -4944,22 +4960,22 @@
}
],
"outcome": "nonmatch",
"source": "s32 UpdateHUDCounterDisplay(u32 a0, u32 a1) {\n s32 v0;\n s32 v1;\n s32 v2;\n s32 v3;\n s32 v4;\n if (gUnk_03004C20.unkA == 1 || gUnk_03004C20.level == 6) {\n v2 = 100;\n } else {\n v2 = 30;\n }\n v0 = *(u16 *)&gUnk_03005220;\n if (v2 != (u32)(v0 << 20) >> 25) {\n v3 = 0;\n } else {\n v3 = 1;\n }\n if (gUnk_03004C20.unkA == 1 || gUnk_03004C20.level == 6) {\n if ((u32)(v0 << 20) >> 25 <= 99) {\n v4 = 1;\n } else {\n gBgTilemapBufs[0][594] = gBgTilemapBufs[0][659];\n gBgTilemapBufs[0][626] = gBgTilemapBufs[0][691];\n v4 = 1;\n }\n } else {\n v4 = 0;\n }\n v1 = *(u16 *)&gUnk_03005220;\n if ((u32)(v1 << 20) >> 25 > 9) {\n gBgTilemapBufs[0][(149 << 2) - v4] = gBgTilemapBufs[0][((u32)(v1 << 20) >> 25) / 10 + 658];\n gBgTilemapBufs[0][(157 << 2) - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) / 10 + 690];\n }\n gBgTilemapBufs[0][597 - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) % 10 + 658];\n gBgTilemapBufs[0][629 - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) % 10 + 690];\n return v3;\n}\n",
"score": 60,
"maxScore": 147,
"source": "s32 UpdateHUDCounterDisplay(u32 a0, u32 a1) {\n s32 v0;\n s32 v1;\n s32 v3;\n s32 v4;\n u16 * p0;\n if (gUnk_03004C20.unkA == 1 || gUnk_03004C20.level == 6) {\n v4 = 100;\n } else {\n v4 = 30;\n }\n v0 = *(u16 *)&gUnk_03005220;\n if (v4 != (u32)(v0 << 20) >> 25) {\n v3 = 0;\n } else {\n v3 = 1;\n }\n if (gUnk_03004C20.unkA == 1 || gUnk_03004C20.level == 6) {\n if ((u32)(v0 << 20) >> 25 > 99) {\n p0 = (u16 *)&gBgTilemapBufs;\n p0[594] = p0[659];\n p0[626] = p0[691];\n }\n v4 = 1;\n } else {\n v4 = 0;\n }\n v1 = *(u16 *)&gUnk_03005220;\n if ((u32)(v1 << 20) >> 25 > 9) {\n gBgTilemapBufs[0][(149 << 2) - v4] = gBgTilemapBufs[0][((u32)(v1 << 20) >> 25) / 10 + 658];\n gBgTilemapBufs[0][(157 << 2) - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) / 10 + 690];\n }\n gBgTilemapBufs[0][597 - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) % 10 + 658];\n gBgTilemapBufs[0][629 - v4] = gBgTilemapBufs[0][((u32)(*(u16 *)&gUnk_03005220 << 20) >> 25) % 10 + 690];\n return v3;\n}\n",
"score": 18,
"maxScore": 139,
"compileErrors": null,
"breakdown": {
"insert": 11,
"delete": 8,
"insert": 3,
"delete": 3,
"replace": 0,
"opMismatch": 3,
"argMismatch": 38
"argMismatch": 9
},
"quality": {
"score": 88,
"lines": 37,
"lines": 36,
"gotos": 0,
"casts": 12,
"casts": 13,
"unkGlue": 0,
"rawMem": 0,
"addrDeref": 0
Expand Down Expand Up @@ -4991,15 +5007,15 @@
},
"gapSize": {
"decompiler": "asmlift",
"score": 60,
"maxScore": 147,
"ratio": 0.40816326530612246,
"score": 18,
"maxScore": 139,
"ratio": 0.12949640287769784,
"kinds": {
"insert": 11,
"delete": 8,
"insert": 3,
"delete": 3,
"replace": 0,
"opMismatch": 3,
"argMismatch": 38
"argMismatch": 9
}
},
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/data/summary.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"asmlift": 350,
"m2c": 340
},
"commit": "2716c392e5fcaf4171e2aa6caf3ef8c1939f9377",
"commit": "4cc1eb3eef09dc1d8f427bde8142bc6a0a7f745a",
"m2cCommit": "94098d4de68c2fcc13fb8cf1096a1520eb171abe",
"dirty": false
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/benchmark/data/results.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion packages/core/src/l3/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,12 @@ export function exprEquals(a: Expr, b: Expr): boolean {
}
case 'field': {
const bb = b as typeof a;
return a.name === bb.name && exprEquals(a.base, bb.base);
// `dot` is part of the SPELLING, and for the same reason `lead` is compared above: a CSE or
// dedup that treats these as equal keeps one node and discards the other, silently respelling
// `p->field_4` as `p.field_4` (or the reverse). Both compile only for the base type each
// belongs to, so collapsing them is how a valid access becomes an invalid one — or worse, a
// valid one against a different object.
return a.name === bb.name && (a.dot ?? false) === (bb.dot ?? false) && exprEquals(a.base, bb.base);
}
case 'marker': {
const bb = b as typeof a;
Expand Down
146 changes: 146 additions & 0 deletions packages/core/src/l3/coalesce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { typeToString } from '../ir/types';
import type { Expr, SFn, Stmt } from './ast';
import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';

function namesIn(e: Expr, out: Set<string>): void {
// `addr` names a GLOBAL, never a local — collected anyway. A name reaching BOTH forms would
// otherwise get a span that ignores its `addr` mentions, and a SHORT span is a clobber while a
// long one is only a missed merge. `structure.ts` keeps locals to /^[vt]\d+$/ and excludes global
// names, so this cannot fire today; collecting is the direction that stays safe if that changes.
if (e.k === 'var' || e.k === 'addr') out.add(e.name);

Check warning on line 10 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
for (const c of exprChildren(e)) namesIn(c, out);

Check warning on line 11 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'for-of'
}

/** Does `e` mention `n` anywhere? */
function mentions(e: Expr, n: string): boolean {
const seen = new Set<string>();
namesIn(e, seen);
return seen.has(n);
}
interface Span {
first: number;
last: number;
inLoop: boolean;
constFed: boolean;
/** the local's FIRST mention is a write, not a read */
firstIsWrite: boolean;
}
function spans(body: Stmt[]): Map<string, Span> {
const out = new Map<string, Span>();
let at = 0;
const walk = (list: Stmt[], inLoop: boolean): void => {
for (const s of list) {
at++;
const here = new Set<string>();
if (s.k === 'assign') here.add(s.name);

Check warning on line 35 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
for (const e of stmtExprs(s)) namesIn(e, here);

Check warning on line 36 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'for-of'
for (const n of here) {
const sp = out.get(n) ?? {
first: at,
last: at,
inLoop,
constFed: true,
// an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
// let `g` receive the absorbed value
firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
};
sp.last = at;
sp.inLoop ||= inLoop;
if (s.k === 'assign' && s.name === n && s.value.k !== 'const') sp.constFed = false;

Check warning on line 49 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
out.set(n, sp);
}
walk(stmtChildren(s), inLoop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for');
}
};
walk(body, false);
return out;
}
function rename(body: Stmt[], from: string, to: string): Stmt[] {
const inExpr = (e: Expr): Expr =>
e.k === 'var' && e.name === from ? { ...e, name: to } : mapExprChildren(e, inExpr);
const inStmt = (s: Stmt): Stmt => {
const r = { ...s } as Record<string, unknown>;
if (s.k === 'assign' && s.name === from) r.name = to;

Check warning on line 63 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
for (const key of ['value', 'lval', 'cond', 'scrutinee'] as const) {
const v = (s as Record<string, unknown>)[key];
if (v !== undefined) r[key] = inExpr(v as Expr);

Check warning on line 66 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
}
for (const key of ['then', 'else', 'body', 'default'] as const) {
const v = (s as Record<string, unknown>)[key];
if (Array.isArray(v)) r[key] = (v as Stmt[]).map(inStmt);

Check warning on line 70 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
}
if (s.k === 'for') {
r.init = inStmt(s.init);
r.inc = inStmt(s.inc);
}
if (s.k === 'switch') r.cases = s.cases.map((c) => ({ ...c, body: c.body.map(inStmt) }));

Check warning on line 76 in packages/core/src/l3/coalesce.ts

View workflow job for this annotation

GitHub Actions / ci

Expected { after 'if' condition
return r as Stmt;
};
return body.map(inStmt);
}
/** Every legal single merge, each as its own tree — NOT one committed choice.
*
* Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
* it wrong: on kleod:UpdateHUDCounterDisplay the two legal merges score 18 and 40 against a
* no-merge baseline of 21, and declaration order picks the 40. `rank.ts` already has the idiom for
* exactly this — `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so
* every candidate is emitted and the differ referees.
*
* GATES:
* - a local mentioned inside a loop BODY is excluded. SOUND-critical: it is what makes preorder
* statement order a sufficient approximation of liveness. Preorder is a topological order of the
* CFG except where a later-indexed statement can run before an earlier one, and the positions
* that do that — a `for`'s `init`/`inc`, and everything in any loop body — are inside a loop, so
* the gate covers them. A loop's own CONDITION is NOT covered: it is visited at the loop
* statement's own index with the ENCLOSING loop flag. That is safe only because a condition
* cannot WRITE, so it can extend a read range but never reorder a definition — an earlier
* version of this comment claimed the gate covered conditions too, which it does not.
* Differential fuzzing supports this: removing the gate produces clobbers immediately, leaving
* it on produces none. No such harness is committed, so nothing here re-checks it.
* - both must be CONSTANT-fed. A codegen heuristic, not soundness — removing it stayed
* clobber-free under the same (uncommitted) fuzz and simply scored worse, because a load-fed
* local is one the compiler had a reason to keep where it was. It is also what currently BOUNDS
* candidate growth: merges are `L(L-1)/2` in the local count, each a distinct source and so a
* distinct compile, and nothing else caps that. Corpus-wide today: 2 rows, 13 kept sources.
* - the survivor's first mention must be an ASSIGN THAT DOES NOT ALSO READ IT. `b = g(b)` is a
* write and a read in one statement; counting it as a pure write let `g` receive the absorbed
* value. These two gates are NOT independent: `constFed` also rejects a self-reading assign
* (its value is not a literal), so it masks this one. No committed test isolates it — this is
* defence-in-depth for the day `constFed` is relaxed, which the note above makes plausible.
*
* ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
* the paths that skip it. The original read an uninitialized local there, so both spellings are
* ill-defined rather than one being wrong — but this is a real difference and the differ, not this
* gate, is what keeps it from faking a match. */
export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
if (sfn.locals.length < 2) {
return [];
}
const params = new Set(sfn.params.map((p) => p.name));
const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
const sp = spans(sfn.body);
const out: { merged: string; sfn: SFn }[] = [];
for (const a of sfn.locals.map((l) => l.name)) {
for (const b of sfn.locals.map((l) => l.name)) {
const x = sp.get(a);
const y = sp.get(b);
if (a === b || !x || !y || params.has(a) || params.has(b)) {
continue;
}
if (typeOf.get(a) !== typeOf.get(b) || x.inLoop || y.inLoop || !x.constFed || !y.constFed) {
continue;
}
if (x.last >= y.first || !y.firstIsWrite) {
continue;
}
// Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
// at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
// that is wrong but plausible.
out.push({
merged: `${a}-${b}`,
sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
});
}
}
return out;
}
Loading
Loading