Skip to content

Extend @gba-kit/debug-info for address→symbol maps - #5

Open
macabeus wants to merge 12 commits into
mainfrom
extend-debug-info-for-symbol-maps
Open

Extend @gba-kit/debug-info for address→symbol maps#5
macabeus wants to merge 12 commits into
mainfrom
extend-debug-info-for-symbol-maps

Conversation

@macabeus

@macabeus macabeus commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Six self-contained additions to @gba-kit/debug-info, taking it from a GBA-shaped reader to a general ELF32/DWARF one: declaration facts alongside layout, both byte orders, relocatable objects, and a .debug_line walk that survives real producer output. (The motivating consumer is asmlift, which builds an address→symbol map out of an ELF; nothing here is specific to it.) Each commit stands alone — the full battery (build, test, check-types, lint, check-deps, format) is green at every one, and @gba-kit/debug-info goes 87 → 186 tests.

variableShape — classify a global's declaration shape

Walks a variable DIE's type through typedefs and cv-qualifiers to scalar | pointer | array | struct, with element size/signedness for arrays and tag name/byte size for structs. null doubles as the "is this name declared in the headers?" probe. Layout was already exposed; this answers what kind of thing a name is — a small closed set, where a full DIE→C-type renderer is neither needed nor cheap.

Big-endian ELF/DWARF + RELA-relocated debug sections

Byte order is read from e_ident and threaded through every multi-byte read (Cursor gains a littleEndian flag), so the MSB-first ELF32 images MIPS and PowerPC toolchains emit parse. Separately, PowerPC relocatable objects are RELA-style: string/section offsets in raw .debug_* bytes are zeros with the real values in .rela.<sec> addends (ARM/MIPS REL keeps them in the field), so sectionData applies those addends to a cached copy and DWARF in a raw .o parses identically across both conventions.

Verified against real artifacts: a big-endian MIPS sidecar object (283 variables + 209 structs), a big-endian PPC32 sidecar including struct layouts, and a GameCube main.elf (36,588 symbols).

Declaration facts for members and variables

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 now carry signed (the member's base-type DW_AT_encoding — offset and size alone do not say whether a byte reads as -1 or as 255), pointer (which separates the 4-byte cases sharing signed: null — pointer vs enum vs nested struct, indistinguishable by offset and size, and not interchangeable since pointers compare as unsigned), and volatile (the vu16 field; MMIO idiom — accesses to the field are observable rather than foldable). variableShape carries volatile / const, collected while resolving the typedef and cv-qualifier chain; for an array DWARF puts the qualifier on the element type, so the element chain feeds the same accumulator. Both ARM 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; their ELFs and binutils oracles are rebuilt from that source.

Walk .debug_line by its own terminators

unit_length is not a dependable end marker. agbcc (GCC 2.95) sizes a line unit by predicting the encoded length of the statements it is about to emit, and mispredicts: in pokeemerald 28 of 303 units under-count — 18 by one byte, 6 by two, 2 by three, one by four, and event_object_movement.o by 51. text.o shows it plainly, a 9401-byte .debug_line whose unit_length declares 9400, with the closing DW_LNE_end_sequence needing the byte past the declared end. A walk that seeks to the declared end therefore resumes mid-statement and reads the next unit's header as line-program bytes.

The line program is self-delimiting, so DW_LNE_end_sequence is the authority on where a program — and so a unit — ends: statements run to the declared end and past it while a sequence is still open. Well-formed units end on that terminator precisely and see no difference. Around that, one unreadable unit does not cost the section: unsupported versions (DWARF 5 rewrote the header) 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, and every read in the program loop is bounded, so a hostile section yields fewer rows rather than an exception. A sequence that never closes discards its own rows, so recovery cannot invent data.

pokeemerald yields 193,233 rows across all 303 units, consuming the section to its last byte, and agrees with readelf --debug-dump=decodedline on every row readelf decodes. The regression tests build on the committed agbcc DWARF-2 bytes, shortening a unit's unit_length by 1–7 and asserting the decoded rows are unchanged.

Big-endian MIPS + PowerPC test projects

mips-min (mips-linux-gnu-gcc, MIPS o32) and ppc-min (powerpc-linux-gnu-gcc, PowerPC 32) join the two ARM projects, compiling one byte-identical source with -g -O2 -fno-eliminate-unused-debug-types, linked freestanding and never executed. They cover what no little-endian ELF can: DWARF read MSB-first end to end, and bitfields allocated from the most significant end of the storage unit. 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. Ground truth for the fixed numbers is each compiler's own read-modify-write of g_bits.cross. ppc-min also vendors build/main.o — the only artifact shape that exercises the RELA path — whose .debug_info has 59 relocations, every raw field zero. CI installs the stock Ubuntu cross packages (the runner is x86 Linux, so they are native — no Docker or qemu) and rebuilds all four projects from scratch, so the committed artifacts stay honest.

Cross-endian equivalence, retiring the synthetic ELF

elf-endian.spec.ts hand-built a 280-byte ELF32 in both byte orders and asserted that each parses and that both yield the same logical content. The first claim is subsumed by the real big-endian ELFs; the second is worth keeping and deserved better inputs than one symbol, so it moves to real-projects.spec.ts as a block comparing all four projects: the shared declaration set is pinned (anything absent from a project is listed by name, so a shape that quietly stops parsing fails there rather than shrinking the comparison), every project must report the same byte layout and the same declaration facts, and the bitfield shifts must be mirrorsleShift + beShift + bitWidth === size * 8 — not merely different. The trade was checked against deliberately broken parsers one mutation at a time: forcing littleEndian = true, reading .debug_info LSB-first, and flipping the bitfield normalization each kill the new block, while the deleted suite passed all three.


No API is removed or changed in shape — every addition is a new field or a new method, so existing consumers are unaffected. If this looks right, it wants a 0.4.0 release.

@macabeus
macabeus force-pushed the extend-debug-info-for-symbol-maps branch 5 times, most recently from b4ebd3a to 2d0c274 Compare July 31, 2026 14:34
macabeus and others added 6 commits July 31, 2026 22:42
…aration shape

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 <noreply@anthropic.com>
…ctions

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.<sec>` (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 <noreply@anthropic.com>
…iables

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.
…y unit_length alone

`.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) <noreply@anthropic.com>
Big-endian had CI coverage only from a synthetic ELF built byte by byte inside
`elf-endian.spec.ts`, which pins the container and nothing else: 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 none. 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.

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: 111 tests → 169, 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) <noreply@anthropic.com>
…g the synthetic one

