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
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-04T11:14:50.301Z"
"generatedAt": "2026-08-04T22:55:26.323Z"
}
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-04T11:14:52.578Z"
"generatedAt": "2026-08-04T22:55:28.621Z"
}
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-04T11:14:57.705Z"
"generatedAt": "2026-08-04T22:55:33.839Z"
}
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-04T11:15:00.716Z"
"generatedAt": "2026-08-04T22:55:36.770Z"
}
Binary file modified apps/benchmark/dataset/real/tu/pokeemerald/symbols.json.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/benchmark/dataset/real/tu/sa3/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"commit": "a069e81bb4c7128e12bfb182bdcc49141ec5f7fb",
"dirty": false,
"cpp": "cpp-14 (Homebrew GCC 14.2.0_1) 14.2.0",
"generatedAt": "2026-08-04T11:15:04.070Z"
"generatedAt": "2026-08-04T22:55:40.120Z"
}
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-04T11:15:09.023Z"
"generatedAt": "2026-08-04T22:55:44.967Z"
}
422 changes: 213 additions & 209 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": 743,
"match": {
"asmlift": 354,
"asmlift": 360,
"m2c": 342
},
"commit": "50aa12e80bee71fe6f7f69b9b5f6e84bdef087bc",
"commit": "63f02b63ec56b2cd75d3abe72fad9c74281e47c4",
"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.

40 changes: 34 additions & 6 deletions packages/cli/src/symbols-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface DwarfMember {
volatile?: true;
const?: true;
bitWidth?: number;
bitOffset?: number;
elemSize?: number;
elemSigned?: boolean;
length?: number;
Expand Down Expand Up @@ -121,6 +122,19 @@ function assertMemberFactsPresent(m: object, elfPath: string): void {
}
}

/** The same probe for a BITFIELD member's position: a package that reports `bitWidth` without
* `bitOffset` leaves the field unseatable — dropping it silently would make the extract
* un-nameable with no explanation, so the provider refuses loudly instead. Key presence, never
* a version string, same as every other gate here. */
function assertBitfieldFactsPresent(m: object, elfPath: string): void {
if (!('bitOffset' in m)) {
throw new Error(
`cannot build a symbol map from ${elfPath}: the installed @gba-kit/debug-info reports a ` +
`bitfield member with no 'bitOffset' key, so the field cannot be seated in its layout — ${UPGRADE}`,
);
}
}

