From b373fe4f4ac4075d451228f47ed1c9656df4b934 Mon Sep 17 00:00:00 2001 From: macabeus Date: Wed, 22 Jul 2026 22:27:24 +0100 Subject: [PATCH 01/15] =?UTF-8?q?`@gba-kit/debug-info`:=20add=20`variableS?= =?UTF-8?q?hape`=20=E2=80=94=20classify=20a=20global's=20declaration=20sha?= =?UTF-8?q?pe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks a variable DIE's type through typedefs/cv-qualifiers to scalar | pointer | array | struct, with element size/signedness for arrays and tag name/byte size for structs. A consumer that reconstructs a declaration needs its shape rather than a rendered C type: the shape is what decides how a name is spelled (`extern u16 tbl[]` vs a scalar vs a struct), and it is a small closed set, where a full DIE→C-type renderer is neither needed nor cheap. `null` when the name has no DWARF DIE, which doubles as the "is this name declared in the headers?" probe. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/real-projects.spec.ts | 11 +++ packages/debug-info/src/types.ts | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/debug-info/src/__tests__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 0e6ee80..2b4749d 100644 --- a/packages/debug-info/src/__tests__/real-projects.spec.ts +++ b/packages/debug-info/src/__tests__/real-projects.spec.ts @@ -145,6 +145,17 @@ 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 + expect(di.types.variableShape('g_counter')).toEqual({ kind: 'scalar', size: 4, signed: true }); + // struct global, by tag name + expect(di.types.variableShape('g_probe')).toEqual({ kind: 'struct', structName: 'Probe', size: 32 }); + // typedef'd anonymous struct: shape resolves through the typedef (the tag is unnamed) + expect(di.types.variableShape('g_pair')).toMatchObject({ kind: 'struct', size: 8 }); + // no DIE ⇒ null — the "is this name declared?" probe + expect(di.types.variableShape('g_no_such')).toBeNull(); + }); + it('returns null for unknown types and missing members', () => { expect(di.struct('NoSuchType')).toBeNull(); expect(di.structMember('Probe', 'nope')).toBeNull(); diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index 47ad540..911603e 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -42,6 +42,7 @@ const DW_AT_data_bit_offset = 0x6b; // DWARF 4+ bitfield: absolute bit offset fr const DW_AT_count = 0x37; const DW_AT_data_member_location = 0x38; const DW_AT_declaration = 0x3c; +const DW_AT_encoding = 0x3e; const DW_AT_type = 0x49; const DW_AT_str_offsets_base = 0x72; @@ -119,6 +120,18 @@ export interface StructType { members: StructMember[]; } +/** + * 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. + */ +export type VariableShape = + | { kind: 'scalar'; size: number | null; signed: boolean | null } + | { kind: 'pointer' } + | { kind: 'array'; elemSize: number | null; elemSigned: boolean | null; length: number | null } + | { kind: 'struct'; structName: string | null; size: number | null }; + /** A member's read location: its byte offset + size, plus bitfield shift/width. */ export type MemberLocation = Omit; @@ -287,6 +300,51 @@ 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`. + */ + variableShape(varName: string): VariableShape | null { + const variable = this.#variableByName.get(varName); + if (!variable) { + return null; + } + const die = this.#stripTypedefs(variable.attrs.get(DW_AT_type)); + if (!die) { + return null; + } + switch (die.tag) { + case DW_TAG_pointer_type: + return { kind: 'pointer' }; + case DW_TAG_array_type: { + const elem = this.#stripTypedefs(die.attrs.get(DW_AT_type)); + return { + kind: 'array', + elemSize: this.#typeRefSize(die.attrs.get(DW_AT_type)), + elemSigned: elem ? baseTypeSignedness(elem) : null, + length: arrayLength(die), + }; + } + case DW_TAG_structure_type: + case DW_TAG_union_type: { + const structName = die.attrs.get(DW_AT_name); + return { + kind: 'struct', + structName: typeof structName === 'string' ? structName : null, + size: numberAttr(die, DW_AT_byte_size), + }; + } + default: + return { + kind: 'scalar', + size: this.#typeRefSize(variable.attrs.get(DW_AT_type)), + signed: baseTypeSignedness(die), + }; + } + } + /** 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('.'); @@ -560,6 +618,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; From 57a2222358b2af7f8430f05827b9397fa9677074 Mon Sep 17 00:00:00 2001 From: macabeus Date: Thu, 23 Jul 2026 01:39:28 +0100 Subject: [PATCH 02/15] `@gba-kit/debug-info`: big-endian ELF/DWARF + RELA-relocated debug sections Byte order is read from e_ident and threaded through every multi-byte read: Cursor carries a littleEndian flag, ElfFile exposes the container's order, and the DWARF payload always shares it. Big-endian ELF32 is what MIPS and PowerPC toolchains emit, so those images parse now. PowerPC relocatable objects are RELA-style: string and section offsets inside raw `.debug_*` bytes are zeros, and the real values live in the addends of `.rela.` (ARM/MIPS REL keeps them in the field itself). sectionData applies those addends to a cached copy, so DWARF in a raw `.o` parses identically whichever relocation style the target uses. Verified against cross-gcc sidecar objects (BE MIPS R3000: 283 vars + 209 structs from a real N64 ctx; BE PPC32 including struct layouts) and a GameCube main.elf (36,588 symbols). Co-Authored-By: Claude Fable 5 --- .../debug-info/src/__tests__/reader.spec.ts | 13 +++ packages/debug-info/src/debug-info.ts | 2 +- packages/debug-info/src/debug-line.ts | 4 +- packages/debug-info/src/elf.ts | 81 +++++++++++++++---- packages/debug-info/src/reader.ts | 19 +++-- packages/debug-info/src/symbols.ts | 2 +- packages/debug-info/src/types.ts | 13 +-- 7 files changed, 102 insertions(+), 32 deletions(-) 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/debug-info.ts b/packages/debug-info/src/debug-info.ts index 1786522..f3a1af9 100644 --- a/packages/debug-info/src/debug-info.ts +++ b/packages/debug-info/src/debug-info.ts @@ -41,7 +41,7 @@ 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); } diff --git a/packages/debug-info/src/debug-line.ts b/packages/debug-info/src/debug-line.ts index 67e57d9..5ef1e28 100644 --- a/packages/debug-info/src/debug-line.ts +++ b/packages/debug-info/src/debug-line.ts @@ -35,9 +35,9 @@ const DW_LNE_set_address = 2; const DW_LNE_define_file = 3; /** 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); 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/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 911603e..a6dbfc1 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -155,6 +155,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. */ @@ -433,6 +435,7 @@ 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), @@ -658,9 +661,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(); @@ -698,12 +701,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. @@ -977,6 +980,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)); } From dfaa5f30b4e325e5f030dde1e6dbe72bde9fabfe Mon Sep 17 00:00:00 2001 From: macabeus Date: Fri, 31 Jul 2026 01:32:20 +0100 Subject: [PATCH 03/15] `@gba-kit/debug-info`: declaration-fidelity facts for members and variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout does not describe a declaration on its own: several declarations produce the same bytes at the same offset, and what separates them lives in the type chain rather than in the offsets. `struct()` members and `variableShape` carry those facts. - members carry `signed`, from the member's base-type `DW_AT_encoding` — offset and size alone do not say whether a byte reads as -1 or as 255 - members carry `pointer`, which separates the 4-byte cases that share `signed: null` (pointer vs enum vs nested struct): indistinguishable by offset and size, and not interchangeable — pointers compare as unsigned - members carry `volatile` — the `vu16 field;` MMIO idiom, which says repeated accesses to the field are observable rather than foldable - `variableShape` carries `volatile` / `const`, collected while resolving the typedef and cv-qualifier chain. `const` is the ROM-table spelling; for an array DWARF puts the qualifier on the ELEMENT type, so the element chain feeds the same accumulator Both minimal test projects declare a volatile struct with a `signed char` member, a member-level volatile, a pointer member, a volatile scalar and a const table, so every fact is covered in both DWARF dialects (agbcc DWARF-2 and modern GCC); their ELFs and binutils oracles are rebuilt from that source. Those declarations sit above `add()` in agbcc-min/main.c, which puts its body at line 94. gba-emulator's scripting tests assert that line against the same shared ELF, so they name 94 too. --- .../src/__tests__/real-projects.spec.ts | 90 ++++++++++++++---- packages/debug-info/src/types.ts | 85 ++++++++++++++--- .../test-projects/agbcc-min/build/min.elf | Bin 7632 -> 8076 bytes .../test-projects/agbcc-min/build/oracle.json | 23 +++-- .../debug-info/test-projects/agbcc-min/main.c | 17 ++++ .../test-projects/devkitarm-min/build/min.elf | Bin 9184 -> 9612 bytes .../devkitarm-min/build/oracle.json | 56 ++++++----- .../test-projects/devkitarm-min/source/main.c | 17 ++++ .../__tests__/scripting-debug-info.spec.ts | 4 +- 9 files changed, 220 insertions(+), 72 deletions(-) diff --git a/packages/debug-info/src/__tests__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 2b4749d..140b759 100644 --- a/packages/debug-info/src/__tests__/real-projects.spec.ts +++ b/packages/debug-info/src/__tests__/real-projects.spec.ts @@ -114,13 +114,13 @@ 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 }, + { name: 'name', offset: 10, size: 6, signed: null }, // char[6] → element size × length; not a base type + { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true }, // pointer → 4 bytes + { name: 'inner', offset: 20, size: 8, signed: null }, // nested struct + { name: 'tail', offset: 28, size: 4, signed: true }, ], }); }); @@ -132,8 +132,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 }, ], }); }); @@ -146,16 +146,68 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { }); it('classifies a variable declaration shape — TypeIndex.variableShape', () => { - // scalar int: signed, 4 bytes - expect(di.types.variableShape('g_counter')).toEqual({ kind: 'scalar', size: 4, signed: true }); + // 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 }); + expect(di.types.variableShape('g_probe')).toEqual({ + kind: 'struct', + structName: 'Probe', + size: 32, + volatile: false, + const: false, + }); // typedef'd anonymous struct: shape resolves through the typedef (the tag is unnamed) expect(di.types.variableShape('g_pair')).toMatchObject({ kind: 'struct', size: 8 }); // no DIE ⇒ null — the "is this name declared?" probe expect(di.types.variableShape('g_no_such')).toBeNull(); }); + 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 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(); @@ -185,11 +237,11 @@ describe.each(PROJECTS)('DebugInfo vs binutils oracle on $label', (project) => { 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 ], }); }); @@ -201,8 +253,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 }, ], }); }); diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index a6dbfc1..f97ceba 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -103,6 +103,26 @@ export interface StructMember { * for a bitfield it's the minimal little-endian span covering `bitOffset`+`bitWidth`. */ size: number | null; + /** + * 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; + /** + * 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; /** * 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. @@ -125,15 +145,29 @@ export interface StructType { * 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. */ export type VariableShape = - | { kind: 'scalar'; size: number | null; signed: boolean | null } - | { kind: 'pointer' } - | { kind: 'array'; elemSize: number | null; elemSigned: boolean | null; length: number | null } - | { kind: 'struct'; structName: string | null; size: number | null }; + | { kind: 'scalar'; size: number | null; signed: boolean | null; volatile: boolean; const: boolean } + | { kind: 'pointer'; 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 }; -/** A member's read location: its byte offset + size, plus bitfield shift/width. */ -export type MemberLocation = Omit; +/** A member's read location: its byte offset + size, plus bitfield shift/width. (Signedness, + * pointer-ness and volatility are declaration facts, not locations — they stay on + * {@link StructMember} / `struct()`.) */ +export type MemberLocation = Omit; /** A parsed DIE: its tag plus the attributes we kept, and its child DIEs. */ interface Die { @@ -265,7 +299,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 }; } @@ -313,20 +347,24 @@ export class TypeIndex { if (!variable) { return null; } - const die = this.#stripTypedefs(variable.attrs.get(DW_AT_type)); + const cv = { volatile: false, const: false }; + const die = this.#stripTypedefs(variable.attrs.get(DW_AT_type), cv); if (!die) { return null; } switch (die.tag) { case DW_TAG_pointer_type: - return { kind: 'pointer' }; + return { kind: 'pointer', ...cv }; case DW_TAG_array_type: { - const elem = this.#stripTypedefs(die.attrs.get(DW_AT_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); return { kind: 'array', elemSize: this.#typeRefSize(die.attrs.get(DW_AT_type)), elemSigned: elem ? baseTypeSignedness(elem) : null, length: arrayLength(die), + ...cv, }; } case DW_TAG_structure_type: @@ -336,6 +374,7 @@ export class TypeIndex { kind: 'struct', structName: typeof structName === 'string' ? structName : null, size: numberAttr(die, DW_AT_byte_size), + ...cv, }; } default: @@ -343,6 +382,7 @@ export class TypeIndex { kind: 'scalar', size: this.#typeRefSize(variable.attrs.get(DW_AT_type)), signed: baseTypeSignedness(die), + ...cv, }; } } @@ -530,11 +570,32 @@ 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, and volatility — + * resolved through typedef/cv-qualifier chains (see the {@link StructMember} field docs). */ + #memberFacts(member: Die): Pick { + 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 } : {}), + ...(cv.volatile ? { volatile: true as const } : {}), + }; + } + + /** 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. */ + #stripTypedefs(ref: AttrValue | undefined, cv?: { volatile: boolean; const: boolean }): 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; + } seen.add(die.offset); die = this.#deref(die.attrs.get(DW_AT_type)); } 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 49991a8d478f131c9a91df9d96541061a07fdb8f..3032d83e4c75d6be1b63ce1c77b30ac59f8e6d27 100755 GIT binary patch delta 1709 zcmY*aU1(cX9RHttZ?fC1w)tv%V-mdmSlM73b4p2YsBLS61Dv%c~T+Su}TVS@ns$Ll^zxeRqZF@Ft*s~%z zJ)S$lIhqg2FEB|r3XrP?w6uwvOvrDN#%Xf|l-`A9}g2J?}Z+GaQ@(pfS7UMORv z^%=9XoFB-DHx+vzqjUx`N7JuR#Gui+*DHBj&W93n8MN@kPPdx|9>cm%q=z=UyBl4a zFq2}myL+pvCA+&fvD1$BkogEPaA+1?0 z*GomSP_Y^$g3%4=wPt0t(K6TSo{a}k_UtL=3hN}!{0a{dP}8UrT>}q&tyWznvB)`3 z!o=@nuc~3k+NV&gfZpNOSgqw+*76GGe{BpVxbpGGb`ghO`J)h%4~FgiJQiC5th>SI z)@oF3jvEv#I(B((q#Wj_MR+I(WjdhAzeeg)_mI%(_JPAV?dd4k+XA~E#KPxJlU3uH z)COxjN&?w{w(y67@~$x{w~fbQ+!i~jUPWf59?uN2(kGR4c`7~~9)rRU;Dow-Hy)K= z#LZOQOM}sze0-U1w4nX+Y&`k~&**EI>6JO}k3IH7t|K2eh0)q^Aoxe}bCAD-e+U>s z#TS6!RTBt*nR`6fR_Sd>e06@7^N^p0%+)#TJn#y#OaM8{MPRze|3k<;Z4em7w4VYq zJ@KDI9)kS>kmIib`Tm@G_;!$kiFcCDU6pgTRc*Le*mGy{a^gg=v0AiRmb{mEzl~y# zh?6jlV&T=GyxYuu%*hS;#Cj&adLE3Qo&)e(aCv8k=+F`DoX@8%7#3Yc+tK;PXCe7K z8xBQ?F7_yV7ANvQzfFAGOa(Z?CEt&m@p-oVW)S`4x;VQaD42>^y^b4k`Fv{f@IP^) oOPF>q%p+KQKs;0*mNzswq6>KIU(1hEbvAhSx?*F{q0oPGE-p@=T~|H)PWApSRE| zxRc2kYni>z8EOm#L|ta@Q^pm@?0v*eJFdU1uV;3*m8d@3NrfhNjv((B$t~lgNJz~+ z);Uh3SCXdO1$u$^9Q%=D&pY_k!DkLG137P5esv!|!4?~UdI!1K3n%!cgP4gC0;3K#h?rIXq4-P5H@* zVYdj z!j31^=R%9@aU^%aCUA>f_z`ijrIDku*jI|P=5Ckj)Z-M{;)}?KK1*)-THV(W%uYN< zRvDMo{-9Wvg?@ieJ-n~;_?B!Osocbkf@guy|Jxm~R}gOi%TezV5V{%yB3{1o--;m)d(Nz>MeV>>2Elco-|0d;ft;q^BA z>h3y82_;cmQB^=Ff(lYBwX{^^N5v1Iv=Tu=6%rIs5h@i0gv5_ZEkZ(t(h@k z1UeDuM4%Ia|5F4a&(nxVT10P~*POcdngOiq+Br zf6wJpzwRG6bu#FJ9YH%3zc@*8%=+W$_dyr< zi-V3h%uG)1u{P)OXHFNJ`;H&kV(qvwwIj8iM!?fhiC{6xAav9eL?+-?VOXQB5OU?Dl2FXvoAK9+_4>?Oo&Esm9EJv4iimWxi!t5k>P9E7KqMeP22kqkNz9PN zN|WTvpqR1y&>H8c&(8v6+)rx)ibT35zl)LGjL9BAR{W1F(Xk%s1n z$T~IF)V>CIu=@>gyNz12PV2k>0XU-BXgBoUfwoWk31o)U*g?{T`6Ccb*2{uH6~h?ASrUe}S_lfJUrAv(3knQvW3eu4S-G z6q&c($DQvJ&`Q2TtlRetgKW$90rb0aH-E8t?N7JW*F|0U8Cp@GHe934`I-( z>y;>gb~yBcQv5m;$0yH_A?`GHVRLC?AkRhj@IKQjXils@$Ycsd(CNbW1JE$hP3S`V zKoYM5x~q@JO8rl4_XZH|PVgwHzX5{Do9Y5kwG&}{ zC5UH2yjplO8OSE{Z-)!-n6HA9_) zk3(#|+V4GohuD`C5r8_7V(CS&H`v)MiO1ALaj^J&4WTjL1#<~CB93WIw0DRdME?ApE$T7Io!@Kp z9Er0^774=S(=V2yHl5wXa`&K~L>g{mL}Hxu@Sdc&Ij}o08@nx`A<$Z=opw_IE{sqhaBDyh!!|I@)&OS8 zn9#$4fI~}~B8Ee{85#z`>snTo49~_wfzu$GLd?ck%zuy`5ElS+Z=_jtfh4*yNCelm zEJ*6i3GGq>;bnEOmZ&EsZcDW6-;|h*MMbY~BL3+FtB=f*7XK_9ipqAIwf-0OBSrK^ z#SP(D;4u;SvheBQb>S2JQ4s+n<~oZ_(bogNd7?Ej0S^NlCDy#wfXlZAn72&qH^gdE z9HBAW2PXPkdL9+Z$bk?%<1nsc=aI5qm(*PB(OxCCUMlVeESKp_IU92w89FF9ifosa zA{7sJuonz$Vx`p`*&ZisTooKO0xJ2OiRIPit>CV963(ify z<7#%cmT$Fpo{4OI*9uFVRWg?sgDmWCol>w+6aU6mz|Rq&5UOZY7wg$PQQ0n5Qb-y^ zi_KywwZIU`9V9hAy83%0q>Rb1Pe_rJqrTx5HYUSSrfqX*J&+g~lRZOn1hfsZcUTHN zXvu`WC}ar2Lo$?K@aG20GBW( zBfv#c^TQn(fuS(i)Gq;np%GtH`jf0c`eTxf^7{_CJ*@MI6bMNfKvET3H31cOVz}-+ zc>Y_ARAf|)NRDDc&J}u(nvZQ0!?G`;rc_}X({M>3ipaZk9R+W#&xC`a)?>#@iOlC> zK^v!2WMNgjoI4#hAJ#6379C8L>i4$ z7Rwp0ABE}p`q^szG$`5H;w^CQqs?5o2-L1;7Z^V?-n2#w*67il*693vJ&%ysm0Yn> zpo(2)GwRiHx@l)%1pMJefFVxgokc?YU~bbcmgsb`lB1m6w3R5^>KxuN_w4Rh@lQ9D z=_hcCkFkq|G;dqSU02@S#|>-_=|0%`@OXuPM8sunXxpDOjG&k^f<5Ax5s9H$n?REo z6mv?}XDLA+Xg;(18U0z>c4g-kdeUJ!`&$E3GuoLL)S8wh4P0x-3^UR$v!+d^ixt)Q zqX64}-xl?u4AU3o+1i9tHM+^s@(;MDGP|CgT ze&u`%uK@?=*N^l2s)O_61tse7)i1FQ4Xy6{^4f;8y~{;+hP4Pf`CBjpI(4tYaj&%% zy_Gc_qE4YUE|13pqpj%F<9g^24f@-4c8o*mPz3P|7u#@`v4Zc#2N#Eu=Pxj4ys=_N zPe13JcKu&ph4X968_#RZ&txVYdN_{T_I` zzBhsM`g!(vPlk~ujpUqHLl)Ua-5|@k%)MsZM2obm@>x2aX*AM&&ot9%D(uTNW{|k; zl9$i<%fXS!S8^mEIclrenYUa;1DOIm3&8b?x-Y7Pjd0cKOxJs!?wOAKFlkF$ zy;5*-{7oliH?atrMcm3*$p5jdgB+hX^?xP2S#a2}2U~^r0vDVkCC6uh_vjMj+%e3? z?sE!fZ`Q|$kaI2SisH%b0+shE%kddPRsY}O39C&GWDlw*=k0Y*hI>!ne!x8Mb|#|m zy!>GI3iG%ga(j<{6>abltt{^qBTDqDHyMZ?Z@vv?+211RJ;y6+U-n?(W#@U9Y`P>~)-YH!^LS;I$L1z8^_k$1zQsG)<(mRBUE{^fJ)PKraKm4D>S4 z%Rny!y$t-HGLU_lrbMwo^p^cj_{_dzfl9vf_rL5PTU|71etwKr7qhpGFF)sreNT;L zU(Q#ayZ5QFMB%Q=`-`VfioNpM(hG%KwYzFx zsAXRhuhAlZ3z&TC@@3<;u}9`<4Bjst`Zaiq^YhmicGcW-D_(Hm#L=q?d#^6-EzQyt za$w*gLPTg2_||Y*VPpgEHpUIQU~0VpdhyQ;>ySti%E{mmzD{g67q^;p5!gYP4s;#C zft@Uaw5+5DAbhB#(?}mHkof>@NioYJ%a9MkQvLx{ zVeEAl22P#w{fv^4L^rOCEaSJpw<$B*Xu@Wq|BvAGo2!9O+x!0pJZsFN-!Xg#`Vr$9 z=u9fx39^L!ESP=ll{JUCMNB#*$Qq)*Bn_!T*0vt+=qmzkqjB+eb0@)JjU7g|K6JG&~ z5{?OXqaKKT-43p_J}k|O|z z7&ndN#{m`@Ps-#Vz+uL%0po3erD#^-Y7j@#FDb{b!tqv1+$r~9Q>8u&$;mWlD)nb{ zrv`cHNTzt;JC>kfd>ZJ9Fj1o^Ff0Gh2H)mXt0W#)1C6mFo(XjJ3WURW(&D%gpua=xRma2L52lTb+ONR7jm5Ot z4{iSuI&oiN7Z^tvT_5-wKy#AEFM!G4^Tay4&9tbwo7o5O@RVtXQ>&5ilrA4vK67F{ zwOr{-FPM|P%1G;U|U}VPuMmybRDG9p91wUoOhZy?b|I?lT`g(-kzceR*fO;IX!bIQzM^qep=sZ=N~+9HSZ zu?rq1pqAWlRm?2q(y0|N?Zfmj@i5f-gjmY4e*f0)h(&V=jMRo5!ot#*#4Y)5pjYOX zazkP`v489%c{WckkumlVBG1TpoW|&P4vrF`KE5xLOFb)6pArc(vn_LKbVy_&$VPL( z>JJrZDK~@Ih6gb(9x;~_m{W@L!b6N$nB>hVmTfS%SYk74>uA^PDWQTJO~W;(Y>dYs z%12XCu6ZUmDz$N`q}#9_b~wUPE}Fo^VZr(0Ft(RS7y?nn1q7yiNhvttRId-DFcw%AU_6R?*a4UJOmg_^Y;sqT#*l1U1CU85! z^s3*!*R2MfnedwB_|562*P=$dRWGP+t<~}BEw@(S3r^_!;E_`cRQ21P4t1r~Wyhzg z=T{ppEvd1N9=mm+eC*I6>fE>H_%1EGj*qbs0*}sHkSc4 z{cV2*oa*Y@O_=C4L9OY5bo}Zv)92=b!gRebeQaN0`s`WXMS7f8&1==E*bCL_Y@A^ah_hi%=<5#YNQ`+ z$PSiU;zGcUPl0ay=Q6xNEo1h1%Nh_Tt${&t+{)(AZOx<0Pl%IBH&IZE3Gfnj{|n|t z+VkSR7wP#>817U9Lc@v+VbmR#H7#6cj$3v%uCpbklVnGy`Rs5yp$J}!2|Cj`BC-lp;xHfc~_Q&-hWQP78*+lalCzPAStH_sAW(>g}Vo`sa zTo*hJm1EG5=G5 zMcDKBY<~~ncF;UN(`NwV<0Chue-^NR%$4|uh>riGpyT85Jq@v{y2}2Z1|9eJ8PI&A z*7N-$XuiE`e=mU6`!5UouWqvc2IyB~`mcb-aH`MZ+6G~m5R}VQKTzo`qRcuCU48lf9qPEP z8f&1MafynGY|K0?&E>MzQ{`Hht+J~M91CHpUM4(fbtSCjvz0P;G&?HFcm7b+Fr{8QEfMyid%ClYxT0@JFU74c}RM#vu%w# zm5T44(^SK2xf(y~a|Mq~Fs?=$?P>#qcD?zndPL)UL&*t{Pn^AXhib?6<|L`-%fhO_uj&|A`j#bo>okNxU4jpFmk*h@gm z_>60ub-#xzvm0Y*^!z5hLZ0XmHi()I;c?Jxqvv}8dT(eak&MY_BOE}rN3oDx4|LNe z7r@Xs%XXk>!ezH<**C$#nyqTVHPG9w2|%sKFqQ_YK`#r#Ecm=GJP*eCZNz&(w*YU~ W-)jO;`x^ot*E?1~O#@Fit@j`LET6vs 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..e6509c0 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,56 @@ { "symbols": { "add": 134217756, - "__bss_end__": 134222040, - "_bss_end__": 134222040, - "__bss_start": 134221960, - "__bss_start__": 134221960, + "__bss_end__": 134222080, + "_bss_end__": 134222080, + "__bss_start": 134221992, + "__bss_start__": 134221992, "bump": 134217768, - "__data_start": 134221956, - "_edata": 134221956, - "__end__": 134222040, - "_end": 134222040, + "__data_start": 134221986, + "_edata": 134221986, + "__end__": 134222080, + "_end": 134222080, "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_mmio": 134222052, + "g_mode": 134222037, + "g_pair": 134222028, + "g_probe": 134221996, + "g_rom_table": 134217884, + "g_shape": 134222056, + "g_util_pair": 134222076, + "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..369850b 100644 --- a/packages/debug-info/test-projects/devkitarm-min/source/main.c +++ b/packages/debug-info/test-projects/devkitarm-min/source/main.c @@ -75,6 +75,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) + // 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. @@ -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 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', () => { From f8008409a5bd01de679efcbcad1ea16beac0f9cc Mon Sep 17 00:00:00 2001 From: macabeus Date: Fri, 31 Jul 2026 14:52:37 +0100 Subject: [PATCH 04/15] `@gba-kit/debug-info`: walk .debug_line by its own terminators, not by unit_length alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.debug_line` is a concatenation of independent units, so finding where a unit ends is as much of the parser's job as decoding one. Two properties of the input shape how that is done. `unit_length` is not a dependable end marker. agbcc (GCC 2.95, the pret compiler) sizes a line unit by *predicting* the encoded length of every statement it is about to emit, and mispredicts, so the declared length can stop short of the program it describes: in pokeemerald 28 of 303 units undercount — 18 by 1 byte, 6 by 2, 2 by 3, 1 by 4, and event_object_movement.o by 51. It is visible in the objects themselves: build/emerald/src/text.o has a 9401-byte .debug_line whose unit_length says 9400, and the final `00 01 01` (DW_LNE_end_sequence) at 0x4c30 needs 0x4c33 while the unit is declared to end at 0x4c32. A walk that trusts the declared end desyncs — it stops mid-statement, then executes the next unit's header as line-program bytes, yielding garbage addresses, a nonsense `unit_length` and a read past the section. The line program is self-delimiting: every sequence ends with DW_LNE_end_sequence. That terminator is the authority on where a unit ends, so statements run to the declared end *and* past it while a sequence is still open. Well-formed units are unaffected — they end on the terminator exactly. Around that, one unwalkable unit costs only itself. A version this parser does not model (DWARF 5 rewrote the header: address_size/segment_selector_size, and directory/file tables described by entry formats instead of NUL-terminated lists) and 64-bit DWARF are skipped by their own length; zero-word padding is stepped over; a unit whose length runs past the section is decoded as far as it goes; every read in the program loop is bounded. A hostile section yields fewer rows, never an exception — parseDebugLine is the only thing standing between a bad `.debug_line` and DebugInfo.fromElf, which must still deliver symbols and types. pokeemerald decodes to 193,233 rows across all 303 units, consuming the section to its last byte, and agrees with readelf on every row readelf decodes. The two test-project ELFs decode identically to readelf. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/debug-line.spec.ts | 161 +++++++++++ packages/debug-info/src/debug-line.ts | 263 ++++++++++++++---- 2 files changed, 364 insertions(+), 60 deletions(-) create mode 100644 packages/debug-info/src/__tests__/debug-line.spec.ts 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/debug-line.ts b/packages/debug-info/src/debug-line.ts index 5ef1e28..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, littleEndian = true): LineTable { const rows: LineRow[] = []; 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, littleEndian = true): LineTa 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. */ From 9ffdda312f074f13c420f78e0982a1121e4ac224 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 00:24:00 +0100 Subject: [PATCH 05/15] `@gba-kit/debug-info`: big-endian MIPS + PowerPC test projects, proving cross-endian equivalence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big-endian support landed with no committed big-endian artifact: no DWARF payload was ever read MSB-first by a test, and the RELA path in `sectionData` — the one that makes a PowerPC `.o` readable at all — had no coverage. Two projects built by stock Ubuntu cross packages close both gaps, alongside the two ARM ones. `mips-min` (mips-linux-gnu-gcc 12.4, MIPS o32) and `ppc-min` (powerpc-linux-gnu-gcc 13.3, PowerPC 32) compile ONE source — their `main.c` / `util.c` are byte-identical — with `-g -O2 -fno-eliminate-unused-debug-types`, linked freestanding, never executed. Every declaration is one shape the parser classifies: a scalar, a pointer, `short g_table[4]`, `const short g_rom_table[3]`, a `volatile` scalar, `struct Probe` (named members at 0/4/8/10/16/20/28, size 32), `struct Bits` (bitfields), and `struct Cv` (a `signed char` next to a member-level `volatile unsigned short`). `char` signedness is never left to the default — it is signed on MIPS and unsigned on PowerPC — so both projects assert the same numbers. `triple` lives in `util.c`, so each linked ELF has two CUs and its `.debug_line` two sequences; both are read entirely MSB-first, and every function entry agrees with `addr2line` on `{func, file, line}`. Bitfields are the assertion class the little-endian projects structurally cannot make. A big-endian target allocates them from the MOST significant end, so the identical declaration that ARM pins as hearts@0>>0, stars@0>>2, cross@0..1>>5, wide@1>>4 lands mirrored: hearts@0>>6, stars@0>>3, cross@0..1>>4, wide@1>>0. That mirror was NOT what the parser reported. `DW_AT_data_bit_offset` (and DWARF 2/3's `DW_AT_bit_offset`) is measured from the end the target allocates from, and `#memberLayout` normalized it as if that end were always the least significant one — so every big-endian bitfield was reported at its little-endian position, silently, with a plausible shape. `TypeIndex` now carries the ELF's byte order and flips the intra-unit shift. The compilers' own read-modify-write of `g_bits.cross` is the ground truth for the fixed numbers, and both agree on a 2-byte access at offset 0, 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) `ppc-min` also vendors `build/main.o`, a relocatable object — the only artifact shape that exercises RELA. Its `.debug_info` has 59 relocations, and the raw field at every one of the 59 sites is ZERO: unrelocated, every `DW_FORM_strp` would resolve to `.debug_str` offset 0, one single name for the whole unit. The tests assert that (all 59 raw fields zero), that each patched word equals `symbol value + addend`, and that the five sites whose symbol is a data symbol rather than a section symbol carry a NON-zero `st_value` (g_bits 4, g_vol 12, g_table 16, g_ptr 24, g_counter 28 — the `.bss` offsets `nm` reports), which is what pins the "symbol value +" half of the sum. Every struct tag and long member name in that object resolves, and its layouts equal the linked ELF's. Cross-endian equivalence gets its own block in `real-projects.spec.ts`, comparing all FOUR projects against each other: - The shared declaration set is computed, then PINNED: `Probe`, `Inner`, `Bits`, `Cv`, `UtilPair`, and `g_counter` / `g_probe` / `g_bits` / `g_cv` / `g_rom_table` / `g_util_pair`. Everything else is listed with the projects that lack it (`Pair`/`g_pair`/`g_color`/`g_mode`/`g_mmio` absent from the big-endian sources; `Shape`/`Blob`/`g_shape`/`g_wide`/`g_blob` devkitarm-only, since GCC 2.95 rejects anonymous unions and flexible array members; `g_ptr` / `g_table` / `g_vol` big-endian-only). A skip is a named fact, so a shape that quietly stops parsing shrinks the comparison and fails HERE rather than passing a smaller one. - Every project reports the same byte layout: Probe `tag@0:1 count@4:4 flags@8:2 name@10:6 ptr@16:4 inner@20:8 tail@28:4`, size 32; Inner `x@0:4 y@4:2`, size 8; Bits `hearts@0:1 stars@0:1 cross@0:2 wide@1:1 after@4:4`, size 8; Cv `level@0:1 gain@2:2`, size 4; UtilPair `lo@0:2 hi@2:2`, size 4. - All four agree on every remaining declaration fact — signedness, pointer-ness, member-level volatile, bitfield WIDTHS — with only the intra-unit shift dropped from the comparison, since that is the one field byte order may change. - `variableShape` is identical in all four for each shared global. - Bitfields carry the load-bearing assertion. Each side must match its own ABI while offset, read size and width stay identical on both. And the two tables must be MIRRORS, not merely different: `leShift + beShift + bitWidth === size * 8` holds for all four fields (0+6+2=8, 2+3+3=8, 5+4+7=16, 4+0+4=8). The mirror survives to `resolveVariable`, which reports `g_bits.cross` as a 2-byte read at the symbol with shift 5 on the little-endian ELFs and shift 4 on the big-endian ones. The block was checked against deliberately broken parsers, one mutation at a time: - `ElfFile.parse` forced to `littleEndian = true` (ELFDATA2MSB ignored): it dies at load — `RangeError: Offset is outside the bounds of the DataView`, reading the real MIPS ELF's section header table. - `.debug_info` read LSB-first (`TypeIndex.fromElf` given `true` instead of `elf.littleEndian`): 5 tests fail, including the shared-set pin — `Cv` stops resolving in the big-endian ELFs, and the guard catches the comparison shrinking instead of quietly comparing less. - Bitfield normalization flipped (the little-endian rule applied to both): the block reports mips-min at `hearts 0 / stars 2 / cross 5 / wide 4` where the ABI says `6 / 3 / 4 / 0`, and `resolveVariable` fails with it. This is the regression that shipped silently before `88c0bed`, and it is now caught by the cross-endian block on its own. CI installs gcc/binutils-{mips,powerpc}-linux-gnu — the runner is x86 Linux, so they are native, no Docker or qemu — and `globalSetup` rebuilds all four projects from scratch there, so the committed artifacts stay honest. Locally the two new projects rebuild through `./build.sh` in `ubuntu:24.04`, so no cross toolchain is needed on a contributor's box. @gba-kit/debug-info: 186 tests, all green; `pnpm turbo build test check-types lint check-deps` and `pnpm run format:check` clean. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 11 +- packages/debug-info/README.md | 51 +- .../src/__tests__/real-projects.spec.ts | 522 +++++++++++++++++- packages/debug-info/src/types.ts | 63 ++- packages/debug-info/test-projects/README.md | 59 +- .../test-projects/mips-min/.gitignore | 3 + .../test-projects/mips-min/Makefile | 37 ++ .../test-projects/mips-min/build.sh | 15 + .../test-projects/mips-min/build/min.elf | Bin 0 -> 5456 bytes .../test-projects/mips-min/build/oracle.json | 53 ++ .../debug-info/test-projects/mips-min/main.c | 104 ++++ .../debug-info/test-projects/mips-min/util.c | 17 + .../test-projects/ppc-min/.gitignore | 5 + .../debug-info/test-projects/ppc-min/Makefile | 52 ++ .../debug-info/test-projects/ppc-min/build.sh | 16 + .../test-projects/ppc-min/build/main.o | Bin 0 -> 5604 bytes .../test-projects/ppc-min/build/min.elf | Bin 0 -> 8376 bytes .../ppc-min/build/oracle-obj.json | 33 ++ .../test-projects/ppc-min/build/oracle.json | 50 ++ .../debug-info/test-projects/ppc-min/main.c | 104 ++++ .../debug-info/test-projects/ppc-min/util.c | 17 + packages/debug-info/vitest.globalSetup.ts | 45 +- 22 files changed, 1186 insertions(+), 71 deletions(-) create mode 100644 packages/debug-info/test-projects/mips-min/.gitignore create mode 100644 packages/debug-info/test-projects/mips-min/Makefile create mode 100755 packages/debug-info/test-projects/mips-min/build.sh create mode 100755 packages/debug-info/test-projects/mips-min/build/min.elf create mode 100644 packages/debug-info/test-projects/mips-min/build/oracle.json create mode 100644 packages/debug-info/test-projects/mips-min/main.c create mode 100644 packages/debug-info/test-projects/mips-min/util.c create mode 100644 packages/debug-info/test-projects/ppc-min/.gitignore create mode 100644 packages/debug-info/test-projects/ppc-min/Makefile create mode 100755 packages/debug-info/test-projects/ppc-min/build.sh create mode 100644 packages/debug-info/test-projects/ppc-min/build/main.o create mode 100755 packages/debug-info/test-projects/ppc-min/build/min.elf create mode 100644 packages/debug-info/test-projects/ppc-min/build/oracle-obj.json create mode 100644 packages/debug-info/test-projects/ppc-min/build/oracle.json create mode 100644 packages/debug-info/test-projects/ppc-min/main.c create mode 100644 packages/debug-info/test-projects/ppc-min/util.c 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..9343e5a 100644 --- a/packages/debug-info/README.md +++ b/packages/debug-info/README.md @@ -1,17 +1,31 @@ # @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`. -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 @@ -37,14 +51,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 | -- `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) +`ppc-min` vendors a relocatable `main.o` as well as the linked ELF — the artifact +shape that exercises the RELA path. + +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__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 140b759..79380a9 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))); @@ -231,7 +248,8 @@ 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', @@ -328,3 +346,495 @@ 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 }, // unsigned char[6] → elem size × length + { name: 'ptr', offset: 16, size: 4, signed: null, pointer: 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, + }); + expect(di.types.variableShape('g_ptr')).toEqual({ kind: 'pointer', 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('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_table', + 'g_vol', + 'g_shape', // devkitarm-min only (agbcc rejects anonymous unions / flexible arrays) + 'g_wide', + 'g_blob', + ]; + + 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_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'], + }); + }); + + // 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, + }); + // 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/types.ts b/packages/debug-info/src/types.ts index f97ceba..d349d93 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'; @@ -100,7 +102,7 @@ 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; /** @@ -124,8 +126,10 @@ export interface StructMember { */ volatile?: true; /** - * 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. + * 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. */ @@ -232,9 +236,12 @@ export class TypeIndex { readonly #typedefByName = new Map(); /** global/static variable name → its DIE (carries DW_AT_type). */ readonly #variableByName = 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) { @@ -481,11 +488,11 @@ export class TypeIndex { 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); } } @@ -517,8 +524,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); @@ -526,12 +534,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. */ @@ -638,20 +651,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; diff --git a/packages/debug-info/test-projects/README.md b/packages/debug-info/test-projects/README.md index d63fd24..44ed49e 100644 --- a/packages/debug-info/test-projects/README.md +++ b/packages/debug-info/test-projects/README.md @@ -1,25 +1,52 @@ # 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`. `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. +## 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 +61,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 +69,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/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 0000000000000000000000000000000000000000..95737f56997d57a50d44534579f375818b331a62 GIT binary patch literal 5456 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 ZB^QF^Uw0|=u053Qb$V}r{&=I_zX7KU!Ak%D literal 0 HcmV?d00001 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..8f2dbdf --- /dev/null +++ b/packages/debug-info/test-projects/mips-min/build/oracle.json @@ -0,0 +1,53 @@ +{ + "symbols": { + "_GLOBAL_OFFSET_TABLE_": 4260464, + "__bss_start": 4260480, + "_edata": 4260476, + "_end": 4260560, + "_fbss": 4260480, + "_fdata": 4260464, + "_ftext": 4194640, + "_gp": 4293216, + "add": 4194704, + "bump": 4194720, + "g_bits": 4260484, + "g_counter": 4260540, + "g_cv": 4260480, + "g_probe": 4260492, + "g_ptr": 4260536, + "g_rom_table": 4194912, + "g_table": 4260528, + "g_util_pair": 4260544, + "g_vol": 4260524, + "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..173462f --- /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 */ +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; + +/* 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 0000000000000000000000000000000000000000..8595354b12964c6857b82c20cf3be6e8a3ce8079 GIT binary patch literal 5604 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..76683e3fcaa19d17e4b80320e0cf6296090f23d9 GIT binary patch literal 8376 zcmeHM>u(g-6+d?#>y6i5kG+c@cnP!E5R-Tp8{A+M12(W=r$7J$N~^Z(eT>(eu6Nzt zS(k?iB#{!e>cgnX2P!0xs?bEOB0f}!6rn0otL95kr6_7XMNLyxYATe{v{j@=!u_2) zGi!rD?H{n$zH`sxch5cd+}F(A^NEA|g(9ey6bgeD21(fv(Ykk}Zir0MsgFX`#(I_+ zZYENG>trCLl=VjK4=^9{L8s(eq#=I*N@mc#Zf3cbwf}Mi_E6u+kiid`J^fMe_e=o=`fQO={hjq*COflT7eG13;t zMR!weI&>~C-(ifO^+=5ChwK(gPu2-~46?tXT%z%y@zfPD^~WGM!W@(B-6^0PX5GHl z@$*;20jS4);}9aT%?t2l*ri!kPxT1YBT$b(Jp%Oz)FV)jKs^HW2-G7`k3c;F|0@Cl zAKB`Gy?b|_&cx3g^=oR0QqB88G9E<6Z_Gw_?$hX1Fd*|A$$1!35=|w zD?Cc#rpjYXZ>S1Kc7{cF*xJpn;=V?FF0J`dmp&0;Za#skgWTf;uAYeA7bz+ACY!hn z{bD$k$&g4(=KyL)ZdwBft{RkX15`)wMk;!<1f<6)_d=N~7ToRYNQ2b6DmB!(Z z@_J*Dj`Cbh(ArcsOX5&Xb+a)=hj{Q4jcGc>*GviGR$hm-F>rqA)W0!>mAAoiT(?KS z{6-w-Ij;N&xFZgZF;_r7jeH!L=&G|M#0`}>UJ}#Dl9NQro<~|;XPehQlm%Rqqce>nABoXA^Gxr?w zIr2Lz4HJsg;Et$xA}|t|4DIexILOVbWy6@nYA*!>y8H*z5MnYUKwvdZhPFWQP)Ml3K)|sO^|P)~Uu}{ouu0Z!(rGZHbiB2vgQ&Sx zYz&6rKzHy&N2@R(3c5ZaQY|fTkDD`K+z;ac?hc!7jf>q~=%5rl(lr?x?P778E?WrQ zt8Eo+zOl|T5)(#b{7%J5&y6(`;+!}yJJdc&O6RkPR>uUfjHokCtK0Hy5p~_$j46Fv zE5&XT?3*L*j*G#NSDBrS$T*;-b*nWPMX@p}JY?>uL=g!&W-B3MYaBAojeRXVx*UDU zdV}w~*V)ysG{}*1@$S(0SH=N8(cg)u`Z`T~8Ehtj#E$a+Tq<9TXBa|y2k8ngFZDeV zx*pPnFJ$T=->w?n>d>veR=p*vn~&>lk*MC%t+(}p>(P53)y-Z#qMkAJ*2nZnkKXdI zuKS|;!@dxcfgZhWgWkJdZ;R^220Z}W2EDCEH==qQv=PRJLb~Z|hh2w}J}4?Y)(yuw zQa_f?g@81CK|QclHzNGheOsdJ@u-BOI|m-r<*J=8cwG)TR&T4#vd3Yc(A!PvGRKbJ42Y%5kRRx4IEmbKE= zT+BW_Z&iSpDO*;>P8GBHVvcf2K5#N6ybBP7;bw zu22qRMTJr^XUfN2;7qwxsZdRnnN5``Uo2W>nv~DVcdDtf1xuL)Ip&}iN(D++=jO>S z=jRI+eK()AXr_?LRp>~$l(r}}W21h-TCfV}hgdTCh|30&TI;@fZZpSQ)#rWqv*rL!f|hEV#pQ0;6&yl`liilH@$oNOPpiw--`W zxm!cTCknq8DFatDiYS9uw8(lfrLFH5C9Qv0OmQ^{8q;uIPHCFI@n`C*>ZaFXuMr;v zj`GL@l&04p12t-bfhOY~1MMEBf!E7*!3FNpACR!ef50mE&+Cp~=D1*<4}9>lU|x0+ z{E(}cUFTnUT%L~+a0VYl4Cd2?`F@w@^S+j#mpD%Vo=@*u0?)5JwFDkV!Ab&;vvVbZ zm+z3C1ibT$f-3;u0@CRm2#uEifuiPv%e&~BzQw>#6r50z`?uDM2WXh27{7y})NrbD zf43ukw^15+W8uK|I?-Rd4as9L4-Eolm=fNL@oNKW^FM;P57|-4h!E;=_`Y)sXY4A&K`z?PXm*jc|6G!HCBDXS!tSuYH$_a;(_W@SG9#3_-PsqIi3@6Xt9?%{R zdbxirNrdz8a6B&=$KQ1*WHWN^IBU7QJ-m%Mhr1p&7^ z;3%ZMm_aIF8ii*r?7{0;bu%W&bf#Xfs9$gbbxu@>1&o{md7 t_>Q9M7T9Uf*n(&cGRK(*llOPtGu;&7@&1L!q4ex=z16rcz`uJh?!TX a second compilation unit */ + +int g_counter; /* scalar global (.bss) */ +int *g_ptr; /* pointer global */ +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; + +/* 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..725d4f0 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, 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). From e353132544fc1f4c97d801454c6977aec996f42a Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 00:24:25 +0100 Subject: [PATCH 06/15] `@gba-kit/debug-info`: what a pointer points AT, an array member's elements, and alias naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facts `.debug_info` carries that the API read past. `variableShape`'s pointer arm reported the cv-qualifiers and nothing else — `{ kind: 'pointer', volatile, const }`. Every pointer global classified identically, so a caller holding one could name neither the type it addresses nor its size. It now carries `pointee`: `{ structName, size, volatile, const }` when the target resolves — through typedefs and cv-qualifiers, the same walk the rest of the shape uses — to a struct or union, and `null` for every other target (a scalar, another pointer, a function, `void`). `structName` is deliberately not "the struct tag". It is the name `struct()` looks a layout up by, and for the `typedef struct {…} T;` idiom that is `T`: the struct there is unnamed, and the alias is the only name it has. So the last typedef crossed on the way to the target is reported when the tag is absent, which makes the two calls a round trip — `struct(shape.pointee.structName)` is the layout the pointer addresses. Null when the target has neither, since then no name retrieves it. A target that is only forward-declared carries no `DW_AT_byte_size` of its own, so the size is read from the definition its tag resolves to. The `struct` arm is named by exactly the same rule. It used to read `structName` straight off the resolved DIE's `DW_AT_name` — for `typedef struct {…} T; T g;`, the single most common way a C header names a struct, that DIE is ANONYMOUS, so the arm returned `structName: null` and the caller could not look the layout up with `struct()` at all. The shared tail is `#structTarget(die, alias)`, called by both arms, so a struct global and a pointee are named identically and either name goes straight back into `struct()`. The size comes through it too, which also sizes a struct global whose DIE is only a forward declaration. The pointee's qualifiers are the ones left of the `*` and must not reach the pointer variable's own, right of it. They cannot: the variable's cv is accumulated by the walk that strips down TO the `DW_TAG_pointer_type` DIE, the target's by the separate walk that starts at that pointer's `DW_AT_type`. `devkitarm-min` declares both spellings — `volatile struct Cv *g_cv_ptr` and `struct Cv *volatile g_cv_vptr` — and the tests assert the volatile lands on a different one of the two objects in each. `struct()` members reported `size` — the WHOLE member, 16 for `u8 x[16]`. Nothing in that number says where the n-th element of such a member begins, or how many there are, so an indexed read into one was not expressible from what `struct()` returned. Members now also carry `elemSize` / `elemSigned` / `length`, spelled exactly like `VariableShape`'s array arm and populated by the same helpers, so one member reads `name@10:6 elemSize 1 elemSigned false length 6`. Each key is omitted when the DWARF does not determine it, never defaulted: a flexible array member (`char data[]`) reports `elemSize` and NO `length`, the two being independent facts, and `elemSigned` is absent for an array of structs/pointers/enums exactly as `signed` is null for a member that is not a base type. The presence of `elemSize` is what identifies a member as an array — `signed` stays null there, an array not being a base type. `MemberLocation` is unchanged: the new keys join its `Omit` list, next to `signed`/`pointer`/`volatile`, because they describe the declaration and not where to read. `structMember`, `variableMember` and `resolveVariable` therefore return exactly what they returned before, byte for byte. Test-project declarations added to exercise the new arms on real toolchain output, each fitted into existing lines so every file's line count is unchanged (the committed ELFs and a cross-package `line: 94` assertion are pinned to them): - `mips-min` / `ppc-min` (kept byte-identical): `struct Probe *g_probe_ptr = &g_probe;` — a tag-named pointee, next to the `int *g_ptr` already there, which is now the negative case (a scalar target reports `pointee: null`). Both are also read out of `ppc-min`'s relocatable `main.o`, where the pointee's name lives only in a `.rela.debug_info` addend. - `devkitarm-min`: `Pair *g_pair_ptr = &g_pair;` — the unnamed-struct-behind-a- typedef pointee, plus the two `Cv`-pointer cv spellings. They join the devkitarm-only block because these are little-endian-source shapes; the cross-endian block lists the new globals by name among the ones not shared by all four, so a shape vanishing from a project's DWARF still fails there rather than shrinking the comparison. The initializers put the new globals in `.data`, leaving the `.bss` addresses the RELA block cites unmoved. All four ELFs and their oracles were rebuilt (agbcc-min's sources are untouched, so its artifacts are byte-identical). Checked against deliberately broken parsers, one mutation at a time: - the typedef-alias fallback dropped (`structName: tag ?? null`): the devkitarm block fails — `Pair *` reports a nameless pointee, and `struct()` cannot be handed it. - `elemSize` read from the member's own type size instead of the element type's (16 rather than 1 for `u8 x[16]`): 7 tests fail across all four projects, including the flexible-array member, whose stride would become null. - the struct/union guard removed from `#pointee` so any target reports one: the big-endian pair fails — `int *g_ptr` gains a `{ structName: null }` pointee, the exact "there is a layout here" claim the null is there to deny. @gba-kit/debug-info: 186 tests → 191, all green; `pnpm turbo build test check-types lint check-deps` and `pnpm run format:check` clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/real-projects.spec.ts | 145 +++++++++++++++- packages/debug-info/src/types.ts | 155 +++++++++++++++--- .../test-projects/devkitarm-min/build/min.elf | Bin 9612 -> 9876 bytes .../devkitarm-min/build/oracle.json | 17 +- .../test-projects/devkitarm-min/source/main.c | 14 +- .../test-projects/mips-min/build/min.elf | Bin 5456 -> 5596 bytes .../test-projects/mips-min/build/oracle.json | 29 ++-- .../debug-info/test-projects/mips-min/main.c | 6 +- .../test-projects/ppc-min/build/main.o | Bin 5604 -> 5748 bytes .../test-projects/ppc-min/build/min.elf | Bin 8376 -> 8440 bytes .../ppc-min/build/oracle-obj.json | 3 +- .../test-projects/ppc-min/build/oracle.json | 25 +-- .../debug-info/test-projects/ppc-min/main.c | 6 +- 13 files changed, 327 insertions(+), 73 deletions(-) diff --git a/packages/debug-info/src/__tests__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 79380a9..0f009a6 100644 --- a/packages/debug-info/src/__tests__/real-projects.spec.ts +++ b/packages/debug-info/src/__tests__/real-projects.spec.ts @@ -134,7 +134,9 @@ describe.each(ARM_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) { 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 }, - { name: 'name', offset: 10, size: 6, signed: null }, // char[6] → element size × length; not a base type + // 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 }, { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true }, // pointer → 4 bytes { name: 'inner', offset: 20, size: 8, signed: null }, // nested struct { name: 'tail', offset: 28, size: 4, signed: true }, @@ -179,12 +181,30 @@ describe.each(ARM_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) volatile: false, const: false, }); - // typedef'd anonymous struct: shape resolves through the typedef (the tag is unnamed) - expect(di.types.variableShape('g_pair')).toMatchObject({ kind: 'struct', size: 8 }); + // 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({ @@ -213,6 +233,19 @@ describe.each(ARM_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) }); }); + 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({ @@ -334,6 +367,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', () => { @@ -405,7 +502,7 @@ describe.each(BE_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) = { 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 }, // unsigned char[6] → elem size × length + { name: 'name', offset: 10, size: 6, signed: null, elemSize: 1, elemSigned: false, length: 6 }, { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true }, { name: 'inner', offset: 20, size: 8, signed: null }, // nested struct { name: 'tail', offset: 28, size: 4, signed: true }, @@ -475,7 +572,20 @@ describe.each(BE_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) = volatile: false, const: false, }); - expect(di.types.variableShape('g_ptr')).toEqual({ kind: 'pointer', 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, @@ -517,6 +627,16 @@ describe.each(BE_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) = 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 }); @@ -563,11 +683,15 @@ describe('cross-endian equivalence (same declarations, four toolchains, both byt '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; @@ -613,11 +737,15 @@ describe('cross-endian equivalence (same declarations, four toolchains, both byt 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'], }); }); @@ -815,6 +943,13 @@ describe('DebugInfo on ppc-min/build/main.o (RELA-relocated DWARF)', () => { 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(); diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index d349d93..9e50ddd 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -134,6 +134,26 @@ export interface StructMember { 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; } export interface StructType { @@ -153,11 +173,17 @@ export interface StructType { * 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. + * 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'; volatile: boolean; const: boolean } + | { kind: 'pointer'; pointee: PointeeStruct | null; volatile: boolean; const: boolean } | { kind: 'array'; elemSize: number | null; @@ -168,10 +194,37 @@ export type VariableShape = } | { 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 and volatility are declaration facts, not locations — they stay on - * {@link StructMember} / `struct()`.) */ -export type MemberLocation = Omit; + * pointer-ness, volatility 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' | 'elemSize' | 'elemSigned' | 'length' +>; /** A parsed DIE: its tag plus the attributes we kept, and its child DIEs. */ interface Die { @@ -355,13 +408,14 @@ export class TypeIndex { return null; } const cv = { volatile: false, const: false }; - const die = this.#stripTypedefs(variable.attrs.get(DW_AT_type), cv); + 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', ...cv }; + 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. @@ -375,15 +429,8 @@ export class TypeIndex { }; } case DW_TAG_structure_type: - case DW_TAG_union_type: { - const structName = die.attrs.get(DW_AT_name); - return { - kind: 'struct', - structName: typeof structName === 'string' ? structName : null, - size: numberAttr(die, DW_AT_byte_size), - ...cv, - }; - } + case DW_TAG_union_type: + return { kind: 'struct', ...this.#structTarget(die, alias), ...cv }; default: return { kind: 'scalar', @@ -394,6 +441,40 @@ export class TypeIndex { } } + /** + * 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('.'); @@ -583,23 +664,49 @@ export class TypeIndex { } } - /** A member's declaration facts: base-type signedness, pointer-ness, and volatility — - * resolved through typedef/cv-qualifier chains (see the {@link StructMember} field docs). */ - #memberFacts(member: Die): Pick { + /** A member's declaration facts: base-type signedness, pointer-ness, volatility, 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 { 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 } : {}), ...(cv.volatile ? { volatile: true as const } : {}), + ...(die?.tag === DW_TAG_array_type ? this.#arrayFacts(die) : {}), + }; + } + + /** 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. */ - #stripTypedefs(ref: AttrValue | undefined, cv?: { volatile: boolean; const: boolean }): Die | null { + * 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)) { @@ -609,6 +716,12 @@ export class TypeIndex { 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)); } 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 b701cbd46d06b902eb4fff5307db9f4fae5fb807..a9b7bc07270ac8d2a398410683a1321319c18582 100755 GIT binary patch delta 2333 zcmZ8iYitx%6h3$E%u-QB)vce0B<(LwwgsCAu$3f=9nXDb1YZ#m3tz` zftlYRh)j*m6ydz>FSC3Vq@#KZs3E`O8i6&h$6fR`MP+vY2%- zk(%`(*eaGohIJR%YL@ltk~R-%x+^;pg8Tj0TP!Guqn>5hMeHSXTY>M#ZiX53}D)GB_N~F{bSdt z`X!Eg4{;BL88+lm>`9~nY)O3T1B7M?aZd3o(1#O0_~>H0V+CZZFC}RfX%``HD_`#PpAe&ys0JI1Z~{YK;lkSxwA=2G>Z(_> z>v8m{zGvrNda@j)okE6Ul@FI2=^1#W`G9eGAWw&v12E(@q{_6xS=0TlJqk3-wUKiT zKzqbovhj>82L?M1;UKK`bO<*gQ0@i?mvSut z2K+qE&tM1Yc%d9X%Lh4aOkIgLiUf6!Z#=8=t+Y_)>Eg*KXSJU&N1fjag z*TZ?R*>H^1Oh!xgj83MrS$AfMrKw;6yE)bExgusRTb10O;{0wmX~~f!Hf4~s%4Hx< zt39c0vyZw+pu- z^epV0kL^R?{0@WsBhYVGRl)+V0PqJGIB(+!IFA8zmao4F&eJp4=5gp#3w$3sw&Xt# zUhpJzo-4tEJb-R5i2P@u^P6LPi5L6|0uK}u+{NTN2YzMBfw zoWq168pEI~a#$A$M8j$5H$ptMCd;67Bq!`l8-T@ZaUs y+{a)^vBl-4!r2ysm{aOTLv#2t>>mM+0nVtojcIc12* delta 2070 zcmZ8iZD?Cn7=F(^_aG`a{95mb2l+2qJ?0F$S|b2X_7#f}hMEwOEyb!oaC8b#5wJn9qBgOM@5kemw8z zIqx|qCw9KDV>a!a$P$r8Mmwd!n#rNa+#OB!JkWWI0{nwWLubd^tg2OL0&WtBnzy!# zP3l*xwkD?&86-f7BHe;0C3~wSeW%iP)}DuM8V|x`NY3V{-FA!l4$#U&42IRSb~ac6 zJz+8s&|XpJ?1sWeKSIqseu2Ph7X4zJZE~}3TunvN1y{Q>^gKY{m#9I$ zRW3?ZI*`sO_FV$$uG$7_$gg;d;_%q&;}AnG12BGq7}$0T)PDuG2e^NY{LHp9k(&Pu zSTD57evw~K?YNO z3{%holfrin7%JTb7Q7dbSb;PY-PL~HlngiyrK5)W(Bpu5EF zRAIsIGk_IQ2=}m12Z3WK{TIg<(#-(I+*Dycy2t9dRvIARDN*FKw%QWgC~P$x%UMx0 zDcA3hsb*tY?N6cJjb&^2!WevjM9A($v?2L2`jYBeOzjJj40UBhx7nEq^*cfHNfR}M z=y!yJw)*I}C_t#pQ9qcO0c(RtRrPw9WJ|EabqKAeGZ_x6pQ}@qRzGPqPt|b8c0Ae| zu|-qJF=vH2CwwxL2o1(I+N&D*AsK< zxnxcs%TMPQ7Lz?f{hR8ElD+oUNL+32Qv(}LsT&*inBGH#$*a-&jsmZK%}J*i1U@AU z*f>x;J@)4ZiS<0Vm)wG$gUZ(uyXo5by1j@U!$c{9tKAOH4@nxFEKqxd zc+_Ok@3#iQGo`U|g1yjF(76HDN5IP)n1Jd&6fUgOG&o;3%-=0|44fY%Z$VE(FK^%_ z)JNL2bIJu29QXozD?SCiyx<#9`S5%8x1kpi?{nQWS^&RVR`3JpZ$byd0yVnGLTNm_ z10O#Go!=j?!%OT?nuvw@EkkPoFb2*IehXfn@JHx8!cs#@WqQ;%nY7H0jVU|(;lgsZ zRbW|X8=P1NT)7PIHg5ucG^BPlI>D|o3#mhm3Hv@EJk`zk@%NT`y)ho!0f|xXTSSIF z^+S`B9|lxz(QTq7?y2;7w#T7+GMm1}kvq-PDt(@f2kFUddPem$#bY!7LkSPXjJr0R zI@jOpcLtVPHP__Cc!K4bUd5q)jeBN0Aahd*{`3e5d>;{0u5f0fKx!K2Vd cV8-&OPIcxI#!8y#57$1Cvy9I(M4zzsAHtFSJOBUy 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 e6509c0..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,28 +1,31 @@ { "symbols": { "add": 134217756, - "__bss_end__": 134222080, - "_bss_end__": 134222080, + "__bss_end__": 134222088, + "_bss_end__": 134222088, "__bss_start": 134221992, "__bss_start__": 134221992, "bump": 134217768, - "__data_start": 134221986, - "_edata": 134221986, - "__end__": 134222080, - "_end": 134222080, + "__data_start": 134221988, + "_edata": 134221992, + "__end__": 134222088, + "_end": 134222088, "gAbsGlobal": 50336308, "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": 134222076, + "g_util_pair": 134222084, "g_wide": 134222064, "main": 134217728, "square": 134217760, 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 369850b..b6873f9 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,19 +75,19 @@ 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 +// 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 diff --git a/packages/debug-info/test-projects/mips-min/build/min.elf b/packages/debug-info/test-projects/mips-min/build/min.elf index 95737f56997d57a50d44534579f375818b331a62..45b5a4c926fc31f3c0b9fae5eea5477789835ef9 100755 GIT binary patch delta 2160 zcmZuxZETZO6h8O9Z@<=$wOv^^nA*WU$`GjuT9i6kP(V?`bR#MzqZ`;Y4roS2)S1FU zj3y%t-k}5*GmuCi6@E<6ge^|9gejX&`QZ;pAOTGzG5SM(7&Y@bw|!YgILW!^^PYRo zbMO03HC$cPH{b8C%8=JaODRNF3R49I4=mkbsUo7xf&4paiP9PNIlNvBW4)iuA${`~8t*X)%O zCywJ33nlX3kRG!_9+$&HhO4_}Ae0}Iya-Ntno|Uzhgd89m zdQJL8PV@0Rpty`<<-v-ixyd3_&;!qJ_hGpR?gG2OiUihcaZ69;i<$0{0K~`LDazJgdS7W`Y@1pr^MoE8*7C510baK!#qGO>tBJrf2O{S>6 zj8&2dTf-_{XZ`3xwZYE%_YIu0&N!JDChVy-zz=|r%*Tr`V^DF`#mxd*Kas)kCoQIl8InVa0anZ$FdC>oJJ8!N2A-zIh~D8%|7&t z?I&sYoaIB8O8ml~YRx9wI=4`&HPf}dZA)t=Yg}=L4!X|kE7RE>#)#`*4cHpP?hE{W z3xr+*M%ELp5YsGe{lH*^Mzzy#pXqNLhb*qB#bJfF3`-Ccv;Bav83OzvK;k ze~@Y~ny)7u_ur#G_cPKef2 z2Iv}<-n|YJ;x6Ne_ie-DyKLO>P3EwVA29k+J$boqkxcy+*a8OU?M5DW=4S_;^BBlh zFaxAM2;?zH>%k_O^)|!7A5a9$=c~j;P)y<=^VXs)`sJ5U28#v@K9s2|Lv(*Z#C@|9Z3#5lc#piJv-2F7Mlf0?@eoWWXXlk%g|Ayhcz%#M z$>+tyNWtyIB;tHQS1T7}{%UqXH~&YAd+y}1^21$uJj7g;QCLL0Xk6PqJ$-v7;ARND zGxi+_|A5b&3aY!q7z@-d?f^c2!Yl_@f_WU?&3`w1uO@l9>P4es&ir!U15p1n1CXld E|I339*8l(j delta 1906 zcmZ8hTTEO<82)F@*$aDPfrSOZ3Us*?QeBg^TP%`Y8fdGfDG`jVjV@S%MWjGUZH2hB zlKO%QPOCLG^dj+=6-clV1ubdRq-h$7Z*7|BgEi5rVoD6L zkr8knDYfLEWD$DP4bQdv!Dd1IAU+Ab1o%>(1w3p)6}jLsE&F-~aBA@ICiz)y(VKIb zoGokv>jQP!!Qx#8878~(4as@RICmdH#j$*L@7AF+oU6OQ174>c3En#dn&vEBQnz%P zQ5Ooi-D#jAGL`|g)m{RA!#qT#Y=+4^OlzIc`b^5GfjnDWj&cp=KwBqOyhvTn1m|DDHJH(lJpM|H{0NOf5p9WUveBi_vi$rOz! zDQ+_8vn^RrZ|PAsY{l(~eri6$M2yz-)>jhgsQ z!j5)o;s+L{Lii_;U<{UBoPzZW4p>f$78Y}&m5Lx=1L9;7!S3Ut1qRV*@?DB49+Tgt zcrzDT|Gg(H+y=ZS)OBm6XF|Amm~uVWqRO^isV=8&xI%Z9DD&hv4Nsw`)t@E2ej-nR zoX$F@fkVv9gt{$f)@lDy&i>>m7plOu#;o5ra6oNvePQc-ZlJBbyF=Y}-9;%1`AWzVe07==}=t&N%C_%JNOnejC=W7M6H8}Z-ZujDDj4nyi# z$Y=O9Ls}8dC)#dF?FV-b^`aYTFmjsyHRjW z$^~I;1!GDtj^s7?Kin9wd{^m}#=X88XX8i8-(`BGaVL^EN7|pUOT~Qt_}SIrj?b~L zFD-@Ri{~|!!UdD26H a second compilation unit */ int g_counter; /* scalar global (.bss) */ -int *g_ptr; /* pointer global */ +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) */ @@ -44,8 +44,8 @@ struct Probe { struct Inner inner; int tail; }; - -struct Probe g_probe; +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: diff --git a/packages/debug-info/test-projects/ppc-min/build/main.o b/packages/debug-info/test-projects/ppc-min/build/main.o index 8595354b12964c6857b82c20cf3be6e8a3ce8079..44febdcc6e8a8747d57d50261a4a0db0ba335547 100644 GIT binary patch delta 2195 zcmZuxZD^ZS6h8O9Z`yn(Z<91#(k|`Vt;1DK=b)3pwpOxQ>)e=C=2U8#2=eXE*(oWlxYKhsfz7pKzIo^3g2tqT+;FObtrt;t&EkZJHb%pWqR$DUm}ODU}jIyEmtOK zLl8tUuKAuO;4jsLT(xx=8AV-&4-J4_&8J?fRiKG{3@JxfPPtU8>sqj1s}pi?{K;A@ ze%(XliXG6Bje3_s4={|T!me)v&PZ;aSabw84&C8yFeMXur{N^8t*K!xQj^@j0@xku zHYX=$)wI)^%)@`8S==moDa~1MXVvRYr#PifJDJ^!!7zlR*c{^keo9l@;K*h)#tvvIYyxlcL?nauOt46_^3A_)9((Tf~Y6f zMiU_?nvLF)_Cy?1tcyaSts%$15b@19O{LMwSaD*UN~7h;L&fpYaz(x2Z&%;?``v*9 zl?PNduyJ8mz%N8l-5bpHbMecD*M$pMG5ESSmVu9YRt$bheICqtEh`+(OVq5+2M2|# z@}VOE7ZQBw`_+8tJ{R>&E_mT)!QIXY)gI}NbOScyY`7S4ry7ZD5!=*Eq`P{!#?dt5 zM@^jTogr$`mk9iv$@w1mIMFK0e`xZr0sk4~E&r9t{|G$(oqA8i1d3)%`+(5{##Oou z2iK{St^$UbPWk|No8k8Y-(>iC;JXdK8~6|(5uz|rF?Q*bV+LIUV3}Agzf_r8y-RCQbKgKXALr zIbDU*LzFQ>veyI_!4DdYP30cKM~t+|38Ell2+Fd-Vc-W%4jzg;W-vCA&lsJbfM3Ai zwamW)UVr#Lh4ZTkASVAYqFaFfHMkFN4yVzJ9hcdUL?_dT+tc z;K?nU4ZZ}1i`ZU^#Gl2lzA*gq*U1+69@C^dWH)VMQ=4cSNlG!P^^%QU)6{q=t+ncd*-hFiNKKnn zQjDz7`XZt&16Dz-SSb`l3V|w8F{qR(=EXh;Qv2Xts1T|}F(`ijoSE(ZFf-r(&3~Kg zpX-0@JKCK(LM$6q-%A}-IJWUxT98FHk)0q{I6M`|yTq`F%Q?}}pJ1lcgl>*o6kWef zw&enl&y--)#uiV=MNz2LfX*sAOBtL}28DoW7kP+0?xHuKyW-5z8dgi>9HMR&&k5(d z))9#br&``eJuM7TC8SqbI||>V?4t_b%r-k>&P=9ykosbgu#4kd1{J8CEOx4LzD?e zs{n(8S+PS5QchFz@>9FJ<8*94ZBK$KQNBDqP2xTk8r4`!QaHKc(>hXACArp&iH|ge zOF`CWVkhy^Ub$|^HYenR&WWLQG>(FsT^ek$avzK$b&wJ_##?+lUWh-GONk_?_*(M) zJAE7bmPi1Ow=DFyCvAB-y4o3ivN|hQqN7dw9!{i3s_!RU7+(O)&)BD`?^5S%1g0Mk&(^*m>{{+KP-Ro_bxor92asn=n8oEv#1!` zBkq@P2b;x!ycG1*o(nlTg!vcs`~l!&xb5m-fnU=$-vGZu)Nc55+P)L~1%x;JlD1z2 z|GT!~ym(}spB@Fq@)*}>1qx0>6_y9avsFeoDlww@ao`6u{~qwP=8M2HM5{R{L_DMU zKHwKLz76^CT9v;!`-_*feq2L6I5XA*p?ZSc&+&)ViC;D0o~ z2lofID&}5bJY9vcBNpyUUdOTYg6VsFBi06O^fmAugzt&h0{k#hj4_VQdQ4-!pp)9B z4R}`LF5u^NobP~-ps_FWuWJ50Fy~+%V*P}e{j6+;f*nxFhJdjeWrQ=bJtZFIhw{x`={d$KNf>E;L;ZtKt^8 zsDFYrh?(0C%swM%kYFt;{2ZVi4Hc?6uAqVjAO4(`I7R5Vrgbp^aZ~ES0o{Mm@wGQ% z>~Omp6DNL!j^rvaC*-1d#3g8mSEI;&5WH$5CLrEeW56Ukn}GZUn1F8q8y~<3-XP`C zjF`DMK(Nn9IFTH{*v9MUt-%4-11~*OXbiX&x;j{l{0Er=KLKVR zP6%l_36(z)mQ~#GB)2d1s13aiIg`nyE@~D7i?>ZKd$|;EFZ)=`ft`?xnOyM)z{Zj$ rp>s4|B3@O!qy|s0Q_-KGHbiS z30jh5_Nts1Nn0qINRs$ne>CF7Opz_LGsv)+NJJsS&;xAXpXuzw`hjWjXGOsMSc469 z&^D1jU8la0O{J8dEzFo>aj+aT$uyXFnN!ViAT=_hr~_j#(9Z{W-{-VJ`(6k9LPs0A zf>jRdXsTtiW?B28t~&{=JJuy?V>n0&>oO&|ggJ}Y@<+S_aYNQ@zDS*6R@Fi%Qak4u z&=ZfaTexkXO>8|p7z(F7?6E|MjVBcZX6w{%a0pauv!z4r_nZ$u5Mfn(bg_|Q$$ z;9k81-3Vhl`eDfBSe-3^SlSk@9s{h$>wyOe$D*G^URyD1AnH-;^>&x%=EuizoJzBZ z8sG^$1WF@in}Trj+VB4c{;`YAM_RI6l%amP2k)Gw-3k> zAK{Mpz%YIUdE6h_BxiWEdgUi>lVb zVKJjN!s+0=(C2 z`7PEUE6;ES7@Z_%Zvs(MDr4t^vwNrGf zTx+LY!oB!jz1*6#@s^C|)pBc6e5h{0vxy2-qz6E4Rr%j{pl9|2RYNvGXMYp delta 1290 zcmX|AUufG^6h8O&C;cr+lbb&#X_F?uHEA0)omPiiW_0Un)NOS**1{eH(>67mlrh#& zGMbU9;8ai=uA&uTeNi0B5GvakMJoyeu_(Umah-3DA~GiHllYxGx4=C)-#Ono`R+M4 zTf}bx#{(Ae}YI|$8l`B;wJ+n_z zX+u)+VG_lIvaozj`p4bi>h38?Z+I!%oH8z!mL(z9R5!`L`*WFS=Vb0|pSh!0mASpB zJ<{Jdch)%=EuNG9(gON{-`*>u?E+a#Iq7_Zp6zMrF1}7y+e@jpxX*TVOLU6-%9d~f z%97Qghy}q!lEmNoOCvUBkZhrooeX=31nwq=)sjrNvN0R6vTaI|xqxM#I9VsYd0D`W z#o3+INjJzX${jEbJ`T*FNv6T|XM7w&i8z*E#&KPe;2zhz02mZo*45!i{FMu!??c`ZpCR^-ALc3`-)2_|;72jW z@5T54yqE9sm~$TPF`O|RqUC$fORSoV_<2=uhSd*|w5X|#NIq~%_*wo=eUrS3MStGa zPY19fzfD6CBYt^*oTxJ-`T`N38At}k;~^mt3gpP=d)8-Sgux)(1Cf=)Lu;nmOr)*- zC+f3mTh~(^wO8vClQYw5r0b^Y?H&*D`Up~Zr1&?;f_kU>hRCVo$&7%Xd`vj%N;1=8 zo&{OVtAWRK4=|5t7C3=#o;v5}f&cHP^KSy5t{(Jz9~gsrQ2!z3R{>tc z^W*g|NeW9B&V2#rQIJ=S)W<8yo}Yy9sEu^ShC=C4Rwio~v{%RsATtL0q-tgc?0MYt zvicJFC(!n=`Yn?c3+fIs-(dkw<+@q1M?L2bh+ox=+wbYx1g)YzakDmy|4Oa7y|L6? MxYuy*fB5Cme}!tqCIA2c 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 index 842a434..cabcc37 100644 --- a/packages/debug-info/test-projects/ppc-min/build/oracle-obj.json +++ b/packages/debug-info/test-projects/ppc-min/build/oracle-obj.json @@ -6,8 +6,9 @@ "g_counter": 28, "g_cv": 0, "g_probe": 0, + "g_probe_ptr": 0, "g_ptr": 24, - "g_rom_table": 0, + "g_rom_table": 4, "g_table": 16, "g_vol": 12, "main": 0, diff --git a/packages/debug-info/test-projects/ppc-min/build/oracle.json b/packages/debug-info/test-projects/ppc-min/build/oracle.json index 4de04c5..c638700 100644 --- a/packages/debug-info/test-projects/ppc-min/build/oracle.json +++ b/packages/debug-info/test-projects/ppc-min/build/oracle.json @@ -2,20 +2,21 @@ "symbols": { "_SDA_BASE_": 268472320, "__GNU_EH_FRAME_HDR": 268435968, - "__bss_start": 268439560, - "_edata": 268439558, - "_end": 268439628, + "__bss_start": 268439564, + "_edata": 268439562, + "_end": 268439632, "add": 268435776, "bump": 268435808, - "g_bits": 268439564, - "g_counter": 268439588, - "g_cv": 268439560, - "g_probe": 268439596, - "g_ptr": 268439584, - "g_rom_table": 268439552, - "g_table": 268439576, - "g_util_pair": 268439592, - "g_vol": 268439572, + "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 diff --git a/packages/debug-info/test-projects/ppc-min/main.c b/packages/debug-info/test-projects/ppc-min/main.c index 173462f..6db4311 100644 --- a/packages/debug-info/test-projects/ppc-min/main.c +++ b/packages/debug-info/test-projects/ppc-min/main.c @@ -20,7 +20,7 @@ int triple(int n); /* defined in util.c -> a second compilation unit */ int g_counter; /* scalar global (.bss) */ -int *g_ptr; /* pointer global */ +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) */ @@ -44,8 +44,8 @@ struct Probe { struct Inner inner; int tail; }; - -struct Probe g_probe; +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: From 0fa4a12a66b43663556ee877ab980aad34cbf87c Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 00:24:50 +0100 Subject: [PATCH 07/15] `@gba-kit/debug-info`: a member's const, and what a pointer member points at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two facts that complete `StructMember`'s declaration set. `#memberFacts` already accumulated `{ volatile, const }` while stripping a member's type chain and reported only the first, so a `const`-qualified member was indistinguishable from a plain one. It now reports `const?: true` on `StructMember` alongside `volatile?: true`, populated the same way — present is the fact, absent is its absence. The two are the same class of fact for the same reason: a cv-qualifier moves no field, so nothing about a member's offset or size carries it, and a consumer re-spelling the declaration cannot reproduce what it cannot see. They are not decoration either — a write through a const member is a constraint violation, not another spelling of the same access — so `MemberLocation` still omits both: a location is where to read, not what may be done there. A pointer member reported only `pointer: true`, so a consumer knew the cell is four bytes but not what it addresses. That is not decoration: pointer arithmetic scales by the pointee width, so `p - 4` through a `u16 *` and through a `void *` reach different memory, and a consumer declaring the member had to guess. `pointeeSize`/`pointeeSigned` are reported when the target resolves to a base type, and omitted otherwise (`void *`, `struct S *`, function pointers) — a present key is a fact, never a default. `devkitarm-min`'s `struct Shape` declares its tag field `const int kind;`; const changes no layout, so the existing offsets/sizes and the file's line count are untouched, and `build/min.elf` and its oracle are rebuilt. All four test projects agree that `int *ptr` is a 4-byte signed target. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/real-projects.spec.ts | 19 +++++- packages/debug-info/src/types.ts | 54 +++++++++++++++--- .../test-projects/devkitarm-min/build/min.elf | Bin 9876 -> 9880 bytes .../test-projects/devkitarm-min/source/main.c | 8 +-- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/debug-info/src/__tests__/real-projects.spec.ts b/packages/debug-info/src/__tests__/real-projects.spec.ts index 0f009a6..082c783 100644 --- a/packages/debug-info/src/__tests__/real-projects.spec.ts +++ b/packages/debug-info/src/__tests__/real-projects.spec.ts @@ -137,7 +137,8 @@ describe.each(ARM_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) // 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 }, - { name: 'ptr', offset: 16, size: 4, signed: null, pointer: true }, // pointer → 4 bytes + // 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 }, ], @@ -357,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 }); }); @@ -503,7 +518,7 @@ describe.each(BE_PROJECTS)('DebugInfo vs binutils oracle on $label', (project) = { 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 }, + { 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 }, ], diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index 9e50ddd..ed2c52c 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -119,12 +119,31 @@ export interface StructMember { * (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. @@ -219,11 +238,11 @@ export interface PointeeStruct { } /** A member's read location: its byte offset + size, plus bitfield shift/width. (Signedness, - * pointer-ness, volatility and the array element facts are declaration facts, not locations — + * 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' | 'elemSize' | 'elemSigned' | 'length' + 'name' | 'signed' | 'pointer' | 'volatile' | 'const' | 'elemSize' | 'elemSigned' | 'length' >; /** A parsed DIE: its tag plus the attributes we kept, and its child DIEs. */ @@ -664,22 +683,43 @@ export class TypeIndex { } } - /** A member's declaration facts: base-type signedness, pointer-ness, volatility, and — for an - * array member — its element stride/signedness/count, all resolved through typedef/cv-qualifier - * chains (see the {@link StructMember} field docs). */ + /** 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 { + ): 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 } : {}), + ...(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. */ 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 a9b7bc07270ac8d2a398410683a1321319c18582..05fe577c8d7d8db3621f5ab3f4a6d1e5b893e0f5 100755 GIT binary patch delta 963 zcmXAnTS$~a6vxlZe6#!Q{oBiD?lo__!pcSkg`x9BO(KpynY42ae7Vm^+B0Ji##P|$ME1lvL31+^~ZrxT} z18X6Y+Fk6OOQ?eLr^I`>EQ7j zDs)e_0D8dw>d=4a=GJrgzChvz>y#vjLa%T@S3o~NCk*rWGjQ-(ghxVc z8YkvZYfzE-v33V_11T2u;5En|_tiPzH-IqZ4c%t|V)UK>H+qis(#hrj$Dg5RpVz_5 zzu_z#N&Oq}6p(>HgVX8{TBT}W=7!WW-UUrJsXMVa7lxXQB@1;1(E@0gwemq|8(Twu z3&Yz;_+tnzBkNBfK!vdLtZrhJ-Oo}vYK-D5GS(Pn2z=|Dy?-Z-u;Rq?lB;5(IdR|1 zGHR)+lI4`0t!5w9mTa|bMX&Q}yfUiJWi2+Xu7J7IM7PxUY*bBw-BYt*lVEv=rE(Tq ZRE1o%94p79Y8!k#IZHmOAbO`A{{d5tjD`RJ delta 880 zcmX9+T}TvB82!Gvb9ZKEf81SXTu0YUOIJ~XEDCEQsf1D#wZbxpvM4dc%+|06m7sd9 zxIXk?;Y0RPFCiomLG=&>_6HS02q{9nMNkkF(YfQxx#ynmobP^f@7(H`>bOzKg@;wI z6FjWGCwv}M;bd2{d__cd@p+&*MsBzbgv?9iMFNS0Azw04RPUq*R$02F9fiJr@|`7h zJDDEPHQTs{V#Zkocg*3!2vs;g!4pOwFq`TIy2c_r>w+^fB-nlmF|6BZXKXV-lBFl$ zcQCGxk84FdNRaK>oD0{fid1%ogA!K*>O*xvDHc%YMOYiGLxFG?f~9LEO#q80fV?R* zp+-}c?ZIYSd;oX6ZSc_W9#XSnS-Z?=-gV$m`8_UCy>|PeiV*!{r0$cz=hZz2vYn; zr%VIPhAH(#(`v@e8vlfP=9X{Fn?x(HCArHe0K2)7A_+{HKq2CSTthWd27-j`)pLGs zi#Ng<=GI~^#)ogERxFYIG|rPC(HynTJukT~<~qw}9ro3)>J40_{F+*Rqjm$^)c|lo e-2@8t7s Date: Sun, 2 Aug 2026 00:25:00 +0100 Subject: [PATCH 08/15] `@gba-kit/debug-info`: changeset for the declaration-shape API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch's whole surface so far — `variableShape` and its `pointee`, the declaration facts `struct()` members grew, big-endian ELF/DWARF and RELA-relocated `.debug_*`, and the `.debug_line` terminator walk — has been unreleased and unrecorded. One minor changeset covers it. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/declaration-shapes.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/declaration-shapes.md diff --git a/.changeset/declaration-shapes.md b/.changeset/declaration-shapes.md new file mode 100644 index 0000000..d34c5bf --- /dev/null +++ b/.changeset/declaration-shapes.md @@ -0,0 +1,18 @@ +--- +'@gba-kit/debug-info': minor +--- + +Read what a global is DECLARED as, not just where its fields sit — and parse big-endian ELFs. + +`TypeIndex` gains: + +- `variableShape(varName)` — classify a global/static's declaration as `scalar` | `pointer` | `array` | `struct`, resolved through typedefs and cv-qualifiers, carrying the `volatile`/`const` crossed on the way (`volatile` says accesses are observable, `const` the ROM-table spelling). `null` when the name has no DIE, which also makes it the "is this name declared in the project headers?" probe. +- `pointee` on the `pointer` shape — what the pointer points AT, when its target resolves to a struct or union: the name `struct()` looks that layout up by, the target's byte size, and the target's own `volatile`/`const`. The target's qualifiers are the ones written to the left of the `*` (`volatile struct S *g`) and stay separate from the pointer variable's own, to the right of it (`struct S *volatile g`). + +A struct or union is named by its tag, or — for the `typedef struct {…} T;` idiom, where the struct itself is unnamed and the alias is the only name its layout has — by the last typedef crossed. That holds on the `struct` shape as well as on `pointee`, so either name goes straight back into `struct()`. A target that is only forward-declared carries no size of its own, so it is sized from the definition its tag resolves to. + +`struct()` members now also carry the declaration facts an offset and a size cannot: base-type `signed`ness, `pointer`, the `volatile`/`const` its type chain crosses, and — for an array member — `elemSize`, `elemSigned` and `length`. A cv-qualifier moves no field, so it is only ever visible as a declaration fact, and it is not interchangeable with its absence: repeated accesses to a `volatile` member are observable, and a write through a `const` one is a constraint violation. `size` is the WHOLE member (`char name[6]` → 6), so the element stride is what an indexed read into it needs, and the count is what bounds it; each key is absent when the DWARF does not determine it (a flexible array member declares a stride but no length). + +`ElfFile` and the DWARF parsers read big-endian (`ELFDATA2MSB`) containers and payloads, including bitfields — a big-endian target allocates them from the most significant end of the storage unit, so the same C declaration yields mirrored shifts. RELA relocations are applied to `.debug_*` sections, so the DWARF in a relocatable `.o` resolves too. + +`.debug_line` units are walked by their own `DW_LNE_end_sequence` terminators rather than by `unit_length` alone. agbcc (GCC 2.95) sizes a unit by predicting the encoded length of each statement and mispredicts, so `unit_length` can stop short of the program it describes; stopping there leaves the next unit's header to be read as line-program bytes and loses every row after it. From 62a517cf35bfb4b6bb922eb08ab0b1d8c215a365 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sat, 1 Aug 2026 03:14:46 +0100 Subject: [PATCH 09/15] `@gba-kit/debug-info`: read a compiled function's declared signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `functionSignature(name)` returns what a function returns and the type of each parameter, from the subprogram DIEs a compiler emits for code it compiled from source. Same width/signedness/pointer vocabulary a struct member uses, so a parameter and a field of the same declared type describe identically. Only DEFINITIONS are indexed — `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 means "this ELF did not compile it", never "it takes no arguments". `prototyped` reports the declaration style; the parameter list is authoritative regardless, since a definition records what it was compiled to take. Verified against pokeemerald's agbcc-emitted DWARF-2: 15,678 of 15,858 functions, with signatures matching the sources exactly (`s16 Sin2(u16)`, `u16 CalcCRC16(const void *, s32)`, `u8 *StringCopyN(u8 *, const u8 *, u8)`). Co-Authored-By: Claude Opus 5 (1M context) --- packages/debug-info/src/index.ts | 9 +++- packages/debug-info/src/types.ts | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/debug-info/src/index.ts b/packages/debug-info/src/index.ts index 7315a65..01a8bdf 100644 --- a/packages/debug-info/src/index.ts +++ b/packages/debug-info/src/index.ts @@ -9,4 +9,11 @@ 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 { + TypeIndex, + type StructType, + type StructMember, + type MemberLocation, + type FunctionSignature, + type TypeFacts, +} from './types.js'; diff --git a/packages/debug-info/src/types.ts b/packages/debug-info/src/types.ts index ed2c52c..7a18035 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -28,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; @@ -43,7 +45,9 @@ 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_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; @@ -175,6 +179,31 @@ export interface StructMember { 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 { /** The looked-up name (the struct tag, or the typedef alias that was queried). */ name: string; @@ -308,6 +337,7 @@ 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; @@ -343,6 +373,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 @@ -421,6 +458,56 @@ export class TypeIndex { * 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; + } + const params = fn.children + .filter((c) => c.tag === DW_TAG_formal_parameter) + .map((c) => { + const name = c.attrs.get(DW_AT_name); + return { + name: typeof name === 'string' ? name : null, + ...this.#typeFacts(c.attrs.get(DW_AT_type)), + }; + }); + return { + name: fnName, + returns: fn.attrs.has(DW_AT_type) ? this.#typeFacts(fn.attrs.get(DW_AT_type)) : null, + params, + prototyped: fn.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) { From fbe149a56ef98f9087087bc8464bad762268e315 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sat, 1 Aug 2026 13:23:49 +0100 Subject: [PATCH 10/15] `@gba-kit/debug-info`: read the preprocessor macro table (.debug_macinfo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)`. Parses the DWARF 2/3 form, which is a flat opcode stream carrying its strings INLINE. The DWARF 5 replacement (.debug_macro) is deliberately not read: it splits a 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. A truncated or malformed stream stops rather than throwing: a partial list is still sound (every entry in it was really read), and these sections get grafted between tools often enough that hard-failing would be the wrong default for purely additive data. Verified on two real sidecars: 1,380 definitions (41 address casts) and 1,941 (1 address cast). Co-Authored-By: Claude Opus 5 (1M context) --- packages/debug-info/src/debug-info.ts | 14 +++- packages/debug-info/src/debug-macro.ts | 92 ++++++++++++++++++++++++++ packages/debug-info/src/index.ts | 1 + 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/debug-info/src/debug-macro.ts diff --git a/packages/debug-info/src/debug-info.ts b/packages/debug-info/src/debug-info.ts index f3a1af9..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. */ @@ -43,7 +47,8 @@ export class DebugInfo { const debugLine = elf.sectionData('.debug_line'); 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-macro.ts b/packages/debug-info/src/debug-macro.ts new file mode 100644 index 0000000..2792266 --- /dev/null +++ b/packages/debug-info/src/debug-macro.ts @@ -0,0 +1,92 @@ +/** + * 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(); + 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 + 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/index.ts b/packages/debug-info/src/index.ts index 01a8bdf..5ce89c6 100644 --- a/packages/debug-info/src/index.ts +++ b/packages/debug-info/src/index.ts @@ -9,6 +9,7 @@ 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 { parseDebugMacinfo, type MacroDefinition } from './debug-macro.js'; export { TypeIndex, type StructType, From be34b4523ff0b44fbda19de6185f7e242a3e9f29 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sat, 1 Aug 2026 15:56:22 +0100 Subject: [PATCH 11/15] test-projects: build agbcc-min with the fixed agbcc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agbcc submodule moves to a0f70c9, which fixes two bugs in agbcc's own DWARF output. The relevant one here is the missing `.debug_abbrev` table terminator: every standard tool refused the section outright, so the ELF this project builds to exercise DWARF-2 parsing was one no other reader could load. `readelf --debug-dump=abbrev build/min.elf` now reports zero errors where it previously errored. The parser needed no change — it already bounds each table walk by the next unit's offset rather than trusting the terminator, which is what let it read agbcc output at all — so all 196 tests pass unchanged. The committed artifact is rebuilt with it (4 bytes larger, one terminator per unit); build/oracle.json is byte-identical, which is the evidence that the compiler fix moves no code. Co-Authored-By: Claude Opus 5 (1M context) --- .../debug-info/test-projects/agbcc-min/agbcc | 2 +- .../test-projects/agbcc-min/build/min.elf | Bin 8076 -> 8080 bytes 2 files changed, 1 insertion(+), 1 deletion(-) 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 3032d83e4c75d6be1b63ce1c77b30ac59f8e6d27..874e64f3ec0b416769558e2aa6d86e1137a6eab0 100755 GIT binary patch delta 91 zcmeCNpI|>hfpNn|MS0=L_QGYGw+QRAFp6$|BCgJ~nNzBsg|T~bqMY#LIdTGw+b6G; tQ)hiH!N72SG9ys=8=%;>$$#b48GlSxl~-r%nCvUB&T1vgz)%d52LL^98>RpN delta 94 zcmbPW-(x>PfpN`7MS0=Lj>4szw+rjBFp6w`Ca%u3nO~}wg|TyTubeXD*2x>?)ES>n jz6m6^OlFi Date: Sun, 2 Aug 2026 11:59:32 +0100 Subject: [PATCH 12/15] `@gba-kit/debug-info`: test .debug_macinfo on a committed artifact, and fix the truncation it caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macro parser shipped with zero in-package tests. devkitarm-min now vendors build/macinfo.o — its main.c compiled with -gdwarf-2 -g3 -gstrict-dwarf, the exact macro-sidecar recipe, relocatable on purpose because the graft source in a real project is a .o. Fixture #defines live at the END of main.c (existing pinned line numbers don't move; the spec asserts the fixtures by exact line): two address-cast RAM-cell macros, a plain constant, a function-like macro, and a body-less one. readelf --debug-dump=macro agrees on all 417 defines. min.elf and oracle.json rebuilt via build.sh, byte-identical — the appended defines are unused, and the plain -g build records no macro info, which is itself a pinned test (the -g3 requirement). The truncation test immediately caught a real bug: parseDebugMacinfo's documented contract is "a partial macro list is still sound — every entry in it was really read", but a stream cut mid-string surfaced a CORRUPTED define as a real one (Cursor.cstr returns the partial text when the NUL never arrives: "__INT8_C(c" with its body folded into the name). Grafted sections are the parser's own stated threat model. Both cstr sites now stop at an unterminated string, and the spec pins the prefix property at three cut points: every returned entry equals the full parse's entry, at any cut. 203 tests, typecheck, lint, format all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/debug-macro.spec.ts | 92 ++++++++++++++++++ packages/debug-info/src/debug-macro.ts | 8 ++ packages/debug-info/test-projects/README.md | 5 + .../test-projects/devkitarm-min/.gitignore | 1 + .../test-projects/devkitarm-min/Makefile | 13 ++- .../devkitarm-min/build/macinfo.o | Bin 0 -> 19028 bytes .../test-projects/devkitarm-min/source/main.c | 9 ++ packages/debug-info/vitest.globalSetup.ts | 2 +- 8 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 packages/debug-info/src/__tests__/debug-macro.spec.ts create mode 100644 packages/debug-info/test-projects/devkitarm-min/build/macinfo.o 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/debug-macro.ts b/packages/debug-info/src/debug-macro.ts index 2792266..ec14b90 100644 --- a/packages/debug-info/src/debug-macro.ts +++ b/packages/debug-info/src/debug-macro.ts @@ -61,6 +61,11 @@ export function parseDebugMacinfo(data: Uint8Array): MacroDefinition[] { 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(' '); @@ -81,6 +86,9 @@ export function parseDebugMacinfo(data: Uint8Array): MacroDefinition[] { 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: diff --git a/packages/debug-info/test-projects/README.md b/packages/debug-info/test-projects/README.md index 44ed49e..085a4d4 100644 --- a/packages/debug-info/test-projects/README.md +++ b/packages/debug-info/test-projects/README.md @@ -19,6 +19,11 @@ the sequence-boundary case), `bump`, `triple` (in a second `util.c` → multi-CU 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 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 0000000000000000000000000000000000000000..9f00185d5f784e83110a38262b6d3e7a24468038 GIT binary patch literal 19028 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 literal 0 HcmV?d00001 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 6decace..c975fd4 100644 --- a/packages/debug-info/test-projects/devkitarm-min/source/main.c +++ b/packages/debug-info/test-projects/devkitarm-min/source/main.c @@ -150,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/vitest.globalSetup.ts b/packages/debug-info/vitest.globalSetup.ts index 725d4f0..af47de2 100644 --- a/packages/debug-info/vitest.globalSetup.ts +++ b/packages/debug-info/vitest.globalSetup.ts @@ -44,7 +44,7 @@ const PROJECTS: Project[] = [ }, { dir: join(projects, 'devkitarm-min'), - artifacts: ELF_AND_ORACLE, + artifacts: [...ELF_AND_ORACLE, 'macinfo.o'], rebuildHint: 'cd test-projects/devkitarm-min && ./build.sh # builds in Docker', }, { From d0bb4faaff951364983343229c7ce2b47e70c520 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 12:58:06 +0100 Subject: [PATCH 13/15] test-projects: agbcc-min grows the four producer-quirk shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four committed-artifact reproductions for parser bugs an adversarial review found (the spec that pins them lands with the fixes, next commit): - struct FwdPay: forward-declared in main.c's CU, DEFINED in util.c's, linked in that order — the shadowing scenario where a first-CU-wins index loses the layout to a DW_AT_declaration stub. - g_zero[0] and Flex.data[0]: agbcc encodes a zero-length array's -1 upper bound as unsigned DW_FORM_data4 0xffffffff, at variable and member level. - g_ext_table[]: an unsized extern array whose definition lives in crt0.s — the asm/ldscript-placed-table idiom. agbcc emits upper_bound 0, byte-equal to a real [1]; DW_AT_declaration on the variable is the only disambiguator. g_one_def[1] is the defined [1] that must KEEP its length. - negative controls: g_init_table[][2] and the pret-style forward-declared static g_fwd_sized_table[][2] — agbcc sizes both correctly (bounds patched at the definition), so the -1 fix must not cost them. All shapes appended after the shared core (line-stable above); min.elf and oracle.json rebuilt with the vendored agbcc, 203 existing tests green unchanged. mips-min/ppc-min already carry the abstract/concrete signature split (add/square) and devkitarm's macinfo.o the DWARF-2 prototyped flag — no rebuild needed there. Co-Authored-By: Claude Opus 5 (1M context) --- packages/debug-info/test-projects/README.md | 5 ++ .../test-projects/agbcc-min/build/min.elf | Bin 8080 -> 9100 bytes .../test-projects/agbcc-min/build/oracle.json | 34 +++++++++---- .../debug-info/test-projects/agbcc-min/crt0.s | 9 ++++ .../debug-info/test-projects/agbcc-min/main.c | 45 ++++++++++++++++++ .../debug-info/test-projects/agbcc-min/util.c | 10 ++++ 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/packages/debug-info/test-projects/README.md b/packages/debug-info/test-projects/README.md index 085a4d4..e9b9ab6 100644 --- a/packages/debug-info/test-projects/README.md +++ b/packages/debug-info/test-projects/README.md @@ -15,6 +15,11 @@ The ARM pair compiles the same core shape — `add` / `square` (adjacent, exerci 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. 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 874e64f3ec0b416769558e2aa6d86e1137a6eab0..f6facf10a34b40e50ee0c60baec99e50ad6029af 100755 GIT binary patch delta 2601 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 Ld!~koTCDvG8)+_I 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 cee9914..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,22 +6,31 @@ "add": 134217736, "bump": 134217748, "gAbsGlobal": 50336308, - "g_bits": 50331720, - "g_color": 50331696, + "g_bits": 50331736, + "g_color": 50331708, "g_counter": 50331648, - "g_cv": 50331712, - "g_mmio": 50331700, - "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_rom_table": 134217932, - "g_util_pair": 50331728, + "g_rom_table": 134218040, + "g_util_pair": 50331752, + "g_zero": 50331704, "main": 134217880, + "poke": 134217916, "square": 134217740, - "triple": 134217916 + "triple": 134218016 }, "lines": { - "0x80000bc": { + "0x8000120": { "func": "triple", "file": "util.c", "line": 16 @@ -51,6 +60,11 @@ "file": "main.c", "line": 114 }, + "0x80000bc": { + "func": "poke", + "file": "main.c", + "line": 162 + }, "0x800000c": { "func": "square", "file": "main.c", 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 403ceef..a1e20a9 100644 --- a/packages/debug-info/test-projects/agbcc-min/main.c +++ b/packages/debug-info/test-projects/agbcc-min/main.c @@ -121,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; From f3e416f1deb868e747f2fd2c5102a4cf7a79f5df Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 12:58:25 +0100 Subject: [PATCH 14/15] `@gba-kit/debug-info`: four producer-dialect fixes, TDD-pinned by producer-quirks.spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec was written first against the committed trigger artifacts and failed 8/11 (the 3 passing were its negative controls); these four fixes turn it green without moving any of the 203 existing tests. 1. DW_FORM_flag now decodes to a BOOLEAN. The raw byte satisfied no `=== true` test, so on DWARF 2/3 every declaration check in the file was inert: a forward-declared struct shadowed its own definition depending on link order (agbcc-min's FwdPay came back size null, zero members), and `prototyped` was always false on modern-gcc -gdwarf-2 output. 2. functionSignature resolves the abstract/concrete split. Modern gcc at -O1+ emits an inlined-and-emitted function as an abstract DIE (name, params) plus a concrete DIE (low_pc, abstract_origin); indexing only same-DIE name+low_pc returned null for mips-min/ppc-min's add and square — exactly the small helpers a decompiler wants callee signatures for. The concrete half is indexed under the abstract name, and each fact (params, return, prototyped) is read from the DIE that carries it, per-parameter origins included. 3. arrayLength normalizes GCC 2.95's 0xffffffff upper bound (-1 stored in unsigned data4) before the +1: a zero-length array is unknown-length, not 2^32 elements. This was live in shipped data — pokeemerald's two sWhiteoutRespawn tables claimed 4 and 16 GiB, turning lookupInterior into a wrong-name trap for every unnamed address above 0x0859F5EC. Both now report length null against the real ELF; initializer-sized arrays keep their true bounds (pinned). 4. variableShape reports length null for a DECLARATION's [1]: agbcc encodes an unsized extern array as upper_bound 0, byte-identical to a real [1], and the variable's own DW_AT_declaration is the disambiguator. klonoa's gSineTable (an asm-placed table) drops its confident wrong length: 1; a DEFINED one-element array keeps its length (pinned). The rare genuine `extern T x[1]` gives up a near-information-free fact — weakening over overstating. Validated against the real consumers: klonoa-eod.elf keeps all 222 signatures and gSineTable reads honest; pokeemerald.elf's poisoned entries are sane. 214 tests, typecheck, lint, format green. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/producer-quirks.spec.ts | 135 ++++++++++++++++++ packages/debug-info/src/types.ts | 43 ++++-- 2 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 packages/debug-info/src/__tests__/producer-quirks.spec.ts 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/types.ts b/packages/debug-info/src/types.ts index 7a18035..bf53125 100644 --- a/packages/debug-info/src/types.ts +++ b/packages/debug-info/src/types.ts @@ -46,6 +46,7 @@ const DW_AT_data_bit_offset = 0x6b; // DWARF 4+ bitfield: absolute bit offset fr 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; @@ -355,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; } @@ -476,20 +483,25 @@ export class TypeIndex { if (!fn) { return null; } - const params = fn.children + // 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 name = c.attrs.get(DW_AT_name); + 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(c.attrs.get(DW_AT_type)), + ...this.#typeFacts(p.attrs.get(DW_AT_type)), }; }); return { name: fnName, - returns: fn.attrs.has(DW_AT_type) ? this.#typeFacts(fn.attrs.get(DW_AT_type)) : null, + returns: decl.attrs.has(DW_AT_type) ? this.#typeFacts(decl.attrs.get(DW_AT_type)) : null, params, - prototyped: fn.attrs.get(DW_AT_prototyped) === true, + prototyped: decl.attrs.get(DW_AT_prototyped) === true, }; } @@ -526,11 +538,17 @@ export class TypeIndex { // 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, - length: arrayLength(die), + // 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, }; } @@ -931,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; @@ -1189,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: From ab77619dfa16b1f822b4442eae5bc81998d8f260 Mon Sep 17 00:00:00 2001 From: macabeus Date: Sun, 2 Aug 2026 13:29:34 +0100 Subject: [PATCH 15/15] `@gba-kit/debug-info`: the changeset and README describe the whole release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset had been written at the declaration-shape milestone and never grew: functionSignature, the .debug_macinfo reader and the four producer-dialect fixes — half of what 0.4.0 ships — were absent from what will become the CHANGELOG. Rewritten as one concise bullet per capability, contracts kept ("null means this ELF did not compile it", the truncation prefix property, the flag/2^32/declaration-[1] fixes). The npm-facing README gains the two missing capabilities in the feature list and three Usage lines. Every example value is real output from the klonoa ELF, re-verified against the built package — which caught that the example I first reached for (gStreamPtr) no longer exists in the project's headers; gGfxStreamBuffer is what its macro table actually says today. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/declaration-shapes.md | 38 +++++++++++++++++++++----------- packages/debug-info/README.md | 13 +++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.changeset/declaration-shapes.md b/.changeset/declaration-shapes.md index d34c5bf..2f34c65 100644 --- a/.changeset/declaration-shapes.md +++ b/.changeset/declaration-shapes.md @@ -2,17 +2,29 @@ '@gba-kit/debug-info': minor --- -Read what a global is DECLARED as, not just where its fields sit — and parse big-endian ELFs. +Read what a name is DECLARED as — shapes, signatures and macro names — from either byte order. -`TypeIndex` gains: - -- `variableShape(varName)` — classify a global/static's declaration as `scalar` | `pointer` | `array` | `struct`, resolved through typedefs and cv-qualifiers, carrying the `volatile`/`const` crossed on the way (`volatile` says accesses are observable, `const` the ROM-table spelling). `null` when the name has no DIE, which also makes it the "is this name declared in the project headers?" probe. -- `pointee` on the `pointer` shape — what the pointer points AT, when its target resolves to a struct or union: the name `struct()` looks that layout up by, the target's byte size, and the target's own `volatile`/`const`. The target's qualifiers are the ones written to the left of the `*` (`volatile struct S *g`) and stay separate from the pointer variable's own, to the right of it (`struct S *volatile g`). - -A struct or union is named by its tag, or — for the `typedef struct {…} T;` idiom, where the struct itself is unnamed and the alias is the only name its layout has — by the last typedef crossed. That holds on the `struct` shape as well as on `pointee`, so either name goes straight back into `struct()`. A target that is only forward-declared carries no size of its own, so it is sized from the definition its tag resolves to. - -`struct()` members now also carry the declaration facts an offset and a size cannot: base-type `signed`ness, `pointer`, the `volatile`/`const` its type chain crosses, and — for an array member — `elemSize`, `elemSigned` and `length`. A cv-qualifier moves no field, so it is only ever visible as a declaration fact, and it is not interchangeable with its absence: repeated accesses to a `volatile` member are observable, and a write through a `const` one is a constraint violation. `size` is the WHOLE member (`char name[6]` → 6), so the element stride is what an indexed read into it needs, and the count is what bounds it; each key is absent when the DWARF does not determine it (a flexible array member declares a stride but no length). - -`ElfFile` and the DWARF parsers read big-endian (`ELFDATA2MSB`) containers and payloads, including bitfields — a big-endian target allocates them from the most significant end of the storage unit, so the same C declaration yields mirrored shifts. RELA relocations are applied to `.debug_*` sections, so the DWARF in a relocatable `.o` resolves too. - -`.debug_line` units are walked by their own `DW_LNE_end_sequence` terminators rather than by `unit_length` alone. agbcc (GCC 2.95) sizes a unit by predicting the encoded length of each statement and mispredicts, so `unit_length` can stop short of the program it describes; stopping there leaves the next unit's header to be read as line-program bytes and loses every row after it. +- `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/packages/debug-info/README.md b/packages/debug-info/README.md index 9343e5a..745a776 100644 --- a/packages/debug-info/README.md +++ b/packages/debug-info/README.md @@ -9,6 +9,12 @@ queries a source-level debugger needs: - **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. This is the general ELF/DWARF piece of gba-kit, not a GBA-only one: @@ -40,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