Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/benchmark/dataset/real/tu/af/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "4515c15b5848b1ec85e78cb3776e12990cafe004",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-01T15:04:28.072Z"
"generatedAt": "2026-08-02T14:37:44.577Z"
}
Binary file modified apps/benchmark/dataset/real/tu/af/symbols.json.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/benchmark/dataset/real/tu/kleod/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "704fd74330959112873665d790f911e3679770c0",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-02T12:01:59.124Z"
"generatedAt": "2026-08-02T14:37:46.556Z"
}
Binary file modified apps/benchmark/dataset/real/tu/kleod/symbols.json.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/benchmark/dataset/real/tu/marioparty3/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "6b4380b9493a2bf9a60e3be5f5b6b6e70e4b927e",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-01T15:04:33.605Z"
"generatedAt": "2026-08-02T14:37:50.674Z"
}
Binary file modified apps/benchmark/dataset/real/tu/marioparty3/symbols.json.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/benchmark/dataset/real/tu/pokeemerald/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "9d96fe4bd358bb7f8e70dc58702d5c4cb545b761",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-02T12:02:06.071Z"
"generatedAt": "2026-08-02T14:37:53.199Z"
}
Binary file modified apps/benchmark/dataset/real/tu/pokeemerald/symbols.json.gz
Binary file not shown.
4 changes: 2 additions & 2 deletions apps/benchmark/dataset/real/tu/sa3/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"project": "sa3",
"commit": "a4a344bb0de8a0add5f4034a0f7b1a10c02f30e8",
"commit": "a069e81bb4c7128e12bfb182bdcc49141ec5f7fb",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-01T12:23:01.254Z"
"generatedAt": "2026-08-02T14:37:56.363Z"
}
Binary file modified apps/benchmark/dataset/real/tu/sa3/symbols.json.gz
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "63236cd8dce61b8a1b37765952eb340b66684476",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-01T15:04:38.726Z"
"generatedAt": "2026-08-02T14:38:00.285Z"
}
Binary file modified apps/benchmark/dataset/real/tu/snowboardkids2/symbols.json.gz
Binary file not shown.
824 changes: 437 additions & 387 deletions apps/benchmark/results/results.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/web/src/data/summary.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"total": 675,
"match": {
"asmlift": 343,
"asmlift": 345,
"m2c": 340
},
"commit": "f309fec083fbc669836a6aea250dcea84532eecd",
"commit": "005cccf2660e8bd7aa845c3eddffcfac7d6f21a7",
"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.