/** 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
Expand Down Expand Up @@ -179,8 +193,13 @@ export function assertPointeeCapabilityWitnessed(witnessed: boolean, layoutsSeen
/** A named type's sidecar layout, mapped to the core `SymbolStructField[]` — the ONE copy, shared
* by a struct global's own layout and by a pointer global's pointee.
*
* Bitfield members are excluded: their read width never equals a field size, so they must fall
* through to the honest cast spelling, never a wrong field name. `signed` must be REPORTED by the
* Bitfield members are kept — with both bit facts — only when `littleEndian`: core's extract
* recognizer solves an LSB-first equation and its declaration synthesis lays `u32 name : n` by
* the LE-GCC unit model, neither of which describes a big-endian ELF, so a BE map carries no
* bitfield members at all (each falls through to the honest cast spelling as before). A member
* reporting `bitWidth` without `bitOffset` is the capability gate one release earlier
* (assertBitfieldFactsPresent): dropping it silently would leave the extract un-nameable with no
* explanation, so the provider refuses loudly instead. `signed` must be REPORTED by the
* package (assertMemberFactsPresent); a reported null value is a genuine "DWARF didn't say" and
* stays absent rather than guessed. `pointer`/`volatile` and the array element facts are kept
* only when the package states them, same rule — an absent `elemSize` means "not an array", which
Expand All @@ -189,15 +208,19 @@ export function layoutOf(
di: { struct(name: string): { members: DwarfMember[] } | null },
name: string | null,
elfPath: string,
littleEndian = true,
): SymbolStructField[] | null {
const layout = name ? di.struct(name) : null;
if (!layout) {
return null;
}
return layout.members
.filter((m) => m.bitWidth === undefined)
.filter((m) => m.bitWidth === undefined || littleEndian)
.map((m) => {
assertMemberFactsPresent(m, elfPath);
if (m.bitWidth !== undefined) {
assertBitfieldFactsPresent(m, elfPath);
}
return {
name: m.name,
offset: m.offset,
Expand All @@ -213,14 +236,19 @@ export function layoutOf(
...(m.const === true ? { const: true } : {}),
...(typeof m.elemSize === 'number' ? { elemSize: m.elemSize } : {}),
...(typeof m.elemSigned === 'boolean' ? { elemSigned: m.elemSigned } : {}),
...(typeof m.bitWidth === 'number' ? { bitWidth: m.bitWidth } : {}),
...(typeof m.bitOffset === 'number' ? { bitOffset: m.bitOffset } : {}),
...(typeof m.length === 'number' ? { length: m.length } : {}),
};
});
}

export async function loadSymbolMap(elfPath: string): Promise<SymbolMap> {
const { DebugInfo, STT_FUNC } = await import('@gba-kit/debug-info');
const di = DebugInfo.fromElf(readFileSync(elfPath));
const bytes = readFileSync(elfPath);
// EI_DATA (ELF header byte 5): 1 = little-endian. Gates the bitfield facts — see layoutOf.
const littleEndian = bytes[5] === 1;
const di = DebugInfo.fromElf(bytes);
const types = di.types as unknown as ShapeCapable;
const shapeOf =
di.hasTypeInfo && typeof types.variableShape === 'function'
Expand Down Expand Up @@ -302,7 +330,7 @@ export async function loadSymbolMap(elfPath: string): Promise<SymbolMap> {
if (sh.structName !== null) {
info.structName = sh.structName; // the real tag, for a readable synthesized decl
}
const layout = layoutOf(di, sh.structName, elfPath);
const layout = layoutOf(di, sh.structName, elfPath, littleEndian);
if (layout) {
info.layout = layout;
layoutsSeen++;
Expand All @@ -316,7 +344,7 @@ export async function loadSymbolMap(elfPath: string): Promise<SymbolMap> {
// no field and falls through to the honest cast forms.
const pointee = sh.pointee ?? null;
if (pointee) {
const layout = layoutOf(di, pointee.structName, elfPath);
const layout = layoutOf(di, pointee.structName, elfPath, littleEndian);
if (layout) {
layoutsSeen++;
}
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/test/offline/symbols-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,7 @@ describe('layoutOf — the package facts a layout is allowed to carry', () => {
]);
});

test('drops BITFIELD members — no read width ever equals their size', () => {
// a bitfield must fall through to the honest cast spelling, never to a wrong field name
test('keeps BITFIELD members — with both bit facts — on a little-endian ELF', () => {
const out = layoutOf(
di([
{ name: 'bits', offset: 0, size: 1, signed: false, bitWidth: 3, bitOffset: 0 },
Expand All @@ -94,9 +93,31 @@ describe('layoutOf — the package facts a layout is allowed to carry', () => {
'S',
'/tmp/x.elf',
);
expect(out).toEqual([
{ name: 'bits', offset: 0, size: 1, signed: false, bitWidth: 3, bitOffset: 0 },
{ name: 'plain', offset: 4, size: 4, signed: true },
]);
});

test('drops BITFIELD members on a big-endian ELF — the extract equation and the layout model are LE', () => {
const out = layoutOf(
di([
{ name: 'bits', offset: 0, size: 1, signed: false, bitWidth: 3, bitOffset: 0 },
{ name: 'plain', offset: 4, size: 4, signed: true },
]),
'S',
'/tmp/x.elf',
false,
);
expect(out?.map((m) => m.name)).toEqual(['plain']);
});

test('REFUSES LOUDLY when a bitfield member reports no bitOffset — the field cannot be seated', () => {
expect(() =>
layoutOf(di([{ name: 'bits', offset: 0, size: 1, signed: false, bitWidth: 3 }]), 'S', '/tmp/x.elf'),
).toThrow(/bitOffset/);
});

test('an unnamed type, or one the sidecar has no layout for, yields null — never a guess', () => {
expect(layoutOf(di([]), null, '/tmp/x.elf')).toBeNull();
expect(layoutOf({ struct: () => null }, 'Missing', '/tmp/x.elf')).toBeNull();
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/backend/cfamily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,18 @@ export interface StructFieldDecl {
name: string;
type: IrType;
volatile?: boolean;
/** bitfield width — spells `u32 name : n;` (the map-layout synthesis is the only producer) */
bits?: number;
}

