diff --git a/.changeset/declaration-shapes.md b/.changeset/declaration-shapes.md new file mode 100644 index 0000000..2f34c65 --- /dev/null +++ b/.changeset/declaration-shapes.md @@ -0,0 +1,30 @@ +--- +'@gba-kit/debug-info': minor +--- + +Read what a name is DECLARED as — shapes, signatures and macro names — from either byte order. + +- `variableShape(name)` classifies a global/static as `scalar | pointer | array | struct`, + resolved through typedef/cv chains: `volatile`/`const`, array `elemSize`/`elemSigned`/`length`, + and the pointer's `pointee` (the name `struct()` resolves, its size, its own qualifiers). + A `typedef struct {…} T;` is named by its alias; `null` doubles as the "is this name + declared in the project headers?" probe. +- `struct()` members carry the declaration facts layout alone cannot: `signed`, `pointer`, + `pointeeSize`/`pointeeSigned`, `volatile`/`const`, and array `elemSize`/`elemSigned`/`length`. + Every key is absent when the DWARF does not determine it. +- `functionSignature(name)` returns a COMPILED function's return and parameter types + (`low_pc` is the witness): `null` means "this ELF did not compile it", never "it takes + no arguments". gcc's abstract/concrete split at `-O1+` resolves to one definition. +- `DebugInfo.macros` / `parseDebugMacinfo` read the `-g3` macro table (DWARF 2/3 + `.debug_macinfo`, the self-contained form) — the only place an address-cast `#define` + name survives, since a macro leaves no symbol and no DIE. A truncated stream yields a + sound prefix, never a corrupted entry. +- Big-endian ELF/DWARF end to end — bitfields are allocated from the MSB end and reported + that way — and RELA relocations are applied to `.debug_*` in relocatable objects. +- `.debug_line` is walked by its own `DW_LNE_end_sequence` terminators: agbcc (GCC 2.95) + mispredicts `unit_length`, which used to cost every row after the first short unit. +- Producer-dialect fixes, pinned on committed toolchain output: DWARF 2/3's `DW_FORM_flag` + decodes as a boolean (every declaration/prototyped test was inert, so a forward-declared + struct could shadow its own definition by link order); GCC 2.95's `0xffffffff` upper + bound reads as zero-length, not 2^32 elements; and a DECLARATION's `[1]` array is agbcc's + unsized-extern spelling, reported as `length: null`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd860b6..d181d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,21 @@ jobs: with: submodules: recursive - # GBA cross toolchain for the @gba-kit/debug-info test ELFs + # ARM cross toolchain for the @gba-kit/debug-info little-endian test ELFs - name: Install arm-none-eabi GCC 14 uses: carlosperate/arm-none-eabi-gcc-action@v1 with: release: '14.2.Rel1' + # Big-endian cross toolchains for the mips-min / ppc-min test projects. The + # runner is x86 Linux, so these stock Ubuntu packages run natively — no + # Docker or qemu. vitest's globalSetup rebuilds every project when CI is set. + - name: Install big-endian cross toolchains + run: | + sudo apt-get update -qq + sudo apt-get install -y gcc-mips-linux-gnu binutils-mips-linux-gnu \ + gcc-powerpc-linux-gnu binutils-powerpc-linux-gnu + # Build the agbcc (GCC 2.95) compiler from the submodule once - name: Resolve agbcc commit id: agbcc-rev diff --git a/packages/debug-info/README.md b/packages/debug-info/README.md index 3d4993e..745a776 100644 --- a/packages/debug-info/README.md +++ b/packages/debug-info/README.md @@ -1,17 +1,37 @@ # @gba-kit/debug-info -Parse ELF symbols and DWARF debug info from a (`-g`-built) GBA ELF, and answer -the queries a source-level debugger needs: +Parse ELF symbols and DWARF debug info from a (`-g`-built) ELF32, and answer the +queries a source-level debugger needs: - **PC → function** (`pcToFunction`) — from `.symtab`, so it covers every linked function, including `INCLUDE_ASM` stubs with no DWARF. - **name → address** / **address → symbol** (`symbolToAddress`, `addressToSymbol`). - **PC → C `file:line`** (`pcToSource`) — from the DWARF `.debug_line` table. +- **type layout** (`struct`, `structMember`, `enumValues`) and **declaration shape** + (`types.variableShape`) — from `.debug_info`. +- **function signatures** (`types.functionSignature`) — return and parameter types of + every function the ELF compiled from C; `null` means "not compiled here", never + "takes no arguments". +- **the `-g3` macro table** (`macros`, `parseDebugMacinfo`) — the only place an + address-cast `#define gCounter (*(u16 *)0x03001234)` name survives: a macro leaves + no symbol and no DIE. -It's a small, dependency-free, DOM-free parser meant to be shared by the headless -runtime, the scripting engine, and the webapp's source debug view. The shipped -`.gba` ROM carries no debug info (`objcopy -O binary` strips it); load the sidecar -ELF — its loadable bytes are identical to the ROM, so addresses line up. +This is the general ELF/DWARF piece of gba-kit, not a GBA-only one: + +- **both byte orders** — the order is read from `e_ident` and threaded through the + container and the DWARF payload alike. Big-endian bitfields are allocated from + the most significant end of the storage unit, and are reported that way. +- **linked ELFs and relocatable objects** — in a `.o` whose relocations are + RELA-style (PowerPC), the raw `.debug_*` fields are zeros and the real values sit + in `.rela.
` addends; those are applied on read. +- **DWARF 2 through 5**, as emitted by anything from GCC 2.95 to GCC 14. + +It is exercised against real ARM, MIPS and PowerPC toolchain output (see +[Testing](#testing)). It's a small, dependency-free, DOM-free parser, shared by +the headless runtime, the scripting engine, and the webapp's source debug view. +For the GBA case: the shipped `.gba` ROM carries no debug info +(`objcopy -O binary` strips it); load the sidecar ELF — its loadable bytes are +identical to the ROM, so addresses line up. ## Usage @@ -26,6 +46,13 @@ di.pcToSource(0x0801466a); di.pcToFunction(0x0801466a)?.name; // 'PlayerRespawnOrDeath' di.symbolToAddress('InitLevelGameplay'); // 0x0800ca0c di.addressToSymbol(0x0801466a); // { name: 'PlayerRespawnOrDeath', offset: 0x46 } + +di.types.variableShape('gSineTable'); +// → { kind: 'array', elemSize: 2, elemSigned: true, length: null, const: true, volatile: false } +di.types.functionSignature('ReadUnalignedU16'); +// → { returns: { size: 4, signed: false }, params: [{ name: 'ptr', size: 4, pointer: true, ... }], prototyped: true, ... } +di.macros.find((m) => m.name === 'gGfxStreamBuffer'); // (a -g3 build records the macro table) +// → { name: 'gGfxStreamBuffer', body: '(*(u32 *)0x030007C8)', line: 191 } ``` ## Develop @@ -37,14 +64,23 @@ pnpm --filter @gba-kit/debug-info test ## Testing -`@gba-kit/debug-info` is tested against real GBA ELFs that are **committed** to the -repo (`packages/debug-info/test-projects/*/build/`), so tests run with no cross -toolchain. +`@gba-kit/debug-info` is tested against real ELFs from four minimal projects, +**committed** to the repo (`packages/debug-info/test-projects/*/build/`), so tests +run with no cross toolchain: -You only need to rebuild those ELFs when you change a test project's sources, -and that's a per-project step (see[test-projects/README](packages/debug-info/test-projects/README.md)): +| Project | Toolchain | Target | +| --------------- | ------------------------------- | ---------------------- | +| `agbcc-min` | agbcc (GCC 2.95), git submodule | ARM, little-endian | +| `devkitarm-min` | `arm-none-eabi-gcc` (GCC 14) | ARM, little-endian | +| `mips-min` | `mips-linux-gnu-gcc` | MIPS o32, big-endian | +| `ppc-min` | `powerpc-linux-gnu-gcc` | PowerPC 32, big-endian | + +`ppc-min` vendors a relocatable `main.o` as well as the linked ELF — the artifact +shape that exercises the RELA path. -- `agbcc-min` — `cd packages/debug-info/test-projects/agbcc-min && ./setup.sh` (builds the agbcc submodule) -- `devkitarm-min` — `cd packages/debug-info/test-projects/devkitarm-min && ./build.sh` (builds in **Docker**, so no local devkitARM needed) +You only need to rebuild those ELFs when you change a test project's sources, +and that's a per-project step (see [test-projects/README](test-projects/README.md)): +`agbcc-min` builds the agbcc submodule via `./setup.sh`, the other three build in +**Docker** via `./build.sh`, so no local cross toolchain is needed. -CI rebuilds both from scratch on every run to re-validate the toolchains. +CI rebuilds all four from scratch on every run to re-validate the toolchains. diff --git a/packages/debug-info/src/__tests__/debug-line.spec.ts b/packages/debug-info/src/__tests__/debug-line.spec.ts new file mode 100644 index 0000000..040cdd5 --- /dev/null +++ b/packages/debug-info/src/__tests__/debug-line.spec.ts @@ -0,0 +1,161 @@ +/** + * `.debug_line` section-walking contract. + * + * The line table is a *concatenation* of independent units, so the parser's job is + * as much finding the next unit as decoding one. These tests take the real DWARF-2 + * bytes agbcc (GCC 2.95) emitted for `test-projects/agbcc-min` and perturb the + * section the way real producers do, asserting the decoded rows never change and + * that no perturbation costs more than the unit it belongs to. + * + * The load-bearing case is the first one: agbcc sizes a unit by *predicting* the + * encoded length of each statement, and mispredicts, so `unit_length` can stop a + * few bytes short of the program it describes (in pokeemerald 28 of 303 units, by + * 1–4 bytes and one by 51). Clamping to the declared end leaves the cursor + * mid-statement, and the next unit's header is then read as line-program bytes — + * from there a walk runs off the section and every row after it is lost. The + * DW_LNE_end_sequence terminator, not the declared length, is what ends a unit. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { type LineRow, parseDebugLine } from '../debug-line.js'; +import { ElfFile } from '../elf.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const elfPath = join(here, '..', '..', 'test-projects', 'agbcc-min', 'build', 'min.elf'); + +const elf = ElfFile.parse(new Uint8Array(readFileSync(elfPath))); +/** Real agbcc (GCC 2.95) DWARF-2 line table: two units (main.c, util.c). */ +const section = elf.sectionData('.debug_line')!; +const pristine = parseDebugLine(section).rows; + +const u32At = (bytes: Uint8Array, off: number): number => + new DataView(bytes.buffer, bytes.byteOffset).getUint32(off, true); +const setU32 = (bytes: Uint8Array, off: number, v: number): void => + new DataView(bytes.buffer, bytes.byteOffset).setUint32(off, v, true); + +const concat = (...parts: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +}; + +/** A unit header we deliberately cannot decode, with a valid `unit_length`. */ +function unmodellableUnit(version: number, body = 24): Uint8Array { + const unit = new Uint8Array(4 + 2 + body); + const view = new DataView(unit.buffer); + view.setUint32(0, 2 + body, true); // unit_length covers everything after itself + view.setUint16(4, version, true); + unit.fill(0xaa, 6); // header we never read + return unit; +} + +/** A 64-bit DWARF unit: the 0xffffffff escape then a 64-bit unit_length. */ +function dwarf64Unit(body = 24): Uint8Array { + const unit = new Uint8Array(4 + 8 + body); + const view = new DataView(unit.buffer); + view.setUint32(0, 0xffffffff, true); + view.setUint32(4, body, true); // low half of the 64-bit length + view.setUint32(8, 0, true); // high half + unit.fill(0xaa, 12); + return unit; +} + +const rowsOf = (bytes: Uint8Array): LineRow[] => parseDebugLine(bytes).rows; + +it('the fixture is the shape these tests assume (two units, real rows)', () => { + expect(u32At(section, 0) + 4).toBeLessThan(section.length); // a second unit follows + expect(pristine.length).toBeGreaterThan(20); + expect(new Set(pristine.map((r) => r.file.split('/').pop()))).toEqual(new Set(['main.c', 'util.c'])); +}); + +describe('a unit_length that undercounts its own line program (agbcc / GCC 2.95)', () => { + // The producer bug is a *size misprediction*, so the bytes are correct and only + // the length field is short: shortening it must change nothing we decode. + it.each([1, 2, 3, 4, 7])('recovers when the first unit is declared %d bytes short', (missing) => { + const short = section.slice(); + setU32(short, 0, u32At(short, 0) - missing); + + // The whole point: the *following* unit is still found, so no rows are lost. + expect(rowsOf(short)).toEqual(pristine); + }); + + it('recovers on the last unit too (nothing follows it)', () => { + const lastStart = 4 + u32At(section, 0); + const short = section.slice(); + setU32(short, lastStart, u32At(short, lastStart) - 3); + + expect(rowsOf(short)).toEqual(pristine); + }); +}); + +describe('units we cannot decode are skipped by their own unit_length', () => { + it('keeps the rest of the section when a DWARF 5 unit comes first', () => { + // DWARF 5 rewrote the header (address_size/segment_selector_size, and typed + // directory/file entry formats), so its bytes are not a v2–v4 header. + expect(rowsOf(concat(unmodellableUnit(5), section))).toEqual(pristine); + }); + + it('keeps the rest of the section when a 64-bit DWARF unit comes first', () => { + expect(rowsOf(concat(dwarf64Unit(), section))).toEqual(pristine); + }); + + it('steps over zero-word padding between units', () => { + const pad = new Uint8Array(8); // two zero unit_lengths + expect(rowsOf(concat(pad, section))).toEqual(pristine); + }); +}); + +it('finds the program by header_length, not by walking the file table', () => { + // Insert padding between the end of the file-name table and the program start, + // growing header_length (and unit_length) to match — exactly what an unmodelled + // header field would look like. A parser that starts the program where the file + // table happened to end would run the padding as opcodes. + const pad = 6; + const programStart = 10 + u32At(section, 6); + const grown = concat(section.slice(0, programStart), new Uint8Array(pad).fill(0xaa), section.slice(programStart)); + setU32(grown, 0, u32At(grown, 0) + pad); // unit_length + setU32(grown, 6, u32At(grown, 6) + pad); // header_length + + expect(rowsOf(grown)).toEqual(pristine); +}); + +describe('a section that cannot be walked degrades instead of throwing', () => { + // parseDebugLine is the only thing standing between a hostile .debug_line and + // DebugInfo.fromElf, which must still deliver symbols and types. It reports what + // it decoded and stops — it never throws, so callers need no rescue wrapper. + it('keeps every row of the complete units when the last unit is truncated', () => { + const cut = section.slice(0, section.length - 12); + const rows = rowsOf(cut); + + const fromUnitOne = (rs: LineRow[]) => rs.filter((r) => r.file.endsWith('main.c')); + expect(fromUnitOne(rows)).toEqual(fromUnitOne(pristine)); // unit 1 is untouched + expect(rows.length).toBeLessThan(pristine.length); // unit 2 loses its cut-off tail + }); + + it('returns no rows for garbage, and terminates', () => { + const garbage = new Uint8Array(4096); + for (let i = 0; i < garbage.length; i++) { + garbage[i] = (i * 37) & 0xff; + } + expect(() => rowsOf(garbage)).not.toThrow(); + }); + + it('decodes what it can of a unit whose length runs past the section', () => { + const overlong = section.slice(0, 64); // unit 1 claims 261 bytes; 64 are here + const rows = rowsOf(overlong); + + expect(rows.length).toBeGreaterThan(0); + expect(rows).toEqual(pristine.slice(0, rows.length)); // real rows, no invented ones + }); + + it('handles an empty section', () => { + expect(rowsOf(new Uint8Array(0))).toEqual([]); + }); +}); diff --git a/packages/debug-info/src/__tests__/debug-macro.spec.ts b/packages/debug-info/src/__tests__/debug-macro.spec.ts new file mode 100644 index 0000000..9a417e8 --- /dev/null +++ b/packages/debug-info/src/__tests__/debug-macro.spec.ts @@ -0,0 +1,92 @@ +/** + * `.debug_macinfo` — the macro table, against a real artifact. + * + * `devkitarm-min/build/macinfo.o` is that project's `main.c` compiled the way a decomp's + * macro sidecar is (`-gdwarf-2 -g3 -gstrict-dwarf`; see the project Makefile): one + * self-contained `.debug_macinfo` with inline strings, in a relocatable `.o` — the same + * artifact shape a real project grafts from. The fixture macros live at the END of that + * `main.c` and are asserted by exact line number (append there, never insert above). + * `readelf --debug-dump=macro` agreed on every define when the artifact was added. + * + * The macro channel exists for one decomp idiom above all: a fixed RAM cell named by an + * address-cast `#define` instead of an extern. Such a name is in no symbol table and has + * no DIE — the preprocessor's record is the only place it survives. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { parseDebugMacinfo } from '../debug-macro.js'; +import { ElfFile } from '../elf.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const project = join(here, '..', '..', 'test-projects', 'devkitarm-min', 'build'); + +const elf = ElfFile.parse(new Uint8Array(readFileSync(join(project, 'macinfo.o')))); +const section = elf.sectionData('.debug_macinfo')!; +const macros = parseDebugMacinfo(section); +const byName = (name: string) => macros.find((m) => m.name === name); + +describe('parseDebugMacinfo on a real -gdwarf-2 -g3 object', () => { + it('reads the address-cast fixtures verbatim, with their lines', () => { + expect(byName('REG_DISPSTAT')).toEqual({ + name: 'REG_DISPSTAT', + body: '(*(volatile unsigned short *)0x04000004)', + line: 157, + }); + expect(byName('g_save_slot')).toEqual({ + name: 'g_save_slot', + body: '(*(unsigned char *)0x03007FF0)', + line: 158, + }); + expect(byName('EWRAM_BASE')).toEqual({ name: 'EWRAM_BASE', body: '0x02000000', line: 159 }); + }); + + it('keeps a function-like macro as one name, the parameter list as recorded', () => { + // DWARF stores the define as written post-lex: params squeezed, body spacing kept. + // Splitting "CLAMP(x,lo,hi)" further would invent structure the section lacks. + expect(byName('CLAMP(x,lo,hi)')).toEqual({ + name: 'CLAMP(x,lo,hi)', + body: '((x) < (lo) ? (lo) : (x) > (hi) ? (hi) : (x))', + line: 160, + }); + }); + + it('reports a body-less define with an empty body, not a missing entry', () => { + expect(byName('NO_BODY')).toEqual({ name: 'NO_BODY', body: '', line: 161 }); + }); + + it('reports definitions in stream order', () => { + const lines = ['REG_DISPSTAT', 'g_save_slot', 'EWRAM_BASE', 'CLAMP(x,lo,hi)', 'NO_BODY'].map((n) => + macros.findIndex((m) => m.name === n), + ); + expect(lines.every((i) => i >= 0)).toBe(true); + expect([...lines].sort((a, b) => a - b)).toEqual(lines); + }); + + it('carries the compiler built-ins at line 0 alongside the user macros', () => { + // The exact built-in set is the compiler's business (do not pin a total): assert the + // class exists and is large, which is what makes "grep the table" a real capability. + const builtins = macros.filter((m) => m.line === 0); + expect(builtins.length).toBeGreaterThan(300); + expect(byName('__VERSION__')).toBeDefined(); + }); + + it('a truncated stream yields a sound prefix, never a throw', () => { + // Sections get grafted between tools; the contract is that every returned entry was + // really read. Cutting the stream anywhere must give a prefix of the full parse. + for (const cut of [section.length >> 2, section.length >> 1, section.length - 3]) { + const partial = parseDebugMacinfo(section.slice(0, cut)); + expect(partial.length).toBeLessThanOrEqual(macros.length); + expect(partial).toEqual(macros.slice(0, partial.length)); + } + }); +}); + +describe('the -g3 requirement', () => { + it('a plain -g ELF has no .debug_macinfo at all', () => { + const plain = ElfFile.parse(new Uint8Array(readFileSync(join(project, 'min.elf')))); + expect(plain.sectionData('.debug_macinfo')).toBeUndefined(); + }); +}); diff --git a/packages/debug-info/src/__tests__/producer-quirks.spec.ts b/packages/debug-info/src/__tests__/producer-quirks.spec.ts new file mode 100644 index 0000000..43866be --- /dev/null +++ b/packages/debug-info/src/__tests__/producer-quirks.spec.ts @@ -0,0 +1,135 @@ +/** + * Producer-quirk regressions: four real-compiler encodings the parser once read wrongly, + * each pinned on a committed test-project artifact that reproduces it. + * + * 1. Modern gcc at -O1+ splits a function that is both inlined and emitted into an + * ABSTRACT DIE (name, params) plus a CONCRETE DIE (low_pc, DW_AT_abstract_origin). + * Indexing only same-DIE name+low_pc loses the signature — `add`/`square` in + * mips-min and ppc-min are the split, committed proof. + * 2. DWARF 2/3 encode boolean attributes as DW_FORM_flag (a byte), not DWARF 4+'s + * DW_FORM_flag_present (true). Reading the byte as a number made every + * `=== true` declaration test inert: a forward-declared struct shadowed its own + * definition depending on LINK ORDER (agbcc-min's FwdPay: declared in main.c's CU, + * defined in util.c's, linked in that order), and `prototyped` was always false on + * modern-gcc -gdwarf-2 output (devkitarm-min's macinfo.o). + * 3. agbcc (GCC 2.95) emits DW_AT_upper_bound 0xffffffff (DW_FORM_data4 holding -1) + * for a zero-length array; upper+1 on the raw u32 once claimed 2^32 elements — + * as a variable shape (g_zero) and as a member size (Flex.data). + * 4. agbcc emits DW_AT_upper_bound 0 for an UNSIZED extern array — byte-identical to + * a real [1]. The variable's own DW_AT_declaration is the disambiguator: a + * declaration's single-element read is unknowable (g_ext_table, defined only in + * crt0.s — the ldscript/asm-placed-table idiom), while a defined [1] keeps its + * length (g_one_def). + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { DebugInfo } from '../debug-info.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const projects = join(here, '..', '..', 'test-projects'); +const load = (...p: string[]) => DebugInfo.fromElf(new Uint8Array(readFileSync(join(projects, ...p)))); + +const agbcc = load('agbcc-min', 'build', 'min.elf'); +const macinfoObj = load('devkitarm-min', 'build', 'macinfo.o'); + +describe('1. signatures through the abstract/concrete split (modern gcc -O2)', () => { + // In both BE ELFs, `add` and `square` are inlined into main AND emitted out-of-line, + // so their name/params live on an abstract DIE the concrete one references. + it.each(['mips-min', 'ppc-min'])('%s: add and square resolve with typed params', (project) => { + const di = load(project, 'build', 'min.elf'); + const add = di.types.functionSignature('add'); + expect(add).toMatchObject({ + returns: { size: 4, signed: true }, + params: [ + { name: 'a', size: 4, signed: true }, + { name: 'b', size: 4, signed: true }, + ], + }); + expect(di.types.functionSignature('square')?.params).toHaveLength(1); + }); + + it('control: a non-split definition still resolves (agbcc emits no split)', () => { + expect(agbcc.types.functionSignature('add')?.params).toHaveLength(2); + }); +}); + +describe('2. DW_FORM_flag is a boolean fact, not the number 1', () => { + it('the struct DEFINITION beats a forward declaration from an earlier CU', () => { + // main.c's CU (linked first) has only `struct FwdPay;` — DW_AT_declaration, no + // members. util.c's CU defines it. First-CU-wins would report an empty layout. + const fwd = agbcc.struct('FwdPay'); + expect(fwd?.size).toBe(8); + expect(fwd?.members).toEqual([ + { name: 'amount', offset: 0, size: 4, signed: true }, + { name: 'currency', offset: 4, size: 2, signed: true }, + ]); + }); + + it('a pointee behind the forward declaration sizes from the definition', () => { + expect(agbcc.types.variableShape('g_fwd_ptr')).toMatchObject({ + kind: 'pointer', + pointee: expect.objectContaining({ structName: 'FwdPay', size: 8 }), + }); + }); + + it('prototyped is read on modern-gcc DWARF-2 (DW_FORM_flag)', () => { + expect(macinfoObj.types.functionSignature('main')?.prototyped).toBe(true); + expect(macinfoObj.types.functionSignature('add')?.prototyped).toBe(true); + }); +}); + +describe('3. the 0xffffffff upper bound means zero-length, never 2^32 elements', () => { + it('a zero-length global array has no length, like a flexible member', () => { + expect(agbcc.types.variableShape('g_zero')).toEqual({ + kind: 'array', + elemSize: 1, + elemSigned: false, + length: null, + volatile: false, + const: false, + }); + }); + + it('a zero-length trailing member reads exactly like modern flexible arrays', () => { + // Mirrors the devkitarm-min `Blob.data` pin: stride reported, size and length not. + const data = agbcc.struct('Flex')!.members.find((m) => m.name === 'data')!; + expect(data).toEqual({ name: 'data', offset: 4, size: null, signed: null, elemSize: 1, elemSigned: false }); + expect(agbcc.resolveVariable('g_flex.data')).toBeNull(); + }); + + it('control: initializer-sized arrays keep their real bounds', () => { + // agbcc DOES size these (even the forward-declared-then-defined static), so a fix + // for the -1 encoding must not cost them. + expect(agbcc.types.variableShape('g_init_table')).toMatchObject({ kind: 'array', elemSize: 2, length: 4 }); + expect(agbcc.types.variableShape('g_fwd_sized_table')).toMatchObject({ kind: 'array', elemSize: 2, length: 6 }); + }); +}); + +describe('4. an unsized extern array is not [1]', () => { + it('a DECLARATION with upper_bound 0 reports length null', () => { + // g_ext_table is defined only in crt0.s: no C compilation ever saw its size, and + // the DWARF's [1] is the encoding's ambiguity, not a fact about the table. + expect(agbcc.types.variableShape('g_ext_table')).toEqual({ + kind: 'array', + elemSize: 2, + elemSigned: true, + length: null, + volatile: false, + const: true, + }); + }); + + it('control: a DEFINED one-element array keeps length 1', () => { + expect(agbcc.types.variableShape('g_one_def')).toEqual({ + kind: 'array', + elemSize: 2, + elemSigned: true, + length: 1, + volatile: false, + const: false, + }); + }); +}); diff --git a/packages/debug-info/src/__tests__/reader.spec.ts b/packages/debug-info/src/__tests__/reader.spec.ts index 895d873..2f305b2 100644 --- a/packages/debug-info/src/__tests__/reader.spec.ts +++ b/packages/debug-info/src/__tests__/reader.spec.ts @@ -18,6 +18,19 @@ describe('Cursor', () => { expect(new Cursor(bytes(0x00, 0x00, 0x00, 0x80)).u32()).toBe(0x80000000); }); + it('reads BIG-endian integers when constructed with littleEndian=false', () => { + const c = new Cursor(bytes(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07), 0, false); + expect(c.u8()).toBe(0x01); + expect(c.u16()).toBe(0x0203); + expect(c.u32()).toBe(0x04050607); + expect(c.u16At(1)).toBe(0x0203); + expect(c.u32At(3)).toBe(0x04050607); + }); + + it('BE u32 stays unsigned', () => { + expect(new Cursor(bytes(0x80, 0x00, 0x00, 0x00), 0, false).u32()).toBe(0x80000000); + }); + it('decodes ULEB128 (verified encodings)', () => { expect(new Cursor(bytes(0x00)).uleb()).toBe(0); expect(new Cursor(bytes(0x7f)).uleb()).toBe(127); diff --git a/packages/debug-info/src/__tests__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 0e6ee80..082c783 100644 --- a/packages/debug-info/src/__tests__/real-projects.spec.ts +++ b/packages/debug-info/src/__tests__/real-projects.spec.ts @@ -1,13 +1,22 @@ /** - * Tests the parser against REAL ELFs produced by the two minimal GBA projects in + * Tests the parser against REAL ELFs produced by the minimal projects in * ../../test-projects. The ELFs are compiled fresh before the suite runs (see * ../../vitest.globalSetup.ts) from vendored toolchains: * + * little-endian ARM (GBA): * - agbcc-min — agbcc (GCC 2.95), DWARF-2 line table * - devkitarm-min — modern arm-none-eabi-gcc (GCC 14), DWARF-3+ line table * - * Both compile the same shape, so one parametrized suite exercises the whole - * surface across both DWARF dialects. + * big-endian (MSB-first container AND DWARF payload): + * - mips-min — mips-linux-gnu-gcc, MIPS o32 + * - ppc-min — powerpc-linux-gnu-gcc, PowerPC 32 (also vendors a .o, below) + * + * The two projects within each byte order compile the same shape, so one + * parametrized suite per byte order exercises the whole surface across both DWARF + * dialects. The layouts differ between the groups only where the ABI differs — the + * bitfield allocation end above all — so they are separate blocks, not one. A third + * block then compares the four ELFs against EACH OTHER, pinning what byte order may + * and may not change (see "cross-endian equivalence" below). * * Oracle: each project's Makefile generates build/oracle.json next to the ELF. * The test just reads that JSON and asserts DebugInfo agrees with it. @@ -18,6 +27,9 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { DebugInfo } from '../debug-info.js'; +import { ElfFile } from '../elf.js'; +import { Cursor } from '../reader.js'; +import type { StructType, VariableShape } from '../types.js'; const here = dirname(fileURLToPath(import.meta.url)); const projectsDir = join(here, '..', '..', 'test-projects'); @@ -34,16 +46,21 @@ interface Project { dir: string; } -const PROJECTS: Project[] = [ +const ARM_PROJECTS: Project[] = [ { label: 'agbcc-min (GCC 2.95, DWARF-2)', dir: join(projectsDir, 'agbcc-min') }, { label: 'devkitarm-min (modern GCC, DWARF-3+)', dir: join(projectsDir, 'devkitarm-min') }, ]; +const BE_PROJECTS: Project[] = [ + { label: 'mips-min (MIPS o32, big-endian)', dir: join(projectsDir, 'mips-min') }, + { label: 'ppc-min (PowerPC 32, big-endian)', dir: join(projectsDir, 'ppc-min') }, +]; + const FUNCS = ['add', 'square', 'bump', 'triple', 'main'] as const; const hex = (addr: number): string => '0x' + addr.toString(16); -describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { +describe.each(ARM_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { const elf = join(project.dir, 'build', 'min.elf'); const oracle = JSON.parse(readFileSync(join(project.dir, 'build', 'oracle.json'), 'utf8')) as Oracle; const di = DebugInfo.fromElf(new Uint8Array(readFileSync(elf))); @@ -114,13 +131,16 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { name: 'Probe', size: 32, members: [ - { name: 'tag', offset: 0, size: 1 }, - { name: 'count', offset: 4, size: 4 }, - { name: 'flags', offset: 8, size: 2 }, - { name: 'name', offset: 10, size: 6 }, // char[6] → element size × length - { name: 'ptr', offset: 16, size: 4 }, // pointer → 4 bytes - { name: 'inner', offset: 20, size: 8 }, // nested struct - { name: 'tail', offset: 28, size: 4 }, + { name: 'tag', offset: 0, size: 1, signed: false }, // plain char is unsigned on ARM + { name: 'count', offset: 4, size: 4, signed: true }, + { name: 'flags', offset: 8, size: 2, signed: true }, + // char[6] → `size` is the WHOLE member (element size × length); the element facts are + // what an indexed read into it needs, and `signed` stays null (an array is not a base type) + { name: 'name', offset: 10, size: 6, signed: null, elemSize: 1, elemSigned: false, length: 6 }, + // pointer → 4 bytes, and the pointee facts pointer arithmetic scales by + { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true, pointeeSize: 4, pointeeSigned: true }, + { name: 'inner', offset: 20, size: 8, signed: null }, // nested struct + { name: 'tail', offset: 28, size: 4, signed: true }, ], }); }); @@ -132,8 +152,8 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { name: 'Pair', size: 8, members: [ - { name: 'a', offset: 0, size: 4 }, - { name: 'b', offset: 4, size: 4 }, + { name: 'a', offset: 0, size: 4, signed: true }, + { name: 'b', offset: 4, size: 4, signed: true }, ], }); }); @@ -145,6 +165,100 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { expect(di.structMember('Probe', ['inner', 'x'])).toEqual({ offset: 20, size: 4 }); // array form }); + it('classifies a variable declaration shape — TypeIndex.variableShape', () => { + // scalar int: signed, 4 bytes, unqualified + expect(di.types.variableShape('g_counter')).toEqual({ + kind: 'scalar', + size: 4, + signed: true, + volatile: false, + const: false, + }); + // struct global, by tag name + expect(di.types.variableShape('g_probe')).toEqual({ + kind: 'struct', + structName: 'Probe', + size: 32, + volatile: false, + const: false, + }); + // typedef'd anonymous struct: the tag is unnamed, so the alias is the name it is known by + expect(di.types.variableShape('g_pair')).toEqual({ + kind: 'struct', + structName: 'Pair', + size: 8, + volatile: false, + const: false, + }); + // no DIE ⇒ null — the "is this name declared?" probe + expect(di.types.variableShape('g_no_such')).toBeNull(); + }); + + it('names an anonymous typedef struct GLOBAL the way struct() looks a layout up — the round trip', () => { + // `typedef struct {…} Pair; Pair g_pair;` — the struct itself has no tag, so the alias is the + // only name its layout has. The name is what makes the shape actionable: it is the argument + // struct() takes, so a consumer holding only the global's declaration reaches its members. + const shape = di.types.variableShape('g_pair')!; + expect(shape.kind).toBe('struct'); + const structName = shape.kind === 'struct' ? shape.structName : null; + expect(structName).toBe('Pair'); + expect(di.struct(structName!)).toEqual(di.struct('Pair')); + expect(di.struct(structName!)!.members.map((m) => m.name)).toEqual(['a', 'b']); + }); + + it('reports the cv-qualifiers variableShape resolves through — volatile scalar, const array, volatile struct', () => { + // volatile unsigned short g_mmio — the MMIO idiom; the qualifier is part of the declaration + expect(di.types.variableShape('g_mmio')).toEqual({ + kind: 'scalar', + size: 2, + signed: false, + volatile: true, + const: false, + }); + // const short g_rom_table[3] — the ROM-table idiom; the const qualifies the ELEMENT in DWARF + expect(di.types.variableShape('g_rom_table')).toEqual({ + kind: 'array', + elemSize: 2, + elemSigned: true, + length: 3, + volatile: false, + const: true, + }); + // volatile struct Cv g_cv — the qualifier survives to the struct classification + expect(di.types.variableShape('g_cv')).toEqual({ + kind: 'struct', + structName: 'Cv', + size: 4, + volatile: true, + const: false, + }); + }); + + it('reports an array member’s element stride/signedness/count — the facts `size` cannot carry', () => { + // `char name[6]` at offset 10: `size` is 6 (the whole member), so the position of its n-th + // element is only derivable from the element stride, and how many there are only from `length`. + const members = Object.fromEntries(di.struct('Probe')!.members.map((m) => [m.name, m])); + expect(members.name).toMatchObject({ size: 6, elemSize: 1, elemSigned: false, length: 6 }); + // Non-array members carry none of the three — their presence is what marks a member an array. + for (const plain of ['tag', 'count', 'ptr', 'inner']) { + expect(members[plain]).not.toHaveProperty('elemSize'); + expect(members[plain]).not.toHaveProperty('elemSigned'); + expect(members[plain]).not.toHaveProperty('length'); + } + }); + + it('reports member base-type signedness — the s8-vs-u8 fact offsets cannot carry', () => { + // struct Cv { signed char level; unsigned short gain; } + expect(di.struct('Cv')).toEqual({ + name: 'Cv', + size: 4, + members: [ + { name: 'level', offset: 0, size: 1, signed: true }, + { name: 'gain', offset: 2, size: 2, signed: false, volatile: true }, // vu16-field idiom + ], + }); + }); + it('returns null for unknown types and missing members', () => { expect(di.struct('NoSuchType')).toBeNull(); expect(di.structMember('Probe', 'nope')).toBeNull(); @@ -168,17 +282,18 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { // Bitfields: hearts:2, stars:3, cross:7, wide:4 packed LSB-first into one unit, // then a plain int. Normalized identically from DWARF-2 (bit_offset from MSB) - // and DWARF-5 (data_bit_offset). + // and DWARF-5 (data_bit_offset). The big-endian block below asserts the mirror + // image of these numbers for the very same declaration. it('resolves bitfield members to offset + shift + width — DebugInfo.struct', () => { expect(di.struct('Bits')).toEqual({ name: 'Bits', size: 8, members: [ - { name: 'hearts', offset: 0, size: 1, bitOffset: 0, bitWidth: 2 }, - { name: 'stars', offset: 0, size: 1, bitOffset: 2, bitWidth: 3 }, - { name: 'cross', offset: 0, size: 2, bitOffset: 5, bitWidth: 7 }, // crosses byte boundary → 2-byte read - { name: 'wide', offset: 1, size: 1, bitOffset: 4, bitWidth: 4 }, - { name: 'after', offset: 4, size: 4 }, // plain member: no bitOffset/bitWidth + { name: 'hearts', offset: 0, size: 1, bitOffset: 0, bitWidth: 2, signed: false }, + { name: 'stars', offset: 0, size: 1, bitOffset: 2, bitWidth: 3, signed: false }, + { name: 'cross', offset: 0, size: 2, bitOffset: 5, bitWidth: 7, signed: false }, // crosses byte boundary → 2-byte read + { name: 'wide', offset: 1, size: 1, bitOffset: 4, bitWidth: 4, signed: false }, + { name: 'after', offset: 4, size: 4, signed: true }, // plain member: no bitOffset/bitWidth ], }); }); @@ -190,8 +305,8 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { name: 'UtilPair', size: 4, members: [ - { name: 'lo', offset: 0, size: 2 }, - { name: 'hi', offset: 2, size: 2 }, + { name: 'lo', offset: 0, size: 2, signed: true }, + { name: 'hi', offset: 2, size: 2, signed: true }, ], }); }); @@ -243,6 +358,20 @@ describe('DebugInfo on devkitarm-min-only shapes', () => { expect(di.resolveVariable('g_shape.pair')).toEqual({ address: shape + 4, size: 2 }); }); + it('reports a const member, which nothing about its location says', () => { + // `struct Shape { const int kind; … };` — const moves no field, so a consumer re-spelling + // the declaration can only get it from here, and a write through the member is a constraint + // violation rather than another spelling of the same access. + const kind = di.struct('Shape')!.members.find((m) => m.name === 'kind')!; + expect(kind).toEqual({ name: 'kind', offset: 0, size: 4, signed: true, const: true }); + // Unqualified members carry neither key — presence is the fact, as it is for volatile. + const level = di.struct('Cv')!.members.find((m) => m.name === 'level')!; + expect(level).not.toHaveProperty('const'); + expect(level).not.toHaveProperty('volatile'); + // The location is the same either way, so `const` is not part of one. + expect(di.structMember('Shape', 'kind')).toEqual({ offset: 0, size: 4 }); + }); + it('reports the byte size of an 8-byte global (long long)', () => { expect(di.resolveVariable('g_wide')).toEqual({ address: di.symbolToAddress('g_wide'), size: 8 }); }); @@ -253,6 +382,70 @@ describe('DebugInfo on devkitarm-min-only shapes', () => { expect(di.structMember('Blob', 'data')).toEqual({ offset: 4, size: null }); // The null size propagates, so resolveVariable refuses to size the read. expect(di.resolveVariable('g_blob.data')).toBeNull(); + // The member still declares an element STRIDE — what it has no bound. So `elemSize` is + // reported and `length` is absent, the two facts being independent. + const data = di.struct('Blob')!.members.find((m) => m.name === 'data')!; + expect(data).toEqual({ name: 'data', offset: 4, size: null, signed: null, elemSize: 1, elemSigned: false }); + }); + + it('names an UNNAMED pointee by the typedef that aliases it', () => { + // `typedef struct {…} Pair; Pair *g_pair_ptr;` — the struct itself has no tag, so the alias + // is the only name its layout has, and struct() is the consumer that must accept it. + expect(di.types.variableShape('g_pair_ptr')).toEqual({ + kind: 'pointer', + pointee: { structName: 'Pair', size: 8, volatile: false, const: false }, + volatile: false, + const: false, + }); + expect(di.struct('Pair')).toEqual({ + name: 'Pair', + size: 8, + members: [ + { name: 'a', offset: 0, size: 4, signed: true }, + { name: 'b', offset: 4, size: 4, signed: true }, + ], + }); + }); + + it('reports a pointee’s OWN cv-qualifiers, on the side of the * they were written', () => { + // `volatile struct Cv *g_cv_ptr;` — accesses THROUGH the pointer are observable, the pointer + // variable itself is an ordinary object. Its mirror `struct Cv *volatile g_cv_vptr;` qualifies + // the pointer and not its target. The two declarations differ only in that placement, so the + // volatile must land on a different one of the two objects in each. + expect(di.types.variableShape('g_cv_ptr')).toEqual({ + kind: 'pointer', + pointee: { structName: 'Cv', size: 4, volatile: true, const: false }, + volatile: false, + const: false, + }); + expect(di.types.variableShape('g_cv_vptr')).toEqual({ + kind: 'pointer', + pointee: { structName: 'Cv', size: 4, volatile: false, const: false }, + volatile: true, + const: false, + }); + }); + + it('keeps a pointee’s qualifiers out of the pointer variable’s own, and vice versa', () => { + // The same two declarations read as the pair of facts a consumer re-spelling the declaration + // needs: each object's volatility, from its own side of the *. They are never the same walk. + const asPointer = (name: string) => { + const shape = di.types.variableShape(name)!; + return shape.kind === 'pointer' ? shape : null; + }; + const target = asPointer('g_cv_ptr')!; + const self = asPointer('g_cv_vptr')!; + + expect([target.volatile, target.pointee!.volatile]).toEqual([false, true]); + expect([self.volatile, self.pointee!.volatile]).toEqual([true, false]); + // An unqualified pointer to an unqualified struct is the control: neither side is set. + const plain = asPointer('g_pair_ptr')!; + expect([plain.volatile, plain.pointee!.volatile, plain.const, plain.pointee!.const]).toEqual([ + false, + false, + false, + false, + ]); }); it('keeps absolute ldscript globals but excludes section-relative linker markers', () => { @@ -265,3 +458,533 @@ describe('DebugInfo on devkitarm-min-only shapes', () => { expect(di.symbolToAddress('__bss_start')).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Big-endian. mips-min and ppc-min compile ONE shared source (their main.c/util.c +// are byte-identical), so both linked ELFs must yield the same layout — and both +// have the container AND the whole DWARF payload stored MSB-first. +// --------------------------------------------------------------------------- +describe.each(BE_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { + const elf = join(project.dir, 'build', 'min.elf'); + const oracle = JSON.parse(readFileSync(join(project.dir, 'build', 'oracle.json'), 'utf8')) as Oracle; + const di = DebugInfo.fromElf(new Uint8Array(readFileSync(elf))); + + it('is a big-endian ELF (ELFDATA2MSB)', () => { + expect(di.elf.littleEndian).toBe(false); + }); + + it('parses a DWARF line table from the big-endian payload', () => { + expect(di.hasLineInfo).toBe(true); + expect(di.hasTypeInfo).toBe(true); + }); + + it('spans multiple compilation units (main.c + util.c)', () => { + const files = new Set(di.lines.rows.map((r) => basename(r.file))); + expect(files.has('main.c')).toBe(true); + expect(files.has('util.c')).toBe(true); + }); + + it.each(FUNCS)('symbolToAddress(%s) matches nm', (fn) => { + expect(di.symbolToAddress(fn)).toBe(oracle.symbols[fn]); + }); + + it.each(FUNCS)('pcToFunction(%s entry) matches nm', (fn) => { + expect(di.pcToFunction(oracle.symbols[fn]!)?.name).toBe(fn); + }); + + it.each(FUNCS)('pcToSource(%s entry) matches addr2line', (fn) => { + // The whole .debug_line program — header, opcodes, DW_LNE_set_address operand — + // is read MSB-first here; a byte-order slip would put every row elsewhere. + const addr = oracle.symbols[fn]!; + const want = oracle.lines[hex(addr)]!; + const src = di.pcToSource(addr); + expect(src?.func).toBe(want.func); + expect(basename(src!.file)).toBe(basename(want.file)); + expect(src?.line).toBe(want.line); + }); + + it('returns null for a PC outside any function/sequence', () => { + expect(di.pcToSource(0x7f000000)).toBeNull(); + expect(di.pcToFunction(0x7f000000)).toBeNull(); + }); + + it('resolves a named struct layout (offsets + sizes) — DebugInfo.struct', () => { + // Identical to the ARM layout: both ABIs align a 32-bit int to 4 bytes. + expect(di.struct('Probe')).toEqual({ + name: 'Probe', + size: 32, + members: [ + { name: 'tag', offset: 0, size: 1, signed: false }, + { name: 'count', offset: 4, size: 4, signed: true }, + { name: 'flags', offset: 8, size: 2, signed: true }, + { name: 'name', offset: 10, size: 6, signed: null, elemSize: 1, elemSigned: false, length: 6 }, + { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true, pointeeSize: 4, pointeeSigned: true }, + { name: 'inner', offset: 20, size: 8, signed: null }, // nested struct + { name: 'tail', offset: 28, size: 4, signed: true }, + ], + }); + expect(di.structMember('Probe', 'inner.y')).toEqual({ offset: 24, size: 2 }); + }); + + it('resolves a struct from a second compilation unit (multi-abbrev-table)', () => { + expect(di.struct('UtilPair')).toEqual({ + name: 'UtilPair', + size: 4, + members: [ + { name: 'lo', offset: 0, size: 2, signed: true }, + { name: 'hi', offset: 2, size: 2, signed: true }, + ], + }); + }); + + it('reports member base-type signedness and member-level volatile', () => { + expect(di.struct('Cv')).toEqual({ + name: 'Cv', + size: 4, + members: [ + { name: 'level', offset: 0, size: 1, signed: true }, + { name: 'gain', offset: 2, size: 2, signed: false, volatile: true }, + ], + }); + }); + + // THE big-endian assertion class: a big-endian target allocates bitfields from the + // MOST significant end of the storage unit, so the identical C declaration that the + // ARM projects pin as {hearts@0>>0, stars@0>>2, cross@0..1>>5, wide@1>>4} is the + // mirror image here. Ground truth from the compilers' own read-modify-write of + // `cross` (a 2-byte load at offset 0, then insert at shift 4, width 7): + // MIPS lhu $t2,g_bits ; ins $t2,$v0,0x4,0x7 ; sh $t2,g_bits + // PPC lhz r6,0(r7) ; rlwimi r6,r9,4,21,27 ; sth r6,0(r7) + it('resolves BIG-ENDIAN bitfields MSB-first — DebugInfo.struct', () => { + expect(di.struct('Bits')).toEqual({ + name: 'Bits', + size: 8, + members: [ + { name: 'hearts', offset: 0, size: 1, bitOffset: 6, bitWidth: 2, signed: false }, // top 2 bits of byte 0 + { name: 'stars', offset: 0, size: 1, bitOffset: 3, bitWidth: 3, signed: false }, + { name: 'cross', offset: 0, size: 2, bitOffset: 4, bitWidth: 7, signed: false }, // crosses the byte boundary + { name: 'wide', offset: 1, size: 1, bitOffset: 0, bitWidth: 4, signed: false }, // bottom 4 bits of byte 1 + { name: 'after', offset: 4, size: 4, signed: true }, // plain member: no bitOffset/bitWidth + ], + }); + }); + + it('carries the big-endian bitfield shift through resolveVariable', () => { + expect(di.resolveVariable('g_bits.cross')).toEqual({ + address: di.symbolToAddress('g_bits'), + size: 2, + bitOffset: 4, + bitWidth: 7, + }); + expect(di.variableMember('g_bits', 'wide')).toEqual({ offset: 1, size: 1, bitOffset: 0, bitWidth: 4 }); + }); + + it('classifies every declaration shape — TypeIndex.variableShape', () => { + expect(di.types.variableShape('g_counter')).toEqual({ + kind: 'scalar', + size: 4, + signed: true, + volatile: false, + const: false, + }); + // `int *g_ptr` — the target is a scalar, so there is no struct pointee to report + expect(di.types.variableShape('g_ptr')).toEqual({ + kind: 'pointer', + pointee: null, + volatile: false, + const: false, + }); + // `struct Probe *g_probe_ptr` — the target IS a struct, named by its tag and sized + expect(di.types.variableShape('g_probe_ptr')).toEqual({ + kind: 'pointer', + pointee: { structName: 'Probe', size: 32, volatile: false, const: false }, + volatile: false, + const: false, + }); + expect(di.types.variableShape('g_table')).toEqual({ + kind: 'array', + elemSize: 2, + elemSigned: true, + length: 4, + volatile: false, + const: false, + }); + // const short g_rom_table[3] — the const qualifies the ELEMENT in DWARF + expect(di.types.variableShape('g_rom_table')).toEqual({ + kind: 'array', + elemSize: 2, + elemSigned: true, + length: 3, + volatile: false, + const: true, + }); + expect(di.types.variableShape('g_vol')).toEqual({ + kind: 'scalar', + size: 4, + signed: true, + volatile: true, + const: false, + }); + expect(di.types.variableShape('g_probe')).toEqual({ + kind: 'struct', + structName: 'Probe', + size: 32, + volatile: false, + const: false, + }); + expect(di.types.variableShape('g_cv')).toEqual({ + kind: 'struct', + structName: 'Cv', + size: 4, + volatile: true, + const: false, + }); + expect(di.types.variableShape('g_no_such')).toBeNull(); + }); + + it('names a pointee the way struct() looks a layout up — the round trip', () => { + // The point of reporting the name: it is the argument struct() takes, so a consumer holding + // only the pointer's own declaration can reach the layout it points at. + const shape = di.types.variableShape('g_probe_ptr')!; + expect(shape.kind).toBe('pointer'); + const pointee = shape.kind === 'pointer' ? shape.pointee : null; + expect(di.struct(pointee!.structName!)).toEqual(di.struct('Probe')); + expect(pointee!.size).toBe(di.struct('Probe')!.size); + }); + + it('resolves whole-variable reads to address + size', () => { + expect(di.resolveVariable('g_table')).toEqual({ address: oracle.symbols.g_table, size: 8 }); + expect(di.resolveVariable('g_rom_table')).toEqual({ address: oracle.symbols.g_rom_table, size: 6 }); + expect(di.resolveVariable('g_probe.inner.y')).toEqual({ address: oracle.symbols.g_probe! + 24, size: 2 }); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-endian equivalence. The four projects declare a shared core (struct Probe / +// Inner / Bits / Cv / UtilPair and six globals) and compile it with four different +// compilers across BOTH byte orders. So: +// +// - everything the ABI fixes must come out IDENTICAL in all four, and +// - the one thing byte order legitimately changes — the intra-unit bitfield shift +// — must come out MIRRORED, not merely different. +// +// The second half is the assertion that proves byte order is threaded through the +// reader rather than working by accident: a parser that ignored ELFDATA2MSB would +// either fail outright or report the little-endian shifts for the big-endian ELFs. +// --------------------------------------------------------------------------- +describe('cross-endian equivalence (same declarations, four toolchains, both byte orders)', () => { + const load = (project: Project) => ({ + name: basename(project.dir), + di: DebugInfo.fromElf(new Uint8Array(readFileSync(join(project.dir, 'build', 'min.elf')))), + }); + const LE = ARM_PROJECTS.map(load); + const BE = BE_PROJECTS.map(load); + const ALL = [...LE, ...BE]; + type Loaded = (typeof ALL)[number]; + + // Every type/global any of the four declares. The ones not present in all four are + // enumerated by name below, so a project that genuinely lacks a shape is skipped + // EXPLICITLY rather than dropped silently. + const CANDIDATE_TYPES = ['Probe', 'Inner', 'Bits', 'Cv', 'UtilPair', 'Pair', 'Shape', 'Blob']; + const CANDIDATE_GLOBALS = [ + 'g_counter', + 'g_probe', + 'g_bits', + 'g_cv', + 'g_rom_table', + 'g_util_pair', + 'g_pair', // little-endian sources only + 'g_color', + 'g_mode', + 'g_mmio', + 'g_ptr', // big-endian sources only + 'g_probe_ptr', + 'g_table', + 'g_vol', + 'g_shape', // devkitarm-min only (agbcc rejects anonymous unions / flexible arrays) + 'g_wide', + 'g_blob', + 'g_pair_ptr', + 'g_cv_ptr', + 'g_cv_vptr', + ]; + + const hasType = (p: Loaded, name: string): boolean => p.di.struct(name) !== null; + const hasGlobal = (p: Loaded, name: string): boolean => p.di.types.variableShape(name) !== null; + + const sharedTypes = CANDIDATE_TYPES.filter((n) => ALL.every((p) => hasType(p, n))); + const sharedGlobals = CANDIDATE_GLOBALS.filter((n) => ALL.every((p) => hasGlobal(p, n))); + + /** name → the projects that do NOT declare it, for everything not shared by all four. */ + const skipped = (names: string[], has: (p: Loaded, n: string) => boolean): Record => + Object.fromEntries( + names + .map((n) => [n, ALL.filter((p) => !has(p, n)).map((p) => p.name)] as const) + .filter(([, absent]) => absent.length > 0), + ); + + it('compares two little-endian ELFs against two big-endian ones', () => { + expect(LE.map((p) => [p.name, p.di.elf.littleEndian])).toEqual([ + ['agbcc-min', true], + ['devkitarm-min', true], + ]); + expect(BE.map((p) => [p.name, p.di.elf.littleEndian])).toEqual([ + ['mips-min', false], + ['ppc-min', false], + ]); + }); + + it('shares exactly this declaration set — every other shape is skipped BY NAME', () => { + expect(sharedTypes).toEqual(['Probe', 'Inner', 'Bits', 'Cv', 'UtilPair']); + expect(sharedGlobals).toEqual(['g_counter', 'g_probe', 'g_bits', 'g_cv', 'g_rom_table', 'g_util_pair']); + // The rest, and who lacks each. These are SOURCE facts (the big-endian projects + // declare a different set of globals; agbcc/GCC 2.95 rejects anonymous unions and + // flexible array members), not parser gaps — pinned so a shape silently vanishing + // from a project's DWARF fails here instead of shrinking the comparison. + expect(skipped(CANDIDATE_TYPES, hasType)).toEqual({ + Pair: ['mips-min', 'ppc-min'], + Shape: ['agbcc-min', 'mips-min', 'ppc-min'], + Blob: ['agbcc-min', 'mips-min', 'ppc-min'], + }); + expect(skipped(CANDIDATE_GLOBALS, hasGlobal)).toEqual({ + g_pair: ['mips-min', 'ppc-min'], + g_color: ['mips-min', 'ppc-min'], + g_mode: ['mips-min', 'ppc-min'], + g_mmio: ['mips-min', 'ppc-min'], + g_ptr: ['agbcc-min', 'devkitarm-min'], + g_probe_ptr: ['agbcc-min', 'devkitarm-min'], + g_table: ['agbcc-min', 'devkitarm-min'], + g_vol: ['agbcc-min', 'devkitarm-min'], + g_shape: ['agbcc-min', 'mips-min', 'ppc-min'], + g_wide: ['agbcc-min', 'mips-min', 'ppc-min'], + g_blob: ['agbcc-min', 'mips-min', 'ppc-min'], + g_pair_ptr: ['agbcc-min', 'mips-min', 'ppc-min'], + g_cv_ptr: ['agbcc-min', 'mips-min', 'ppc-min'], + g_cv_vptr: ['agbcc-min', 'mips-min', 'ppc-min'], + }); + }); + + // The byte layout every project must report, spelled `member@offset:size`. Both + // 32-bit ABIs align an int to 4, so these numbers are byte-order-independent. + const SHARED_LAYOUT: Record = { + Probe: { size: 32, members: 'tag@0:1 count@4:4 flags@8:2 name@10:6 ptr@16:4 inner@20:8 tail@28:4' }, + Inner: { size: 8, members: 'x@0:4 y@4:2' }, + // `cross` spans the byte boundary in both byte orders, hence its 2-byte read. + Bits: { size: 8, members: 'hearts@0:1 stars@0:1 cross@0:2 wide@1:1 after@4:4' }, + Cv: { size: 4, members: 'level@0:1 gain@2:2' }, + UtilPair: { size: 4, members: 'lo@0:2 hi@2:2' }, + }; + + it.each(sharedTypes)('every project reports the same byte layout for %s', (type) => { + for (const p of ALL) { + const layout = p.di.struct(type)!; + expect({ + project: p.name, + size: layout.size, + members: layout.members.map((m) => `${m.name}@${m.offset}:${m.size}`).join(' '), + }).toEqual({ project: p.name, ...SHARED_LAYOUT[type] }); + } + }); + + /** A layout with the ONE field byte order may change (the intra-unit shift) dropped. + * Everything left — names, offsets, sizes, signedness, pointer-ness, member-level + * volatile, and bitfield WIDTHS — is ABI, so all four must agree exactly. */ + const withoutShift = (layout: StructType): StructType => ({ + ...layout, + members: layout.members.map(({ bitOffset: _shift, ...rest }) => rest), + }); + + it.each(sharedTypes)('all four agree on every declaration fact of %s except the intra-unit shift', (type) => { + const reference = withoutShift(LE[0]!.di.struct(type)!); + for (const p of ALL) { + expect({ project: p.name, layout: withoutShift(p.di.struct(type)!) }).toEqual({ + project: p.name, + layout: reference, + }); + } + }); + + // Declaration shapes: scalar size/signedness, array elemSize/elemSigned/length, + // struct name/size, and the cv-qualifiers — all byte-order-independent. + const SHARED_SHAPES: Record = { + g_counter: { kind: 'scalar', size: 4, signed: true, volatile: false, const: false }, + g_probe: { kind: 'struct', structName: 'Probe', size: 32, volatile: false, const: false }, + g_bits: { kind: 'struct', structName: 'Bits', size: 8, volatile: false, const: false }, + g_cv: { kind: 'struct', structName: 'Cv', size: 4, volatile: true, const: false }, + g_rom_table: { kind: 'array', elemSize: 2, elemSigned: true, length: 3, volatile: false, const: true }, + g_util_pair: { kind: 'struct', structName: 'UtilPair', size: 4, volatile: false, const: false }, + }; + + it.each(sharedGlobals)('every project classifies %s to the same shape — TypeIndex.variableShape', (name) => { + for (const p of ALL) { + expect({ project: p.name, shape: p.di.types.variableShape(name) }).toEqual({ + project: p.name, + shape: SHARED_SHAPES[name], + }); + } + }); + + // Bitfields: `unsigned hearts:2, stars:3, cross:7, wide:4` share ONE 4-byte storage + // unit at offset 0 under both ABIs. A little-endian target fills it from the LSB, a + // big-endian one from the MSB, so the shifts are mirror images of each other: + // leShift + beShift + bitWidth === size * 8 + // Ground truth for the big-endian side is the compilers' own read-modify-write of + // `cross` (MIPS `ins $t2,$v0,0x4,0x7`, PPC `rlwimi r6,r9,4,21,27` — see above). + const SHIFTS_LE: Record = { hearts: 0, stars: 2, cross: 5, wide: 4 }; + const SHIFTS_BE: Record = { hearts: 6, stars: 3, cross: 4, wide: 0 }; + + it('bitfields differ ONLY in the shift, and each side matches its own ABI', () => { + const shifts = (p: Loaded): Record => + Object.fromEntries( + p.di + .struct('Bits')! + .members.filter((m) => m.bitWidth !== undefined) + .map((m) => [m.name, m.bitOffset]), + ); + for (const p of LE) { + expect({ project: p.name, ...shifts(p) }).toEqual({ project: p.name, ...SHIFTS_LE }); + } + for (const p of BE) { + expect({ project: p.name, ...shifts(p) }).toEqual({ project: p.name, ...SHIFTS_BE }); + } + + // …and they are MIRRORS of one another, not merely two different tables: each + // field starts the same distance from the opposite end of its own read. + for (const m of LE[0]!.di.struct('Bits')!.members) { + if (m.bitWidth === undefined) { + expect(m.bitOffset).toBeUndefined(); // `after` is a plain member, not a bitfield + continue; + } + expect(SHIFTS_LE[m.name]! + SHIFTS_BE[m.name]! + m.bitWidth).toBe(m.size! * 8); + } + }); + + it('carries the byte-order-correct shift all the way through resolveVariable', () => { + // The end-to-end consumer view: same C field, same address and read size on both + // sides, opposite shift. (`g_bits` sits at a different address per project, so the + // address is compared to each project's own symbol.) + for (const p of ALL) { + const want = p.di.elf.littleEndian ? SHIFTS_LE : SHIFTS_BE; + expect({ project: p.name, resolved: p.di.resolveVariable('g_bits.cross') }).toEqual({ + project: p.name, + resolved: { + address: p.di.symbolToAddress('g_bits'), + size: 2, + bitOffset: want.cross, + bitWidth: 7, + }, + }); + } + }); +}); + +// --------------------------------------------------------------------------- +// ppc-min's RELOCATABLE object. PowerPC uses RELA relocations, so in a .o every +// cross-section reference inside `.debug_*` is a ZERO field plus an addend parked +// in `.rela.
`. Nothing in that DWARF reads correctly until ElfFile +// applies them — this is the only artifact shape that exercises that path. +// --------------------------------------------------------------------------- +describe('DebugInfo on ppc-min/build/main.o (RELA-relocated DWARF)', () => { + const objDir = join(projectsDir, 'ppc-min', 'build'); + const bytes = new Uint8Array(readFileSync(join(objDir, 'main.o'))); + const oracle = JSON.parse(readFileSync(join(objDir, 'oracle-obj.json'), 'utf8')) as Oracle; + const elf = ElfFile.parse(bytes); + const di = DebugInfo.fromElf(bytes); + + /** The `.rela.debug_info` entries, decoded as { r_offset, symbol value, addend }. */ + const relocations = (): { at: number; symValue: number; addend: number }[] => { + const rela = elf.section('.rela.debug_info')!; + const symtab = elf.sections.find((s) => s.type === 2 /* SHT_SYMTAB */)!; + const rc = new Cursor(bytes, 0, false); + const symData = elf.sectionDataByIndex(elf.sections.indexOf(symtab))!; + const sc = new Cursor(symData, 0, false); + const out = []; + for (let off = rela.offset; off < rela.offset + rela.size; off += 12) { + const symIndex = rc.u32At(off + 4) >>> 8; // r_info >> 8 + out.push({ at: rc.u32At(off), symValue: sc.u32At(symIndex * 16 + 4), addend: rc.u32At(off + 8) }); + } + return out; + }; + + it('is a big-endian relocatable object with RELA-style debug relocations', () => { + expect(di.elf.littleEndian).toBe(false); + expect(elf.section('.rela.debug_info')?.type).toBe(4 /* SHT_RELA */); + // 59 of them with the toolchain CI installs; the count itself is a toolchain + // detail, so only its order of magnitude is asserted. + expect(relocations().length).toBeGreaterThan(20); + }); + + it('has NOTHING readable in the raw section — every relocated field is zero', () => { + // The premise of the RELA path: unrelocated, every site reads 0, so every + // DW_FORM_strp would resolve to .debug_str offset 0 (one and the same name). + const raw = new Cursor(bytes, elf.section('.debug_info')!.offset, false); + for (const { at } of relocations()) { + expect(raw.u32At(raw.offset + at)).toBe(0); + } + }); + + it('writes symbol value + addend at every relocation site', () => { + const patched = new Cursor(elf.sectionData('.debug_info')!, 0, false); + for (const { at, symValue, addend } of relocations()) { + expect(patched.u32At(at)).toBe(symValue + addend); + } + // Most sites target a section symbol (st_value 0, so the addend alone would do). + // The DW_AT_location of each global targets the data symbol itself, whose + // st_value is NOT 0 — those are what pin the "symbol value +" half of the sum. + const symValues = new Set(relocations().map((r) => r.symValue)); + for (const global of ['g_bits', 'g_vol', 'g_table', 'g_ptr', 'g_counter'] as const) { + expect(oracle.symbols[global]).toBeGreaterThan(0); // nm: 4, 12, 16, 24, 28 + expect(symValues.has(oracle.symbols[global]!)).toBe(true); + } + }); + + it('resolves DW_FORM_strp names — only reachable through the addends', () => { + // Every name here (the struct tags, and each member name too long for + // DW_FORM_string) is an offset into .debug_str that lives ONLY in an addend. + expect(di.struct('Probe')?.members.map((m) => m.name)).toEqual([ + 'tag', + 'count', + 'flags', + 'name', + 'ptr', + 'inner', + 'tail', + ]); + expect(di.struct('Bits')?.members.map((m) => m.name)).toEqual(['hearts', 'stars', 'cross', 'wide', 'after']); + expect(di.types.variableShape('g_probe')).toEqual({ + kind: 'struct', + structName: 'Probe', + size: 32, + volatile: false, + const: false, + }); + // A pointee is named the same way, so it is unreachable through the same addends. + expect(di.types.variableShape('g_probe_ptr')).toEqual({ + kind: 'pointer', + pointee: { structName: 'Probe', size: 32, volatile: false, const: false }, + volatile: false, + const: false, + }); + // util.c is a different object, so its type is absent here — the .o carries + // exactly one compilation unit. + expect(di.struct('UtilPair')).toBeNull(); + }); + + it('agrees with the linked ELF on every layout it shares', () => { + const linked = DebugInfo.fromElf(new Uint8Array(readFileSync(join(objDir, 'min.elf')))); + for (const type of ['Probe', 'Bits', 'Cv', 'Inner']) { + expect(di.struct(type)).toEqual(linked.struct(type)); + } + }); + + it('parses the object’s .debug_line program', () => { + // The PCs are section-relative and .text / .text.startup BOTH start at 0 in an + // unlinked object, so a PC does not identify a row here. Assert the rows by + // content instead: one file, and every function-entry line addr2line reports. + expect(new Set(di.lines.rows.map((r) => basename(r.file)))).toEqual(new Set(['main.c'])); + const lines = new Set(di.lines.rows.map((r) => r.line)); + for (const want of Object.values(oracle.lines)) { + expect(lines.has(want.line)).toBe(true); + } + }); +}); diff --git a/packages/debug-info/src/debug-info.ts b/packages/debug-info/src/debug-info.ts index 1786522..8f2a941 100644 --- a/packages/debug-info/src/debug-info.ts +++ b/packages/debug-info/src/debug-info.ts @@ -3,6 +3,7 @@ * debugger / scripting engine needs — PC→function, name→address, PC→C source. */ import { LineTable, parseDebugLine } from './debug-line.js'; +import { type MacroDefinition, parseDebugMacinfo } from './debug-macro.js'; import { ElfFile } from './elf.js'; import { type FunctionEntry, SymbolIndex } from './symbols.js'; import { type MemberLocation, type StructType, TypeIndex } from './types.js'; @@ -27,13 +28,16 @@ export class DebugInfo { readonly symbols: SymbolIndex; readonly lines: LineTable; readonly types: TypeIndex; + /** Every `#define` the ELF recorded (`-g3`), in stream order; empty when it carried none. */ + readonly macros: MacroDefinition[]; /** Use {@link DebugInfo.fromElf}; this constructor is an internal detail. */ - constructor(elf: ElfFile, symbols: SymbolIndex, lines: LineTable, types: TypeIndex) { + constructor(elf: ElfFile, symbols: SymbolIndex, lines: LineTable, types: TypeIndex, macros: MacroDefinition[] = []) { this.elf = elf; this.symbols = symbols; this.lines = lines; this.types = types; + this.macros = macros; } /** Parse a (`-g`-built) GBA ELF image into a queryable DebugInfo. */ @@ -41,9 +45,10 @@ export class DebugInfo { const elf = ElfFile.parse(bytes); const symbols = SymbolIndex.fromElf(elf); const debugLine = elf.sectionData('.debug_line'); - const lines = debugLine ? parseDebugLine(debugLine) : new LineTable([]); + const lines = debugLine ? parseDebugLine(debugLine, elf.littleEndian) : new LineTable([]); const types = TypeIndex.fromElf(elf); - return new DebugInfo(elf, symbols, lines, types); + const macinfo = elf.sectionData('.debug_macinfo'); + return new DebugInfo(elf, symbols, lines, types, macinfo ? parseDebugMacinfo(macinfo) : []); } /** True if the ELF actually carried a DWARF line table. */ @@ -51,6 +56,11 @@ export class DebugInfo { return this.lines.rows.length > 0; } + /** True if the ELF recorded preprocessor macro definitions (built with `-g3`). */ + get hasMacroInfo(): boolean { + return this.macros.length > 0; + } + /** True if the ELF carried DWARF struct/union type info. */ get hasTypeInfo(): boolean { return this.types.hasTypes; diff --git a/packages/debug-info/src/debug-line.ts b/packages/debug-info/src/debug-line.ts index 67e57d9..75145a1 100644 --- a/packages/debug-info/src/debug-line.ts +++ b/packages/debug-info/src/debug-line.ts @@ -4,6 +4,10 @@ * Produces a flat, address-sorted table of rows so a runtime PC can be mapped to * a source `file:line`. Handles the traditional line-program header (DWARF 2/3/4) * emitted by both old (GCC 2.95) and modern (GCC 14 / devkitARM) toolchains. + * + * The section is a concatenation of independent units, so parsing is per-unit and + * never all-or-nothing: a unit we can't model (DWARF 5, 64-bit DWARF) is skipped + * by its own `unit_length` and the remaining units still yield rows. */ import { Cursor } from './reader.js'; @@ -34,13 +38,25 @@ const DW_LNE_end_sequence = 1; const DW_LNE_set_address = 2; const DW_LNE_define_file = 3; +/** The lowest reserved value of an initial-length field (0xfffffff0–0xffffffff). */ +const RESERVED_LENGTH = 0xfffffff0; +/** Initial length escape introducing 64-bit DWARF (a 64-bit length follows). */ +const DWARF64_ESCAPE = 0xffffffff; + /** Parse all compilation units in a `.debug_line` section into a sorted table. */ -export function parseDebugLine(section: Uint8Array): LineTable { +export function parseDebugLine(section: Uint8Array, littleEndian = true): LineTable { const rows: LineRow[] = []; - const c = new Cursor(section); + const c = new Cursor(section, 0, littleEndian); while (c.remaining >= 4) { - parseUnit(c, rows); + const unitStart = c.offset; + const next = parseUnit(c, rows); + if (next === null || next <= unitStart) { + // Either the unit told us nothing can be trusted after it, or it made no + // forward progress. Keep the rows collected so far and stop walking. + break; + } + c.seek(next); } // Sort by address; at a shared address put an end_sequence boundary BEFORE a @@ -50,37 +66,82 @@ export function parseDebugLine(section: Uint8Array): LineTable { return new LineTable(rows); } -function parseUnit(c: Cursor, rows: LineRow[]): void { +/** + * Parse the unit at `c.offset`, appending its rows. + * + * Returns the section offset where the next unit begins, or `null` when nothing + * past this point can be walked (truncated/reserved/64-bit-too-large unit) — the + * caller keeps every row parsed so far. + */ +function parseUnit(c: Cursor, rows: LineRow[]): number | null { + const sectionEnd = c.bytes.length; const unitStart = c.offset; const unitLength = c.u32(); - if (unitLength === 0 || unitLength === 0xffffffff) { - // 0 = padding; 0xffffffff = 64-bit DWARF (unsupported here). Stop this unit. - c.seek(c.bytes.length); - return; + + if (unitLength === 0) { + // Not a unit: some producers pad the section with zero words. Step over it. + return unitStart + 4; } - const unitEnd = c.offset + unitLength; + if (unitLength >= RESERVED_LENGTH) { + if (unitLength !== DWARF64_ESCAPE || sectionEnd - c.offset < 8) { + return null; // Reserved initial-length value: the section is unwalkable. + } + // 64-bit DWARF: a 64-bit unit_length follows the escape. We don't parse the + // unit (GBA/32-bit targets never emit it), but we can skip it precisely. + const low = c.u32(); + const high = c.u32(); + const end = unitStart + 12 + low; + return high === 0 && end <= sectionEnd ? end : null; + } + + const unitEnd = unitStart + 4 + unitLength; + // A unit that claims more bytes than the section holds: parse what is there, + // then stop — there is no next unit to find. + const truncated = unitEnd > sectionEnd; + const limit = truncated ? sectionEnd : unitEnd; + const skipUnit = (): number | null => (truncated ? null : unitEnd); + if (limit - c.offset < 6) { + return skipUnit(); // No room for version + header_length. + } const version = c.u16(); + if (version < 2 || version > 4) { + // DWARF 5 rewrote this header (address_size/segment_selector_size, and + // directory/file tables described by entry formats instead of NUL-terminated + // lists), so its bytes cannot be read as a v2–v4 header. Skip the unit rather + // than mis-decode it; the other units in the section still parse. + return skipUnit(); + } + + // The line program starts header_length bytes after the header_length field — + // always seek there rather than trusting where parsing the dir/file tables + // lands, so an unmodelled header field can't desync the program. const headerLength = c.u32(); const programStart = c.offset + headerLength; + if (programStart < c.offset || programStart > limit) { + return skipUnit(); + } + if (programStart - c.offset < (version >= 4 ? 6 : 5)) { + return skipUnit(); // No room for the fixed header fields. + } const minInstLength = c.u8(); if (version >= 4) { - c.u8(); // maximum_operations_per_instruction (unused for ARM) + c.u8(); // maximum_operations_per_instruction (always 1 on ARM/MIPS/PPC) } - const defaultIsStmt = c.u8() !== 0; + c.u8(); // default_is_stmt — parsed for layout; rows don't carry is_stmt const lineBase = c.s8(); - const lineRange = c.u8(); - const opcodeBase = c.u8(); + const lineRange = c.u8() || 1; // guard against a divide-by-zero on a bogus header + const opcodeBase = c.u8() || 1; const standardOpcodeLengths: number[] = [0]; // 1-indexed - for (let i = 1; i < opcodeBase; i++) { + for (let i = 1; i < opcodeBase && c.offset < programStart; i++) { standardOpcodeLengths.push(c.u8()); } // include_directories: NUL-terminated strings, ended by an empty string. const dirs: string[] = ['']; // index 0 = compilation directory (implicit) - for (;;) { + while (c.offset < programStart) { const dir = c.cstr(); if (dir === '') { break; @@ -90,14 +151,14 @@ function parseUnit(c: Cursor, rows: LineRow[]): void { // file_names: { name, dir_index(uleb), mtime(uleb), size(uleb) }, ended by empty name. const files: { name: string; dir: number }[] = [{ name: '', dir: 0 }]; // 1-based; [0] unused - for (;;) { + while (c.offset < programStart) { const name = c.cstr(); if (name === '') { break; } - const dir = c.uleb(); - c.uleb(); // mtime - c.uleb(); // size + const dir = readUleb(c, programStart); + readUleb(c, programStart); // mtime + readUleb(c, programStart); // size files.push({ name, dir }); } @@ -118,19 +179,40 @@ function parseUnit(c: Cursor, rows: LineRow[]): void { let address = 0; let file = 1; let line = 1; - let isStmt = defaultIsStmt; - let endSequence = false; + /** + * True once a statement has run without the sequence being terminated, i.e. the + * program is mid-sequence. The line program is self-delimiting — every sequence + * ends with DW_LNE_end_sequence — so this flag, not `unit_length`, is what says + * whether the program is still running (see the loop condition below). + */ + let inSequence = false; + /** rows.length when execution first reached the declared unit end. */ + let rowsAtUnitEnd = -1; - const emit = () => rows.push({ address: address >>> 0, fileIndex: file, file: resolveFile(file), line, endSequence }); - const reset = () => { + const emit = () => + rows.push({ address: address >>> 0, fileIndex: file, file: resolveFile(file), line, endSequence: false }); + const endSequence = () => { + rows.push({ address: address >>> 0, fileIndex: file, file: resolveFile(file), line, endSequence: true }); address = 0; file = 1; line = 1; - isStmt = defaultIsStmt; - endSequence = false; + inSequence = false; }; - while (c.offset < unitEnd) { + // Statements run to the declared unit end — and past it while a sequence is + // still open. `unit_length` is not a dependable end marker: agbcc (GCC 2.95, as + // shipped with the pret decomps) sizes a unit by *predicting* the encoded length + // of each statement, and mispredicts by a few bytes, so the tail of the last + // sequence can spill past it (in pokeemerald 28 of 303 units, by 1–4 bytes and + // one by 51). Stopping at the declared end would leave the cursor mid-statement + // and mis-read the next unit's header as line-program bytes, which desyncs the + // rest of the section. DW_LNE_end_sequence is the authority on where the program + // — and so the unit — ends; well-formed units end with it precisely, so they see + // no difference. + while ((c.offset < limit || inSequence) && c.offset < sectionEnd) { + if (rowsAtUnitEnd < 0 && c.offset >= limit) { + rowsAtUnitEnd = rows.length; + } const opcode = c.u8(); if (opcode >= opcodeBase) { @@ -138,77 +220,138 @@ function parseUnit(c: Cursor, rows: LineRow[]): void { const adjusted = opcode - opcodeBase; address += Math.floor(adjusted / lineRange) * minInstLength; line += lineBase + (adjusted % lineRange); + inSequence = true; emit(); continue; } - switch (opcode) { - case 0: { - // Extended opcode. - const len = c.uleb(); - const extStart = c.offset; - const sub = c.u8(); - switch (sub) { - case DW_LNE_end_sequence: - endSequence = true; - emit(); - reset(); - break; - case DW_LNE_set_address: - address = c.u32(); // 32-bit target - break; - case DW_LNE_define_file: - // name, dir, mtime, size — rarely used; skip via len. - break; - default: - break; + if (opcode === 0) { + // Extended opcode: . Unknown + // sub-opcodes (and DW_LNE_define_file) are skipped by that length. + const len = readUleb(c, sectionEnd); + const extStart = c.offset; + const extEnd = extStart + len; + if (len < 1 || extEnd > sectionEnd) { + break; // Truncated statement: nothing further in this unit is readable. + } + const sub = c.u8(); + switch (sub) { + case DW_LNE_end_sequence: + endSequence(); + break; + case DW_LNE_set_address: { + // The operand is the target's address; its size is whatever the rest of + // the statement holds (4 on every target this parses; 2 on tiny targets, + // 8 on 64-bit ones, where the low word is the addressable part). + const size = len - 1; + if (size >= 4) { + address = c.u32(); + } else if (size === 2) { + address = c.u16(); + } else if (size === 1) { + address = c.u8(); + } + inSequence = true; + break; } - c.seek(extStart + len); - break; + case DW_LNE_define_file: + default: + break; } + c.seek(extEnd); + continue; + } + + inSequence = true; + switch (opcode) { case DW_LNS_copy: emit(); break; case DW_LNS_advance_pc: - address += c.uleb() * minInstLength; + address += readUleb(c, sectionEnd) * minInstLength; break; case DW_LNS_advance_line: - line += c.sleb(); + line += readSleb(c, sectionEnd); break; case DW_LNS_set_file: - file = c.uleb(); + file = readUleb(c, sectionEnd); break; case DW_LNS_set_column: - c.uleb(); + readUleb(c, sectionEnd); break; case DW_LNS_negate_stmt: - isStmt = !isStmt; - break; case DW_LNS_set_basic_block: break; case DW_LNS_const_add_pc: address += Math.floor((255 - opcodeBase) / lineRange) * minInstLength; break; case DW_LNS_fixed_advance_pc: + if (sectionEnd - c.offset < 2) { + return null; // Truncated operand: nothing past this is readable. + } address += c.u16(); break; default: { - // Unknown standard opcode: skip its ULEB operands. + // Unknown standard opcode (vendor extension): skip its declared ULEB operands. const n = standardOpcodeLengths[opcode] ?? 0; for (let i = 0; i < n; i++) { - c.uleb(); + readUleb(c, sectionEnd); } break; } } } - c.seek(unitEnd); - // Parsed while running the program but not surfaced: rows carry only - // address/file/line/endSequence. - void isStmt; - void unitStart; - void version; + if (inSequence) { + // The program ran to the end of the section with a sequence still open: the + // tail was not a line program, so drop the rows read past the declared end and + // stop — everything before the unit end stays. + if (rowsAtUnitEnd >= 0) { + rows.length = rowsAtUnitEnd; + } + return null; + } + + const end = Math.max(c.offset, unitEnd); + return truncated || end > sectionEnd ? null : end; +} + +/** + * ULEB128 bounded by `limit`: a varint whose continuation bits run past the end + * of the readable region stops there instead of reading out of bounds. + */ +function readUleb(c: Cursor, limit: number): number { + let result = 0; + let shift = 0; + while (c.offset < limit) { + const byte = c.u8(); + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + break; + } + shift += 7; + } + return result >>> 0; +} + +/** SLEB128 bounded by `limit` (see {@link readUleb}). */ +function readSleb(c: Cursor, limit: number): number { + let result = 0; + let shift = 0; + let byte = 0; + while (c.offset < limit) { + byte = c.u8(); + result |= (byte & 0x7f) << shift; + shift += 7; + if ((byte & 0x80) === 0) { + break; + } + } + // Sign-extend if the last byte's sign bit is set. + if (shift < 32 && byte & 0x40) { + result |= -(1 << shift); + } + return result; } /** Address-sorted line rows with PC→source lookup. */ diff --git a/packages/debug-info/src/debug-macro.ts b/packages/debug-info/src/debug-macro.ts new file mode 100644 index 0000000..ec14b90 --- /dev/null +++ b/packages/debug-info/src/debug-macro.ts @@ -0,0 +1,100 @@ +/** + * DWARF `.debug_macinfo` — the preprocessor's own record of what each macro was defined as. + * + * A compiler invoked with `-g3` records every `#define` it saw. That is the only place some + * facts survive at all: a macro leaves no symbol, no type and no DIE, so a consumer reading an + * ELF has no other way to learn that a project spells a fixed address `gCounter` rather than + * `(*(u16 *)0x03001234)`. + * + * This parses the DWARF 2/3 form (`.debug_macinfo`, section 6.3), which is a flat opcode stream + * carrying its strings INLINE. The DWARF 5 replacement (`.debug_macro`) is deliberately not read + * here: it splits a translation unit's macros across COMDAT group sections joined by + * `DW_MACRO_import` and refers to `.debug_str` for every name, so neither survives being lifted + * out of one object — whereas this form is self-contained by construction. + */ +import { Cursor } from './reader.js'; + +const DW_MACINFO_define = 0x01; +const DW_MACINFO_undef = 0x02; +const DW_MACINFO_start_file = 0x03; +const DW_MACINFO_end_file = 0x04; +const DW_MACINFO_vendor_ext = 0xff; + +/** One recorded `#define`, split at the first space exactly as DWARF stores it. */ +export interface MacroDefinition { + /** + * The defined name. For a function-like macro this includes the parameter list as written + * (`MAX(a,b)`), because that is what DWARF records and splitting it further would invent a + * structure the section does not have. + */ + name: string; + /** The replacement text, verbatim. Empty for `#define FOO` with no body. */ + body: string; + /** Source line of the definition, as recorded. */ + line: number; +} + +/** + * Parse `.debug_macinfo` into the definitions it records, in stream order. + * + * Undefines, file boundaries and vendor extensions are consumed but not reported: the question + * this answers is "what text did this name expand to", and a consumer that needs scoping would + * need the file table too, which lives elsewhere. A truncated or malformed stream STOPS rather + * than throwing — a partial macro list is still sound (every entry in it was really read), and + * these sections are grafted between tools often enough that a hard failure would be the wrong + * default for data that is purely additive. + */ +export function parseDebugMacinfo(data: Uint8Array): MacroDefinition[] { + const out: MacroDefinition[] = []; + // The opcode stream is bytes, ULEBs and inline strings, so byte order never applies here. + const cur = new Cursor(data); + while (cur.remaining > 0) { + const opcode = cur.u8(); + if (opcode === 0) { + // end of this compilation unit's list; another may follow immediately + continue; + } + switch (opcode) { + case DW_MACINFO_define: + case DW_MACINFO_undef: { + if (cur.remaining <= 0) { + return out; // truncated + } + const line = cur.uleb(); + if (data.indexOf(0, cur.offset) === -1) { + // The string's NUL never arrives: a stream cut mid-define. Reporting the bytes we do + // have would surface a corrupted name/body as a real one — stop instead. + return out; + } + const text = cur.cstr(); + if (opcode === DW_MACINFO_define) { + const sp = text.indexOf(' '); + out.push( + sp === -1 ? { name: text, body: '', line } : { name: text.slice(0, sp), body: text.slice(sp + 1), line }, + ); + } + break; + } + case DW_MACINFO_start_file: + cur.uleb(); // line + cur.uleb(); // file index + break; + case DW_MACINFO_end_file: + break; + case DW_MACINFO_vendor_ext: + if (cur.remaining <= 0) { + return out; + } + cur.uleb(); // constant + if (data.indexOf(0, cur.offset) === -1) { + return out; // truncated mid-string, same as a define + } + cur.cstr(); + break; + default: + // An unrecognized opcode has no length encoding, so the stream cannot be resynchronized. + return out; + } + } + return out; +} diff --git a/packages/debug-info/src/elf.ts b/packages/debug-info/src/elf.ts index 1e1fd60..257304c 100644 --- a/packages/debug-info/src/elf.ts +++ b/packages/debug-info/src/elf.ts @@ -1,7 +1,8 @@ /** - * Minimal ELF32 little-endian container reader — just enough to pull named - * sections (symbol tables, DWARF) out of a linked ELF. GBA ELFs are always - * ELF32 / EM_ARM / little-endian, so we validate and bail otherwise. + * Minimal ELF32 container reader — just enough to pull named sections (symbol + * tables, DWARF) out of a linked ELF or relocatable object. Little-endian (ARM + * GBA) and big-endian (MIPS, PowerPC) are both supported; the byte order is read + * from e_ident and threaded through every multi-byte read. */ import { Cursor, cstrAt } from './reader.js'; @@ -17,19 +18,27 @@ export interface ElfSection { entsize: number; } -const ELF_MAGIC = 0x464c457f; // "\x7fELF" little-endian +const ELF_MAGIC = 0x464c457f; // "\x7fELF" read as an LE u32 (byte-order independent: e_ident is bytes) const ELFCLASS32 = 1; const ELFDATA2LSB = 1; +const ELFDATA2MSB = 2; +const SHT_SYMTAB = 2; +const SHT_RELA = 4; export class ElfFile { readonly #bytes: Uint8Array; readonly sections: ElfSection[]; + /** Byte order of the container AND of its DWARF payload (they always agree). */ + readonly littleEndian: boolean; readonly #byName = new Map(); + /** RELA-patched copies of section data, materialized lazily (see sectionData). */ + readonly #relocated = new Map(); /** Use {@link ElfFile.parse}; this constructor is an internal detail. */ - constructor(bytes: Uint8Array, sections: ElfSection[]) { + constructor(bytes: Uint8Array, sections: ElfSection[], littleEndian = true) { this.#bytes = bytes; this.sections = sections; + this.littleEndian = littleEndian; for (const s of sections) { // First occurrence wins (a name should be unique anyway). if (!this.#byName.has(s.name)) { @@ -38,20 +47,22 @@ export class ElfFile { } } - /** Parse an ELF32-LE image. Throws on a non-ELF / unsupported file. */ + /** Parse an ELF32 image (either byte order). Throws on a non-ELF / unsupported file. */ static parse(bytes: Uint8Array): ElfFile { - const c = new Cursor(bytes); - if (c.u32() !== ELF_MAGIC) { + const ident = new Cursor(bytes); // e_ident is byte-oriented — endianness not yet known + if (ident.u32() !== ELF_MAGIC) { throw new Error('Not an ELF file (bad magic)'); } - const eiClass = c.u8(); - const eiData = c.u8(); + const eiClass = ident.u8(); + const eiData = ident.u8(); if (eiClass !== ELFCLASS32) { throw new Error(`Unsupported ELF class ${eiClass} (expected ELF32)`); } - if (eiData !== ELFDATA2LSB) { - throw new Error(`Unsupported ELF endianness ${eiData} (expected little-endian)`); + if (eiData !== ELFDATA2LSB && eiData !== ELFDATA2MSB) { + throw new Error(`Unsupported ELF endianness ${eiData}`); } + const littleEndian = eiData === ELFDATA2LSB; + const c = new Cursor(bytes, 0, littleEndian); // Section header table location lives at fixed offsets in the ELF32 header. const shoff = c.u32At(0x20); @@ -85,20 +96,60 @@ export class ElfFile { const shstrtab = bytes.subarray(shstr.offset, shstr.offset + shstr.size); const sections: ElfSection[] = raw.map((s, i) => ({ name: cstrAt(shstrtab, nameOffsets[i]!), ...s })); - return new ElfFile(bytes, sections); + return new ElfFile(bytes, sections, littleEndian); } section(name: string): ElfSection | undefined { return this.#byName.get(name); } - /** Raw bytes of a named section, or undefined if absent. */ + /** Raw bytes of a named section, or undefined if absent. In a RELOCATABLE object whose + * relocations are RELA-style (PowerPC, unlike ARM/MIPS REL where the addend already sits in + * the field), the raw `.debug_*` bytes carry ZEROS where string/section offsets belong — the + * real values live in `.rela.` addends. Those are applied here (into a cached copy), + * so DWARF in a raw `.o` parses identically across REL and RELA targets. */ sectionData(name: string): Uint8Array | undefined { const s = this.#byName.get(name); if (!s) { return undefined; } - return this.#bytes.subarray(s.offset, s.offset + s.size); + const raw = this.#bytes.subarray(s.offset, s.offset + s.size); + const rela = this.#byName.get(`.rela${name}`); + if (!rela || rela.type !== SHT_RELA) { + return raw; + } + const cached = this.#relocated.get(name); + if (cached) { + return cached; + } + const patched = raw.slice(); + const out = new Cursor(patched, 0, this.littleEndian); + const rc = new Cursor(this.#bytes.subarray(rela.offset, rela.offset + rela.size), 0, this.littleEndian); + const symtab = this.sections.find((sec) => sec.type === SHT_SYMTAB); + const symData = symtab ? this.#bytes.subarray(symtab.offset, symtab.offset + symtab.size) : undefined; + const symCursor = symData ? new Cursor(symData, 0, this.littleEndian) : undefined; + // Elf32_Rela = { r_offset u32, r_info u32, r_addend s32 } — 12 bytes each. + for (let off = 0; off + 12 <= rela.size; off += 12) { + const rOffset = rc.u32At(off); + const rInfo = rc.u32At(off + 4); + const rAddend = rc.u32At(off + 8) | 0; + if (rOffset + 4 > patched.length) { + continue; + } + // field = symbol value + addend (the section symbols debug relocs reference have value 0 + // in a .o, so this is normally just the addend). 32-bit data relocs only — which is all + // the compiler emits into debug sections. + const symIndex = rInfo >>> 8; + const symValue = symCursor && (symIndex + 1) * 16 <= symData!.length ? symCursor.u32At(symIndex * 16 + 4) : 0; + const value = (symValue + rAddend) >>> 0; + if (this.littleEndian) { + out.view.setUint32(rOffset, value, true); + } else { + out.view.setUint32(rOffset, value, false); + } + } + this.#relocated.set(name, patched); + return patched; } /** Bytes of a section referenced by index (e.g. a symtab's linked strtab). */ diff --git a/packages/debug-info/src/index.ts b/packages/debug-info/src/index.ts index 7315a65..5ce89c6 100644 --- a/packages/debug-info/src/index.ts +++ b/packages/debug-info/src/index.ts @@ -9,4 +9,12 @@ export { DebugInfo, type SourceLocation, type ResolvedLocation } from './debug-i export { ElfFile, type ElfSection } from './elf.js'; export { SymbolIndex, type ElfSymbol, type FunctionEntry, STT_FUNC, STT_NOTYPE, STT_OBJECT } from './symbols.js'; export { LineTable, parseDebugLine, type LineRow } from './debug-line.js'; -export { TypeIndex, type StructType, type StructMember, type MemberLocation } from './types.js'; +export { parseDebugMacinfo, type MacroDefinition } from './debug-macro.js'; +export { + TypeIndex, + type StructType, + type StructMember, + type MemberLocation, + type FunctionSignature, + type TypeFacts, +} from './types.js'; diff --git a/packages/debug-info/src/reader.ts b/packages/debug-info/src/reader.ts index ea9d7cb..e8a3a31 100644 --- a/packages/debug-info/src/reader.ts +++ b/packages/debug-info/src/reader.ts @@ -1,16 +1,19 @@ /** - * Little-endian byte cursor to walk ELF tables and DWARF programs. - * Tailored for ELF/DWARF relevant for ARM GBA. + * Byte cursor to walk ELF tables and DWARF programs. Little-endian by default + * (ARM GBA); big-endian for MSB-first targets (MIPS, PowerPC) — the DWARF + * payload's byte order always matches its ELF container's. */ export class Cursor { readonly view: DataView; readonly bytes: Uint8Array; + readonly littleEndian: boolean; offset: number; - constructor(bytes: Uint8Array, offset = 0) { + constructor(bytes: Uint8Array, offset = 0, littleEndian = true) { this.bytes = bytes; this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); this.offset = offset; + this.littleEndian = littleEndian; } get eof(): boolean { @@ -38,28 +41,28 @@ export class Cursor { } u16(): number { - const v = this.view.getUint16(this.offset, true); + const v = this.view.getUint16(this.offset, this.littleEndian); this.offset += 2; return v; } u32(): number { - const v = this.view.getUint32(this.offset, true); + const v = this.view.getUint32(this.offset, this.littleEndian); this.offset += 4; return v >>> 0; } - /** Absolute little-endian reads that don't move `offset` (for fixed-layout tables). */ + /** Absolute reads that don't move `offset` (for fixed-layout tables). */ u8At(offset: number): number { return this.view.getUint8(offset); } u16At(offset: number): number { - return this.view.getUint16(offset, true); + return this.view.getUint16(offset, this.littleEndian); } u32At(offset: number): number { - return this.view.getUint32(offset, true) >>> 0; + return this.view.getUint32(offset, this.littleEndian) >>> 0; } /** Unsigned LEB128 */ diff --git a/packages/debug-info/src/symbols.ts b/packages/debug-info/src/symbols.ts index 01cfc9a..cd5ef75 100644 --- a/packages/debug-info/src/symbols.ts +++ b/packages/debug-info/src/symbols.ts @@ -79,7 +79,7 @@ export class SymbolIndex { const strtab = elf.sectionDataByIndex(symtab.link) ?? new Uint8Array(0); const SYM_SIZE = 16; // Elf32_Sym - const c = new Cursor(data); + const c = new Cursor(data, 0, elf.littleEndian); const symbols: ElfSymbol[] = []; for (let off = 0; off + SYM_SIZE <= data.length; off += SYM_SIZE) { const stName = c.u32At(off); diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index 47ad540..bf53125 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -8,8 +8,10 @@ * enough to read any field straight out of memory. * * Handles the DIE forest in `.debug_info` (resolved against `.debug_abbrev` and - * `.debug_str`) for DWARF 2–5 as emitted by agbcc (GCC 2.95) and modern - * arm-none-eabi-gcc. 64-bit DWARF is not supported (GBA ELFs are 32-bit). + * `.debug_str`) for DWARF 2–5, in either byte order — a big-endian payload is read + * MSB-first, and bitfields there are allocated from the opposite end of the storage + * unit (see {@link bitfieldAbsBitOffset}). 64-bit DWARF is not supported (the ELFs + * are 32-bit). */ import { ElfFile } from './elf.js'; import { Cursor, cstrAt } from './reader.js'; @@ -26,6 +28,8 @@ const DW_TAG_typedef = 0x16; const DW_TAG_union_type = 0x17; const DW_TAG_base_type = 0x24; const DW_TAG_const_type = 0x26; +const DW_TAG_subprogram = 0x2e; +const DW_TAG_formal_parameter = 0x05; const DW_TAG_variable = 0x34; const DW_TAG_volatile_type = 0x35; const DW_TAG_restrict_type = 0x37; @@ -41,7 +45,11 @@ const DW_AT_upper_bound = 0x2f; const DW_AT_data_bit_offset = 0x6b; // DWARF 4+ bitfield: absolute bit offset from the struct start const DW_AT_count = 0x37; const DW_AT_data_member_location = 0x38; +const DW_AT_low_pc = 0x11; // present on a subprogram DEFINITION; absent on a mere declaration +const DW_AT_abstract_origin = 0x31; // concrete half of an inlined-and-emitted definition → its abstract half const DW_AT_declaration = 0x3c; +const DW_AT_prototyped = 0x27; // the declaration stated an argument list (not K&R) +const DW_AT_encoding = 0x3e; const DW_AT_type = 0x49; const DW_AT_str_offsets_base = 0x72; @@ -99,16 +107,102 @@ export interface StructMember { offset: number; /** * Bytes to read at `offset`. For a plain member this is the member's type size; - * for a bitfield it's the minimal little-endian span covering `bitOffset`+`bitWidth`. + * for a bitfield it's the minimal span covering `bitOffset`+`bitWidth`. */ size: number | null; /** - * Bitfield only: right-shift to apply to the `size`-byte little-endian value read - * at `offset` to reach the field's least-significant bit. Absent for plain members. + * Signedness of the member's type, resolved through typedefs/cv-qualifiers to a base + * type (`DW_AT_encoding`); null when the member's type is not a base type (arrays, + * pointers, nested structs, enums). Offset and size alone do not carry it: the same + * byte reads as -1 or as 255 depending only on this. + */ + signed: boolean | null; + /** + * Present (true) when the member's resolved type is a pointer. Disambiguates the + * `signed: null` 4-byte cases (pointer vs enum vs nested struct), which offset and + * size cannot tell apart and which differ in how the value may be compared + * (pointers compare as unsigned). + */ + pointer?: true; + /** + * Pointer member only: the byte size of the type it points AT, when that type is a base + * type (`u16 *` → 2). Absent when the target is not a base type (`void *`, `struct S *`, a + * function pointer) or the DWARF does not size it. + * + * The member's own size is 4 whatever it addresses, so this describes the OTHER end — and it + * is not decoration: pointer arithmetic scales by it, so `p - 4` on a `u16 *` and on a + * `void *` address different bytes. + */ + pointeeSize?: number; + /** Pointer member only: signedness of the pointed-at base type, on the same terms as + * {@link pointeeSize} (`s8 *` → true). Absent whenever `pointeeSize` is. */ + pointeeSigned?: boolean; + /** + * Present (true) when the member's type chain crosses a volatile qualifier — the + * `vu16 field;` MMIO idiom. Part of the declaration rather than of the layout: it + * says repeated accesses to the field are observable and not interchangeable. + */ + volatile?: true; + /** + * Present (true) when the member's type chain crosses a const qualifier — the read-only-field + * idiom. Part of the declaration rather than of the layout, and not interchangeable with an + * unqualified member: a write through it is a constraint violation, not another spelling. + */ + const?: true; + /** + * Bitfield only: right-shift to apply to the `size`-byte value read at `offset` + * — in the ELF's own byte order — to reach the field's least-significant bit. + * Absent for plain members. A big-endian target allocates bitfields MSB-first, so + * the same C declaration yields mirrored shifts there (see {@link TypeIndex}). */ bitOffset?: number; /** Bitfield only: width in bits. Absent for plain members. */ bitWidth?: number; + /** + * Array member only: the byte size of ONE element. `size` above is the WHOLE member + * (`u8 x[16]` → 16), so it cannot express where the n-th element of that member starts; + * this is the stride that does. Absent when the member's type is not an array — its + * presence is what identifies one. Spelled like {@link VariableShape}'s array arm. + */ + elemSize?: number; + /** + * Array member only: signedness of the ELEMENT's base type — the same fact `signed` carries + * for a plain member (the same byte reads as -1 or as 255 depending only on it; `signed` is + * null for an array, whose own type is not a base type). Absent when the element is not a + * base type (an array of structs/pointers/enums), or the member is not an array. + */ + elemSigned?: boolean; + /** + * Array member only: the element count — the product of the DW_TAG_subrange dimensions, so a + * multidimensional member reports its total. Absent when no dimension bounds it (a flexible + * array member, `char data[]`, which declares a stride but no length). + */ + length?: number; +} + +/** The width/signedness facts of one declared type — shared by a parameter and a return type. */ +export interface TypeFacts { + /** byte width, or null when the DWARF does not size the type */ + size: number | null; + /** base-type signedness; null when the type is not a base type (pointer, struct, enum, array) */ + signed: boolean | null; + /** the resolved type is a pointer */ + pointer?: true; + /** the type chain crosses a volatile qualifier */ + volatile?: true; + /** the type chain crosses a const qualifier */ + const?: true; +} + +/** A compiled function's declared signature — see {@link TypeIndex.functionSignature}. */ +export interface FunctionSignature { + name: string; + /** the return type's facts, or null for a `void` function */ + returns: TypeFacts | null; + params: (TypeFacts & { name: string | null })[]; + /** the declaration stated an argument list; false means K&R, where `params` being empty says + * nothing about how many arguments the function takes */ + prototyped: boolean; } export interface StructType { @@ -119,8 +213,67 @@ export interface StructType { members: StructMember[]; } -/** A member's read location: its byte offset + size, plus bitfield shift/width. */ -export type MemberLocation = Omit; +/** + * The declaration SHAPE of a global variable — what kind of thing its C type is, resolved + * through typedefs and cv-qualifiers. A small closed set, for a consumer that needs to know how + * a name is declared (`extern u16 tbl[]` vs a scalar vs a struct) without a full DIE→C-type + * renderer. + * + * The cv-qualifiers crossed while resolving are part of the shape: `volatile` says accesses to + * the object are observable and may not be folded or reordered, `const` that it is read-only + * (the ROM-table spelling). For arrays the element chain's qualifiers count too — `const u16 + * tbl[]` qualifies the element type in DWARF. On the `pointer` arm they are the POINTER + * variable's own qualifiers (`struct S *volatile g`); what it points at carries its own, on + * {@link PointeeStruct}. + * + * The `struct` arm's `structName` is the name {@link TypeIndex.struct} looks the layout up by, + * under the same rule as {@link PointeeStruct}: the tag when the type has one, otherwise the + * typedef alias that names it. + */ +export type VariableShape = + | { kind: 'scalar'; size: number | null; signed: boolean | null; volatile: boolean; const: boolean } + | { kind: 'pointer'; pointee: PointeeStruct | null; volatile: boolean; const: boolean } + | { + kind: 'array'; + elemSize: number | null; + elemSigned: boolean | null; + length: number | null; + volatile: boolean; + const: boolean; + } + | { kind: 'struct'; structName: string | null; size: number | null; volatile: boolean; const: boolean }; + +/** + * What a `pointer` shape points AT, when its target resolves (through typedefs/cv-qualifiers) to + * a struct or union: enough to name that type and to size it, without a full DIE→C-type renderer. + * `null` on the pointer arm when the target is anything else — a scalar, another pointer, a + * function, or `void`. + * + * `structName` is the name {@link TypeIndex.struct} looks the layout up by, which is not always a + * tag: for the `typedef struct {…} T;` idiom the struct itself is unnamed and `T` is the only name + * it has, so the last typedef alias crossed on the way is reported instead. Null when the target + * has neither — an unnamed struct reached without an alias, whose layout no name can retrieve. + * + * `volatile` / `const` are the TARGET's qualifiers — the ones a declaration spells to the LEFT of + * the `*` (`volatile struct S *g`): accesses made THROUGH the pointer are observable / read-only. + * They are a different fact from the pointer variable's own qualifiers to the right of it + * (`struct S *volatile g`), which stay on the enclosing {@link VariableShape}. + */ +export interface PointeeStruct { + structName: string | null; + /** DW_AT_byte_size of the target struct/union, or null if absent (an incomplete type). */ + size: number | null; + volatile: boolean; + const: boolean; +} + +/** A member's read location: its byte offset + size, plus bitfield shift/width. (Signedness, + * pointer-ness, cv-qualifiers and the array element facts are declaration facts, not locations — + * they stay on {@link StructMember} / `struct()`.) */ +export type MemberLocation = Omit< + StructMember, + 'name' | 'signed' | 'pointer' | 'volatile' | 'const' | 'elemSize' | 'elemSigned' | 'length' +>; /** A parsed DIE: its tag plus the attributes we kept, and its child DIEs. */ interface Die { @@ -142,6 +295,8 @@ type AttrValue = number | string | Uint8Array | boolean; /** The DWARF string sections an attribute form may resolve a name against. */ interface DebugStrings { + /** byte order of the DWARF payload (matches the ELF container) */ + littleEndian: boolean; /** `.debug_str` — DW_FORM_strp and the targets of DW_FORM_strx. */ str: Uint8Array; /** `.debug_line_str` — DW_FORM_line_strp. */ @@ -183,9 +338,13 @@ export class TypeIndex { readonly #typedefByName = new Map(); /** global/static variable name → its DIE (carries DW_AT_type). */ readonly #variableByName = new Map(); + readonly #functionByName = new Map(); + /** Byte order of the target — decides which end bitfields are allocated from. */ + readonly #littleEndian: boolean; /** Use {@link TypeIndex.fromElf}; this constructor is an internal detail. */ - constructor(roots: Die[]) { + constructor(roots: Die[], littleEndian = true) { + this.#littleEndian = littleEndian; const index = (die: Die): void => { this.#byOffset.set(die.offset, die); for (const child of die.children) { @@ -197,7 +356,13 @@ export class TypeIndex { } for (const die of this.#byOffset.values()) { - const name = die.attrs.get(DW_AT_name); + let name = die.attrs.get(DW_AT_name); + if (typeof name !== 'string' && die.tag === DW_TAG_subprogram && die.attrs.has(DW_AT_low_pc)) { + // Modern gcc at -O1+ splits a function that is both inlined and emitted into an + // ABSTRACT DIE (name, params) and a CONCRETE one (low_pc) referencing it. The + // pair is one definition; the concrete half is indexed under the abstract name. + name = this.#deref(die.attrs.get(DW_AT_abstract_origin))?.attrs.get(DW_AT_name); + } if (typeof name !== 'string') { continue; } @@ -215,6 +380,13 @@ export class TypeIndex { } } else if (die.tag === DW_TAG_typedef && !this.#typedefByName.has(name)) { this.#typedefByName.set(name, die); + } else if (die.tag === DW_TAG_subprogram) { + // Index DEFINITIONS only. A compiler emits a subprogram DIE for a function it + // COMPILED; a body-less declaration carries no parameter list worth reading, and + // (for gcc-2.x) is not emitted at all. `low_pc` is the definition witness. + if (die.attrs.has(DW_AT_low_pc) && !this.#functionByName.has(name)) { + this.#functionByName.set(name, die); + } } else if (die.tag === DW_TAG_variable && die.attrs.has(DW_AT_type)) { const existing = this.#variableByName.get(name); // The same global appears once per CU that includes its header; most are @@ -250,7 +422,7 @@ export class TypeIndex { if (typeof memberName !== 'string') { continue; // anonymous member (e.g. an unnamed union) — skip } - members.push({ name: memberName, ...this.#memberLayout(child) }); + members.push({ name: memberName, ...this.#memberLayout(child), ...this.#memberFacts(child) }); } return { name, size: numberAttr(die, DW_AT_byte_size), members }; } @@ -287,6 +459,146 @@ export class TypeIndex { return variable ? this.#typeRefSize(variable.attrs.get(DW_AT_type)) : null; } + /** + * Classify a global/static variable's declaration shape (scalar | pointer | array | struct), + * resolved through typedefs/cv-qualifiers. `null` when the variable has no DWARF DIE — which + * also makes this the "is this name declared in the project headers?" probe. An unsized + * extern array (`extern u16 tbl[]`) classifies as `array` with `length: null`. + */ + /** + * The DECLARED signature of a compiled function: what it returns and the type of each + * parameter, as the compiler recorded them. + * + * Only functions the ELF was compiled WITH a body for are known — `low_pc` is the witness. A + * function that is still hand-written assembly, or merely declared in a header, has no + * subprogram DIE at all (gcc-2.x drops body-less declarations outright), so `null` here means + * "this ELF did not compile that function", never "it takes no arguments". + * + * `returns: null` is a `void` function. `params` is the DEFINITION's own parameter list and is + * authoritative even when `prototyped` is false — that flag describes how the function was + * DECLARED (gcc-2.x never sets it), not what it was compiled to take. + */ + functionSignature(fnName: string): FunctionSignature | null { + const fn = this.#functionByName.get(fnName); + if (!fn) { + return null; + } + // For a split definition the DECLARED facts (params, return type, prototyped) live on + // the abstract DIE; the concrete one contributes the address and parameter DIEs that + // are themselves just abstract_origin references. Read each fact where it was written. + const decl = this.#deref(fn.attrs.get(DW_AT_abstract_origin)) ?? fn; + const params = decl.children + .filter((c) => c.tag === DW_TAG_formal_parameter) + .map((c) => { + const p = c.attrs.has(DW_AT_type) ? c : (this.#deref(c.attrs.get(DW_AT_abstract_origin)) ?? c); + const name = p.attrs.get(DW_AT_name); + return { + name: typeof name === 'string' ? name : null, + ...this.#typeFacts(p.attrs.get(DW_AT_type)), + }; + }); + return { + name: fnName, + returns: decl.attrs.has(DW_AT_type) ? this.#typeFacts(decl.attrs.get(DW_AT_type)) : null, + params, + prototyped: decl.attrs.get(DW_AT_prototyped) === true, + }; + } + + /** The width/signedness/pointer-ness of a type reference, resolved through typedef and + * cv-qualifier chains — the same vocabulary {@link StructMember} uses for a plain member, so a + * parameter and a field of the same declared type describe identically. */ + #typeFacts(ref: AttrValue | undefined): TypeFacts { + const cv = { volatile: false, const: false }; + const die = this.#stripTypedefs(ref, cv); + return { + size: this.#typeRefSize(ref), + signed: die ? baseTypeSignedness(die) : null, + ...(die?.tag === DW_TAG_pointer_type ? { pointer: true as const } : {}), + ...(cv.volatile ? { volatile: true as const } : {}), + ...(cv.const ? { const: true as const } : {}), + }; + } + + variableShape(varName: string): VariableShape | null { + const variable = this.#variableByName.get(varName); + if (!variable) { + return null; + } + const cv = { volatile: false, const: false }; + const alias = { typedef: null as string | null }; + const die = this.#stripTypedefs(variable.attrs.get(DW_AT_type), cv, alias); + if (!die) { + return null; + } + switch (die.tag) { + case DW_TAG_pointer_type: + return { kind: 'pointer', pointee: this.#pointee(die.attrs.get(DW_AT_type)), ...cv }; + case DW_TAG_array_type: { + // The element chain's qualifiers count toward the variable's declaration + // (`const u16 tbl[]` qualifies the ELEMENT type in DWARF) — collect into the same cv. + const elem = this.#stripTypedefs(die.attrs.get(DW_AT_type), cv); + const length = arrayLength(die); + return { + kind: 'array', + elemSize: this.#typeRefSize(die.attrs.get(DW_AT_type)), + elemSigned: elem ? baseTypeSignedness(elem) : null, + // A DECLARATION's [1] is GCC 2.95's spelling of an UNSIZED extern array + // (upper_bound 0, byte-identical to a real [1]). No compilation ever saw the + // size, so it is reported unknown; the rare genuine `extern T x[1]` loses a + // near-information-free fact. A DEFINED [1] keeps its length — the definition + // is the witness. + length: isDeclaration(variable) && length === 1 ? null : length, + ...cv, + }; + } + case DW_TAG_structure_type: + case DW_TAG_union_type: + return { kind: 'struct', ...this.#structTarget(die, alias), ...cv }; + default: + return { + kind: 'scalar', + size: this.#typeRefSize(variable.attrs.get(DW_AT_type)), + signed: baseTypeSignedness(die), + ...cv, + }; + } + } + + /** + * The struct/union a pointer's target resolves to, named the way {@link TypeIndex.struct} looks + * a layout up and carrying the target's own cv-qualifiers (see {@link PointeeStruct}), or null + * when the target is not a struct/union. The qualifiers are those crossed BETWEEN the pointer + * and its target, so they are the pointee's alone — the pointer variable's own are accumulated + * by the separate walk that reached the `DW_TAG_pointer_type` DIE. + */ + #pointee(ref: AttrValue | undefined): PointeeStruct | null { + const cv = { volatile: false, const: false }; + const alias = { typedef: null as string | null }; + const die = this.#stripTypedefs(ref, cv, alias); + if (!die || (die.tag !== DW_TAG_structure_type && die.tag !== DW_TAG_union_type)) { + return null; + } + return { ...this.#structTarget(die, alias), ...cv }; + } + + /** + * Name and size a resolved struct/union DIE, given the `alias` accumulated by the walk that + * reached it. The name is the one {@link TypeIndex.struct} looks a layout up by: the tag when + * the type has one, else the last typedef crossed — the `typedef struct {…} T;` idiom leaves + * the struct unnamed, so `T` is the only name its layout has. A DIE that is only a forward + * declaration carries no `DW_AT_byte_size`, so the size is read from the definition its tag + * resolves to. + */ + #structTarget(die: Die, alias: { typedef: string | null }): { structName: string | null; size: number | null } { + const tag = die.attrs.get(DW_AT_name); + const defined = isDeclaration(die) ? this.#resolveStructByName(asString(tag)) : die; + return { + structName: typeof tag === 'string' ? tag : alias.typedef, + size: defined ? numberAttr(defined, DW_AT_byte_size) : null, + }; + } + /** Walk `path` from a struct/union DIE, accumulating member byte offsets. */ #memberPath(structDie: Die | null, path: string | string[]): MemberLocation | null { const segments = Array.isArray(path) ? path : path.split('.'); @@ -375,16 +687,17 @@ export class TypeIndex { return new TypeIndex([]); } const strings: DebugStrings = { + littleEndian: elf.littleEndian, str: elf.sectionData('.debug_str') ?? new Uint8Array(0), lineStr: elf.sectionData('.debug_line_str') ?? new Uint8Array(0), strOffsets: elf.sectionData('.debug_str_offsets') ?? new Uint8Array(0), }; try { - return new TypeIndex(parseDebugInfo(info, abbrev, strings)); + return new TypeIndex(parseDebugInfo(info, abbrev, strings), elf.littleEndian); } catch { // Type parsing is best-effort: a malformed .debug_info must never take down // the rest of DebugInfo (symbols, line table). Fall back to "no types". - return new TypeIndex([]); + return new TypeIndex([], elf.littleEndian); } } @@ -416,8 +729,9 @@ export class TypeIndex { * Compute a member's read location. Plain members report `{ offset, size }` * (byte offset + type size). Bitfields additionally report `{ bitOffset, bitWidth }` * and a minimal byte `offset`/`size` such that - * `(read(offset, size) >>> bitOffset) & (2 ** bitWidth - 1)` is the field value - * (the `2 **` form stays correct for a full-width 32-bit field, where `1 << 32` wraps). + * `(read(offset, size) >>> bitOffset) & (2 ** bitWidth - 1)` is the field value, + * where `read` decodes `size` bytes in the ELF's own byte order (the `2 **` form + * stays correct for a full-width 32-bit field, where `1 << 32` wraps). */ #memberLayout(member: Die): MemberLocation { const bitWidth = numberAttr(member, DW_AT_bit_size); @@ -425,12 +739,17 @@ export class TypeIndex { if (bitWidth === null) { return { offset: memberOffset(member), size: typeSize }; } - // Bitfield: normalize both DWARF encodings to an absolute bit offset, then to a - // little-endian byte read (offset + minimal byte span + intra-byte shift). - const absBitOffset = bitfieldAbsBitOffset(member, typeSize); + // Bitfield: normalize both DWARF encodings to an absolute bit offset counted from + // the end the target allocates from, then to a byte read (offset + minimal byte + // span + intra-unit shift). + const absBitOffset = bitfieldAbsBitOffset(member, typeSize, this.#littleEndian); const offset = absBitOffset >> 3; - const bitOffset = absBitOffset & 7; - return { offset, size: Math.ceil((bitOffset + bitWidth) / 8), bitOffset, bitWidth }; + const bitsIntoByte = absBitOffset & 7; + const size = Math.ceil((bitsIntoByte + bitWidth) / 8); + // Little-endian: the bits counted so far already sit below the field, so they ARE + // the shift. Big-endian: they sit above it, so the shift is what remains beneath. + const bitOffset = this.#littleEndian ? bitsIntoByte : size * 8 - bitsIntoByte - bitWidth; + return { offset, size, bitOffset, bitWidth }; } /** Follow a type reference through typedef/qualifier chains to a struct/union. */ @@ -469,11 +788,85 @@ export class TypeIndex { } } - /** Follow typedef / cv-qualifier links to the underlying type DIE (cycle-guarded). */ - #stripTypedefs(ref: AttrValue | undefined): Die | null { + /** A member's declaration facts: base-type signedness, pointer-ness, its cv-qualifiers, and — + * for an array member — its element stride/signedness/count, all resolved through + * typedef/cv-qualifier chains (see the {@link StructMember} field docs). */ + #memberFacts( + member: Die, + ): Pick< + StructMember, + 'signed' | 'pointer' | 'pointeeSize' | 'pointeeSigned' | 'volatile' | 'const' | 'elemSize' | 'elemSigned' | 'length' + > { + const cv = { volatile: false, const: false }; + const die = this.#stripTypedefs(member.attrs.get(DW_AT_type), cv); + return { + signed: die ? baseTypeSignedness(die) : null, + ...(die?.tag === DW_TAG_pointer_type ? { pointer: true as const, ...this.#pointeeFacts(die) } : {}), + ...(cv.volatile ? { volatile: true as const } : {}), + ...(cv.const ? { const: true as const } : {}), + ...(die?.tag === DW_TAG_array_type ? this.#arrayFacts(die) : {}), + }; + } + + /** A pointer member's target facts, when the target resolves to a BASE type. Anything else — + * `void *`, a struct/function pointer, an unsized target — reports nothing, so a present key + * is always a fact rather than a default. */ + #pointeeFacts(pointerDie: Die): Pick { + const targetRef = pointerDie.attrs.get(DW_AT_type); + const target = this.#stripTypedefs(targetRef); + if (!target || target.tag !== DW_TAG_base_type) { + return {}; + } + const pointeeSize = this.#typeRefSize(targetRef); + const pointeeSigned = baseTypeSignedness(target); + return { + ...(pointeeSize !== null ? { pointeeSize } : {}), + ...(pointeeSigned !== null ? { pointeeSigned } : {}), + }; + } + + /** An array member's element facts. Each is omitted when the DWARF does not determine it — an + * unsized element type has no stride, a flexible array member no length — so a present key is + * always a fact, never a default. */ + #arrayFacts(arrayDie: Die): Pick { + const elemRef = arrayDie.attrs.get(DW_AT_type); + const elem = this.#stripTypedefs(elemRef); + const elemSize = this.#typeRefSize(elemRef); + const elemSigned = elem ? baseTypeSignedness(elem) : null; + const length = arrayLength(arrayDie); + return { + ...(elemSize !== null ? { elemSize } : {}), + ...(elemSigned !== null ? { elemSigned } : {}), + ...(length !== null ? { length } : {}), + }; + } + + /** Follow typedef / cv-qualifier links to the underlying type DIE (cycle-guarded). With a + * `cv` accumulator, the const/volatile qualifiers crossed on the way are recorded into it — + * callers that report a declaration (variableShape, #memberFacts) need them; callers that + * only want the underlying type omit it. The `alias` accumulator is the same idea for the last + * TYPEDEF name crossed, which naming an unnamed struct needs (see {@link #structTarget}) and the + * cv object must not carry (it is spread straight into a declaration shape). */ + #stripTypedefs( + ref: AttrValue | undefined, + cv?: { volatile: boolean; const: boolean }, + alias?: { typedef: string | null }, + ): Die | null { let die = this.#deref(ref); const seen = new Set(); while (die && isQualifierOrTypedef(die.tag) && !seen.has(die.offset)) { + if (cv && die.tag === DW_TAG_volatile_type) { + cv.volatile = true; + } + if (cv && die.tag === DW_TAG_const_type) { + cv.const = true; + } + if (alias && die.tag === DW_TAG_typedef) { + const name = die.attrs.get(DW_AT_name); + if (typeof name === 'string') { + alias.typedef = name; + } + } seen.add(die.offset); die = this.#deref(die.attrs.get(DW_AT_type)); } @@ -516,20 +909,26 @@ function memberOffset(member: Die): number { } /** - * Absolute bit offset of a bitfield member from the start of its struct, normalized - * across the two DWARF encodings: - * - DWARF 4+: `DW_AT_data_bit_offset` is already that absolute bit offset. + * Absolute bit offset of a bitfield member from the start of its struct, counted from + * the end the target allocates bitfields from: the LSB of the first byte on a + * little-endian target, the MSB of it on a big-endian one. Normalized across the two + * DWARF encodings: + * - DWARF 4+: `DW_AT_data_bit_offset` is already that offset — DWARF numbers bits + * from the same end the target allocates from, so it needs no adjustment. * - DWARF 2/3: `DW_AT_bit_offset` counts from the MSB of the storage unit (whose - * byte size is `DW_AT_byte_size`, at `DW_AT_data_member_location`). On a - * little-endian target the LSB offset within the unit is - * `storageBits - bit_offset - bit_size`. + * byte size is `DW_AT_byte_size`, at `DW_AT_data_member_location`). That is + * already the big-endian answer; on little-endian the offset within the unit + * flips to `storageBits - bit_offset - bit_size`. */ -function bitfieldAbsBitOffset(member: Die, typeSize: number | null): number { +function bitfieldAbsBitOffset(member: Die, typeSize: number | null, littleEndian: boolean): number { const dataBitOffset = numberAttr(member, DW_AT_data_bit_offset); if (dataBitOffset !== null) { return dataBitOffset; } const bitOffsetFromMsb = numberAttr(member, DW_AT_bit_offset) ?? 0; + if (!littleEndian) { + return memberOffset(member) * 8 + bitOffsetFromMsb; + } const bitWidth = numberAttr(member, DW_AT_bit_size) ?? 0; const storageBytes = numberAttr(member, DW_AT_byte_size) ?? typeSize ?? 0; const lsbWithinUnit = storageBytes * 8 - bitOffsetFromMsb - bitWidth; @@ -550,7 +949,10 @@ function arrayLength(arrayDie: Die): number | null { } sawDimension = true; const explicit = numberAttr(child, DW_AT_count); - const upper = numberAttr(child, DW_AT_upper_bound); + const rawUpper = numberAttr(child, DW_AT_upper_bound); + // GCC 2.95 stores a zero-length array's -1 upper bound in UNSIGNED DW_FORM_data4; + // read raw, upper+1 would claim 2^32 elements. Normalize to the modern sdata reading. + const upper = rawUpper === 0xffffffff ? -1 : rawUpper; const dim = explicit !== null ? explicit : upper !== null ? upper + 1 : null; if (dim === null || dim <= 0) { return null; @@ -560,6 +962,16 @@ function arrayLength(arrayDie: Die): number | null { return sawDimension ? count : null; } +/** Signedness of a base type from DW_AT_encoding (DW_ATE_signed/signed_char = signed; + * unsigned/unsigned_char/boolean = unsigned). Non-base types (enums, etc.) → null. */ +function baseTypeSignedness(die: Die): boolean | null { + if (die.tag !== DW_TAG_base_type) { + return null; + } + const enc = numberAttr(die, DW_AT_encoding); + return enc === null ? null : enc === 0x05 || enc === 0x06; +} + function numberAttr(die: Die, attr: number): number | null { const value = die.attrs.get(attr); return typeof value === 'number' ? value : null; @@ -590,9 +1002,9 @@ interface CuHeader { * DWARF), or the first truncated/inconsistent header — returning the units decoded * so far rather than throwing, so a malformed tail unit can't lose the whole section. */ -function collectCuHeaders(info: Uint8Array): CuHeader[] { +function collectCuHeaders(info: Uint8Array, littleEndian: boolean): CuHeader[] { const headers: CuHeader[] = []; - const c = new Cursor(info); + const c = new Cursor(info, 0, littleEndian); while (c.remaining >= 4) { const cuStart = c.offset; const unitLength = c.u32(); @@ -630,12 +1042,12 @@ function collectCuHeaders(info: Uint8Array): CuHeader[] { function parseDebugInfo(info: Uint8Array, abbrev: Uint8Array, strings: DebugStrings): Die[] { const roots: Die[] = []; const abbrevTables = new Map>(); - const headers = collectCuHeaders(info); + const headers = collectCuHeaders(info, strings.littleEndian); // agbcc (DWARF-2) does not emit a trailing 0-code terminator on each abbrev // table — tables abut and are delimited only by the CUs' debug_abbrev_offset. // Bound each table to the next one's start (in addition to the 0-code terminator). const boundaries = abbrevTableBoundaries(headers, abbrev.length); - const c = new Cursor(info); + const c = new Cursor(info, 0, strings.littleEndian); for (const h of headers) { // Skeleton/split units carry a dwo_id we don't handle — skip the unit. @@ -798,8 +1210,12 @@ function readForm( case DW_FORM_addr: return readBytes(c, ctx.addressSize); case DW_FORM_data1: - case DW_FORM_flag: return c.u8(); + case DW_FORM_flag: + // DWARF 2/3's boolean form (a byte). Returning the raw number made every `=== true` + // test in this file inert on those dialects — only DWARF 4+'s flag_present produced + // a boolean. A flag is a fact, not a number. + return c.u8() !== 0; case DW_FORM_data2: return c.u16(); case DW_FORM_data4: @@ -909,6 +1325,6 @@ function resolveStrx(index: number, ctx: UnitContext, strings: DebugStrings): st if (at + 4 > strings.strOffsets.length) { return ''; } - const c = new Cursor(strings.strOffsets); + const c = new Cursor(strings.strOffsets, 0, strings.littleEndian); return cstrAt(strings.str, c.u32At(at)); } diff --git a/packages/debug-info/test-projects/README.md b/packages/debug-info/test-projects/README.md index d63fd24..e9b9ab6 100644 --- a/packages/debug-info/test-projects/README.md +++ b/packages/debug-info/test-projects/README.md @@ -1,25 +1,62 @@ # Test projects for `@gba-kit/debug-info` -Two tiny GBA programs that produce real ELFs with symbols + DWARF, used as the -test inputs for the parser — so the tests run against actual toolchain output -across the two ecosystems GBA developers use: +Four tiny programs that produce real ELFs with symbols + DWARF, used as the test +inputs for the parser — so the tests run against actual toolchain output across +both byte orders and three architectures: -| Project | Toolchain | DWARF (line) | -| --------------- | ------------------------------------------------------------------ | ------------ | -| `agbcc-min` | agbcc (GCC 2.95), as a git submodule | v2 | -| `devkitarm-min` | modern `arm-none-eabi-gcc`, GCC 14 (devkitARM / ARM GNU Toolchain) | v3+ | +| Project | Toolchain | Target | DWARF (line) | +| --------------- | ------------------------------------------------------------------ | ---------------------- | ------------ | +| `agbcc-min` | agbcc (GCC 2.95), as a git submodule | ARM, little-endian | v2 | +| `devkitarm-min` | modern `arm-none-eabi-gcc`, GCC 14 (devkitARM / ARM GNU Toolchain) | ARM, little-endian | v3+ | +| `mips-min` | `mips-linux-gnu-gcc` (stock Ubuntu cross package) | MIPS o32, big-endian | v3+ | +| `ppc-min` | `powerpc-linux-gnu-gcc` (stock Ubuntu cross package) | PowerPC 32, big-endian | v3+ | -Both compile the same core shape — `add` / `square` (adjacent, exercises the -sequence-boundary case), `bump`, `triple` (in a second `util.c` → multi-CU), +The ARM pair compiles the same core shape — `add` / `square` (adjacent, exercises +the sequence-boundary case), `bump`, `triple` (in a second `util.c` → multi-CU), `main`, and a global `g_counter` — with `-g -O2`. +`agbcc-min` additionally carries the producer-quirk shapes `producer-quirks.spec.ts` +pins (a struct forward-declared in the first CU and defined in the second, zero-length +and unsized-extern arrays, an asm-defined table in `crt0.s`) — all appended after the +shared core shape, which stays line-stable. + `devkitarm-min` additionally carries a few shapes agbcc (GCC 2.95) can't compile: an anonymous union (`struct Shape`), an 8-byte `long long` global (`g_wide`), and a flexible array member (`struct Blob`). It's covered by a devkitarm-only test block. +It also vendors `build/macinfo.o`: its `main.c` compiled with +`-gdwarf-2 -g3 -gstrict-dwarf` — the macro-sidecar recipe — whose `.debug_macinfo` +records the fixture `#define`s at the end of that file (asserted by exact line number +in `debug-macro.spec.ts`, so append there, never insert above). + +## The big-endian pair + +`mips-min` and `ppc-min` compile one shared source (their `main.c` / `util.c` are +byte-identical) with `-g -O2 -fno-eliminate-unused-debug-types`. Both the ELF +container and the whole DWARF payload are stored MSB-first. Every declaration in +that `main.c` is one shape the parser classifies: a scalar, a pointer, an array, a +`const` array, a `volatile` scalar, a struct with named members, a struct with +**bitfields**, and a struct with a member-level `volatile` next to a signed narrow +member. + +The bitfields are the assertion class the little-endian projects structurally +cannot make: a big-endian target allocates them from the **most** significant end +of the storage unit, so the identical C declaration lands mirrored — `cross` is a +2-byte read at offset 0 shifted right by 4 here, by 5 on ARM. The compilers' own +read-modify-write of that field is the ground truth: MIPS +`lhu $t2 ; ins $t2,$v0,0x4,0x7 ; sh $t2`, PowerPC `lhz r6 ; rlwimi r6,r9,4,21,27 ; sth r6`. + +`ppc-min` also vendors `build/main.o`, a **relocatable** object. PowerPC uses RELA +relocations, so in a `.o` the `.debug_*` sections hold zeros where string and +section offsets belong, and the real values sit in `.rela.
` addends — 59 +of them in the vendored object. None of its DWARF resolves until `ElfFile.sectionData` applies +`symbol value + addend`, which makes it the only artifact shape that exercises +that path. + ## What's committed, and what runs the tests -Each project's `build/min.elf` and `build/oracle.json` are **committed** (the rest +Each project's built artifacts (`build/min.elf` + `build/oracle.json`, plus +`ppc-min`'s `build/main.o` + `build/oracle-obj.json`) are **committed** (the rest of `build/` — `.o`/`.i`/`.s` intermediates — is git-ignored). So a normal clone runs `pnpm --filter @gba-kit/debug-info test` with **no toolchain**: vitest's `globalSetup` just checks the committed artifacts exist and the tests read them. @@ -34,7 +71,7 @@ is machine-independent. ## Rebuilding (only when you change a project's sources) The build is per-project and rarely needed. After editing a project's sources, -rebuild it and commit the refreshed `build/min.elf` + `build/oracle.json`: +rebuild it and commit the refreshed `build/` artifacts: ```bash # agbcc-min — builds the GCC 2.95 compiler from the submodule, then the ELF + oracle. @@ -42,14 +79,16 @@ rebuild it and commit the refreshed `build/min.elf` + `build/oracle.json`: git submodule update --init --recursive # first time only cd agbcc-min && ./setup.sh -# devkitarm-min — builds in Docker (devkitpro/devkitarm), so no local devkitARM -# or arm-none-eabi install is required. Node is installed inside the container for -# the oracle step. +# The other three build in Docker, so no local cross toolchain is required: +# devkitarm-min → devkitpro/devkitarm +# mips-min, ppc-min → ubuntu:24.04 + the stock gcc-{mips,powerpc}-linux-gnu packages cd devkitarm-min && ./build.sh +cd mips-min && ./build.sh +cd ppc-min && ./build.sh ``` `agbcc-min/agbcc` is the [`Dream-Atelier/agbcc`](https://github.com/Dream-Atelier/agbcc) -submodule, pinned by commit. CI rebuilds **both** projects natively from scratch on +submodule, pinned by commit. CI rebuilds **all four** projects natively from scratch on every run (see `../../../.github/workflows/ci.yml`), so the committed artifacts stay honest — `globalSetup` rebuilds when `process.env.CI` is set. diff --git a/packages/debug-info/test-projects/agbcc-min/agbcc b/packages/debug-info/test-projects/agbcc-min/agbcc index 61b9c52..a0f70c9 160000 --- a/packages/debug-info/test-projects/agbcc-min/agbcc +++ b/packages/debug-info/test-projects/agbcc-min/agbcc @@ -1 +1 @@ -Subproject commit 61b9c52f16ae9bce3b36cee6917e72d1494e2b09 +Subproject commit a0f70c956e8f982ee2a1f67f9fbead46cbaef339 diff --git a/packages/debug-info/test-projects/agbcc-min/build/min.elf b/packages/debug-info/test-projects/agbcc-min/build/min.elf index 49991a8..f6facf1 100755 Binary files a/packages/debug-info/test-projects/agbcc-min/build/min.elf and b/packages/debug-info/test-projects/agbcc-min/build/min.elf differ diff --git a/packages/debug-info/test-projects/agbcc-min/build/oracle.json b/packages/debug-info/test-projects/agbcc-min/build/oracle.json index a3045b5..95fb490 100644 --- a/packages/debug-info/test-projects/agbcc-min/build/oracle.json +++ b/packages/debug-info/test-projects/agbcc-min/build/oracle.json @@ -6,19 +6,31 @@ "add": 134217736, "bump": 134217748, "gAbsGlobal": 50336308, - "g_bits": 50331712, - "g_color": 50331696, + "g_bits": 50331736, + "g_color": 50331708, "g_counter": 50331648, - "g_mode": 50331652, - "g_pair": 50331704, + "g_cv": 50331728, + "g_ext_table": 134218032, + "g_flex": 50331652, + "g_fwd_pay": 50331744, + "g_fwd_ptr": 50331696, + "g_fwd_sized_table": 134218054, + "g_init_table": 134218046, + "g_mmio": 50331712, + "g_mode": 50331656, + "g_one_def": 50331700, + "g_pair": 50331720, "g_probe": 50331664, - "g_util_pair": 50331720, - "main": 134217844, + "g_rom_table": 134218040, + "g_util_pair": 50331752, + "g_zero": 50331704, + "main": 134217880, + "poke": 134217916, "square": 134217740, - "triple": 134217880 + "triple": 134218016 }, "lines": { - "0x8000098": { + "0x8000120": { "func": "triple", "file": "util.c", "line": 16 @@ -26,7 +38,7 @@ "0x8000008": { "func": "add", "file": "main.c", - "line": 80 + "line": 94 }, "0x8000006": { "func": "__gccmain", @@ -41,17 +53,22 @@ "0x8000014": { "func": "bump", "file": "main.c", - "line": 87 + "line": 101 }, - "0x8000074": { + "0x8000098": { "func": "main", "file": "main.c", - "line": 97 + "line": 114 + }, + "0x80000bc": { + "func": "poke", + "file": "main.c", + "line": 162 }, "0x800000c": { "func": "square", "file": "main.c", - "line": 84 + "line": 98 } } } diff --git a/packages/debug-info/test-projects/agbcc-min/crt0.s b/packages/debug-info/test-projects/agbcc-min/crt0.s index 5169d91..a376a6c 100644 --- a/packages/debug-info/test-projects/agbcc-min/crt0.s +++ b/packages/debug-info/test-projects/agbcc-min/crt0.s @@ -17,3 +17,12 @@ _start: .thumb_func __gccmain: bx lr + +@ Data defined OUTSIDE C, mirroring a decomp's ldscript/asm-placed table: main.c +@ declares `extern const short g_ext_table[];` and only this assembly defines it. +@ Its DWARF is therefore a DECLARATION with no knowable bound (see main.c). + .section .rodata + .global g_ext_table + .align 1 +g_ext_table: + .hword 10, 20, 30, 40 diff --git a/packages/debug-info/test-projects/agbcc-min/main.c b/packages/debug-info/test-projects/agbcc-min/main.c index c02acd5..a1e20a9 100644 --- a/packages/debug-info/test-projects/agbcc-min/main.c +++ b/packages/debug-info/test-projects/agbcc-min/main.c @@ -76,6 +76,20 @@ struct Bits { struct Bits g_bits; +/* cv-qualified globals + a SIGNED narrow member — declaration facts that offsets + * and sizes alone cannot carry: the volatile/const qualifiers variableShape must + * resolve through, and each member's base-type signedness from struct() (the same + * byte reads as -1 or as 255 depending on it). + * Cv: level @0 (1, signed) gain @2 (2, unsigned) size 4 */ +struct Cv { + signed char level; + volatile unsigned short gain; /* member-level volatile (the vu16-field MMIO idiom) */ +}; + +volatile struct Cv g_cv; /* volatile struct (an MMIO-block idiom) */ +volatile unsigned short g_mmio; /* volatile scalar (an MMIO register idiom) */ +const short g_rom_table[3] = {1, 2, 3}; /* const array (a ROM-table idiom) */ + int add(int a, int b) { return a + b; } @@ -92,6 +106,9 @@ void bump(void) { g_mode = MODE_ON; /* keep enum Mode live */ g_bits.cross = g_counter; /* keep struct Bits + its type live */ g_bits.after = g_counter; + g_cv.level = (signed char) g_counter; /* keep struct Cv + its quals live */ + g_mmio = (unsigned short) g_counter; /* keep the volatile scalar live */ + g_probe.count = g_rom_table[g_counter & 1]; /* keep the const table live */ } int main(void) { @@ -104,3 +121,48 @@ int main(void) { } return acc; } + +/* ---- Producer-quirk shapes (append-only; the oracle pins lines above). Each of + * these is a spelling agbcc encodes ambiguously or wrongly enough to have caused + * a real parser bug; producer-quirks.spec.ts pins the correct reading. */ + +/* A struct this CU only ever sees FORWARD-DECLARED. The definition is in util.c, + * whose CU links AFTER this one: the by-name index must still prefer it, or this + * decl-only DIE (DW_AT_declaration, no members) shadows the layout. */ +struct FwdPay; +struct FwdPay *g_fwd_ptr; + +/* agbcc encodes both of these subranges as upper_bound 0xffffffff (DW_FORM_data4 + * holding -1): the pre-C99 zero-length trailing array, and — unlike modern gcc — + * even an initializer-SIZED array. Reading that as upper+1 = 2^32 once claimed a + * 4 GiB member. */ +struct Flex { + int n; + unsigned char data[0]; +}; +struct Flex g_flex; +const unsigned short g_init_table[][2] = {{1, 2}, {3, 4}}; + +/* An unsized extern array (defined in crt0.s, i.e. outside any C compilation — + * the ldscript-placed-table idiom). agbcc emits upper_bound 0 for it, byte-equal + * to a real [1]; DW_AT_declaration on the variable is the disambiguator. The + * sized local definition below pins that a REAL one-element array keeps its 1. */ +extern const short g_ext_table[]; +short g_one_def[1]; + +/* Negative control for the two above: the pret forward-declared static table — + * declared unsized, used, then sized by its initializer. agbcc patches the type + * at the definition (upper bounds 2,1), so this must keep length 3*2 = 6. */ +static const unsigned short g_fwd_sized_table[][2]; + +/* The GNU zero-length array as a GLOBAL: its subrange is upper_bound 0xffffffff + * at the variable level, the encoding that once became a 2^32-element shape. */ +unsigned char g_zero[0]; + +int poke(int i) { /* keeps every shape above live in the DWARF */ + g_one_def[0] = (short) (g_ext_table[i] + g_init_table[i & 1][0] + g_fwd_sized_table[i & 1][1]); + g_flex.n = i; + return g_fwd_ptr != 0 && g_zero == g_flex.data; +} + +static const unsigned short g_fwd_sized_table[][2] = {{5, 6}, {7, 8}, {9, 10}}; diff --git a/packages/debug-info/test-projects/agbcc-min/util.c b/packages/debug-info/test-projects/agbcc-min/util.c index c413669..4453f78 100644 --- a/packages/debug-info/test-projects/agbcc-min/util.c +++ b/packages/debug-info/test-projects/agbcc-min/util.c @@ -16,3 +16,13 @@ int triple(int n) { g_util_pair.lo = (short) n; return n * 3; } + +/* The DEFINITION of the struct main.c only forward-declares (see main.c: this CU + * links second, so a first-CU-wins index would lose this layout). + * FwdPay: amount @0 (4) currency @4 (2) size 8 */ +struct FwdPay { + int amount; + short currency; +}; + +struct FwdPay g_fwd_pay; diff --git a/packages/debug-info/test-projects/devkitarm-min/.gitignore b/packages/debug-info/test-projects/devkitarm-min/.gitignore index 9d285f0..25e589b 100644 --- a/packages/debug-info/test-projects/devkitarm-min/.gitignore +++ b/packages/debug-info/test-projects/devkitarm-min/.gitignore @@ -1,3 +1,4 @@ build/* !build/min.elf !build/oracle.json +!build/macinfo.o diff --git a/packages/debug-info/test-projects/devkitarm-min/Makefile b/packages/debug-info/test-projects/devkitarm-min/Makefile index edd0edc..243cab0 100644 --- a/packages/debug-info/test-projects/devkitarm-min/Makefile +++ b/packages/debug-info/test-projects/devkitarm-min/Makefile @@ -28,14 +28,23 @@ LDFLAGS := -nostdlib -Wl,-Ttext=0x08000000 -Wl,-e,main -Wl,--defsym,gAbsGlobal=0 GEN_ORACLE := ../tools/gen-oracle.mjs -# Default: build the ELF and its test oracle (build/oracle.json) together. -all: build/min.elf build/oracle.json +# Default: build the ELF, its test oracle (build/oracle.json) and the macro table. +all: build/min.elf build/oracle.json build/macinfo.o .PHONY: all build/min.elf: source/main.c source/util.c source/util.h @mkdir -p build $(CC) $(CFLAGS) $(LDFLAGS) source/main.c source/util.c -o $@ +# The macro table, compiled the way a decomp's macro sidecar is: -g3 records every +# #define, and -gdwarf-2 -gstrict-dwarf keeps the record in ONE self-contained +# .debug_macinfo with inline strings (plain -g3 emits DWARF 5's .debug_macro, split +# across COMDAT groups and leaning on .debug_str). Relocatable on purpose — the +# graft source in a real project is a .o, not a link. +build/macinfo.o: source/main.c source/util.h + @mkdir -p build + $(CC) $(CFLAGS) -gdwarf-2 -g3 -gstrict-dwarf -c source/main.c -o $@ + # Oracle for the parser tests: nm/addr2line reference output for this exact ELF. build/oracle.json: build/min.elf $(GEN_ORACLE) node $(GEN_ORACLE) build/min.elf $(PREFIX) > $@ diff --git a/packages/debug-info/test-projects/devkitarm-min/build/macinfo.o b/packages/debug-info/test-projects/devkitarm-min/build/macinfo.o new file mode 100644 index 0000000..9f00185 Binary files /dev/null and b/packages/debug-info/test-projects/devkitarm-min/build/macinfo.o differ diff --git a/packages/debug-info/test-projects/devkitarm-min/build/min.elf b/packages/debug-info/test-projects/devkitarm-min/build/min.elf index 4040b92..05fe577 100755 Binary files a/packages/debug-info/test-projects/devkitarm-min/build/min.elf and b/packages/debug-info/test-projects/devkitarm-min/build/min.elf differ diff --git a/packages/debug-info/test-projects/devkitarm-min/build/oracle.json b/packages/debug-info/test-projects/devkitarm-min/build/oracle.json index ec11082..b7f913a 100644 --- a/packages/debug-info/test-projects/devkitarm-min/build/oracle.json +++ b/packages/debug-info/test-projects/devkitarm-min/build/oracle.json @@ -1,58 +1,59 @@ { "symbols": { "add": 134217756, - "__bss_end__": 134222040, - "_bss_end__": 134222040, - "__bss_start": 134221960, - "__bss_start__": 134221960, + "__bss_end__": 134222088, + "_bss_end__": 134222088, + "__bss_start": 134221992, + "__bss_start__": 134221992, "bump": 134217768, - "__data_start": 134221956, - "_edata": 134221956, - "__end__": 134222040, - "_end": 134222040, + "__data_start": 134221988, + "_edata": 134221992, + "__end__": 134222088, + "_end": 134222088, "gAbsGlobal": 50336308, - "g_bits": 134222008, - "g_blob": 134222032, - "g_color": 134222004, - "g_counter": 134221960, - "g_mode": 134222005, - "g_pair": 134221996, - "g_probe": 134221964, - "g_shape": 134222016, - "g_util_pair": 134222036, - "g_wide": 134222024, + "g_bits": 134222040, + "g_blob": 134222072, + "g_color": 134222036, + "g_counter": 134221992, + "g_cv": 134222048, + "g_cv_ptr": 134222080, + "g_cv_vptr": 134222076, + "g_mmio": 134222052, + "g_mode": 134222037, + "g_pair": 134222028, + "g_pair_ptr": 134221988, + "g_probe": 134221996, + "g_rom_table": 134217884, + "g_shape": 134222056, + "g_util_pair": 134222084, + "g_wide": 134222064, "main": 134217728, "square": 134217760, "_stack": 524288, - "triple": 134217844 + "triple": 134217868 }, "lines": { "0x800001c": { "func": "add", "file": "main.c", - "line": 106 + "line": 120 }, "0x8000028": { "func": "bump", "file": "main.c", - "line": 114 - }, - "0x8001084": { - "func": "??", - "file": "??", - "line": 0 + "line": 128 }, "0x8000000": { "func": "main", "file": "main.c", - "line": 126 + "line": 143 }, "0x8000020": { "func": "square", "file": "main.c", - "line": 110 + "line": 124 }, - "0x8000074": { + "0x800008c": { "func": "triple", "file": "util.c", "line": 17 diff --git a/packages/debug-info/test-projects/devkitarm-min/source/main.c b/packages/debug-info/test-projects/devkitarm-min/source/main.c index b65bbf4..c975fd4 100644 --- a/packages/debug-info/test-projects/devkitarm-min/source/main.c +++ b/packages/debug-info/test-projects/devkitarm-min/source/main.c @@ -39,8 +39,8 @@ typedef struct { int a; int b; } Pair; - Pair g_pair; +Pair *g_pair_ptr = &g_pair; // pointer to an UNNAMED struct: only the typedef names it // A tagged enum (explicit + continued values) and a typedef of an anonymous // enum — the parser must read both, and Mode via its typedef alias. @@ -75,11 +75,25 @@ struct Bits { struct Bits g_bits; -// An anonymous union member — its fields are accessed transparently as -// g_shape.circle / g_shape.pair, so the parser must descend into the unnamed -// union to resolve them. Layout: kind @0 (4), union @4 (4), size 8. +// cv-qualified globals + a SIGNED narrow member — declaration facts that offsets and sizes alone +// cannot carry: the volatile/const qualifiers variableShape must resolve through (and, for a +// pointer, WHICH SIDE of the * they fall on), and each member's base-type signedness from struct(). +struct Cv { + signed char level; + volatile unsigned short gain; /* member-level volatile (the vu16-field MMIO idiom) */ +}; // Cv: level @0 (1, signed) gain @2 (2, unsigned) size 4 + +volatile struct Cv g_cv; // volatile struct (an MMIO-block idiom) +volatile unsigned short g_mmio; // volatile scalar (an MMIO register idiom) +const short g_rom_table[3] = {1, 2, 3}; // const array (a ROM-table idiom) +volatile struct Cv *g_cv_ptr; // the TARGET is volatile — the qualifier is left of the * +struct Cv *volatile g_cv_vptr; // the mirror: the POINTER is volatile, its target is not + +// An anonymous union member — its fields are accessed transparently as g_shape.circle / +// g_shape.pair, so the parser must descend into the unnamed union to resolve them. Its tag field +// is const, the read-only-member idiom. Layout: kind @0 (4), union @4 (4), size 8. struct Shape { - int kind; + const int kind; /* const does not move a field, so it is only visible as a declaration fact */ union { int circle; short pair; @@ -118,6 +132,9 @@ __attribute__((noinline)) void bump(void) { g_mode = MODE_ON; // keep enum Mode live g_bits.cross = g_counter; // keep struct Bits + its type live g_bits.after = g_counter; + g_cv.level = (signed char) g_counter; // keep struct Cv + its quals live + g_mmio = (unsigned short) g_counter; // keep the volatile scalar live + g_probe.count = g_rom_table[g_counter & 1]; // keep the const table live g_shape.circle = g_counter; // keep struct Shape + its anon union live g_wide = g_counter; // keep g_wide live g_blob.len = g_counter; // keep struct Blob + its flexible array live @@ -133,3 +150,12 @@ int main(void) { } return acc; } + +/* Macro-table fixtures, read from build/macinfo.o (see the Makefile's -g3 rule). + * The spellings a real decomp names fixed cells with. debug-macro.spec.ts asserts + * these by exact line number: append below, never insert above. */ +#define REG_DISPSTAT (*(volatile unsigned short *)0x04000004) +#define g_save_slot (*(unsigned char *)0x03007FF0) +#define EWRAM_BASE 0x02000000 +#define CLAMP(x, lo, hi) ((x) < (lo) ? (lo) : (x) > (hi) ? (hi) : (x)) +#define NO_BODY diff --git a/packages/debug-info/test-projects/mips-min/.gitignore b/packages/debug-info/test-projects/mips-min/.gitignore new file mode 100644 index 0000000..9d285f0 --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/.gitignore @@ -0,0 +1,3 @@ +build/* +!build/min.elf +!build/oracle.json diff --git a/packages/debug-info/test-projects/mips-min/Makefile b/packages/debug-info/test-projects/mips-min/Makefile new file mode 100644 index 0000000..c18165e --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/Makefile @@ -0,0 +1,37 @@ +# Minimal big-endian MIPS build — produces build/min.elf with DWARF (-g). +# Mirrors an N64-style target: 32-bit MIPS o32, ELFDATA2MSB, so both the ELF +# container AND its DWARF payload are stored MSB-first. +# +# Uses stock Ubuntu cross packages (gcc-mips-linux-gnu / binutils-mips-linux-gnu), +# which CI installs natively. The ELF is never executed, so it links freestanding +# (no libc, no crt0) — a linker and the compiler are all that is needed. +# +# Override PREFIX=/path/to/mips-linux-gnu- to use a different toolchain. + +PREFIX ?= mips-linux-gnu- +CC := $(PREFIX)gcc + +# -g: DWARF debug info. -O2: realistic optimized layout (adjacent functions), so +# .debug_line carries a non-trivial program. -fno-eliminate-unused-debug-types +# keeps a DIE for every declared type, including the ones no code reads. +CFLAGS := -g -O2 -fno-eliminate-unused-debug-types -ffreestanding -Wall +LDFLAGS := -nostdlib -Wl,-e,main + +GEN_ORACLE := ../tools/gen-oracle.mjs + +# Default: build the ELF and its test oracle (build/oracle.json) together. +all: build/min.elf build/oracle.json +.PHONY: all + +build/min.elf: main.c util.c + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) main.c util.c -o $@ + +# Oracle for the parser tests: nm/addr2line reference output for this exact ELF. +build/oracle.json: build/min.elf $(GEN_ORACLE) + node $(GEN_ORACLE) build/min.elf $(PREFIX) > $@ + +clean: + rm -rf build + +.PHONY: clean diff --git a/packages/debug-info/test-projects/mips-min/build.sh b/packages/debug-info/test-projects/mips-min/build.sh new file mode 100755 index 0000000..a6d6bfa --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Rebuild this project's committed artifacts (build/min.elf + build/oracle.json) in +# Docker, so a contributor needs only Docker — no local mips-linux-gnu install. Run +# this after changing the sources, then commit build/min.elf and build/oracle.json. +# (Normal test runs use the committed files and need none of this.) +# +# The toolchain is the stock Ubuntu cross package, the same one CI installs +# natively; --platform linux/amd64 keeps the image identical on an arm64 host. +set -euo pipefail +cd "$(dirname "$0")" +docker run --rm --platform linux/amd64 -v "$PWD/..":/test-projects -w /test-projects/mips-min ubuntu:24.04 bash -lc ' + apt-get update -qq && apt-get install -y -qq make gcc-mips-linux-gnu binutils-mips-linux-gnu nodejs >/dev/null + make clean && make +' +echo "Built build/min.elf + build/oracle.json" diff --git a/packages/debug-info/test-projects/mips-min/build/min.elf b/packages/debug-info/test-projects/mips-min/build/min.elf new file mode 100755 index 0000000..45b5a4c Binary files /dev/null and b/packages/debug-info/test-projects/mips-min/build/min.elf differ diff --git a/packages/debug-info/test-projects/mips-min/build/oracle.json b/packages/debug-info/test-projects/mips-min/build/oracle.json new file mode 100644 index 0000000..a2a0f9a --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/build/oracle.json @@ -0,0 +1,54 @@ +{ + "symbols": { + "_GLOBAL_OFFSET_TABLE_": 4260480, + "__bss_start": 4260496, + "_edata": 4260492, + "_end": 4260576, + "_fbss": 4260496, + "_fdata": 4260464, + "_ftext": 4194640, + "_gp": 4293232, + "add": 4194704, + "bump": 4194720, + "g_bits": 4260500, + "g_counter": 4260556, + "g_cv": 4260496, + "g_probe": 4260508, + "g_probe_ptr": 4260464, + "g_ptr": 4260552, + "g_rom_table": 4194912, + "g_table": 4260544, + "g_util_pair": 4260560, + "g_vol": 4260540, + "main": 4194640, + "square": 4194712, + "triple": 4194880 + }, + "lines": { + "0x400150": { + "func": "main", + "file": "main.c", + "line": 97 + }, + "0x400190": { + "func": "add", + "file": "main.c", + "line": 79 + }, + "0x4001a0": { + "func": "bump", + "file": "main.c", + "line": 86 + }, + "0x400198": { + "func": "square", + "file": "main.c", + "line": 83 + }, + "0x400240": { + "func": "triple", + "file": "util.c", + "line": 15 + } + } +} diff --git a/packages/debug-info/test-projects/mips-min/main.c b/packages/debug-info/test-projects/mips-min/main.c new file mode 100644 index 0000000..6db4311 --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/main.c @@ -0,0 +1,104 @@ +/* Minimal BIG-ENDIAN probe program, used as real-world input for the + * @gba-kit/debug-info tests. It is never executed — it exists only so a + * big-endian cross toolchain emits a real ELF whose symbol table and DWARF + * payload (.debug_info / .debug_abbrev / .debug_str / .debug_line) are all + * stored MSB-first, which is what the parser must read. + * + * Keep this byte-for-byte identical to the sibling big-endian project's main.c + * (mips-min / ppc-min): MIPS o32 and PowerPC SysV are both 32-bit big-endian + * with 4-byte int alignment, so every layout asserted by the tests holds for + * both. + * + * Every declaration below is one shape the parser classifies. Types that are + * only declared (never read) still get a DIE thanks to + * -fno-eliminate-unused-debug-types. + * + * char signedness is deliberately never left to the default: it is signed on + * MIPS and unsigned on PowerPC, so plain `char` would not agree across the two + * projects. Each narrow member spells its own signedness. */ + +int triple(int n); /* defined in util.c -> a second compilation unit */ + +int g_counter; /* scalar global (.bss) */ +int *g_ptr; /* pointer global: its target is a scalar */ +short g_table[4]; /* array: element size 2, length 4, signed elements */ +volatile int g_vol; /* volatile scalar (an MMIO-register idiom) */ + +const short g_rom_table[3] = {1, 2, 3}; /* const array (a ROM-table idiom) */ + +/* A struct with a deterministic layout on both 32-bit big-endian ABIs: + * Inner: x @0 (4) y @4 (2) size 8 + * Probe: tag @0 (1) count @4 (4) flags @8 (2) name @10 (6) + * ptr @16 (4) inner @20 (8: x@20 y@24) tail @28 (4) size 32 */ +struct Inner { + int x; + short y; +}; + +struct Probe { + unsigned char tag; + int count; + short flags; + unsigned char name[6]; + int *ptr; + struct Inner inner; + int tail; +}; +struct Probe g_probe; /* struct global */ +struct Probe *g_probe_ptr = &g_probe; /* pointer whose target is a STRUCT, not a scalar */ + +/* Bitfields. A big-endian target allocates them MSB-FIRST within the storage + * unit, the mirror image of the little-endian projects' identical declaration: + * hearts bits 31-30 of the 4-byte unit at 0 -> byte 0, top 2 bits + * stars bits 29-27 -> byte 0 + * cross bits 26-20 -> byte 0..1 (crosses the boundary) + * wide bits 19-16 -> byte 1, low 4 bits + * after plain int at offset 4 */ +struct Bits { + unsigned hearts : 2; + unsigned stars : 3; + unsigned cross : 7; + unsigned wide : 4; + int after; +}; + +struct Bits g_bits; + +/* cv-qualified declarations the parser must resolve THROUGH: a member-level + * volatile, and a signed narrow member next to an unsigned one. + * Cv: level @0 (1, signed) gain @2 (2, unsigned) size 4 */ +struct Cv { + signed char level; + volatile unsigned short gain; +}; + +volatile struct Cv g_cv; + +int add(int a, int b) { + return a + b; +} + +int square(int n) { + return n * n; +} + +void bump(void) { + g_counter += 1; + g_ptr = &g_counter; /* keep the pointer global live */ + g_table[g_counter & 3] = (short) g_counter; /* keep the array live */ + g_vol = g_counter; /* keep the volatile scalar live */ + g_probe.tail = g_probe.inner.x + g_counter; /* keep g_probe + its type live */ + g_bits.cross = g_counter; /* keep struct Bits + its type live */ + g_bits.after = g_counter; + g_cv.level = (signed char) g_counter; /* keep struct Cv + its quals live */ + g_probe.count = g_rom_table[g_counter & 1]; /* keep the const table live */ +} + +int main(void) { + int acc = 0; + acc = add(acc, 1); + acc = square(acc); + acc = triple(acc); + bump(); + return acc; +} diff --git a/packages/debug-info/test-projects/mips-min/util.c b/packages/debug-info/test-projects/mips-min/util.c new file mode 100644 index 0000000..2eecce2 --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/util.c @@ -0,0 +1,17 @@ +/* A second translation unit, so the linked ELF has more than one DWARF + * compilation unit — the multi-CU path (per-CU abbrev tables, one .debug_line + * sequence per CU) read out of a big-endian payload. + * + * Keep this byte-for-byte identical to the sibling big-endian project's util.c. */ + +struct UtilPair { + short lo; + short hi; +}; + +struct UtilPair g_util_pair; + +int triple(int n) { + g_util_pair.lo = (short) n; + return n * 3; +} diff --git a/packages/debug-info/test-projects/ppc-min/.gitignore b/packages/debug-info/test-projects/ppc-min/.gitignore new file mode 100644 index 0000000..9ef0505 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/.gitignore @@ -0,0 +1,5 @@ +build/* +!build/main.o +!build/min.elf +!build/oracle.json +!build/oracle-obj.json diff --git a/packages/debug-info/test-projects/ppc-min/Makefile b/packages/debug-info/test-projects/ppc-min/Makefile new file mode 100644 index 0000000..74e083b --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/Makefile @@ -0,0 +1,52 @@ +# Minimal big-endian PowerPC 32 build — produces both artifact shapes with DWARF (-g): +# +# build/main.o — a RELOCATABLE object. This is the point of this project: PowerPC +# uses RELA relocations, so in a .o the `.debug_*` sections carry +# ZEROS where string/section offsets belong and the real values sit +# in `.rela.
` addends. Parsing its DWARF at all requires +# applying those addends (ElfFile.sectionData). +# build/min.elf — the same two units linked, where the relocations are already +# resolved: the plain big-endian read path, alongside mips-min. +# +# Uses stock Ubuntu cross packages (gcc-powerpc-linux-gnu / binutils-powerpc-linux-gnu), +# which CI installs natively. The ELF is never executed, so it links freestanding +# (no libc, no crt0). +# +# Override PREFIX=/path/to/powerpc-linux-gnu- to use a different toolchain. + +PREFIX ?= powerpc-linux-gnu- +CC := $(PREFIX)gcc + +# -g: DWARF debug info. -O2: realistic optimized layout (adjacent functions), so +# .debug_line carries a non-trivial program. -fno-eliminate-unused-debug-types +# keeps a DIE for every declared type, including the ones no code reads. +CFLAGS := -g -O2 -fno-eliminate-unused-debug-types -ffreestanding -Wall +# max-page-size: the PowerPC default (64 KiB) would pad the file out to 64 KiB of +# zeros between .text and .data. The ELF is never loaded, so page size is free. +LDFLAGS := -nostdlib -Wl,-e,main -Wl,-z,max-page-size=0x1000 + +GEN_ORACLE := ../tools/gen-oracle.mjs + +# Default: both artifacts and their test oracles. +all: build/min.elf build/oracle.json build/oracle-obj.json +.PHONY: all + +build/min.elf: build/main.o build/util.o + $(CC) $(LDFLAGS) build/main.o build/util.o -o $@ + +build/%.o: %.c + @mkdir -p build + $(CC) $(CFLAGS) -c $< -o $@ + +# Oracles for the parser tests: nm/addr2line reference output for each artifact. +# The .o gets its own — its symbols/PCs are section-relative, not linked addresses. +build/oracle.json: build/min.elf $(GEN_ORACLE) + node $(GEN_ORACLE) build/min.elf $(PREFIX) > $@ + +build/oracle-obj.json: build/main.o $(GEN_ORACLE) + node $(GEN_ORACLE) build/main.o $(PREFIX) > $@ + +clean: + rm -rf build + +.PHONY: clean diff --git a/packages/debug-info/test-projects/ppc-min/build.sh b/packages/debug-info/test-projects/ppc-min/build.sh new file mode 100755 index 0000000..fe8a427 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Rebuild this project's committed artifacts (build/main.o, build/min.elf and their +# oracles) in Docker, so a contributor needs only Docker — no local +# powerpc-linux-gnu install. Run this after changing the sources, then commit the +# refreshed build/ artifacts. (Normal test runs use the committed files and need +# none of this.) +# +# The toolchain is the stock Ubuntu cross package, the same one CI installs +# natively; --platform linux/amd64 keeps the image identical on an arm64 host. +set -euo pipefail +cd "$(dirname "$0")" +docker run --rm --platform linux/amd64 -v "$PWD/..":/test-projects -w /test-projects/ppc-min ubuntu:24.04 bash -lc ' + apt-get update -qq && apt-get install -y -qq make gcc-powerpc-linux-gnu binutils-powerpc-linux-gnu nodejs >/dev/null + make clean && make +' +echo "Built build/main.o + build/min.elf + oracles" diff --git a/packages/debug-info/test-projects/ppc-min/build/main.o b/packages/debug-info/test-projects/ppc-min/build/main.o new file mode 100644 index 0000000..44febdc Binary files /dev/null and b/packages/debug-info/test-projects/ppc-min/build/main.o differ diff --git a/packages/debug-info/test-projects/ppc-min/build/min.elf b/packages/debug-info/test-projects/ppc-min/build/min.elf new file mode 100755 index 0000000..44fee28 Binary files /dev/null and b/packages/debug-info/test-projects/ppc-min/build/min.elf differ diff --git a/packages/debug-info/test-projects/ppc-min/build/oracle-obj.json b/packages/debug-info/test-projects/ppc-min/build/oracle-obj.json new file mode 100644 index 0000000..cabcc37 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/build/oracle-obj.json @@ -0,0 +1,34 @@ +{ + "symbols": { + "add": 0, + "bump": 32, + "g_bits": 4, + "g_counter": 28, + "g_cv": 0, + "g_probe": 0, + "g_probe_ptr": 0, + "g_ptr": 24, + "g_rom_table": 4, + "g_table": 16, + "g_vol": 12, + "main": 0, + "square": 16 + }, + "lines": { + "0x0": { + "func": "add", + "file": "main.c", + "line": 79 + }, + "0x20": { + "func": "bump", + "file": "main.c", + "line": 85 + }, + "0x10": { + "func": "square", + "file": "main.c", + "line": 83 + } + } +} diff --git a/packages/debug-info/test-projects/ppc-min/build/oracle.json b/packages/debug-info/test-projects/ppc-min/build/oracle.json new file mode 100644 index 0000000..c638700 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/build/oracle.json @@ -0,0 +1,51 @@ +{ + "symbols": { + "_SDA_BASE_": 268472320, + "__GNU_EH_FRAME_HDR": 268435968, + "__bss_start": 268439564, + "_edata": 268439562, + "_end": 268439632, + "add": 268435776, + "bump": 268435808, + "g_bits": 268439568, + "g_counter": 268439592, + "g_cv": 268439564, + "g_probe": 268439600, + "g_probe_ptr": 268439552, + "g_ptr": 268439588, + "g_rom_table": 268439556, + "g_table": 268439580, + "g_util_pair": 268439596, + "g_vol": 268439576, + "main": 268435712, + "square": 268435792, + "triple": 268435952 + }, + "lines": { + "0x10000140": { + "func": "add", + "file": "main.c", + "line": 79 + }, + "0x10000160": { + "func": "bump", + "file": "main.c", + "line": 85 + }, + "0x10000100": { + "func": "main", + "file": "main.c", + "line": 97 + }, + "0x10000150": { + "func": "square", + "file": "main.c", + "line": 83 + }, + "0x100001f0": { + "func": "triple", + "file": "util.c", + "line": 15 + } + } +} diff --git a/packages/debug-info/test-projects/ppc-min/main.c b/packages/debug-info/test-projects/ppc-min/main.c new file mode 100644 index 0000000..6db4311 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/main.c @@ -0,0 +1,104 @@ +/* Minimal BIG-ENDIAN probe program, used as real-world input for the + * @gba-kit/debug-info tests. It is never executed — it exists only so a + * big-endian cross toolchain emits a real ELF whose symbol table and DWARF + * payload (.debug_info / .debug_abbrev / .debug_str / .debug_line) are all + * stored MSB-first, which is what the parser must read. + * + * Keep this byte-for-byte identical to the sibling big-endian project's main.c + * (mips-min / ppc-min): MIPS o32 and PowerPC SysV are both 32-bit big-endian + * with 4-byte int alignment, so every layout asserted by the tests holds for + * both. + * + * Every declaration below is one shape the parser classifies. Types that are + * only declared (never read) still get a DIE thanks to + * -fno-eliminate-unused-debug-types. + * + * char signedness is deliberately never left to the default: it is signed on + * MIPS and unsigned on PowerPC, so plain `char` would not agree across the two + * projects. Each narrow member spells its own signedness. */ + +int triple(int n); /* defined in util.c -> a second compilation unit */ + +int g_counter; /* scalar global (.bss) */ +int *g_ptr; /* pointer global: its target is a scalar */ +short g_table[4]; /* array: element size 2, length 4, signed elements */ +volatile int g_vol; /* volatile scalar (an MMIO-register idiom) */ + +const short g_rom_table[3] = {1, 2, 3}; /* const array (a ROM-table idiom) */ + +/* A struct with a deterministic layout on both 32-bit big-endian ABIs: + * Inner: x @0 (4) y @4 (2) size 8 + * Probe: tag @0 (1) count @4 (4) flags @8 (2) name @10 (6) + * ptr @16 (4) inner @20 (8: x@20 y@24) tail @28 (4) size 32 */ +struct Inner { + int x; + short y; +}; + +struct Probe { + unsigned char tag; + int count; + short flags; + unsigned char name[6]; + int *ptr; + struct Inner inner; + int tail; +}; +struct Probe g_probe; /* struct global */ +struct Probe *g_probe_ptr = &g_probe; /* pointer whose target is a STRUCT, not a scalar */ + +/* Bitfields. A big-endian target allocates them MSB-FIRST within the storage + * unit, the mirror image of the little-endian projects' identical declaration: + * hearts bits 31-30 of the 4-byte unit at 0 -> byte 0, top 2 bits + * stars bits 29-27 -> byte 0 + * cross bits 26-20 -> byte 0..1 (crosses the boundary) + * wide bits 19-16 -> byte 1, low 4 bits + * after plain int at offset 4 */ +struct Bits { + unsigned hearts : 2; + unsigned stars : 3; + unsigned cross : 7; + unsigned wide : 4; + int after; +}; + +struct Bits g_bits; + +/* cv-qualified declarations the parser must resolve THROUGH: a member-level + * volatile, and a signed narrow member next to an unsigned one. + * Cv: level @0 (1, signed) gain @2 (2, unsigned) size 4 */ +struct Cv { + signed char level; + volatile unsigned short gain; +}; + +volatile struct Cv g_cv; + +int add(int a, int b) { + return a + b; +} + +int square(int n) { + return n * n; +} + +void bump(void) { + g_counter += 1; + g_ptr = &g_counter; /* keep the pointer global live */ + g_table[g_counter & 3] = (short) g_counter; /* keep the array live */ + g_vol = g_counter; /* keep the volatile scalar live */ + g_probe.tail = g_probe.inner.x + g_counter; /* keep g_probe + its type live */ + g_bits.cross = g_counter; /* keep struct Bits + its type live */ + g_bits.after = g_counter; + g_cv.level = (signed char) g_counter; /* keep struct Cv + its quals live */ + g_probe.count = g_rom_table[g_counter & 1]; /* keep the const table live */ +} + +int main(void) { + int acc = 0; + acc = add(acc, 1); + acc = square(acc); + acc = triple(acc); + bump(); + return acc; +} diff --git a/packages/debug-info/test-projects/ppc-min/util.c b/packages/debug-info/test-projects/ppc-min/util.c new file mode 100644 index 0000000..2eecce2 --- /dev/null +++ b/packages/debug-info/test-projects/ppc-min/util.c @@ -0,0 +1,17 @@ +/* A second translation unit, so the linked ELF has more than one DWARF + * compilation unit — the multi-CU path (per-CU abbrev tables, one .debug_line + * sequence per CU) read out of a big-endian payload. + * + * Keep this byte-for-byte identical to the sibling big-endian project's util.c. */ + +struct UtilPair { + short lo; + short hi; +}; + +struct UtilPair g_util_pair; + +int triple(int n) { + g_util_pair.lo = (short) n; + return n * 3; +} diff --git a/packages/debug-info/vitest.globalSetup.ts b/packages/debug-info/vitest.globalSetup.ts index c4b2db7..af47de2 100644 --- a/packages/debug-info/vitest.globalSetup.ts +++ b/packages/debug-info/vitest.globalSetup.ts @@ -1,18 +1,21 @@ /** - * Ensures the two vendored test ELFs (and their build/oracle.json) are present - * before the suite runs. They are checked into git, so a normal clone runs the - * tests with NO toolchain at all: + * Ensures the vendored test artifacts (each project's ELF, plus its build/oracle.json) + * are present before the suite runs. They are checked into git, so a normal clone runs + * the tests with NO toolchain at all: * - * - test-projects/agbcc-min → agbcc (GCC 2.95), DWARF-2 - * - test-projects/devkitarm-min → modern arm-none-eabi-gcc (GCC 14), DWARF-3+ + * - test-projects/agbcc-min → agbcc (GCC 2.95), ARM, little-endian, DWARF-2 + * - test-projects/devkitarm-min → arm-none-eabi-gcc (GCC 14), little-endian, DWARF-3+ + * - test-projects/mips-min → mips-linux-gnu-gcc, MIPS o32, big-endian + * - test-projects/ppc-min → powerpc-linux-gnu-gcc, PowerPC 32, big-endian + * (also a relocatable main.o, for the RELA path) * * Behaviour: * - On CI (process.env.CI): always rebuild natively, so every CI run re-validates - * that the vendored toolchains still produce the expected ELFs. + * that the vendored toolchains still produce the expected artifacts. * - Locally: use the committed artifacts as-is. They only need rebuilding when you * change a project's sources — and that is a manual, per-project step (see the * error message / test-projects/README.md): agbcc-min via its submodule - * (./setup.sh), devkitarm-min via Docker (./build.sh). globalSetup never builds + * (./setup.sh), the others via Docker (./build.sh). globalSetup never builds * locally, so it never needs Docker or a cross toolchain on a contributor's box. */ import { execSync } from 'node:child_process'; @@ -25,26 +28,42 @@ const projects = join(here, 'test-projects'); interface Project { dir: string; + /** Files under build/ that are committed and that the tests read. */ + artifacts: string[]; /** How a developer rebuilds this project's committed artifacts locally. */ rebuildHint: string; } +const ELF_AND_ORACLE = ['min.elf', 'oracle.json']; + const PROJECTS: Project[] = [ { dir: join(projects, 'agbcc-min'), + artifacts: ELF_AND_ORACLE, rebuildHint: 'cd test-projects/agbcc-min && ./setup.sh # builds the agbcc submodule', }, { dir: join(projects, 'devkitarm-min'), + artifacts: [...ELF_AND_ORACLE, 'macinfo.o'], rebuildHint: 'cd test-projects/devkitarm-min && ./build.sh # builds in Docker', }, + { + dir: join(projects, 'mips-min'), + artifacts: ELF_AND_ORACLE, + rebuildHint: 'cd test-projects/mips-min && ./build.sh # builds in Docker', + }, + { + dir: join(projects, 'ppc-min'), + artifacts: [...ELF_AND_ORACLE, 'main.o', 'oracle-obj.json'], + rebuildHint: 'cd test-projects/ppc-min && ./build.sh # builds in Docker', + }, ]; function run(cmd: string, cwd: string): void { execSync(cmd, { cwd, stdio: 'inherit' }); } -/** Rebuild a project's ELF + oracle natively (used on CI only). */ +/** Rebuild a project's artifacts natively (used on CI only). */ function buildNative(dir: string): void { const isAgbcc = dir.endsWith('agbcc-min'); if (isAgbcc && !existsSync(join(dir, 'agbcc', 'agbcc'))) { @@ -56,16 +75,16 @@ function buildNative(dir: string): void { } export default function setup(): void { - for (const { dir, rebuildHint } of PROJECTS) { + for (const { dir, artifacts, rebuildHint } of PROJECTS) { if (process.env.CI) { buildNative(dir); continue; } - const haveArtifacts = existsSync(join(dir, 'build', 'min.elf')) && existsSync(join(dir, 'build', 'oracle.json')); - if (!haveArtifacts) { + const missing = artifacts.filter((file) => !existsSync(join(dir, 'build', file))); + if (missing.length > 0) { throw new Error( - `[test-projects] ${dir}: committed build/min.elf or build/oracle.json is missing.\n` + - `Rebuild it, then commit build/min.elf + build/oracle.json:\n ${rebuildHint}`, + `[test-projects] ${dir}: committed ${missing.map((f) => `build/${f}`).join(', ')} missing.\n` + + `Rebuild it, then commit the refreshed build/ artifacts:\n ${rebuildHint}`, ); } // Artifacts present → use the committed ones (no toolchain needed). diff --git a/packages/gba-emulator/src/__tests__/scripting-debug-info.spec.ts b/packages/gba-emulator/src/__tests__/scripting-debug-info.spec.ts index 215b8d0..9065161 100644 --- a/packages/gba-emulator/src/__tests__/scripting-debug-info.spec.ts +++ b/packages/gba-emulator/src/__tests__/scripting-debug-info.spec.ts @@ -52,7 +52,7 @@ describe('ScriptingEngine debug info', () => { const src = engine.pcToSource(0x08000008); expect(src?.func).toBe('add'); expect(src?.file.replace(/^.*\//, '')).toBe('main.c'); - expect(src?.line).toBe(80); // add()'s body line in debug-info's agbcc-min/main.c + expect(src?.line).toBe(94); // add()'s body line in debug-info's agbcc-min/main.c }); it('annotates watch hits with the writer source line', () => { @@ -71,7 +71,7 @@ describe('ScriptingEngine debug info', () => { expect(w.hits).toHaveLength(1); expect(w.hits[0]!.instructionAddress).toBe(0x08000008); expect(w.hits[0]!.location?.func).toBe('add'); - expect(w.hits[0]!.location?.line).toBe(80); // add()'s body line in debug-info's agbcc-min/main.c + expect(w.hits[0]!.location?.line).toBe(94); // add()'s body line in debug-info's agbcc-min/main.c }); it('watchSymbol resolves a named global and records writes', () => {