5 changes: 4 additions & 1 deletion apps/web/src/pages/playground/cpp-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ export function deriveSpec(name: string, sfn: SFn): CppFnSpec {

const maxIdx = new Map<string, number>([...recv.values()].map((c) => [c, -1]));
const visitE = (e: Expr): void => {
if (e.k === 'index' && e.base.k === 'var' && e.idx.k === 'const') {
// `!e.lead?.length` mirrors core's cpp.ts leaf hook: a multidimensional array global's access
// is not a receiver access, and its `[0]` prefix is not a member index. Kept in step with that
// copy — the two predicates must not drift.
if (e.k === 'index' && e.base.k === 'var' && e.idx.k === 'const' && !e.lead?.length) {
const cls = recv.get(e.base.name);
if (cls !== undefined) {
maxIdx.set(cls, Math.max(maxIdx.get(cls) ?? -1, e.idx.value));
Expand Down
18 changes: 13 additions & 5 deletions apps/web/src/pages/playground/score-wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
// engine failure throws, and a row that cannot be displayed can never count as matched.
import { cBackend } from '@asmlift/core/backend/c';
import { renderDeclarations } from '@asmlift/core/declare';
import { type DroppedCandidate, type RankedResult, type Scored, enumerateCandidates } from '@asmlift/core/rank';
import {
type DroppedCandidate,
type RankedResult,
type Scored,
compareScored,
enumerateCandidates,
} from '@asmlift/core/rank';
import type { SymbolMap } from '@asmlift/core/symbols';
import { C_TYPEDEFS, type TargetDescription } from '@asmlift/core/target';
import { assemble, compileToObject } from 'agbcc';
Expand Down Expand Up @@ -195,9 +201,11 @@ export async function rankCandidatesInBrowser(

// Mirrors core's `rankBy` (which this cannot reuse — the wasm scorer is async): a candidate
// that fails to build is DROPPED rather than allowed to sink a sibling that compiles, and each
// drop is RECORDED so a failed spelling is never invisible. Enumeration order breaks a score
// tie, exactly as `rankBy` spells it — the named spelling is enumerated before its
// `/raw-globals` sibling, so equal bytes show the reader the named one.
// drop is RECORDED so a failed spelling is never invisible.
//
// The ORDERING is not re-spelled here, it is IMPORTED — `compareScored` is core's one copy. Two
// drivers over the same enumeration with two hand-written comparators is how the playground and
// the CLI come to disagree about which spelling of the same function is best.
const results: (Scored<MatchScore> & { order: number })[] = [];
const dropped: DroppedCandidate[] = [];
let lastErr: unknown = null;
Expand All @@ -219,6 +227,6 @@ export async function rankCandidatesInBrowser(
const why = lastErr instanceof Error ? lastErr.message.split('\n')[0] : String(lastErr ?? 'no candidate produced');
throw new Error(`no scorable candidate for '${name}': ${why}`, { cause: lastErr });
}
results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
results.sort(compareScored);
return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@asmlift/core": "workspace:^",
"@gba-kit/debug-info": "^0.4.0",
"@gba-kit/debug-info": "^0.5.0",
"objdiff-wasm": "3.7.3",
"yaml": "^2.9.0"
},
Expand Down
35 changes: 33 additions & 2 deletions packages/cli/src/symbols-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
// its own capability, so `UPGRADE` names the required version in ONE place, as remediation advice
// rather than as the test. `assertPointeeFactPresent` and `assertPointeeCapabilityWitnessed` are
// the same gate one release later, for the facts an INTERIOR spelling through a pointer global
// needs (what it points at, and which of a layout's members are arrays).
// needs (what it points at, and which of a layout's members are arrays); `assertArrayDimsPresent`
// is the release after that, for an array's RANK.
import { addressCastMacrosFrom } from '@asmlift/core/macros';
import type { SymbolInfo, SymbolMap, SymbolStructField } from '@asmlift/core/symbols';
import { readFileSync } from 'node:fs';
Expand All @@ -48,6 +49,10 @@ type DwarfShape =
elemSize: number | null;
elemSigned: boolean | null;
length: number | null;
/** the per-dimension extents (the RANK). OPTIONAL at this boundary only because the type
* must also describe a package that predates the fact; its runtime AVAILABILITY is
* asserted separately (assertArrayDimsPresent). */
dims?: (number | null)[] | null;
volatile?: boolean;
const?: boolean;
}
Expand Down Expand Up @@ -86,7 +91,7 @@ interface DwarfMember {
* declares; emitting one produces non-compiling output, so they never win the canonical pick. */
export const PLACEHOLDER = /^(?:sub_|_)[0-9A-Fa-f]{6,8}$/;

const UPGRADE = 'upgrade @gba-kit/debug-info to >= 0.4.0 (or drop tools.asmlift.elf to run without a map)';
const UPGRADE = 'upgrade @gba-kit/debug-info to >= 0.5.0 (or drop tools.asmlift.elf to run without a map)';

/** The cv-qualifier facts, probed on the first shaped variable. A 0.3-era package returns the
* same object KINDS from `variableShape` but never sets `volatile`/`const`, so a
Expand Down Expand Up @@ -116,6 +121,25 @@ function assertMemberFactsPresent(m: object, elfPath: string): void {
}
}

/** The same probe for the ARRAY arm's RANK. A package that reports only the flattened element
* count leaves core reading every array as rank 1, so `gBgTilemapBufs[i]` gets emitted for a
* `u16[4][0x400]`. Against the project's own header that is usually a type error — but where the
* row address flows into an integer context it is only a warning, and then the emitted C
* addresses a DIFFERENT object than the asm did. That is the plausible-but-wrong class, so the
* fact is required rather than defaulted. Key presence, not value: `dims` is legitimately null
* for an array whose DWARF carries no subranges, and an unreleased build reports the fact while
* still carrying its previous version number. Witnessed on the first array shape — any array
* exercises it, and an ELF with none can spell no array access to get wrong. */
export function assertArrayDimsPresent(sh: DwarfShape, elfPath: string): void {
if (sh.kind === 'array' && !('dims' in sh)) {
throw new Error(
`cannot build a symbol map from ${elfPath}: the installed @gba-kit/debug-info reports no ` +
`array rank (an array variableShape() result has no 'dims' key), so a multidimensional ` +
`global would be indexed with one subscript — ${UPGRADE}`,
);
}
}

/** The same probe for the POINTER arm's pointee — but this fact cannot be witnessed on every
* variable, so the witness is tracked across the whole load and settled by
* {@link assertPointeeCapabilityWitnessed}. A pointer shape carries `pointee` whatever it points
Expand Down Expand Up @@ -239,6 +263,7 @@ export async function loadSymbolMap(elfPath: string): Promise<SymbolMap> {
const sh = shapeOf(s.name);
if (sh) {
assertShapeFactsPresent(sh, elfPath);
assertArrayDimsPresent(sh, elfPath);
assertPointeeFactPresent(sh, elfPath);
if (sh.kind === 'pointer') {
pointeeWitnessed = true; // the probe above ran and passed on a real pointer shape
Expand All @@ -263,6 +288,12 @@ export async function loadSymbolMap(elfPath: string): Promise<SymbolMap> {
if (sh.elemSize !== null && sh.length !== null) {
info.size = sh.elemSize * sh.length;
}
// The RANK. `length` above is the PRODUCT of the dimensions, so it cannot say how many
// subscripts reach an element — see SymbolInfo.dims. Absent when the DWARF gave the
// array no subranges; core then attempts no bare-name spelling for it.
if (sh.dims !== null && sh.dims !== undefined) {
info.dims = sh.dims;
}
} else if (sh.kind === 'struct') {
info.shape = 'struct';
if (sh.size !== null) {
Expand Down
26 changes: 16 additions & 10 deletions packages/cli/test/matching/m2.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// M2 — a SERIALIZABLE idiom pattern that MOVES A REAL objdiff score on a function
// the naive lift gets wrong.
// M2 — a SERIALIZABLE idiom pattern that folds a real idiom to the source spelling.
//
// `half` = agbcc's signed x/2, lowered (no hw-divide) to `lsr #31; add; asr #1`. Without
// the pattern, asmlift emits the raw shifts — which agbcc recompiles with an `asr` where
// the target has `lsr` (the sign-bit shift), so it does NOT match. With the pattern (a
// pure data object) the idiom folds to `x / 2` and recompiles byte-exact. The score
// delta is measured by real objdiff, not asserted.
// `half` = agbcc's signed x/2, lowered (no hw-divide) to `lsr #31; add; asr #1`. With the
// pattern (a pure data object) the idiom folds to `x / 2` and recompiles byte-exact.
//
// HISTORICAL NOTE — this test used to assert the pattern MOVED the score, because the raw
// lowering did not match: asmlift spelled the sign-bit shift as a bare `x >> 31`, C's ARITHMETIC
// shift, where the target has `lsr`. That was the shift-direction miscompile, not a fact about
// the pattern, and it is fixed at the rendering layer — the raw lowering now spells
// `(s32)(a0 + ((u32)a0 >> 31)) >> 1` and matches on its own. So the pattern's payoff is
// READABILITY, and the claim under test is that folding it stays byte-exact rather than that it
// rescues a broken lift. The signedness lever is still pinned on a shape no spelling can hide
// (m3.test.ts, division).
import { SDIV_POW2_2 } from '@asmlift/core/pattern/engine';
import { decompile } from '@asmlift/core/pipeline';
import { ARMV4T_AGBCC } from '@asmlift/core/target';
Expand All @@ -29,10 +34,11 @@ test('M2: the sdiv-pow2 pattern moves the objdiff score to 0', () => {
console.log('without pattern:', without.source.trim(), '→ score', sWithout.score);
console.log('with pattern: ', withPat.source.trim(), '→ score', sWith.score);

// the pattern fired, folded the idiom, and moved the score strictly toward 0
// the pattern fired, folded the idiom, and the folded spelling is byte-exact
expect(withPat.patternHits).toBe(1);
expect(without.patternHits).toBe(0);
expect(sWith.score).toBeLessThan(sWithout.score); // the score MOVED
expect(sWith.match).toBe(true); // …all the way to byte-exact
expect(sWith.score).toBeLessThanOrEqual(sWithout.score); // folding never COSTS bytes
expect(sWith.match).toBe(true); // …and lands byte-exact
expect(withPat.source).toContain('/ 2');
expect(without.source).not.toContain('/ 2'); // the baseline really is the unfolded shift tree
});
45 changes: 33 additions & 12 deletions packages/cli/test/matching/m3.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
// M3 — type recovery as RANKED CANDIDATES re-ranked by the differ.
// `x >> 1` compiles to `lsr` if x is unsigned but `asr` if signed — the asm alone can't
// say which. asmlift emits both candidates; the objdiff score, not a guess, picks the
// one that matches. This is "types are differ-ranked levers", demonstrated end-to-end.
// M3 — type recovery as RANKED CANDIDATES re-ranked by the differ: "types are differ-ranked
// levers", demonstrated end-to-end. The asm alone cannot say whether a value is signed; asmlift
// emits both candidates and the objdiff score, not a guess, picks the one that matches.
//
// The discriminating shape is DIVISION. `x / 3` calls `__udivsi3` when x is unsigned and
// `__divsi3` when it is signed — a different relocation, so no spelling of the body can hide the
// choice and the type is the only channel that carries it.
//
// `x >> 1` used to be that shape too, and is kept below as a control on what changed: since the
// shift-direction fix, the emitted C states `shr_u` explicitly (`(u32)a0 >> 1`) instead of leaving
// it to the parameter's declared type, so BOTH candidates now recompile byte-exact. The lever is
// intact — that spelling simply stopped depending on it.
import { ARMV4T_AGBCC } from '@asmlift/core/target';
import { assembleTarget, compileTargetAsm } from '@asmlift/toolchains';
import { expect, test } from 'vitest';

import { decompileRanked } from '../../src/rank';

test('M3: the differ picks the correct signedness candidate', () => {
// target built from the UNSIGNED shift → `lsr r0, r0, #1`
const targetAsm = compileTargetAsm('unsigned ushr(unsigned x){ return x >> 1; }');
const targetObj = assembleTarget(targetAsm);

const ranked = decompileRanked('ushr', targetAsm, ARMV4T_AGBCC, targetObj);
for (const c of ranked.candidates) {
console.log(` ${c.label}: score ${c.score.score} ${c.source.trim()}`);
const rank = (sym: string, c: string) => {
const targetAsm = compileTargetAsm(c);
const ranked = decompileRanked(sym, targetAsm, ARMV4T_AGBCC, assembleTarget(targetAsm));
for (const cand of ranked.candidates) {
console.log(` ${sym} ${cand.label}: score ${cand.score.score} ${cand.source.trim()}`);
}
return ranked;
};

test('M3: the differ picks the correct signedness candidate', () => {
// target built from the UNSIGNED division → a `__udivsi3` call
const ranked = rank('udiv', 'unsigned udiv(unsigned x){ return x / 3; }');

// the winner is the unsigned candidate, and it matches byte-exact
expect(ranked.best.label).toBe('unsigned');
Expand All @@ -25,3 +36,13 @@ test('M3: the differ picks the correct signedness candidate', () => {
const signed = ranked.candidates.find((c) => c.label === 'signed')!;
expect(signed.score.score).toBeGreaterThan(ranked.best.score.score);
});

test('M3 control: `x >> 1` no longer needs the lever — the spelling carries the shift direction', () => {
const ranked = rank('ushr', 'unsigned ushr(unsigned x){ return x >> 1; }');
expect(ranked.best.label).toBe('unsigned'); // the simpler spelling still wins the tie
expect(ranked.best.score.match).toBe(true);
// Both match: the signed candidate spells `(u32)a0 >> 1`, which is the same bytes. Before the
// shift-direction fix it spelled a bare `a0 >> 1` — C's ARITHMETIC shift, `asr` where the
// target has `lsr` — and lost. Byte-equality here is the fix working, not the lever failing.
expect(ranked.candidates.every((c) => c.score.match)).toBe(true);
});
10 changes: 7 additions & 3 deletions packages/cli/test/matching/m5.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ test('M5: report captures stages, a scored pattern event, candidates, and the sc
expect(ids).toEqual(['stage:lift', 'stage:idiom', 'stage:recover', 'stage:structure', 'stage:emit']);
expect(report.trace.every((t) => t.verified)).toBe(true);

// the idiom pattern fired and its objdiff score delta is recorded (moved toward match)
// the idiom pattern fired and its objdiff score delta is RECORDED — what this pins is that the
// report carries a real, measured before/after, not that this particular fold improves it. The
// sdiv-pow2 baseline used to score 1 because the raw lowering spelled the sign-bit shift as C's
// arithmetic `>>`; since the shift-direction fix it matches unfolded, so the delta is 0 and the
// fold's payoff is readability (see m2.test.ts).
expect(report.patternEvents).toHaveLength(1);
const ev = report.patternEvents[0];
expect(ev.hits).toBe(1);
expect(ev.scoreBefore).toBe(1);
expect(ev.scoreAfter).toBe(0);
expect(ev.scoreDelta).toBe(-1);
expect(ev.scoreDelta).toBe(ev.scoreAfter! - ev.scoreBefore!);
expect(ev.scoreDelta).toBeLessThanOrEqual(0); // a fold must never cost bytes

// ranked candidates + a byte-exact final score
expect(report.candidates?.length).toBe(2);
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/test/matching/mips-controlflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ describe('MIPS (IDO) ranked candidates — scoring dispatches to the right compi
const ranked = decompileRanked('ushr', asm, MIPS_IDO, obj);
expect(ranked.best.label).toBe('unsigned'); // srl ⇒ unsigned wins; agbcc-scoring couldn't tell
expect(ranked.best.score.match).toBe(true); // byte-exact via the IDO scorer, not agbcc
const signed = ranked.candidates.find((c) => c.label === 'signed')!;
expect(signed.score.score).toBeGreaterThan(0); // the wrong candidate is genuinely worse
// What this pins is the DISPATCH: an agbcc-scored MIPS candidate would be compiled by the
// wrong compiler and could not score 0 at all. It no longer pins signedness discrimination —
// since the shift-direction fix the signed candidate spells `(u32)a0 >> 1`, the same bytes.
// The signedness lever itself is pinned on a shape that cannot be spelled away (m3.test.ts,
// division: `__udivsi3` vs `__divsi3` is a different relocation).
expect(ranked.candidates.every((c) => c.score.match)).toBe(true);
});
});
8 changes: 6 additions & 2 deletions packages/cli/test/matching/ppc-mwcc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,16 @@ const WIDEN_CASES: { sym: string; c: string; proto?: Prototypes; expect: string
{
sym: 'extract',
c: 'int extract(int x){ return (x >> 5) & 0xff; }',
expect: 's32 extract(s32 a0) {\n return a0 >> 5 & 255;\n}\n',
// the rotate makes rlwinm's shift LOGICAL (the lift records `shr_u`), so the operand is
// spelled unsigned — a bare `>>` over the `s32`-declared a0 is C's arithmetic shift
expect: 's32 extract(s32 a0) {\n return (u32)a0 >> 5 & 255;\n}\n',
},
{
sym: 'shr_and',
c: 'unsigned shr_and(unsigned x){ return (x >> 8) & 0xf; }',
expect: 's32 shr_and(s32 a0) {\n return a0 >> 8 & 15;\n}\n',
// source shifts an `unsigned`; the old spelling was the arithmetic shift and matched only
// because `& 15` masks the difference away
expect: 's32 shr_and(s32 a0) {\n return (u32)a0 >> 8 & 15;\n}\n',
},
];

Expand Down
Loading
Loading