/** THE struct-declaration spelling — every `struct N { ... };` asmlift prints comes from here,
* so the backend's recovered-struct decls and the scoring layer's synthesized decls cannot
* drift apart. One line, fields in caller order (the type is self-describing: padding is the
* caller's discipline, already present as real fields). */
export function renderStructDecl(name: string, fields: StructFieldDecl[]): string {
return `struct ${name} { ${fields.map((f) => `${f.volatile ? 'volatile ' : ''}${cDeclare(f.type, f.name)};`).join(' ')} };`;
const one = (f: StructFieldDecl) =>
`${f.volatile ? 'volatile ' : ''}${cDeclare(f.type, f.name)}${f.bits !== undefined ? ` : ${f.bits}` : ''};`;
return `struct ${name} { ${fields.map(one).join(' ')} };`;
}

// A LEAF hook lets a C-family backend override how a `var` or `index` node spells WITHOUT
Expand Down
34 changes: 26 additions & 8 deletions packages/core/src/declare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,41 @@ function structDecl(tag: string, layout: SymbolStructField[] | undefined, size:
return null;
}
const fields: StructFieldDecl[] = [];
let cursor = 0;
// The cursor is in BITS (declaredFields' own discipline) so bitfield members seat exactly.
// Gaps pad as the u8 arrays they always were when both ends are byte-aligned, and as named
// `u32 asmlift_pad_N : k` bitfields otherwise — split at 32-bit unit boundaries, matching the
// no-straddle allocation rule declaredFields verified each kept member against. For a
// bitfield-free layout every gap is byte-aligned, so the emitted text is unchanged.
let bitCursor = 0;
let pad = 0;
for (const m of members) {
if (m.offset > cursor) {
const padTo = (lo: number): void => {
while (bitCursor < lo) {
// asmlift_-prefixed so a REAL member named pad_N (a decomp-header idiom) never collides
fields.push({ name: `asmlift_pad_${pad++}`, type: T.array(T.u(8), m.offset - cursor) });
const name = `asmlift_pad_${pad++}`;
if (bitCursor % 8 === 0 && lo % 8 === 0) {
fields.push({ name, type: T.array(T.u(8), (lo - bitCursor) / 8) });
bitCursor = lo;
} else {
const k = Math.min(lo - bitCursor, 32 - (bitCursor % 32));
fields.push({ name, type: T.u(32), bits: k });
bitCursor += k;
}
}
};
for (const m of members) {
const bits = m.bitWidth !== undefined;
const lo = m.offset * 8 + (bits ? m.bitOffset! : 0);
padTo(lo);
fields.push({
name: m.name,
type: fieldType(m),
...(m.volatile ? { volatile: true } : {}),
...(bits ? { bits: m.bitWidth } : {}),
});
cursor = m.offset + m.size;
bitCursor = bits ? lo + m.bitWidth! : (m.offset + m.size) * 8;
}
if (size !== undefined && size > cursor) {
// tail padding to the declared size
fields.push({ name: `asmlift_pad_${pad}`, type: T.array(T.u(8), size - cursor) });
if (size !== undefined) {
padTo(size * 8); // tail padding to the declared size
}
return renderStructDecl(tag, fields);
}
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/pattern/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,14 @@ const sextPat = (w: number, k: number): RewritePattern => ({

/** Byte/half zero- and sign-extension casts. Byte = shift by 24, half = shift by 16. The
* zero-extend forms fix a miscompile; the sign-extend forms already byte-matched as raw shifts and
* fold here for readability + `(s8)`/`(s16)` parity, staying byte-exact (`(s8)x` → `lsl;asr`). */
* fold here for readability + `(s8)`/`(s16)` parity, staying byte-exact (`(s8)x` → `lsl;asr`).
*
* SHADOWING NOTE: these run at the idiom stage, BEFORE structuring — so a symbol-map BITFIELD of
* width exactly 8 or 16 whose bits start at bit 0 of its load is folded to `(u8)x`/`(u16)x` here
* and never reaches the bitfield member recognizer (structure.ts, which matches the raw
* `shr(shl(load))` shape only). Honest output, not a miscompile — the field just keeps the cast
* spelling at those widths. Teaching the recognizer a zext/sext arm is the coverage extension if
* a row ever needs it. */
export const CAST_PATTERNS: RewritePattern[] = [zextPat(8, 24), zextPat(16, 16), sextPat(8, 24), sextPat(16, 16)];

// The DEFAULT idiom bundle `decompile()` applies when the caller passes no `patterns`. It is
Expand Down
50 changes: 45 additions & 5 deletions packages/core/src/rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export interface EnumerateOptions {
onLeverError?: (label: string, error: string) => void;
}

/** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
/** One distinct candidate spelling — a point in the axis cross (signedness × branch sense ×
* def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
* emitted to source. */
export interface Candidate {
label: string;
source: string;
Expand Down Expand Up @@ -198,10 +200,32 @@ export function enumerateCandidates(
// and let the differ referee. The default sense is always among them, so this never scores
// worse; it only wins where the flip matches.
const defSense = baseOpts.preserveDivergentBranchSense ?? true;
const senseCands = [
{ suffix: '', sense: defSense },
{ suffix: '/flip-branch', sense: !defSense },
// `/defsite` — def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
// structuring axis on the same footing as branch sense: where the asm materialized a merge
// constant is placement evidence, but whether the SOURCE spelled it there is genuinely
// ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
// (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
// below collapses every variant the anchoring left unchanged.
const baseSense = [
{ suffix: '', sense: defSense, anchor: false, bitfields: true },
{ suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
{ suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
{ suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
];
// `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
// The named read recompiles at the DECLARATION's access width; where that diverges from the
// asm's load width, the shifts are the spelling that matches — so both are emitted and the
// differ referees. Enumerated only when the map carries any bitfield member at all (checked
// below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
// every variant where no fold fired.
const mapHasBitfields =
opts.symbols !== undefined &&
[...opts.symbols.values()].some((infos) =>
infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
);
const senseCands = mapHasBitfields
? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
: baseSense;
// Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
// so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
// compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
Expand Down Expand Up @@ -242,7 +266,23 @@ export function enumerateCandidates(
for (const s of senseCands) {
// structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
// senses structure the same recovered function without re-lifting.
const sfn = structureChecked(fn, { ...svOpts, preserveDivergentBranchSense: s.sense });
let sfn: SFn;
try {
sfn = structureChecked(fn, {
...svOpts,
preserveDivergentBranchSense: s.sense,
anchorConstCopies: s.anchor,
spellBitfieldMembers: s.bitfields,
});
} catch (e) {
if (!s.anchor && s.bitfields) {
throw e; // the base axes keep their behavior: a structuring failure aborts the row
}
// an anchored variant that fails structuring or its contracts is a dropped lever, never
// an aborted enumeration — same rule as respell below
opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
continue;
}
// The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
// signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
// genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
Expand Down
Loading
Loading