Extend @gba-kit/debug-info for address→symbol maps - #5
Open
macabeus wants to merge 12 commits into
Open
Conversation
macabeus
force-pushed
the
extend-debug-info-for-symbol-maps
branch
5 times, most recently
from
July 31, 2026 14:34
b4ebd3a to
2d0c274
Compare
…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
force-pushed
the
extend-debug-info-for-symbol-maps
branch
from
July 31, 2026 21:44
a9ceec2 to
d3c7532
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_linewalk 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-infogoes 87 → 186 tests.variableShape— classify a global's declaration shapeWalks 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.nulldoubles 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_identand threaded through every multi-byte read (Cursorgains alittleEndianflag), 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), sosectionDataapplies those addends to a cached copy and DWARF in a raw.oparses 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 carrysigned(the member's base-typeDW_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 sharingsigned: null— pointer vs enum vs nested struct, indistinguishable by offset and size, and not interchangeable since pointers compare as unsigned), andvolatile(thevu16 field;MMIO idiom — accesses to the field are observable rather than foldable).variableShapecarriesvolatile/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 asigned charmember, 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_lineby its own terminatorsunit_lengthis 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, andevent_object_movement.oby 51.text.oshows it plainly, a 9401-byte.debug_linewhoseunit_lengthdeclares 9400, with the closingDW_LNE_end_sequenceneeding 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_sequenceis 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=decodedlineon every row readelf decodes. The regression tests build on the committed agbcc DWARF-2 bytes, shortening a unit'sunit_lengthby 1–7 and asserting the decoded rows are unchanged.Big-endian MIPS + PowerPC test projects
mips-min(mips-linux-gnu-gcc, MIPS o32) andppc-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'sDW_AT_bit_offset) is measured from the end the target allocates from, and#memberLayoutnormalized 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 ofg_bits.cross.ppc-minalso vendorsbuild/main.o— the only artifact shape that exercises the RELA path — whose.debug_infohas 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.tshand-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 toreal-projects.spec.tsas 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 mirrors —leShift + beShift + bitWidth === size * 8— not merely different. The trade was checked against deliberately broken parsers one mutation at a time: forcinglittleEndian = true, reading.debug_infoLSB-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.0release.