`elf-endian.spec.ts` hand-built a 280-byte ELF32 in both byte orders — ~90 lines
of emitter for a null section, a `.shstrtab`, a `.symtab` holding one global and
a `.strtab` — and asserted two things: that each image parses and its symtab
reads, and that both byte orders yield the same logical content. The first claim
was written when no big-endian artifact existed. `mips-min` and `ppc-min` now
do exist, and `real-projects.spec.ts` already parses them end to end: container,
symbol table, two-CU `.debug_line`, the whole `.debug_info` type tree, and a
PowerPC `.o` whose RELA addends the synthetic image could never model. A
synthetic ELF cannot lose a race it is not in.

The second claim is the one worth keeping, and it deserved better inputs than
one symbol. It moves to `real-projects.spec.ts` as a block that compares all
FOUR projects — agbcc-min and devkitarm-min (little-endian ARM), mips-min and
ppc-min (big-endian MIPS o32 / PowerPC) — 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: the scalar
  size/signedness of `g_counter`, `const short g_rom_table[3]` as
  elemSize 2 / elemSigned / length 3 / const, the struct name+size of `g_probe`
  (Probe, 32), `g_bits` (Bits, 8), `g_util_pair` (UtilPair, 4), and the
  `volatile struct Cv g_cv` qualifier.
- Bitfields carry the load-bearing assertion. Each side must match its own ABI —
  little-endian `hearts>>0 stars>>2 cross>>5 wide>>4`, big-endian `hearts>>6
  stars>>3 cross>>4 wide>>0` — 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). `after` is a plain member and carries
  no shift on either side. 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 trade was checked against deliberately broken parsers, one mutation at a
time, with the new block extracted to its own file so nothing else masked it:

- `ElfFile.parse` forced to `littleEndian = true` (ELFDATA2MSB ignored): the
  new block dies at load — `RangeError: Offset is outside the bounds of the
  DataView`, reading the real MIPS ELF's section header table. Same failure the
  deleted suite produced, so its container claim is genuinely subsumed.
- `.debug_info` read LSB-first (`TypeIndex.fromElf` given `true` instead of
  `elf.littleEndian`): 5 of the new 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. The deleted suite:
  all 3 tests PASS.
- Bitfield normalization flipped (the little-endian rule applied to both):
  the new 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. The
  deleted suite: all 3 tests PASS — it has no bitfields at all. This is the
  regression that shipped silently before `88c0bed`, and it is now caught by
  the cross-endian block on its own.

@gba-kit/debug-info: 169 tests → 186 (+20 cross-endian, −3 synthetic), 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) <noreply@anthropic.com>
@macabeus
macabeus force-pushed the extend-debug-info-for-symbol-maps branch from a9ceec2 to d3c7532 Compare July 31, 2026 21:44
macabeus and others added 6 commits July 31, 2026 23:10
…s elements

Two 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, and
the arm was the only one in the union that answered no question about the
declaration beyond its kind. It now carries `pointee`: `{ structName, size }` 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,
asserted as such on the projects that declare each spelling. 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.

`#stripTypedefs` grew a second optional accumulator for that alias rather than a
second copy of the walk. It is kept separate from the `cv` one because that object is
spread straight into the declaration shape and must not gain a key.

`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 three 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.

Three test-project declarations were added to exercise the new arm on real toolchain
output, each replacing a blank line 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. It joins the devkitarm-only block because `Pair` is a
    little-endian-source shape; the cross-endian block lists both 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 both 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) <noreply@anthropic.com>
…ee by its own qualifiers

Two facts `variableShape` had within reach on its own walk and dropped.

The `struct` arm 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 pointer arm already
answered this: it walks with an `alias` accumulator and reports the last typedef
crossed when the tag is absent, and it follows a forward declaration to the
definition for the size. That tail is now `#structTarget(die, alias)`, and both arms
call it, so a struct global and a pointee are named by exactly the same rule 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 — `DW_AT_byte_size`
is absent there and was read as null.

`#pointee` stripped its target's typedefs with no cv accumulator, so `volatile
struct S *g` and `struct S *g` reported an identical pointee. Those are different
declarations: accesses made THROUGH the first pointer are observable. It now
accumulates the qualifiers it crosses and reports them on `PointeeStruct` as
`volatile`/`const` — always-present booleans, the way `VariableShape`'s arms spell
the same facts, so key presence stays a capability witness.

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` now 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.
The declarations were fitted into the existing lines (the layout comment moved onto
the closing brace it describes) so the file's line count, which the `.debug_line`
assertions are indexed by, is unchanged; `build/min.elf` and its oracle are rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch's whole surface — `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) <noreply@anthropic.com>
`#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.

`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; `build/min.elf` and its oracle are rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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. Cross-toolchain fixtures updated: all
four test projects agree that `int *ptr` is a 4-byte signed target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant