Files
pikuma_ps1/scripts/passes/dwarf_injection.lua
2026-07-26 14:40:57 -04:00

2282 lines
128 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- passes/dwarf_injection.lua — Per-atom DWARF injection for tape-atom step-debug.
---
--- Reads the post-link ELF directly (io.open; walks the ELF32 section header table to find
--- `.debug_info` + `.debug_abbrev` + `.debug_str` + `.debug_line` + `.debug_aranges` + `.debug_rnglists`),
--- APPENDS synthetic DWARF line-program sequences for every `code_<name>` atom, EXTENDS the `.debug_aranges`
--- and main-CU range tables with the atom ranges, and INSERTS synthetic atom/component DIE children into the
--- existing main compilation unit in `.debug_info` (no second compilation unit).
--- Per-atom `DW_TAG_subprogram` + per-register `DW_TAG_variable` entries make
--- `RR_PrimCursor` and the other opted-in aliases appear as atom-scoped locals in VSCode's Variables pane.
--- Writes the new section data to `<out_root>/<basename>.dwarf_*.bin`.
---
--- The `build_psyq.ps1` post-link hook then splices those `.bin` files into a copy of the ELF via:
--- mipsel-none-elf-objcopy --update-section .debug_info=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_abbrev=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_str=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_line=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_aranges=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_rnglists=<bin> <elf>
--- mipsel-none-elf-objcopy --add-section .debug_loc=<bin> <elf>
--- (.debug_loc doesn't exist in the source ELF; --add-section creates it.
--- Splice step runs from PowerShell — no Lua subprocess; no cmd /c parsing issues.
--- objcopy's --update-section works fine in PowerShell even though Lua's `os.execute`/`io.popen` would mangle the `=` on Windows.)
---
--- Result: source stepping follows atom-body lines, and wave-context registers appear as atom-scoped locals.
--- Native VSCode stepping, line highlighting, run-to-cursor, conditional breakpoints, and per-atom locals.
--- No VSCode plugin, no Python, no pyelftools — pure Lua + objcopy.
---
--- **Conventions:** tabs (1/level), EmmyLua annotations, Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Bootstrap
-- ════════════════════════════════════════════════════════════════════════════
-- Load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
-- Sets package.path + package.cpath then returns duffle.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ELF32 / DWARF / atoms-source-map utilities (post-link debug-info injection).
-- Sister module to duffle.lua — contains the format-constant tables (ELF32 byte offsets, DWARF opcodes, etc.) and the I/O helpers
-- (read_elf_sections, nm, source-map parser, LE byte r/w). `list_dir` is the general directory primitive in duffle.lua.
local elf_dwarf = require("elf_dwarf")
-- File-scope aliases to elf_dwarf helpers; the canonical implementations live in scripts/elf_dwarf.lua.
local find_abbrev_table_end = elf_dwarf.find_abbrev_table_end
-- Local DWARF opcode constants + length-prefixed integers (uleb128 + sleb128 encoders are in elf_dwarf.lua).
local uleb128 = elf_dwarf.uleb128
local sleb128 = elf_dwarf.sleb128
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- DWARF line-program opcodes + range-list entry encodings (per DWARF5 spec).
-- All values lifted from `elf_dwarf.DWARF_LINE_OPS` + `elf_dwarf.DWARF5_RNGLISTS`.
-- Local aliases preserve the code's readability
-- (e.g. `DW_LNS_copy` reads better than `elf_dwarf.DWARF_LINE_OPS.DW_LNS_copy` in an emitter body).
local DWARF_LINE_OPS = elf_dwarf.DWARF_LINE_OPS
local DWARF5_RNGLISTS = elf_dwarf.DWARF5_RNGLISTS
local MIPS_BYTES_PER_WORD = elf_dwarf.MIPS_BYTES_PER_WORD
local DW_LNS_copy = DWARF_LINE_OPS.DW_LNS_copy
local DW_LNS_advance_pc = DWARF_LINE_OPS.DW_LNS_advance_pc
local DW_LNS_advance_line = DWARF_LINE_OPS.DW_LNS_advance_line
local DW_LNS_set_file = DWARF_LINE_OPS.DW_LNS_set_file
local DW_LNS_negate_stmt = DWARF_LINE_OPS.DW_LNS_negate_stmt
local DW_LNS_extended = DWARF_LINE_OPS.DW_LNS_extended
local DW_LNE_end_sequence = DWARF_LINE_OPS.DW_LNE_end_sequence
local DW_LNE_set_address = DWARF_LINE_OPS.DW_LNE_set_address
local DW_RLE_end_of_list = DWARF5_RNGLISTS.end_of_list
local DW_RLE_start_length = DWARF5_RNGLISTS.start_length
-- File index 11 in the existing main line unit is hello_gte_tape.c.
-- The injector extends that unit rather than appending an unreferenced unit.
local ATOM_SOURCE_FILE_INDEX = 11
-- RR_<R_Name> debug-visible variables come from the merged register_alias_registry filtered to aliases whose code is a valid MIPS GPR 0..31
-- (see collect_per_source_registries + by_alias in build_inserted_children).
-- Names are prefixed with `RR` to avoid collision with the C-level enum (`{R_AtomJmp = 25, R_TapePtr = 24, R_PrimCursor = 15, ...}` declared in duffle headers);
-- without the prefix, gdb's `print R_PrimCursor` resolves to the enum constant, not to this DWARF variable.
-- With `set print enum on`, gdb displays the enum identifier, so the Watch panel would show `R_PrimCursor = R_PrimCursor` instead of the register value.
-- Each RR_<R_Name>'s DW_OP_regN uses the alias's `code` as N.
-- DW_OP_bregN would describe a memory location addressed from a register; the breg form would make gdb dereference the atom register value rather than display it.
-- New abbreviation codes (100+ to avoid collision with gcc's existing 1-60+ codes).
local ABBREV_CU = 0x64 -- 100: DW_TAG_compile_unit
local ABBREV_SUBPROGRAM = 0x65 -- 101: DW_TAG_subprogram
local ABBREV_VARIABLE = 0x66 -- 102: DW_TAG_variable with DW_AT_type = ref4 to U4
local ABBREV_STRUCT_TYPE = 0x67 -- 103: DW_TAG_structure_type with children (Binds_X mirror)
local ABBREV_MEMBER = 0x68 -- 104: DW_TAG_member no children (DW_AT_type = ref4 to U4 base)
local ABBREV_BIND_VAR = 0x69 -- 105: DW_TAG_variable no children + DW_AT_type = ref4 (the bind_args variable)
local ABBREV_BASE_TYPE = 0x6A -- 106: DW_TAG_base_type no children (U4)
-- Component step-into (DW_TAG_inlined_subroutine + abstract DW_TAG_subprogram).
local ABBREV_ABSTRACT_SUBPROGRAM = 0x6B -- 107: DW_TAG_subprogram (abstract — no low_pc/high_pc); for each unique mac_X component
local ABBREV_INLINED_SUBROUTINE = 0x6C -- 108: DW_TAG_inlined_subroutine with children (per-component invocation range)
-- Bind_args uses DW_FORM_sec_offset → .debug_loclists for PC-ranged liveness
-- (each field transitions from tape memory to GPR at load_pc + 8 = MIPS I load-delay slot boundary).
local ABBREV_BIND_VAR_LOCLIST = 0x6D -- 109: DW_TAG_variable no children + DW_AT_type = ref4 + DW_AT_location = sec_offset
-- Typed-view pointer_type (for the synthetic V4_S2* / V3_S2* / U4* / void* chains).
-- MUST be a fresh abbrev code in the appended table — emitting uleb128(9) collides with GCC's
-- existing abbrev 9 (a pointer_type that carries DW_AT_byte_size + DW_AT_type), so gdb misparses
-- our 4-byte ref4 as (byte_size, type[0..2]) and lands the cursor mid-attribute.
local ABBREV_TYPED_VIEW_POINTER = 0x6E -- 110: DW_TAG_pointer_type no children + DW_AT_type = ref4 (typed-view / U4 / void chain)
-- DWARF5 §7.7.3 loclist opcodes.
local DW_LLE_end_of_list = 0x00
local DW_LLE_start_length = 0x08
local DW_OP_reg0 = 0x50 -- base reg op; regN = 0x50 + N
local DW_OP_breg0 = 0x70 -- base breg op; bregN = 0x70 + N (SLEB offset)
local DW_OP_piece = 0x93
local MIPS_LOAD_DELAY_BYTES = 0x08 -- 1 load word + 1 BD-slot word
-- DIE children-list terminator: each DIE that has children ends with a single 0 byte (DWARF5 §7.5.3).
-- This is NOT the same as DW_LLE_end_of_list despite sharing the value 0x00 — different spec sections.
local DIE_CHILDREN_TERMINATOR = 0x00
-- Field/piece byte sizes for the typed-view piece chains.
-- All Binds_* struct fields are U4 (sizeof(uint32_t) on MIPS32 = 4 bytes).
local U4_BYTE_SIZE = 4
-- DWARF3/4/5 opcodes / attributes / forms (for the .debug_info synth).
-- DW_TAG values are stable across DWARF3-5 per the standard's Table 7.1;
-- gcc emits DW_TAG_structure_type=0x13 + DW_TAG_base_type=0x24 even in DWARF5-versioned CUs, so we match those exact byte values.
local DW_TAG_compile_unit = 0x11
local DW_TAG_subprogram = 0x2E
local DW_TAG_variable = 0x34
local DW_TAG_structure_type = 0x13
local DW_TAG_member = 0x0D
local DW_TAG_base_type = 0x24
local DW_TAG_pointer_type = 0x0F
-- Component step-into.
local DW_TAG_inlined_subroutine = 0x1D
local DW_AT_name = 0x03
local DW_AT_low_pc = 0x11
local DW_AT_high_pc = 0x12
local DW_AT_language = 0x13
local DW_AT_location = 0x02
local DW_AT_comp_dir = 0x1B
local DW_AT_byte_size = 0x0B
local DW_AT_encoding = 0x3E -- DWARF5 §7.7.1: DW_AT_encoding for the DW_ATE_unsigned base type
local DW_AT_data_member_location = 0x38
local DW_AT_type = 0x49
local DW_AT_linkage_name = 0x6E -- DWARF5 §7.7.1: DW_AT_linkage_name with DW_FORM_string
local DW_AT_external = 0x3F -- marks a variable/function as externally visible
-- Inlined_subroutine + abstract_origin attributes.
local DW_AT_abstract_origin = 0x31
local DW_AT_call_file = 0x58
local DW_AT_call_line = 0x59
local DW_AT_inline = 0x20 -- DWARF5 §7.7.1: DW_AT_inline (used by abstract subprogram for the mac_X() components)
-- decl_file + decl_line on the abstract subprogram so consumers can resolve an abstract origin back to its definition site even when no inlined_subroutine instance currently maps to it.
local DW_AT_decl_file = 0x3A -- DWARF5 §7.7.1: DW_AT_decl_file (1-based file index into the CU's file table)
local DW_AT_decl_line = 0x3B -- DWARF5 §7.7.1: DW_AT_decl_line
-- File index lookup table for the existing main line unit (Unit 2).
-- Provenance paths come back with mixed slashes; we normalize to basename and look up against the line unit's actual file table.
-- Current scope has two provenance basenames: hello_gte_tape.c (the atom's call site) and lottes_tape.h (the component definition).
-- Both live in the existing gcc-generated line unit; their 1-based indices are stable across rebuilds because the include order
-- in code/gte_hello/hello_gte.c determines the unit's file table.
-- gcc only adds a file to the line table when it has actual line-number entries;
-- headers that are pure macros/typedefs (dsl.h, memory.h, math.h, mips.h, gp.h, gte.h, etc.) never appear.
-- lottes_tape.h is the FIRST include that emits line entries (MipsAtomComp_ declarations), so it is the FIRST entry after the primary file.
local PROVENANCE_BASENAME_TO_FILE_INDEX = {
["hello_gte_tape.c"] = ATOM_SOURCE_FILE_INDEX, -- = 11
["lottes_tape.h"] = 2,
}
--- Resolve an absolute provenance path to the line-unit file index used by the emitting line program.
--- Normalizes mixed `/` and `\` separators to a basename and looks it up against the known file table.
---
--- Fails loudly on an unknown provenance basename: adding a new component source file requires extending
--- `PROVENANCE_BASENAME_TO_FILE_INDEX` so the line-program emission contract stays explicit.
--- Silent fallback to ATOM_SOURCE_FILE_INDEX would mask the new-file case by misattributing component rows to the atom's source file.
--- @param path string -- absolute provenance path (e.g. "C:/.../lottes_thttps://www.youtube.com/watch?v=ORM4yLkdKx8ape.h" or "C:\\...\\lottes_tape.h")
--- @return integer -- 1-based line-unit file index
local function resolve_provenance_file_index(path)
if path == nil or path == "" then
error("[dwarf_injection] resolve_provenance_file_index: empty path")
end
-- Normalize backslashes → forward slashes (paths arrive with mixed separators from the provenance file: forward slashes from Lua's io.lines;
-- backslashes if the input ever round-trips through Windows shell expansion).
local normalized = path:gsub("\\", "/")
-- Take the last path component (the basename).
local basename = normalized:match("([^/]+)$") or normalized
local idx = PROVENANCE_BASENAME_TO_FILE_INDEX[basename]
if idx == nil then
error(string.format(
"[dwarf_injection] resolve_provenance_file_index: unknown provenance basename '%s' (from '%s'). "
.. "Extend PROVENANCE_BASENAME_TO_FILE_INDEX in passes/dwarf_injection.lua.",
basename, path))
end
return idx
end
local DW_FORM_addr = 0x01
local DW_FORM_data1 = 0x0B
local DW_FORM_string = 0x08 -- inline null-terminated
local DW_FORM_strp = 0x0E -- 4-byte offset into .debug_str
local DW_FORM_exprloc = 0x18 -- length-prefixed (ULEB128) DW_OP bytes
local DW_FORM_ref4 = 0x13 -- 4-byte offset within the same .debug_info CU
local DW_FORM_udata = 0x0F -- ULEB128 (DW_AT_byte_size for struct_type, DW_AT_data_member_location for member)
local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker)
local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists)
-- DW_OP_reg0 + DW_OP_piece are declared above (lines 114-116) alongside the other DWARF5 §7.7.3 loclist opcodes.
local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used for U4 base type)
-- (DW_LANG_Mips_Assembler = 0x8001 was used in the, but we want this CU to look like a C TU so VSCode's Variables pane treats it as code.)
-- No DW_AT_language attribute is emitted (see build_debug_info_section's abbrev 100).
-- R_<name> → MIPS GPR lookups go through the merged register_alias_registry
-- (collected by collect_per_source_registries from `corpus.register_alias_registry`).
-- Aliases without an `atom_reg` opt-in are absent from the registry; absent aliases are not debug-visible.
-- See the precedence chain in build_inserted_children for the per-alias type resolution.
-- Build the .debug_loclists section for every rbind atom.
-- Returns the section bytes (DWARF5 layout: unit_length(4) + version=5(2) + address_size=4(1) + segment_size=0(1) + offset_entry_count=0(4) + DW_LLE entries;
-- each atom contributes one loclist terminated by DW_LLE_end_of_list).
--
-- Per rbind atom we emit 2 loclist entries (tape → GPR split) using the last field's transition as the boundary.
-- This is a conservative approximation that always reads the correct GPR value once the first load has retired (load_pc + 8).
--
-- `tape_alias` (default: "R_TapePtr") names the wave-runtime pointer register whose value is the tape address.
-- The GPR integer comes from the merged registry; absent alias fails loud — rbind atoms depend on R_TapePtr being in the registry for their piece-chain DW_OP_breg<N> location.
-- @param atom_table table[] -- list of atoms with .rbind set
-- @param registries table -- merged registries from collect_per_source_registries
-- @return string -- section bytes
local function build_debug_loclists_section(atom_table, registries)
registries = registries or {}
-- R_TapePtr comes from the merged register_alias_registry (only present if the user opted it in via `#define atom_reg` in lottes_tape.h).
-- When absent we emit just the section terminator (a single DW_LLE_end_of_list byte); the .debug_loclists section stays non-empty so the linker accepts it,
-- and `bind_args` will be emitted with no loclist PC range (readelf will display it as having no .debug_loclists entries).
local tape_alias_entry = registries.register_alias_registry and registries.register_alias_registry["R_TapePtr"]
local tape_reg = tape_alias_entry and tape_alias_entry.code
local parts = {}
for _, atom in ipairs(atom_table) do
if atom.rbind and tape_reg then
local fields = atom.rbind.fields or {}
local regs = atom.rbind.regs or {}
local n_fields = #fields
local last_load_pc = atom.addr + (n_fields - 1) * MIPS_BYTES_PER_WORD
local transition_pc = last_load_pc + MIPS_LOAD_DELAY_BYTES
local tape_pieces = {}
for _, f in ipairs(fields) do
local offset = f.offset or 0
local offset_sleb = elf_dwarf.sleb128(offset)
-- (DW_OP_bregN, SLEB128(offset), DW_OP_piece, ULEB128(U4_BYTE_SIZE))
-- 4 = U4_BYTE_SIZE: each piece is sizeof(uint32_t) on MIPS32.
table.insert(tape_pieces, string.char(DW_OP_breg0 + tape_reg) .. offset_sleb .. string.char(DW_OP_piece) .. uleb128(U4_BYTE_SIZE))
end
local tape_expr = table.concat(tape_pieces)
local gpr_pieces = {}
for _, pair in ipairs(regs) do
-- (DW_OP_regN, DW_OP_piece, ULEB128(4)) — one piece per GPR-resident field.
-- The 4 = U4_BYTE_SIZE: each piece is sizeof(uint32_t) on MIPS32.
table.insert(gpr_pieces, string.char(DW_OP_reg0 + pair.reg) .. string.char(DW_OP_piece) .. uleb128(U4_BYTE_SIZE))
end
local gpr_expr = table.concat(gpr_pieces)
parts[#parts + 1] = string.char(DW_LLE_start_length)
.. elf_dwarf.write_u32_le(atom.addr)
.. uleb128(#tape_expr)
.. tape_expr
parts[#parts + 1] = string.char(DW_LLE_start_length)
.. elf_dwarf.write_u32_le(transition_pc)
.. uleb128(#gpr_expr)
.. gpr_expr
parts[#parts + 1] = string.char(DW_LLE_end_of_list)
end
end
-- Loclist unit header (DWARF5 §7.7.2):
-- unit_length(4) + version(2) + address_size(1) + segment_size(1) + offset_entry_count(4) = 12 bytes header.
-- version = 5 (DWARF5); address_size = 4 (MIPS32); segment_size = 0; offset_entry_count = 0 (we use DW_LLE_start_length, not offsets).
local LOCLIST_HEADER_SIZE = 12
local body = table.concat(parts)
local unit_length = LOCLIST_HEADER_SIZE - 4 + #body -- -4 because unit_length excludes itself
local header = elf_dwarf.write_u32_le(unit_length)
.. elf_dwarf.write_u16_le(5) -- DWARF5
.. string.char(U4_BYTE_SIZE) -- address_size
.. string.char(0) -- segment_size
.. elf_dwarf.write_u32_le(0) -- offset_entry_count
return header .. body
end
-- Compute the size of one tape piece for an atom's struct field offset.
-- The piece is: DW_OP_bregN(1) + sleb128(offset)(1..5) + DW_OP_piece(1) + uleb128(4)(1).
-- Sleb128(0) is 1 byte; sleb128(63) is 1 byte; sleb128(64) is 2 bytes; ...; sleb128(2^28) is 5 bytes.
-- 4 = U4_BYTE_SIZE: each field is sizeof(uint32_t) on MIPS32.
-- @param offset integer -- the field's offset within the struct (0..2^32-1)
-- @return integer -- encoded size in bytes (4 for offsets < 64, 5 for offsets 64..8191, ..., 8 for > 2^28)
local function tape_piece_size(offset)
return 1 + elf_dwarf.sleb128_size(offset) + 1 + elf_dwarf.uleb128_size(U4_BYTE_SIZE)
end
-- Compute the per-atom loclist offset within a .debug_loclists section.
-- @param atom_table table[] -- list of atoms with .rbind set
-- @return table -- {[atom_name] = offset_in_section}
local function compute_loclists_offsets(atom_table)
local LOCLIST_ENTRY_HEADER_SIZE = 1 + 4 + 1 -- DW_LLE_start_length(1) + addr(4) + uleb_length(1)
local offsets = {}
-- Loclist unit header (DWARF5 §7.7.2): unit_length(4) + version(2) + address_size(1) + segment_size(1) + offset_entry_count(4) = 12 bytes.
-- The unit_length itself is not counted in the unit_length value, so the body starts at byte 12.
local cursor = 4 + 2 + 1 + 1 + 4 -- = 12
for _, atom in ipairs(atom_table) do
if atom.rbind then
offsets[atom.name] = cursor
local n_fields = #atom.rbind.fields
-- Sum the actual size of each tape piece based on the field's offset (not an assumed constant); this expression mirrors what build_debug_loclists_section produces:
-- 1 (DW_LLE_start_length) + 4 (PC) + 1 (uleb length prefix) + sum(tape_piece_size(field.offset))
-- + 1 (DW_LLE_start_length) + 4 (transition_pc) + 1 (uleb length prefix) + n_fields * 3 (gpr pieces)
-- + 1 (DW_LLE_end_of_list)
local tape_pieces_size = 0
for _, f in ipairs(atom.rbind.fields or {}) do
tape_pieces_size = tape_pieces_size + tape_piece_size(f.offset or 0)
end
local gpr_pieces_size = n_fields * 3 -- each gpr piece: DW_OP_regN(1) + DW_OP_piece(1) + uleb(4)(1) = 3 bytes
local tape_entry = LOCLIST_ENTRY_HEADER_SIZE + tape_pieces_size
local gpr_entry = LOCLIST_ENTRY_HEADER_SIZE + gpr_pieces_size
local body_len = tape_entry + gpr_entry + 1 -- +1 for DW_LLE_end_of_list
cursor = cursor + body_len
end
end
return offsets
end
-- Default name for the synthetic CU (so VSCode lists it as a known source).
local DEFAULT_CU_NAME = "tape_atom_locals"
local DEFAULT_CU_COMP_DIR = "."
-- SECTION_WRITERS owns the .bin output path templates.
-- Default basename if not provided via ctx.
local DEFAULT_BASENAME = "hello_gte"
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class DwarfInjectionCtx
--- @field flags table -- ctx.flags; reads flags.elf_path + flags.dwarf_injection
--- @field sources table[] -- source files (reserved for future per-source state)
--- @field out_root string -- output root (e.g. "build/gen")
--- @field basename string -- input ELF basename (default "hello_gte")
--- Project the corpus registries into the shape the section builders expect.
--- The corpus already owns the merged `register_alias_registry`, `type_name_registry`, `atom_views`, `atom_ctxs`, `atom_phases`, and `atom_infos` projections (populated by `passes.scan_source.lua`).
--- This helper just references them so the rest of `dwarf_injection.lua` keeps the same `registries.<key>` access shape it has always used.
---
--- Every `R_*` lookup and per-atom type override resolution in this file goes through this merged table.
--- Aliases without `atom_reg` adjacent are absent; the absence is treated as "not debug-visible" (see build_inserted_children for the precedence chain).
---
--- When two sources register the same key, the last-writer wins (later sources override earlier).
--- Today only one source declares wave-context enums, so collisions are absent.
--- @param corpus table -- the corpus from `ctx.shared.corpus`
--- @return table -- {
--- register_alias_registry = {[R_Name] = AliasEntry},
--- type_name_registry = {[T] = TypeEntry},
--- atom_views = {[atom_name] = AtomViewEntry},
--- }
local function collect_per_source_registries(corpus)
-- The corpus already holds the merged registries; reference them directly.
-- `passes.scan_source.lua` has already folded every per-source scan into the corpus tables, so no per-source iteration is needed here.
-- `atom_infos` is preserved byte-for-byte with no filtering; consumers consult `corpus.atoms_by_name`
-- themselves when they need to know whether a particular atom_info corresponds to an actual atom record.
local atom_infos_list = {}
for _, ai in ipairs((corpus and corpus.atom_infos) or {}) do
atom_infos_list[#atom_infos_list + 1] = ai
end
return {
register_alias_registry = (corpus and corpus.register_alias_registry) or {},
type_name_registry = (corpus and corpus.type_name_registry) or {},
atom_views = (corpus and corpus.atom_views) or {},
-- Per-atom atom_ctx declarations: atom_name -> {rbind_atom, ...}
-- (populated by scan_source from `atom_ctx(<atom_name>)` sub-calls inside `atom_info`)
atom_ctxs = (corpus and corpus.atom_ctxs) or {},
-- Per-phase atom groups: phase_label -> {atoms = {atom_name1, ...}}
-- (populated by scan_source from `atom_phase(<label>)` sub-calls inside `atom_info`; cross-source merged)
atom_phases = (corpus and corpus.atom_phases) or {},
-- Corpus-wide atom_infos list, byte-for-byte.
atom_infos = atom_infos_list,
}
end
-- Skip semantics live in two DWARF projections: `atom.debug_skip` makes a whole atom opaque, and `invocation.debug_skip`
-- suppresses statement rows and inline DIEs for a selected invocation. No debugger command file is emitted by this pass.
-- ════════════════════════════════════════════════════════════════════════════
-- LEB128 encoders
-- ════════════════════════════════════════════════════════════════════════════
--
-- Uses `elf_dwarf.uleb128` and `elf_dwarf.sleb128`.
-- See those helpers for the bit-layout documentation + named constants (LEB_CONT_BIT, LEB_DATA_MASK, SLEB_SIGN_BIT).
-- File-scope `local uleb128` + `local sleb128` aliases live near the module top so they're resolvable by every function below.
-- ════════════════════════════════════════════════════════════════════════════
-- DWARF line-program encoder
-- ════════════════════════════════════════════════════════════════════════════
--- Build the byte sequence for ONE atom's line program:
--- DW_LNE_set_address(addr)
--- [entry 1 emission]
--- for each subsequent entry (idx 2..N):
--- DW_LNS_advance_pc(1 .word = 4 bytes)
--- [entry emission at new PC]
--- DW_LNE_end_sequence
---
--- Each "entry emission" emits one or more rows at the same PC, tracking the source state {file_idx, line}.
--- State transitions emit DW_LNS_set_file + DW_LNS_advance_line as needed;
--- same-state transitions emit only the row (copy).
---
--- Entry rules:
--- * RAW entry (no invocation): emit (call_file, entry.line) at this PC.
--- * Invocation entry, NOT first word: emit (comp_file, comp_line).
--- * Invocation entry, IS first word: emit TWO rows at the same PC in order: (call_file, inv.call_line), then (comp_file, inv.comp_line).
---
--- atom-entry rules:
--- * Atom entry 1 also gets a duplicate copy_op for the GDB 12 zero-instruction-prologue marker (no advance_pc — same PC).
---
--- Wire format reminder (DWARF5 §6.2.5):
--- - Standard opcodes: 1 byte opcode + payload (per opcode length).
--- - Extended opcode marker byte = 0.
--- - Extended opcodes: marker byte + ULEB128 size + sub_opcode + payload.
---
--- Statement-state rules:
--- * A marked whole atom emits one opaque is_stmt=false range row and no nested component rows; its subprogram symbol/range remains available.
--- * Per-row policy at every other PC:
--- - Call-site row of any invocation's first word: is_stmt = true (unconditional; `want_call = true`).
--- - Body row of any invocation (first or subsequent): is_stmt = not inv.debug_skip (`want_body = not inv.debug_skip`).
--- - RAW word (no containing invocation): is_stmt = true (unconditional).
--- * The previous per-word `marked_idx` ancestor walk and the GDB 12 zero-instruction-prologue duplicate row at atom entry are DELETED; the new
--- first-word emission IS the entry statement.
--- * Whole-atom suppression wins over component markers; no nested inversion.
--- @param atom table -- {name, addr, size_bytes, words, entries, invocations, debug_skip, word_events}
--- @return string
local function build_atom_sequence(atom)
local function set_address(addr)
-- Per DWARF5 §6.2.5.3: marker(0) + size(ULEB128, includes sub_opcode byte) + sub_opcode + payload
-- For set_address: size = 1 (sub_opcode) + 4 (addr) = 5
local addr_bytes = elf_dwarf.write_u32_le(addr)
local sub_size = string.char(DW_LNE_set_address) .. addr_bytes
return string.char(DW_LNS_extended) .. uleb128(#sub_size) .. sub_size
end
local function copy_op() return string.char(DW_LNS_copy) end
local function set_file(file_index) return string.char(DW_LNS_set_file) .. uleb128(file_index) end
local function advance_pc(bytes_delta) return string.char(DW_LNS_advance_pc) .. uleb128(bytes_delta) end
local function advance_line(line_delta) return string.char(DW_LNS_advance_line) .. sleb128(line_delta) end
local function negate_stmt() return string.char(DW_LNS_negate_stmt) end
local function end_sequence()
-- size = 1 (just the sub_opcode byte, no payload)
return string.char(DW_LNS_extended)
.. string.char(DWARF_LINE_OPS.end_sequence_payload_size)
.. string.char(DW_LNE_end_sequence)
end
if not atom.entries or #atom.entries == 0 then return set_address(atom.addr) .. end_sequence() end
-- Whole-atom skip (the `atom.debug_skip` field, set by scan_source.run).
-- Make the atom DWARF-opaque: one non-statement row covers the complete address range; per-word and component source transitions are skipped.
-- The atom's subprogram DIE remains, so symbolic lookup, explicit address breakpoints, and stepi are unaffected.
if atom.debug_skip then
return table.concat({
set_address(atom.addr),
set_file(ATOM_SOURCE_FILE_INDEX),
advance_line(atom.entries[1].line - 1),
negate_stmt(),
copy_op(),
advance_pc(atom.size_bytes),
negate_stmt(),
end_sequence(),
})
end
-- Per-word invocation ancestry:
-- * `innermost_idx[idx]`: Deepest active invocation at idx (nil for RAW words). Used to pick body_lines[k] for body rows.
-- * `ancestry_idx[idx]`: Full active ancestry at idx, ordered outermost-first (widest range first; innermost = last entry).
-- Consumed at the first word of every invocation (atom entry + every `idx == inv.start_pos + 1`).
-- Drives the nested-display rule: the outer invocation's call-site + body_lines[1] rows are
-- re-emitted at the inner's first word PC so the debugger displays the outer body line
-- (not the inner body line) when stepping into the inner. PROBLEM B fix.
--
-- `start_pos` / `end_pos` are 0-based emitted-word positions stamped at construction/close time by `duffle.emit_invoke_begin` / `duffle.emit_invoke_end`;
-- Missing values are a corpus-plumbing bug, so we let the index expression fail loud with arithmetic-on-nil rather than silently producing `0+1=1` for a missing start_pos.
local invs = atom.invocations or {}
local innermost_idx = {}
local ancestry_idx = {}
for idx = 1, #atom.entries do
innermost_idx[idx] = nil
ancestry_idx[idx] = {}
local active = {}
for _, inv in ipairs(invs) do
if idx >= inv.start_pos + 1 and idx <= inv.end_pos + 1 then
active[#active + 1] = inv
end
end
-- Sort outermost-first (widest range first); innermost is the LAST entry (narrowest range).
table.sort(active, function(a, b)
return (a.end_pos - a.start_pos) > (b.end_pos - b.start_pos)
end)
ancestry_idx[idx] = active
if #active > 0 then
innermost_idx[idx] = active[#active]
end
end
-- atom_dbg_step_ux_20260725 (gdb 12.1 fix): per-invocation `body_first_line` cache.
-- Maps invocation_id -> the line in the PARENT'S source where the body's FIRST CONTENT lives.
-- * If the invocation's body has any NESTED invocations (parent_id == top_inv.id), the body's
-- first content is the call_line of the earliest nested invocation (by start_pos).
-- * Otherwise (only RAW words in the body), it's the line of the first raw word = body_lines[1].
-- This is the value the multi-row PC's body_lines[1] row must reference for source-order display:
-- `anc.body_lines[1]` is the line of the FIRST WORD (which for an outer whose body starts with a
-- nested expansion is inside the inner's expansion = wrong for display purposes); `anc.body_first_line`
-- is the body's first content line in the parent's source (= correct for display).
local body_first_line_of = {}
for _, top_inv in ipairs(invs) do
local earliest_nested_call_line = nil
local earliest_nested_start_pos = nil
for _, cand in ipairs(invs) do
if cand.parent_id == top_inv.id and cand.call_line ~= nil then
if earliest_nested_start_pos == nil or cand.start_pos < earliest_nested_start_pos then
earliest_nested_start_pos = cand.start_pos
earliest_nested_call_line = cand.call_line
end
end
end
if earliest_nested_call_line ~= nil then
body_first_line_of[top_inv.id] = earliest_nested_call_line
elseif top_inv.body_lines and top_inv.body_lines[1] ~= nil then
body_first_line_of[top_inv.id] = top_inv.body_lines[1]
else
body_first_line_of[top_inv.id] = 0
end
end
local parts = {
set_address(atom.addr), -- 7 bytes: marker + size + sub + addr; PC := atom.addr
}
-- Source state tracker: emits set_file + advance_line only on transitions, keeps bytes minimal.
-- Both fields stay in sync with what we emit, so we duplicate no set_file and skip no state-change emit.
local cur_file = nil -- line-state.file_idx (nil = uninitialized)
local cur_line = 1 -- line-state.line starts at 1 (per DWARF spec)
local is_stmt = true -- main line unit default_is_stmt; every sequence ends restored
local function set_statement_state(want_stmt)
if is_stmt ~= want_stmt then
parts[#parts + 1] = negate_stmt()
is_stmt = want_stmt
end
end
-- Emit a state transition (set_file + advance_line + is_stmt as needed) before a row, then the row itself.
-- Used for the row(s) at one entry PC.
local function emit_row(file_idx, line, want_stmt)
if cur_file ~= file_idx then
parts[#parts + 1] = set_file(file_idx)
cur_file = file_idx
end
if cur_line ~= line then
parts[#parts + 1] = advance_line(line - cur_line)
cur_line = line
end
set_statement_state(want_stmt)
parts[#parts + 1] = copy_op()
end
local call_file_idx = ATOM_SOURCE_FILE_INDEX
-- --- Atom entry (idx 1) -------------------------------------------------
local entry_1 = atom.entries[1]
local entry_1_ancestry = ancestry_idx[1]
-- If atom entry 1 starts inside an invocation, walk the ancestry and emit a call-site row + (when applicable)
-- a body_lines[1] row for every active ancestor. For a non-nested invocation this is just the one pair;
-- for nested invocations this emits the outer call-site + body_lines[1] rows BEFORE the inner pair so the debugger displays
-- the outer body line at the inner's first word
-- (PROBLEM B fix).
--
-- A marked OUTERMOST ancestor's body_lines[1] row is suppressed at this PC (the existing full-skip
-- contract is preserved for the marked outer range); its call-site row IS still emitted as a
-- statement. Marked INNER ancestors always emit their body_lines[1] row with is_stmt=false
-- (the per-invocation `want_body = not inv.debug_skip` predicate).
if #entry_1_ancestry == 0 then
-- RAW word at atom entry: single call-site row, always a statement target.
emit_row(call_file_idx, entry_1.line, true)
else
-- Atom starts in an invocation. Walk the ancestry outermost-first.
-- Each ancestor emits one call-site row (statement) and one body_lines[1] row
-- (statement iff unmarked; suppressed for marked outermost).
-- The body_lines[1] row references body_first_line_of[anc.id] (= the body's first content
-- line in the parent's source), NOT anc.body_lines[1] (= the line of the first WORD,
-- which is wrong when the outer's body starts with a nested call).
for ai, anc in ipairs(entry_1_ancestry) do
assert(anc.body_lines, "missing body_lines: emitter did not run emission-model")
assert(anc.body_lines[1] ~= nil
, "dwarf_injection: body_lines[1] missing on first-word entry for inv=" .. tostring(anc.component_name))
assert(anc.call_path and anc.call_path ~= ""
, "dwarf_injection: inv.call_path is missing on invocation " .. tostring(anc.component_name) .. "; emitter did not run emission-model.")
emit_row(resolve_provenance_file_index(anc.call_path), anc.call_line, true)
local is_outermost = (ai == 1)
if not (is_outermost and anc.debug_skip) then
emit_row(resolve_provenance_file_index(anc.def_path), body_first_line_of[anc.id] or anc.body_lines[1], not anc.debug_skip)
end
end
end
-- --- Subsequent entries (idx 2..N) --------------------------------------
for idx = 2, #atom.entries do
local entry = atom.entries[idx]
local inv = innermost_idx[idx]
-- Advance PC by 1 .word (4 bytes on MIPS).
parts[#parts + 1] = advance_pc(MIPS_BYTES_PER_WORD)
if inv and idx == inv.start_pos + 1 then
-- First word of the innermost active invocation (PROBLEM B fix — nested-display rule).
-- Walk the active ancestry outermost-first; for each ancestor emit a call-site row
-- (statement) + a body_lines[1] row. The inner-most invocation's call-site + body pair
-- become the LAST two rows in the sequence. Marked outermost ancestors suppress their
-- body_lines[1] row at this PC (the existing full-skip contract is preserved for the
-- marked outer range); all OTHER ancestors emit body_lines[1] with is_stmt = not debug_skip.
--
-- This re-emits the outer ancestor's call-site + body rows at the inner's first word PC
-- for debugger context: source-level stepping now shows the outer body line (not the
-- inner body line) when stepping into the inner. PROBLEM B fix.
-- The body_lines[1] row references body_first_line_of[anc.id] (= the body's first content
-- line in the parent's source), NOT anc.body_lines[1] (= the line of the first WORD,
-- which is wrong when the outer's body starts with a nested call: gdb 12.1 picks the
-- displayed line as the LAST row at the same PC in byte-stream order, so the disc=1 row's
-- value matters for what's shown when stepping into the nested case).
local ancestry = ancestry_idx[idx]
for ai, anc in ipairs(ancestry) do
assert(anc.body_lines, "missing body_lines: emitter did not run emission-model")
assert(anc.body_lines[1] ~= nil
, string.format("missing body_lines[1] for inv=%s start_pos=%d len=%d",
anc.component_name, anc.start_pos, #(anc.body_lines or {})))
assert(anc.call_path and anc.call_path ~= ""
, "dwarf_injection: inv.call_path is missing on invocation " .. tostring(anc.component_name) .. "; emitter did not run emission-model.")
emit_row(resolve_provenance_file_index(anc.call_path), anc.call_line, true)
local is_outermost = (ai == 1)
if not (is_outermost and anc.debug_skip) then
emit_row(resolve_provenance_file_index(anc.def_path), body_first_line_of[anc.id] or anc.body_lines[1], not anc.debug_skip)
end
end
elseif inv then
-- Subsequent body word of the innermost active invocation: `body_lines[k]` is indexed by the 1-based offset of this word inside the invocation.
-- Both `idx` (1-based DWARF entry index) and `inv.start_pos` (0-based emitted-word position stamped at `emit_invoke_begin`) come from the same
-- monotonic counter, so `idx - inv.start_pos` is exactly the 1-based k (the first word of the invocation has `idx == inv.start_pos + 1`, hence `k == 1`).
-- atom_dbg_step_ux_20260725: `want_body = not inv.debug_skip`. The previous `want = not marked_idx[idx]`
-- (which suppressed ALL body rows when any ancestor was marked) is replaced by the per-invocation
-- predicate. Marked invocations emit non-statement body rows at every body word; unmarked
-- invocations emit statement body rows.
assert(inv.body_lines, "missing body_lines: emitter did not run emission-model")
local words_into = idx - inv.start_pos
assert(inv.body_lines[words_into] ~= nil
, string.format("missing body_lines[%d] for inv=%s start_pos=%d len=%d idx=%d",
words_into, inv.component_name, inv.start_pos, #(inv.body_lines or {}), idx))
emit_row(resolve_provenance_file_index(inv.def_path), inv.body_lines[words_into], not inv.debug_skip)
else
-- RAW word: single call-site row, always a statement target (the word itself is unmarked).
emit_row(call_file_idx, entry.line, true)
end
end
-- Every sequence starts from default_is_stmt=true.
-- Restore that state before DW_LNE_end_sequence: a marked whole atom keeps explicit bounded toggles and no state leaks to the following independent atom sequence.
set_statement_state(true)
parts[#parts + 1] = end_sequence()
return table.concat(parts)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Helpers: nm + source-map.txt + atom table
-- ════════════════════════════════════════════════════════════════════════════
--- Build the atom table the section builders consume.
--- Cross-references nm symbols with `corpus.atoms_by_name` and derives word rows + format-1 outermost invocation rows from `atom.paths`.
---
--- The atom table is built entirely from in-memory state. Disk source-map and provenance text artifacts are diagnostic outputs, not semantic inputs;
--- the DWARF injection pass must remain correct regardless of their on-disk content.
---
--- Skip-state ownership:
--- * Whole-atom skip is read from the `atom.debug_skip` field (stamped by `scan_source.run` when the bare `atom_dbg_skip` marker
--- immediately precedes the declaration; `corpus.atoms_by_name` exposes it directly).
--- * Invocation skip is read from `atom.paths.invocations[*].debug_skip` (stamped by `duffle.emit_invoke_begin`
--- from `corpus.components[name].debug_skip`, no second-pass, no source parse, no parallel lookup).
---
--- The result shape (one entry per ELF symbol matched against the corpus):
--- `{name, addr, size_bytes, words, entries, invocations, debug_skip?}`
--- where:
--- * `entries[i].pos` — 0-based `.word` position (matches the source-map format-1 row layout; downstream DWARF builders compare against this).
--- * `entries[i].line` — call-site line for that word.
--- * `entries[i].text` — trimmed encoder token text from `atom.paths.word_events`.
--- * `invocations[j]` — one entry per format-1 outermost `mac_X(...)` invocation with
--- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos, body_lines, debug_skip}`. `body_lines[k]`
--- is the k-th word's source line within the component body.
---
--- @param corpus table -- the corpus from `ctx.shared.corpus`
--- @param addrs table -- ELF symbols keyed by atom name from `elf_dwarf.read_nm`
--- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, debug_skip?}
local function build_atom_table(corpus, addrs)
-- Cross-ref: keep only atoms present in BOTH the nm symbol table AND `corpus.atoms_by_name`. Output is sorted by ascending addr.
local atoms_by_name = corpus.atoms_by_name or {}
-- Per-atom ingest. Returns nil if the atom is absent from the corpus; the caller skips it via the `if atom then ...` guard.
local function ingest_atom(name, info)
local atom_record = atoms_by_name[name]
if not atom_record then return nil end
local paths = atom_record.paths or {}
local word_events = paths.word_events or {}
local invocations_proj = paths.invocations or {}
-- Build the dense entries list from `word_events`.
-- `word_events[i].i` = the 0-based `.word` position
-- `call_line` = the root atom's physical source line for that word (stamped by emission_model)
local entries = {}
for idx, ev in ipairs(word_events) do
entries[#entries + 1] = {
pos = ev.i or (idx - 1),
line = ev.call_line or 0,
text = ev.call_text or "",
}
end
-- Whole-atom skip is read from the atom declaration record; the scanner owns it, no parallel lookup table.
local atom = {
name = name,
addr = info[1],
size_bytes = info[2],
words = #word_events,
entries = entries,
debug_skip = atom_record.debug_skip == true,
}
-- Consume invocation records from `atom.paths.invocations`. It is the single producer of per-invocation body_lines, per-invocation debug_skip,
-- and per-invocation start_pos/end_pos; the walker stamped every field at the construction/close site (`emit_invoke_begin` / `emit_invoke_end` in duffle.lua).
-- DWARF reconstructs invocation ranges by reading `start_pos` / `end_pos` directly (not by grouping `word_events`, not by deriving
-- `start_pos`/`end_pos` from `start_word`/`end_word`).
-- `start_pos` is the 0-based emitted-word position stamped AT `emit_invoke_begin` time, while `start_word` is the 1-based items index
-- (which differs by the count of `invoke_begin`/`invoke_end`/marker items between this call and the previous one).
-- Using `start_word` would shift every `words_into` lookup by the marker count and break the call-site + body row pairing at the first word of every invocation.
atom.invocations = invocations_proj
for _, inv in ipairs(atom.invocations) do
-- `debug_skip` flag is already stamped by `duffle.emit_invoke_begin` from `corpus.components[name].debug_skip`.
-- Normalize to boolean for downstream dispatch. A missing value is a corpus-plumbing bug; the fail-loud error was raised at the construction site.
inv.debug_skip = inv.debug_skip == true
assert(type(inv.start_pos) == "number"
, "dwarf_injection: inv.start_pos (0-based emitted-word position) is missing on invocation " .. tostring(inv.component_name) .. "; the construction site failed to stamp it.")
assert(type(inv.end_pos) == "number"
, "dwarf_injection: inv.end_pos (0-based emitted-word position) is missing on invocation " .. tostring(inv.component_name) .. "; the close site failed to stamp it.")
end
return atom
end
local out = {}
for name, info in pairs(addrs) do
local atom = ingest_atom(name, info)
if atom then out[#out + 1] = atom end
end
table.sort(out, function(a, b) return a.addr < b.addr end)
return out
end
--- Compute the set of distinct components invoked across all atoms.
--- Returns `{name -> {kind, def_file, def_line}}` keyed by component name (e.g. `yield`, `gte_load_tri_verts`).
--- @param atom_table table[]
--- @return table<string, table>
local function collect_component_defs(atom_table)
local out = {}
for _, atom in ipairs(atom_table) do
for _, inv in ipairs(atom.invocations or {}) do
if not out[inv.component_name] then
out[inv.component_name] = {
name = inv.component_name,
def_file = inv.def_path,
def_line = inv.def_line,
}
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- rbind atom detection + (reg, field) pairing
-- ════════════════════════════════════════════════════════════════════════════
--
-- An "rbind atom" has `atom_bind(Binds_X)` in its `atom_info(...)` sub-call.
-- The atom body pops one field per `load_word(R_<reg>, R_TapePtr, O_(Binds_X,Field))`
-- and the order of these load_word calls defines the (reg, field) pairing for the piece-chain DWARF location expression.
--
-- We use the SourceScan payload populated by `passes/scan_source.lua` (the dep-closed upstream pass).
-- That pass walks each source once and populates `src.scan.atom_infos` (the atom_info sub-call parse) and `src.scan.binds` (the Binds_X struct field parse).
--
-- parse_rbind_atoms consumes `scan.binds[i].fields` directly, which is populated by the scan-source pass with the typed-field record ({name, type_name, pointer_depth, offset, byte_size}).
--- Find every `load_word(R_<reg>, R_TapePtr, O_(Binds_X, FieldName))` call in the atom body and return ordered (reg_index, field_name) pairs.
--- Source pattern (single-line, comma-separated at top level):
--- load_word(R_PrimCursor, R_TapePtr, O_(Binds_CubeTri,PrimCursor)),
--- load_word(R_FaceCursor, R_TapePtr, O_(Binds_CubeTri,FaceCursor)),
--- ...
---
--- The GPR for each `R_<reg>` is looked up in the merged register_alias_registry; aliases absent from the registry
--- (no `atom_reg` opt-in) are silently skipped — the resulting rbind record will be incomplete and the atom will fail to bind a usable piece chain.
--- This is intentional: silently falling back to a hardcoded GPR would mask the missing opt-in.
---
--- Pre-tokenized: `body_tokens` is the scan-source pass's pre-split list of top-level
--- statements (each entry is a single `load_word(...)` call or other statement).
--- @param body_tokens table[] -- the atom's pre-tokenized body statements (from atom.body_tokens)
--- @param binds_name string -- expected Binds_X name (skip pairs with mismatching binds)
--- @param registries table -- merged registries from collect_per_source_registries
--- @return table[] -- list of {reg = <MIPS index>, field = <field name>}
local function parse_body_load_pairs(body_tokens, binds_name, registries)
local pairs = {}
local reg_index_by_name = (registries and registries.register_alias_registry) or {}
for _, t in ipairs(body_tokens or {}) do
local tok = duffle.trim(t.tok or "")
-- Match "load_word(...)" — the entire call is one body_tokens entry.
local inner = tok:match("^load_word%s*%((.*)%)$")
if inner then
local args = duffle.split_top_level_commas(inner)
-- Expected shape: (R_<reg>, R_TapePtr, O_(Binds_<X>, FieldName))
if #args >= 3 then
local reg_name = duffle.trim(args[1])
local third_arg = duffle.trim(args[3])
-- Match O_(Binds_<X>, FieldName)
local b, f = third_arg:match("^O_%((Binds_[%w_]+)%s*,%s*(.-)%s*%)$")
local alias_entry = reg_index_by_name[reg_name]
if b and b == binds_name and alias_entry and alias_entry.code then
pairs[#pairs + 1] = {
reg = alias_entry.code,
field = duffle.trim(f),
}
end
end
end
end
return pairs
end
--- Collect every rbind atom + the matching Binds_X struct + (reg, field) pairs.
---
--- Inputs come from the dep-closed `scan-source` pass (the per-source `src.scan` payload is preserved on each `corpus.source_order` entry).
---
--- Returns:
--- rbind_atoms = {[atom_name] = {binds, fields, regs, byte_size, info_line}}
--- rbind_structs = {[binds_name] = {byte_size, fields, atom_names}}
---
--- The `regs` list per atom is ordered: each entry is the MIPS reg index that holds the matching field in the source-order pop sequence.
--- The piece chain uses (DW_OP_regN, DW_OP_piece, ULEB128(field_size)).
---
--- Binds fields come from `scan.binds`; the per-source `scan.binds[i].fields` already carries the typed-field record after the scan-source generalization.
--- @param corpus table -- the corpus from `ctx.shared.corpus`
--- @param atom_table table[] -- the cross-ref'd atom table from build_atom_table
--- @param registries table -- merged registries from collect_per_source_registries
--- @return table, table -- (rbind_atoms, rbind_structs)
local function parse_rbind_atoms(corpus, atom_table, registries)
registries = registries or {}
local rbind_atoms = {}
local rbind_structs = {}
-- Index binds by struct name; consume `scan.binds[i].fields` directly.
-- The scan-source pass emits each Binds_X's fields as {[type_name, pointer_depth, offset, byte_size, ...]},
-- so this pass builds the rbind_structs entry without re-parsing.
local binds_by_name = {}
for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan
if scan then
for _, b in ipairs(scan.binds or {}) do
binds_by_name[b.name] = b
end
end
end
for binds_name, b in pairs(binds_by_name) do
if b.fields and b.bytes then
rbind_structs[binds_name] = {
bytes = b.bytes,
fields = b.fields,
atom_names = {},
}
end
end
-- Walk every atom_info; if `binds` is set, find the atom body_tokens + parse load_word pairs.
local body_tokens_by_atom = {}
for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan
if scan then
for _, atom in ipairs(scan.atoms or {}) do
body_tokens_by_atom[atom.name] = atom.body_tokens
end
end
end
local ai_by_atom = {}
for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan
if scan then
for _, ai in ipairs(scan.atom_infos or {}) do
ai_by_atom[ai.atom_name] = ai
end
end
end
for atom_name, ai in pairs(ai_by_atom) do
if ai.binds then
local struct = rbind_structs[ai.binds]
local body_toks = body_tokens_by_atom[atom_name]
if struct and body_toks then
local pairs = parse_body_load_pairs(body_toks, ai.binds, registries)
if #pairs > 0 then
rbind_atoms[atom_name] = {
binds = ai.binds,
fields = struct.fields, -- {name, offset} from scan.binds
bytes = struct.bytes,
regs = pairs, -- ordered list of {reg, field}
info_line = ai.info_line,
}
table.insert(struct.atom_names, atom_name)
end
end
end
end
-- Mark rbind atoms in the main atom_table.
for _, atom in ipairs(atom_table) do
if rbind_atoms[atom.name] then
atom.rbind = rbind_atoms[atom.name]
end
end
return rbind_atoms, rbind_structs
end
-- ════════════════════════════════════════════════════════════════════════════
-- Section builders (single dispatch table — see guide_metaprogram_ssdl.md §11)
-- ════════════════════════════════════════════════════════════════════════════
--- Append per-atom line-program sequences to the existing main .debug_line unit
--- (the final unit, referenced by the main CU's DW_AT_stmt_list).
---
--- This builder extends the main compilation unit.
--- A detached synthetic line unit has no DW_AT_stmt_list referencing it, so gdb ignored it (a previous experiment); byte 13 is the first special opcode, not the extended-opcode marker.
--- The existing final unit already contains hello_gte_tape.c as file index 11 and ends with a valid end_sequence.
--- We preserve its bytes, append independent atom sequences, and increase only that unit's DWARF32 unit_length.
--- @param existing string -- existing section bytes, byte-for-byte
--- @param atom_table table -- list of {name, addr, size_bytes, words, entries}
--- @return string
local function build_dwarf_line_section(existing, atom_table)
if #atom_table == 0 then return existing end
-- Build the sequences.
local sequences = {}
for _, atom in ipairs(atom_table) do
sequences[#sequences + 1] = build_atom_sequence(atom)
end
local appended = table.concat(sequences)
-- Walk DWARF32 line units and retain the final unit's bounds.
-- The main C CU points at this final unit (DW_AT_stmt_list = 0x5b in today's ELF).
local unit_pos, last_pos, last_length, last_end = 0, nil, nil, nil
while unit_pos < #existing do
if unit_pos + 4 > #existing then return existing end
local unit_length = elf_dwarf.read_u32_le(existing, unit_pos)
if unit_length == elf_dwarf.ELF32.dw_dwarf32_terminator then return existing end
local unit_end_excl = unit_pos + 4 + unit_length
if unit_end_excl > #existing then return existing end
last_pos, last_length, last_end = unit_pos, unit_length, unit_end_excl
unit_pos = unit_end_excl
end
if unit_pos ~= #existing or not last_pos then return existing end
local new_length = last_length + #appended
local new_length_bytes = elf_dwarf.write_u32_le(new_length)
return existing:sub(1, last_pos)
.. new_length_bytes
.. existing:sub(last_pos + 5, last_end)
.. appended
end
--- Extend .debug_aranges with one entry per atom, pointing at the same CU the existing entries point at
--- (read from the existing header's debug_info_offset field).
--- The 8-byte zero terminator is preserved.
---
--- Existing .debug_aranges layout (DWARF4 §6.1.1, 32-bit):
--- unit_length (4 bytes) -- size of the rest
--- version (2 bytes) -- = 2
--- debug_info_offset (4 bytes) -- CU DIE offset in .debug_info
--- address_size (1 byte) -- = 4 on MIPS
--- segment_size (1 byte) -- = 0
--- [entries...] -- address(4) + length(4) per entry
--- terminator -- address=0 + length=0 (8 zero bytes)
--- @param existing string
--- @param atom_table table
--- @return string
local function build_dwarf_aranges_section(existing, atom_table)
if #existing < 12 then
io.stderr:write("[dwarf_injection] WARN: .debug_aranges section too short (" .. #existing .. " bytes) to contain a unit header; passing through unchanged\n")
return existing
end -- header sanity
-- .debug_aranges can contain multiple compilation units (CUs).
-- gcc-mips-elf emits one CU per .text section TU.
-- We extend the LAST unit by replacing its 8-byte terminator with our atom entries followed by a new 8-byte terminator.
-- We bump the unit's length field accordingly.
--
-- Unit structure (DWARF4 §7.21):
-- unit_length (4)
-- version (2)
-- debug_info_offset (4) -- CU DIE offset in .debug_info
-- address_size (1)
-- segment_size (1)
-- entries... (4-byte addr + 4-byte length)
-- terminator (8 bytes: addr=0, length=0)
-- Walk all units and emit each one (preserving existing structure).
-- For the LAST unit, replace the terminator with my entries + new term.
local result = {}
local i = 0 -- zero-based wire offset
local is_last_unit = false
while i < #existing do
-- Read this unit's length.
local ul = elf_dwarf.read_u32_le(existing, i)
if ul == elf_dwarf.ELF32.dw_dwarf32_terminator then
-- DWARF64 marker - not supported.
io.stderr:write("[dwarf_injection] WARN: .debug_aranges contains a DWARF64 marker (0xFFFFFFFF); the 64-bit extension is not supported by this metaprogram; passing through unchanged\n")
return existing
end
local unit_start = i
local unit_end_excl = i + 4 + ul
is_last_unit = (unit_end_excl == #existing)
if is_last_unit then
-- The old terminator is replaced by entries + a new terminator, so net section growth (and unit_length growth) is entries only.
local added_bytes = #atom_table * elf_dwarf.DWARF4_ARANGES.entry_size
local new_ul = ul + added_bytes
local new_ul_bytes = elf_dwarf.write_u32_le(new_ul)
-- Emit everything EXCEPT the last 8 bytes (terminator).
result[#result + 1] = new_ul_bytes
.. existing:sub(i + 5, unit_end_excl - elf_dwarf.DWARF4_ARANGES.terminator_size)
-- Append my atom entries.
for _, atom in ipairs(atom_table) do
local a = atom.addr
local size = atom.size_bytes
result[#result + 1] = elf_dwarf.write_u32_le(a) .. elf_dwarf.write_u32_le(size)
end
-- Append a new terminator.
result[#result + 1] = string.rep("\0", elf_dwarf.DWARF4_ARANGES.terminator_size)
else
-- Emit this unit unchanged.
result[#result + 1] = existing:sub(unit_start + 1, unit_end_excl)
end
i = unit_end_excl
end
if not is_last_unit then
-- Malformed: walked past the end of the section without finding a unit whose end aligns with #existing.
-- A unit's length field overruns the section, or the last unit has no terminator.
-- Return existing unchanged to avoid making it worse; warn so the user can investigate.
io.stderr:write("[dwarf_injection] WARN: .debug_aranges layout is malformed (no unit's end aligned with section end at " .. #existing .. " bytes); passing through unchanged\n")
return existing
end
return table.concat(result)
end
--- Extend the main CU's DWARF5 range list with one DW_RLE_start_length entry per atom.
--- GDB validates an address against DW_AT_ranges before consulting the CU's line program;
--- .debug_aranges alone is not sufficient.
---
--- Current section shape (one DWARF32 table):
--- unit_length(4), version=5(2), address_size=4(1), segment_size=0(1),
--- offset_entry_count=0(4), start_length entries..., end_of_list(1).
--- @param existing string
--- @param atom_table table
--- @return string
local function build_dwarf_rnglists_section(existing, atom_table)
if #existing <= elf_dwarf.DWARF5_RNGLISTS.first_entry_offset or #atom_table == 0 then return existing end
local unit_length = elf_dwarf.read_u32_le(existing, elf_dwarf.DWARF5_RNGLISTS.unit_length_offset)
local version = elf_dwarf.read_u16_le(existing, elf_dwarf.DWARF5_RNGLISTS.version_offset)
local address_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.addr_size_offset + 1)
local segment_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.seg_size_offset + 1)
local offset_entry_count = elf_dwarf.read_u32_le(existing, elf_dwarf.DWARF5_RNGLISTS.offset_count_offset)
if unit_length + 4 ~= #existing
or version ~= elf_dwarf.DWARF5_RNGLISTS.version_expected
or address_size ~= elf_dwarf.DWARF5_RNGLISTS.addr_size_expected
or segment_size ~= elf_dwarf.DWARF5_RNGLISTS.seg_size_expected
or offset_entry_count ~= elf_dwarf.DWARF5_RNGLISTS.offset_count_expected
or existing:byte(#existing) ~= DW_RLE_end_of_list then
return existing
end
local entries = {}
for _, atom in ipairs(atom_table) do
entries[#entries + 1] = string.char(DW_RLE_start_length)
.. elf_dwarf.write_u32_le(atom.addr)
.. uleb128(atom.size_bytes)
end
local appended = table.concat(entries)
local new_length = unit_length + #appended
local new_length_bytes = elf_dwarf.write_u32_le(new_length)
return new_length_bytes
.. existing:sub(5, #existing - 1)
.. appended
.. string.char(DW_RLE_end_of_list)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Helpers: per-atom .debug_info synthesis (DW_TAG_subprogram + DW_TAG_variable)
-- ════════════════════════════════════════════════════════════════════════════
--- Build the piece-chain location bytes (DW_FORM_exprloc) for an rbind atom's bind_args.
--- DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f; DW_OP_reg15 is 0x5f; 0x90 is DW_OP_regx, not the base of the compact register opcode range.
---
--- The location expression is a sequence of (DW_OP_regN, DW_OP_piece, ULEB128(size)) tuples, one per field.
--- GDB composites the pieces into a struct-shaped value matching the Binds_X layout.
---
--- Order matters: the i-th piece corresponds to the i-th byte range in the composite.
--- For Binds_CubeTri (4 fields × 4 bytes), the chain is:
--- reg15 piece(4) -- R_PrimCursor → PrimCursor @ offset 0
--- reg12 piece(4) -- R_FaceCursor → FaceCursor @ offset 4
--- reg13 piece(4) -- R_VertBase → VertBase @ offset 8
--- reg14 piece(4) -- R_OtBase → OtBase @ offset 12
---
--- Each piece's size = byte offset of next field - byte offset of this field (or struct.byte_size for the last piece).
--- Since all fields are U4/pointer (4 bytes each) in the current source, each piece is 4 bytes.
--- @param rbind table -- {regs = {{reg, field}, ...}, fields = {{name, offset}, ...}, bytes = N}
--- @return string -- the exprloc byte sequence (length-prefixed)
local function piece_chain_exprloc(rbind)
local op_bytes = {}
local field_offset_by_name = {}
for _, f in ipairs(rbind.fields) do
field_offset_by_name[f.name] = f.offset
end
local next_offset = rbind.bytes
for i = #rbind.regs, 1, -1 do -- walk backwards to know each piece's size
local pair = rbind.regs[i]
local off = field_offset_by_name[pair.field] or 0
local size
if i == #rbind.regs then
size = next_offset - off
else
local next_off = field_offset_by_name[rbind.regs[i + 1].field]
if not next_off then
-- Defensive: a load_word references a field not in the Binds_X struct.
-- Fall back to struct.bytes so the piece chain stays well-formed.
next_off = rbind.bytes
end
size = next_off - off
end
-- DW_OP_regN, DW_OP_piece, ULEB128(size)
op_bytes[#op_bytes + 1] = string.char(DW_OP_reg0 + pair.reg, DW_OP_piece) .. uleb128(size)
next_offset = off
end
-- We built it back-to-front; reverse it.
local rev = {}
for i = #op_bytes, 1, -1 do rev[#rev + 1] = op_bytes[i] end
local op = table.concat(rev)
return uleb128(#op) .. op
end
-- ════════════════════════════════════════════════════════════════════════════
-- ULEB/SLEB decoders + .debug_abbrev walker + main-CU locator
-- ════════════════════════════════════════════════════════════════════════════
--
-- Per-atom DWARF DIE synthesis from a DETACHED synthetic CU (which GDB ignored at PC lookup) into the MAIN CU as inserted children.
-- That requires us to:
-- 1. Locate the main CU in .debug_info (walk DWARF32 unit lengths).
-- 2. Locate the main CU's abbrev table in .debug_abbrev (from the CU header).
-- 3. Duplicate that table to a new offset + append codes 100..106.
-- 4. Patch the main CU's debug_abbrev_offset to the duplicate.
-- 5. Insert our DIEs as children of the main CU (just before its root terminator).
--
-- All binary coordinates in this section are zero-based wire offsets. Direct Lua string APIs add `+ 1` at the boundary.
--
-- Pure-Lua 5.3 LEB decoders (no `bit` library; `2^shift` arithmetic matches elf_dwarf.lua's
-- "math.floor(/16) instead of bit.rshift for LuaJIT 2.1 compat" comment at line 352).
-- DWARF5 compile-unit header constants.
local DW_VERSION_5 = 5
local DW_UT_compile = 0x01
local DWARF32_TERMINATOR = 0xFFFFFFFF -- sentinel for DWARF64 marker
local CU_HEADER_SIZE = 12 -- 4 + 2 + 1 + 1 + 4
--- Walk .debug_info to find the FINAL compilation unit, validate it as a DWARF5 32-bit compile-unit, and extract its bounds + abbrev-table offset.
--- Returns nil on any layout mismatch. Callers fall back to existing sections.
---
--- Returns (cu_start, cu_end_excl, abbrev_offset), all 0-based:
--- cu_start -- byte offset of the unit_length field
--- cu_end_excl -- byte offset of the first byte AFTER the main CU
--- abbrev_offset -- the main CU's debug_abbrev_offset field value (0-based)
---
--- Validation:
--- - Section is non-empty.
--- - All units are DWARF32 (unit_length != 0xFFFFFFFF).
--- - No truncated units (each unit's end <= section length).
--- - The section ends exactly on a unit boundary.
--- - At least 2 units present (crt CU + main CU).
--- - The FINAL unit is DWARF5 32-bit compile-unit (version=5, unit_type=0x01, address_size=4).
--- - The final byte of the main CU is 0x00 (the CU's root children-terminator).
--- @param existing string -- the .debug_info section bytes
--- @return integer|nil, integer|nil, integer|nil
local function find_main_cu_layout(existing)
local buf_len = #existing
if buf_len < CU_HEADER_SIZE then return nil end
local pos = 0
local main_cu_start = nil
local main_cu_end_excl = nil
while pos + 4 <= buf_len do
local unit_length = elf_dwarf.read_u32_le(existing, pos)
if unit_length == DWARF32_TERMINATOR then return nil end
local unit_end_excl = pos + 4 + unit_length
if unit_end_excl > buf_len then return nil end
main_cu_start = pos
main_cu_end_excl = unit_end_excl
pos = unit_end_excl
end
if pos ~= buf_len or main_cu_start == nil then return nil end
if main_cu_start == 0 then return nil end -- need at least 2 units (crt + main)
-- Validate the main CU header.
-- Bytes relative to main_cu_start (0-based):
-- [0..3] unit_length
-- [4..5] version
-- [6] unit_type
-- [7] address_size
-- [8..11] debug_abbrev_offset
local hdr = main_cu_start + 4
local version = elf_dwarf.read_u16_le(existing, hdr)
local unit_type = existing:byte(hdr + 2 + 1)
local address_size = existing:byte(hdr + 3 + 1)
local abbrev_off = elf_dwarf.read_u32_le(existing, hdr + 4)
if version ~= DW_VERSION_5 or unit_type ~= DW_UT_compile or address_size ~= 4 then
return nil
end
-- Final byte of the main CU must be the root children-terminator (0).
local final_pos = main_cu_end_excl - 1
if final_pos >= buf_len or existing:byte(final_pos + 1) ~= 0 then return nil end
return main_cu_start, main_cu_end_excl, abbrev_off
end
--- Build the new abbreviation declarations that go AFTER the existing gcc-generated ones. We use codes 100+ to avoid collision.
---
--- The new abbreviations define a 4-deep tree:
--- Abbrev 100 (DW_TAG_compile_unit, with children):
--- DW_AT_name (DW_FORM_strp) -- CU name
--- DW_AT_comp_dir (DW_FORM_strp) -- compilation dir
--- DW_AT_language (DW_FORM_data1) -- one byte language code
--- Abbrev 101 (DW_TAG_subprogram, with children):
--- DW_AT_name (DW_FORM_string) -- atom function name
--- DW_AT_low_pc (DW_FORM_addr) -- atom.addr
--- DW_AT_high_pc (DW_FORM_addr) -- atom.addr + size
--- Abbrev 102 (DW_TAG_variable, no children):
--- DW_AT_name (DW_FORM_string) -- "R_PrimCursor" etc.
--- DW_AT_location (DW_FORM_exprloc) -- DW_OP_regN
--- Abbrev 103 (DW_TAG_structure_type, with children):
--- DW_AT_name (DW_FORM_string) -- "Binds_CubeTri" etc.
--- DW_AT_byte_size (DW_FORM_udata) -- struct byte size
--- Abbrev 104 (DW_TAG_member, no children):
--- DW_AT_name (DW_FORM_string) -- "PrimCursor" etc.
--- DW_AT_data_member_location (DW_FORM_udata) -- byte offset in struct
--- DW_AT_type (DW_FORM_ref4) -- → U4 base type
--- Abbrev 105 (DW_TAG_variable, no children):
--- DW_AT_name (DW_FORM_string) -- "bind_args"
--- DW_AT_location (DW_FORM_exprloc) -- DW_OP_regN, piece, regN, piece, ...
--- DW_AT_type (DW_FORM_ref4) -- → DW_TAG_structure_type DIE
--- Abbrev 106 (DW_TAG_base_type, no children):
--- DW_AT_name (DW_FORM_string) -- "unsigned int"
--- DW_AT_byte_size (DW_FORM_data1) -- 4
--- DW_AT_encoding (DW_FORM_data1) -- DW_ATE_unsigned
--- Abbrev 107 (DW_TAG_subprogram, NO children) — abstract origin.
--- DW_AT_name (DW_FORM_string) -- "mac_yield" etc. (component ident)
--- DW_AT_inline (DW_FORM_data1) -- DW_INL_inlined (= 1)
--- DW_AT_external (DW_FORM_data1) -- 1 (visible across the CU)
--- Abbrev 108 (DW_TAG_inlined_subroutine, with children):
--- DW_AT_abstract_origin (DW_FORM_ref4) -- → ABBREV_ABSTRACT_SUBPROGRAM
--- DW_AT_low_pc (DW_FORM_addr) -- invocation start PC
--- DW_AT_high_pc (DW_FORM_addr) -- invocation end PC
--- DW_AT_call_file (DW_FORM_udata) -- file index of the call site
--- DW_AT_call_line (DW_FORM_udata) -- line of the call site
---
--- Returns the 9 abbrev declarations + a trailing `\0` byte (the table terminator per DWARF5 §7.5.3).
--- When APPENDED to the existing .debug_abbrev (which has its own terminator), the result is:
--- [existing declarations] [existing \0] [new declarations] [\0]
--- which is a valid (extended) abbrev table.
--- @return string
local function build_new_abbrev()
local function attr(name, form) return uleb128(name) .. uleb128(form) end
local function abbrev(code, tag, has_children, attrs)
local children = has_children and 0x01 or 0x00 -- DW_CHILDREN_yes / no
return uleb128(code)
.. uleb128(tag)
.. string.char(children)
.. attrs
.. string.char(0x00, 0x00) -- end of attr list (2 zeros)
end
local abbrev_cu = abbrev(ABBREV_CU, DW_TAG_compile_unit, true, -- DW_CHILDREN_yes
attr( DW_AT_name, DW_FORM_strp)
.. attr(DW_AT_comp_dir, DW_FORM_strp)
.. attr(DW_AT_language, DW_FORM_data1))
local abbrev_subprogram = abbrev(ABBREV_SUBPROGRAM, DW_TAG_subprogram, true, -- DW_CHILDREN_yes
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_low_pc, DW_FORM_addr)
.. attr(DW_AT_high_pc, DW_FORM_addr)
.. attr(DW_AT_linkage_name, DW_FORM_string)) -- equals DW_AT_name; lets gdb's symbol-table lookup resolve to our subprogram (not the gcc global `code_<name>` const U4 array)
local abbrev_variable = abbrev(ABBREV_VARIABLE, DW_TAG_variable, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_location, DW_FORM_exprloc)
.. attr(DW_AT_type, DW_FORM_ref4)
.. attr(DW_AT_external, DW_FORM_data1)) -- DW_AT_external=1: visible at CU scope (gdb's `info locals` + Variables pane shows these for the current frame even if the scope lookup misses)
-- rbind composite.
-- DW_FORM_udata (0x0F, ULEB128) is declared at module scope. For small values (struct byte_size, member offsets) 1 byte is enough;
-- we emit ULEB128 anyway for spec compliance with the DWARF abbrev encoding rules.
local abbrev_struct_type = abbrev(ABBREV_STRUCT_TYPE, DW_TAG_structure_type, true, -- DW_CHILDREN_yes
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_byte_size, DW_FORM_udata))
local abbrev_member = abbrev(ABBREV_MEMBER, DW_TAG_member, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_data_member_location, DW_FORM_udata)
.. attr(DW_AT_type, DW_FORM_ref4))
local abbrev_bind_var = abbrev(ABBREV_BIND_VAR, DW_TAG_variable, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_location, DW_FORM_exprloc)
.. attr(DW_AT_type, DW_FORM_ref4))
local abbrev_base_type = abbrev(ABBREV_BASE_TYPE, DW_TAG_base_type, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_byte_size, DW_FORM_data1)
.. attr(DW_AT_encoding, DW_FORM_data1))
-- Abstract subprograms carry DW_AT_decl_file and DW_AT_decl_line for definition-site resolution,
-- even when no inlined_subroutine instance currently maps to it.
-- DW_FORM_udata is consistent with the call_file/call_line forms on abbrev 108.
local abbrev_abstract_subprogram = abbrev(ABBREV_ABSTRACT_SUBPROGRAM, DW_TAG_subprogram, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_inline, DW_FORM_data1)
.. attr(DW_AT_external, DW_FORM_data1)
.. attr(DW_AT_decl_file, DW_FORM_udata)
.. attr(DW_AT_decl_line, DW_FORM_udata))
local abbrev_inlined_subroutine = abbrev(ABBREV_INLINED_SUBROUTINE, DW_TAG_inlined_subroutine, false, -- DW_CHILDREN_no (emits no per-inlined-instance children; the PC range IS the inlining scope)
attr( DW_AT_abstract_origin, DW_FORM_ref4)
.. attr(DW_AT_low_pc, DW_FORM_addr)
.. attr(DW_AT_high_pc, DW_FORM_addr)
.. attr(DW_AT_call_file, DW_FORM_udata)
.. attr(DW_AT_call_line, DW_FORM_udata))
-- bind_args with DW_FORM_sec_offset → .debug_loclists.
-- The .debug_loclists section holds a sequence of DW_LLE entries;
-- the first matching entry for a PC describes each field's value (tape memory DW_OP_breg24 or register DW_OP_regN).
local abbrev_bind_var_loclists = abbrev(ABBREV_BIND_VAR_LOCLIST, DW_TAG_variable, false, -- DW_CHILDREN_no
attr( DW_AT_name, DW_FORM_string)
.. attr(DW_AT_location, DW_FORM_sec_offset)
.. attr(DW_AT_type, DW_FORM_ref4))
-- Typed-view pointer_type: DW_TAG_pointer_type, DW_AT_type = ref4 (no DW_AT_byte_size; the
-- chain target carries the byte size via the base_type or struct_type we point at).
-- MUST be in the appended table — see ABBREV_TYPED_VIEW_POINTER above.
local abbrev_typed_view_pointer = abbrev(ABBREV_TYPED_VIEW_POINTER, DW_TAG_pointer_type, false, -- DW_CHILDREN_no
attr(DW_AT_type, DW_FORM_ref4))
return abbrev_cu .. abbrev_subprogram .. abbrev_variable
.. abbrev_struct_type .. abbrev_member .. abbrev_bind_var .. abbrev_base_type
.. abbrev_abstract_subprogram .. abbrev_inlined_subroutine
.. abbrev_bind_var_loclists
.. abbrev_typed_view_pointer
.. string.char(0x00) -- abbrev table terminator (DWARF5 §7.5.3)
end
--- Strip a leading "R_" prefix from an enum alias name.
--- The .debug_str/.debug_info consumers display the local without the C-enum prefix (RR_PrimCursor, not RR_R_PrimCursor).
--- Without the strip, the C-level identifier `R_PrimCursor` would collide with the enum constant value 15 when the user runs `print R_PrimCursor` in gdb.
--- @param r_name string -- e.g. "R_PrimCursor"
--- @return string -- "PrimCursor" (or the input unchanged if it does not start with "R_")
local function strip_r_prefix(r_name)
if #r_name >= 2 and r_name:byte(1) == 0x52 and r_name:byte(2) == 0x5F then -- "R_"
return r_name:sub(3)
end
return r_name
end
--- Build the new strings to append to .debug_str. Each string is null-terminated.
-- The CU name + comp_dir + one entry per RR_<name> debug-visible alias all go into one new blob.
-- Register names are sourced from the merged registry filtered to MIPS GPR 0..31
-- (the same filter that build_inserted_children applies for the RR_<name> locals, so .debug_str entries stay in sync with .debug_info).
-- @param atom_table table[] -- list of {name, addr, size_bytes, ...}
-- @param registries table -- merged registries from collect_per_source_registries
-- @return string, table -- (new_strings_blob, map_of_name_to_offset_in_new_blob)
local function build_new_strings(atom_table, registries)
registries = registries or {}
-- The CU name + comp_dir are the first two strings (offsets 0 and N1).
-- Then each unique atom name + each register name follows.
local strings = {}
local map = {}
-- CU name at offset 0 in the new blob
strings[#strings + 1] = DEFAULT_CU_NAME .. "\0"
map["__cu_name__"] = 0
local cu_name_len = #strings[#strings]
-- comp_dir at offset cu_name_len
strings[#strings + 1] = DEFAULT_CU_COMP_DIR .. "\0"
map["__comp_dir__"] = cu_name_len
local comp_dir_len = #strings[#strings]
-- Atom names (one per unique atom)
for _, atom in ipairs(atom_table) do
local name = atom.name
if not map[name] then
map[name] = #table.concat(strings)
strings[#strings + 1] = name .. "\0"
end
end
-- Register names (one per unique debug-visible R_Name alias from the merged registry,
-- filtered to MIPS GPR 0..31 — the same filter that build_inserted_children applies
-- for the RR_<name> locals, so .debug_str entries stay in sync with .debug_info).
-- Lua's pairs() is non-deterministic; sort the alias names first so the emitted .debug_str bytes are byte-identical across runs.
local sorted_alias_names = {}
for r_name, alias in pairs(registries.register_alias_registry or {}) do
if alias.code and alias.code >= 0 and alias.code <= 31 then
sorted_alias_names[#sorted_alias_names + 1] = r_name
end
end
table.sort(sorted_alias_names)
for _, r_name in ipairs(sorted_alias_names) do
local rr_name = "RR_" .. strip_r_prefix(r_name)
if not map[rr_name] then
map[rr_name] = #table.concat(strings)
strings[#strings + 1] = rr_name .. "\0"
end
end
return table.concat(strings), map
end
--- Build the DWARF DIE bytes to insert into the MAIN CU as children, immediately
--- before the main CU's root children-terminator (the final 0 byte of the CU).
---
--- Insert the DIEs as children of the main compilation unit. This keeps `RR_PrimCursor` and `bind_args` in scope for atom PCs.
--- Inserting the DIEs as children of the main CU puts them in scope for every PC the main CU owns, including every atom PC
--- (since `.debug_aranges` + `.debug_rnglists` already assign atom PCs to it).
---
--- Layout (matches the pre-build_new_cu exactly; only the insertion point and ref4 basis change):
--- 1) DW_TAG_base_type "unsigned int" (abbrev 106)
--- DW_AT_name = "unsigned int" (DW_FORM_string)
--- DW_AT_byte_size = 4 (DW_FORM_data1)
--- DW_AT_encoding = DW_ATE_unsigned (DW_FORM_data1)
--- 2) Per Binds_X: DW_TAG_structure_type (abbrev 103, children=yes)
--- DW_AT_name = "Binds_CubeTri" etc. (DW_FORM_string)
--- DW_AT_byte_size = struct bytes (DW_FORM_udata)
--- (children: one DW_TAG_member per field)
--- 3) Per field: DW_TAG_member (abbrev 104)
--- DW_AT_name = "PrimCursor" etc.
--- DW_AT_data_member_location = byte offset (DW_FORM_udata)
--- DW_AT_type = ref4 → base_type DIE
--- 4) Per atom: DW_TAG_subprogram (abbrev 101, children=yes)
--- DW_AT_name = atom.name (DW_FORM_string)
--- DW_AT_low_pc = atom.addr
--- DW_AT_high_pc = atom.addr + size_bytes
--- DW_AT_linkage_name = atom.name (DW_FORM_string; same as DW_AT_name)
--- (children: 6 wave-context vars + 1 bind_args var if rbind)
--- 5) Per wave-context reg: DW_TAG_variable (abbrev 102)
--- DW_AT_name = "RR_<reg>" (DW_FORM_string)
--- DW_AT_location = DW_OP_regN (DW_FORM_exprloc)
--- DW_AT_type = ref4 → base_type DIE
--- DW_AT_external = 1
--- 6) For rbind atoms: DW_TAG_variable "bind_args" (abbrev 105)
--- DW_AT_name = "bind_args"
--- DW_AT_location = piece-chain (DW_FORM_exprloc)
--- DW_AT_type = ref4 → structure_type DIE
---
--- This function does NOT emit the final 0 byte (root terminator). build_debug_info_section splices bytes ahead of the root terminator
--- and preserves existing DIE bytes exactly.
---
--- ref4 basis: DW_FORM_ref4 is CU-relative (offset from the first byte of the CU header).
--- Our inserted DIEs live in the main CU, so every ref4 = (target section offset) - main_cu_offset.
--- Per-die section offsets are tracked via the running `next_offset` cursor (= section offset of the NEXT byte to emit).
---
--- @param main_cu_offset integer -- 0-based section offset of the main CU's unit_length field
--- @param main_cu_end_excl integer -- 0-based section offset of the first byte AFTER the main CU
--- @param atom_table table[] -- atoms (with atom.rbind set if rbind; atom.invocations set if mac_X(...) calls)
--- @param rbind_structs table -- {[binds_name] = {bytes, fields, atom_names}}
--- @param loclists_offsets table -- {[atom_name] = section-relative offset into the new .debug_loclists}
--- @param registries table -- merged registries from collect_per_source_registries
--- @return string -- bytes to splice into the main CU just before its root terminator
local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_table, rbind_structs, loclists_offsets, registries)
rbind_structs = rbind_structs or {}
loclists_offsets = loclists_offsets or {}
registries = registries or {}
-- by_alias: the merged register_alias_registry filtered to aliases whose `code` is a valid MIPS GPR 0..31.
-- Aliases absent from the merged registry are skipped entirely; absent aliases have no debug-visible fallback GPR.
-- by_alias_order: sorted list of by_alias keys, for deterministic iteration order.
-- Lua's pairs() order is implementation-defined and varies between runs; without sorting, the per-atom variable emission order
-- would be non-deterministic and the .debug_info bytes would differ across builds.
local by_alias = {}
for r_name, alias in pairs(registries.register_alias_registry or {}) do
if alias.code and alias.code >= 0 and alias.code <= 31 then
by_alias[r_name] = alias
end
end
local by_alias_order = {}
for r_name in pairs(by_alias) do by_alias_order[#by_alias_order + 1] = r_name end
table.sort(by_alias_order)
-- Build a name->atom lookup for fast rbind_atom resolution during the per-atom phase/ctx propagation.
-- Cheap (O(atom_table)) and built once.
local function build_atom_name_index(atoms)
local m = {}
for _, a in ipairs(atoms or {}) do
if a and a.name then m[a.name] = a end
end
return m
end
local atom_by_name_global = build_atom_name_index(atom_table)
-- We insert IMMEDIATELY BEFORE the main CU's root children-terminator (the last byte of the main CU).
-- The first emitted byte lives at section offset (main_cu_end_excl - 1).
local insertion_start = main_cu_end_excl - 1
-- Closure state: bytes + next_offset + the typed-view/structure/abstract offset caches.
-- All step-emitters mutate this state in place.
local S = {
bytes = {},
next_offset = insertion_start, -- 0-based section offset of the NEXT byte to emit
type_chain_offsets = {}, -- {[type_name.."|"..depth] = section_offset for the outermost pointer_type}
struct_section_offsets = {}, -- {[binds_name] = section_offset for the structure_type}
abstract_offsets = {}, -- {[comp_name] = section_offset for the abstract subprogram}
member_base_type_offsets = {}, -- {[tn.."|"..byte_size.."|"..encoding] = section_offset}
base_type_section_offset = nil, -- set by emit_unsigned_int_base_type
}
local function emit(s)
S.bytes[#S.bytes + 1] = s
S.next_offset = S.next_offset + #s
end
local function ref4_of(section_offset)
if section_offset == nil then return 0 end
return section_offset - main_cu_offset
end
-- 1) Emit the base_type DIE first (member ref4s reference it).
local base_type_section_offset = S.next_offset
emit(uleb128(ABBREV_BASE_TYPE))
emit("unsigned int\0") -- DW_FORM_string (DW_AT_name)
emit(string.char(4)) -- DW_FORM_data1 (DW_AT_byte_size)
emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding)
-- The function body below reads S.next_offset directly via the `next_offset` function;
-- this keeps offsets synchronized with emitted data.
local function next_offset() return S.next_offset end
-- Typed local views.
-- For each unique (type_name, pointer_depth) pair across all rbind atom fields, emit a synthetic type chain.
-- The chain is: ... → pointer_type → typedef → base_type.
-- The outermost pointer_type (depth = N) is the variable's DW_AT_type.
-- For a field declared "V4_S2*" (depth=1), the chain is:
-- V4_S2 (typedef, references base_type 4-byte unsigned) + ptr_to_V4_S2_1 (pointer_type, byte_size 4, refs the typedef)
-- gdb walks: variable type = ptr_to_V4_S2_1 → V4_S2 → base_type, and displays "V4_S2 *" (the typedef's name + the pointer depth).
local type_offsets = {} -- {[type_name] = section_offset for the typedef DIE}
-- Always include the base_type "unsigned int" as the U4 target.
type_offsets["U4"] = base_type_section_offset
-- Collect every unique (type_name, max_pointer_depth) used by any rbind field.
local used_typed_views = {} -- { [type_name] = max_depth }
for _, atom in ipairs(atom_table) do
if atom.rbind and atom.rbind.fields then
for _, f in ipairs(atom.rbind.fields) do
if f.type_name and f.pointer_depth and f.pointer_depth > 0 then
local depth = used_typed_views[f.type_name] or 0
if f.pointer_depth > depth then
used_typed_views[f.type_name] = f.pointer_depth
end
end
end
end
end
-- Sort for deterministic emission.
local sorted_typed_types = {}
for tn in pairs(used_typed_views) do sorted_typed_types[#sorted_typed_types + 1] = tn end
table.sort(sorted_typed_types)
-- For each non-U4 type, emit a typedef (DW_TAG_typedef) named after the type and referencing the base_type "unsigned int" (4 bytes).
-- The typedef gives gdb a named anchor; the pointer_type chain wraps it.
-- The cleanest path is to define new abbrevs in build_new_abbrev(); we emit fresh DW_TAG_base_type DIEs (byte_size 4, encoding unsigned)
-- and the variable's DW_AT_type references the pointer chain's outermost base_type.
-- The displayed type name is the type_name (e.g. "V4_S2") because we use DW_FORM_string on the field type;
-- gdb walks the chain and displays the name.
--
-- For each (type_name, depth), emit a real structure_type with proper member layout,
-- then a single DW_TAG_pointer_type pointing at the structure_type. gdb's
-- `print *<var>` then expands the struct and lists its members (x, y, z, w for V4_S2; etc.).
--
-- Hardcoded member-info table for the 6 typed views the prototype uses (V2_S2 / V3_S2 / V4_S2 / V2_S4 / V3_S4 / V4_S4).
-- Each row is {byte_size, members}, where each member is {name, offset, byte_size, type_name}.
-- `type_name` is the base type name from `type_name_registry`; "S2" / "S4" need a signed base_type emit too.
--
-- The table is small + explicit — the prototype principle treats the typed-view struct layout as data, not derived state.
local STRUCT_MEMBER_TABLE = {
-- 2-element signed short vector (rare; placeholder for future use).
V2_S2 = { byte_size = 4, members = {
{ name = "x", offset = 0, byte_size = 2 },
{ name = "y", offset = 2, byte_size = 2 },
}},
-- 4-element signed short vector (the dominant face-cursor type).
V4_S2 = { byte_size = 8, members = {
{ name = "x", offset = 0, byte_size = 2 },
{ name = "y", offset = 2, byte_size = 2 },
{ name = "z", offset = 4, byte_size = 2 },
{ name = "w", offset = 6, byte_size = 2 },
}},
-- 3-element signed short vector (the floor face-cursor type; includes explicit pad byte to match math.h's S2 pad member).
V3_S2 = { byte_size = 8, members = {
{ name = "x", offset = 0, byte_size = 2 },
{ name = "y", offset = 2, byte_size = 2 },
{ name = "z", offset = 4, byte_size = 2 },
{ name = "pad", offset = 6, byte_size = 2 },
}},
-- 2-element signed int vector.
V2_S4 = { byte_size = 8, members = {
{ name = "x", offset = 0, byte_size = 4 },
{ name = "y", offset = 4, byte_size = 4 },
}},
-- 3-element signed int vector.
V3_S4 = { byte_size = 16, members = {
{ name = "x", offset = 0, byte_size = 4 },
{ name = "y", offset = 4, byte_size = 4 },
{ name = "z", offset = 8, byte_size = 4 },
{ name = "pad", offset = 12, byte_size = 4 },
}},
-- 4-element signed int vector.
V4_S4 = { byte_size = 16, members = {
{ name = "x", offset = 0, byte_size = 4 },
{ name = "y", offset = 4, byte_size = 4 },
{ name = "z", offset = 8, byte_size = 4 },
{ name = "w", offset = 12, byte_size = 4 },
}},
}
local S2_TYPE_BYTE_SIZE = 2 -- S2 = signed 16-bit (see dsl.h)
local S4_TYPE_BYTE_SIZE = 4 -- S4 = signed 32-bit (see dsl.h)
-- Pre-emit the signed base types (S2, S4) once if any typed view's member needs them.
-- We emit per-typed-view lazily below; build a member-base-type offset cache (idempotent).
local member_base_type_offsets = {}
local function ensure_member_base_type(tn, byte_size, encoding)
local key = tn .. "|" .. byte_size .. "|" .. encoding
if member_base_type_offsets[key] then return member_base_type_offsets[key] end
local off = next_offset()
emit(uleb128(ABBREV_BASE_TYPE))
emit(tn .. "\0")
emit(string.char(byte_size))
emit(string.char(encoding))
member_base_type_offsets[key] = off
return off
end
-- Always emit S2 + S4 (the v*_S2 / v*_S4 family base types) once before any typed-view,
-- even if no atom currently declares a v*_S2 field, so future atoms pick them up without rewiring.
ensure_member_base_type("S2", S2_TYPE_BYTE_SIZE, 5) -- DW_ATE_signed = 5
ensure_member_base_type("S4", S4_TYPE_BYTE_SIZE, 5)
local type_chain_offsets = {}
for _, tn in ipairs(sorted_typed_types) do
if tn ~= "U4" then
local depth = used_typed_views[tn]
local type_info = STRUCT_MEMBER_TABLE[tn]
if not type_info then
-- Unknown typed view: fall back to a generic 4-byte unsigned base_type to keep the wire valid.
-- gdb renders as the typename but `print *ptr` only sees the first 4 bytes.
local innermost_offset = next_offset()
emit(uleb128(ABBREV_BASE_TYPE))
emit(tn .. "\0")
emit(string.char(U4_BYTE_SIZE)) -- byte_size = 4 (fallback for unknown types)
emit(string.char(DW_ATE_unsigned)) -- encoding = unsigned
local outermost_offset = next_offset()
emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9)
emit(elf_dwarf.write_u32_le(ref4_of(innermost_offset)))
type_chain_offsets[tn .. "|" .. depth] = outermost_offset
else
-- Emit a proper structure_type DIE for this typed view.
local struct_offset = next_offset()
emit(uleb128(ABBREV_STRUCT_TYPE))
emit(tn .. "\0") -- DW_AT_name (struct_type has children, no name in abbrev 103 [DW_FORM_string only])
emit(uleb128(type_info.byte_size)) -- DW_AT_byte_size (DW_FORM_udata)
-- For each member, emit ABBREV_MEMBER (name + data_member_location + type ref4).
for _, m in ipairs(type_info.members) do
emit(uleb128(ABBREV_MEMBER))
emit(m.name .. "\0")
emit(uleb128(m.offset))
-- The member's type is S2 (for v*_S2 family) or S4 (for v*_S4 family), based on the member's byte_size.
local member_type_name, member_type_encoding
if m.byte_size == 2 then
member_type_name = "S2"
member_type_encoding = 5 -- DW_ATE_signed = 5
elseif m.byte_size == 4 then
member_type_name = "S4"
member_type_encoding = 5 -- DW_ATE_signed = 5
else
-- Unexpected byte_size; fall back to U4 (unsigned int) since U4 base type is already emitted
member_type_name = "U4"
member_type_encoding = 7 -- DW_ATE_unsigned = 7
end
local member_base_off = ensure_member_base_type(member_type_name, m.byte_size, member_type_encoding)
emit(elf_dwarf.write_u32_le(ref4_of(member_base_off))) -- DW_AT_type ref4 → base_type
end
emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of structure_type's children (DWARF5 §7.5.3)
-- Emit a single DW_TAG_pointer_type (abbrev 110) pointing at the structure_type. We picked 110 over 9 (the duplicated gcc pointer_type) because
-- abbrev 9 carries DW_AT_byte_size + DW_AT_type and gdb would misparse our ref4 as those attributes. Abbrev 110 has only DW_AT_type, so a 4-byte ref4 lands cleanly on the target type.
-- For depth > 1, we'd chain pointer_type → pointer_type → ... → structure_type; that path isn't exercised today.
if depth == 1 then
local outermost_offset = next_offset()
emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9)
emit(elf_dwarf.write_u32_le(ref4_of(struct_offset))) -- DW_AT_type → structure_type
type_chain_offsets[tn .. "|" .. depth] = outermost_offset
else
error("typed-view: pointer_depth > 1 is not yet supported in this emission path")
end
end
end
end
-- 1b) Emit the void* fallback chain (used by step (f) of the per-RR_<R_Name> precedence chain).
-- One DW_TAG_base_type DIE named "void" + one DW_TAG_pointer_type pointing at it.
-- Unannotated RR_<R_X> locals reference the outermost pointer_type; gdb displays the value as `(void *) 0x...` (hex)
-- per the prototype principle rather than `unsigned int`.
-- The void base_type is emitted BEFORE any other typed chain so its ref4 pointer remains stable.
-- Follow the SAME pattern as the typed-views chain above: capture the offset BEFORE the uleb tag
-- (this is the ref4 target), emit the DIE bytes, then emit the pointer_type pointing at the offset.
local void_chain_offset = next_offset()
emit(uleb128(ABBREV_BASE_TYPE))
emit("void\0") -- DW_FORM_string (DW_AT_name)
emit(string.char(1)) -- DW_FORM_data1 (DW_AT_byte_size = 1; DWARF's "void" base_type)
emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding = unsigned; DWARF doesn't define a void encoding but gdb reads the name "void" off the DIE and renders it correctly as `(void *)` when wrapped in a pointer_type)
emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9; void chain target)
emit(elf_dwarf.write_u32_le(ref4_of(void_chain_offset))) -- 4-byte ref4: points at the void base_type's tag byte
-- type_chain_offsets["void|1"] is what step (f) of the per-RR_<R_Name> chain looks up.
type_chain_offsets["void|1"] = void_chain_offset -- both the base_type offset and the pointer_type are emitted consecutively; the OUTERMOST is the pointer_type. The variable's DW_AT_type must reference the pointer_type, not the base_type. Patch below.
-- Capture the pointer_type's offset (the last-thing-emitted DIE start) and overwrite the lookup.
-- The pointer_type was emitted as: uleb(9) (1 byte) + 4-byte ref4 = 5 bytes. Its tag byte is at void_chain_offset + 8 (the base_type's 8 bytes: 1 tag + 5 name + 1 byte_size + 1 encoding).
local ptr_void_offset = void_chain_offset + 8
type_chain_offsets["void|1"] = ptr_void_offset
-- 1c) Emit the U4 * pointer chain (step (e) fallback for enum-site `atom_type(U4 *)`).
-- The chain is just one DW_TAG_pointer_type pointing at the pre-emitted "unsigned int" base_type at `base_type_section_offset`.
-- This is intentionally separate from the per-type typed-views chain (which emits V4_S2* / V3_S2* etc.)
-- because U4 is the C-side typedef alias for `unsigned int`, not a fresh struct;
-- reusing the pre-emitted base type keeps the wire consistent.
-- Once this chain is registered as `type_chain_offsets["U4|1"]`, step (e) of the per-RR_<R_Name> precedence chain will resolve `atom_type(U4 *)`
-- declarations on aliases like `R_PrimCursor` and `R_OtBase` to `U4 *` (gdb renders as `(unsigned int *)` with the value displayed in hex).
emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9; U4 chain target)
emit(elf_dwarf.write_u32_le(ref4_of(base_type_section_offset))) -- 4-byte ref4 → "unsigned int" base_type
local u4_chain_offset = next_offset() - 5 -- 1 (uleb tag) + 4 (ref4) = 5 bytes; capture the pointer_type's start offset
type_chain_offsets["U4|1"] = u4_chain_offset
-- 2) Emit one DW_TAG_structure_type per unique Binds_X.
local struct_section_offsets = {}
local sorted_struct_names = {}
for k in pairs(rbind_structs) do sorted_struct_names[#sorted_struct_names + 1] = k end
table.sort(sorted_struct_names)
for _, binds_name in ipairs(sorted_struct_names) do
local struct = rbind_structs[binds_name]
struct_section_offsets[binds_name] = next_offset()
emit(uleb128(ABBREV_STRUCT_TYPE))
emit(binds_name .. "\0") -- DW_FORM_string (DW_AT_name)
emit(uleb128(struct.bytes)) -- DW_FORM_udata (DW_AT_byte_size)
-- Emit DW_TAG_member children (one per field).
for _, field in ipairs(struct.fields) do
emit(uleb128(ABBREV_MEMBER))
emit(field.name .. "\0") -- DW_FORM_string (DW_AT_name)
emit(uleb128(field.offset)) -- DW_FORM_udata (DW_AT_data_member_location)
-- typed field:
-- For a pointer-typed field, the member's DW_AT_type points at the deepest pointer_type in its chain.
-- For U4 (no pointer), it points at the base_type.
local field_type_offset
if field.pointer_depth and field.pointer_depth > 0 then
field_type_offset = type_chain_offsets[field.type_name .. "|" .. field.pointer_depth]
end
if not field_type_offset then
field_type_offset = base_type_section_offset
end
emit(elf_dwarf.write_u32_le(ref4_of(field_type_offset))) -- DW_FORM_ref4 → type
end
emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of structure_type's children (DWARF5 §7.5.3)
end
-- 3) Emit one abstract DW_TAG_subprogram per unique mac_X component.
-- Each abstract DIE is a CU-level child (sibling of the per-atom subprograms below).
-- The abstract DIE's section offset is later used by inlined_subroutine DIEs (which embed `DW_AT_abstract_origin = ref4 → abstract DIE`).
-- Each abstract DIE also carries DW_AT_decl_file + DW_AT_decl_line pointing at the component's definition site (file path + body line).
local component_defs = collect_component_defs(atom_table)
local abstract_offsets = {} -- name -> section offset
local sorted_comp_names = {}
for name in pairs(component_defs) do sorted_comp_names[#sorted_comp_names + 1] = name end
table.sort(sorted_comp_names)
-- DW_INL_inlined (1) = "this subroutine was inlined" — accurate for the mac_* components.
local DW_INL_inlined = 0x01
for _, comp_name in ipairs(sorted_comp_names) do
local def = component_defs[comp_name]
abstract_offsets[comp_name] = next_offset()
emit(uleb128(ABBREV_ABSTRACT_SUBPROGRAM))
emit("mac_" .. comp_name .. "\0") -- DW_FORM_string (DW_AT_name)
emit(string.char(DW_INL_inlined)) -- DW_FORM_data1 (DW_AT_inline)
emit(string.char(0x01)) -- DW_FORM_data1 (DW_AT_external=1)
-- decl_file + decl_line resolve the abstract origin back to its definition site even when no inlined_subroutine instance maps to it.
emit(uleb128(resolve_provenance_file_index(def.def_file))) -- DW_FORM_udata (DW_AT_decl_file)
emit(uleb128(def.def_line)) -- DW_FORM_udata (DW_AT_decl_line)
end
-- 4) Emit per-atom DW_TAG_subprograms (children of main CU).
-- Subprogram names match nm symbols without a `code_` prefix.
-- The gcc global `<name>[]` is a DW_TAG_variable without children; our subprogram has the wave-context var children.
-- gdb's symbol resolution picks our subprogram (it has low_pc/high_pc + children) over the gcc global for function-context lookups.
for _, atom in ipairs(atom_table) do
emit(uleb128(ABBREV_SUBPROGRAM))
emit(atom.name .. "\0") -- DW_FORM_string (DW_AT_name)
emit(elf_dwarf.write_u32_le(atom.addr))
emit(elf_dwarf.write_u32_le(atom.addr + atom.size_bytes))
emit(atom.name .. "\0") -- DW_FORM_string (DW_AT_linkage_name; same as DW_AT_name for non-mangled C)
-- Per debug-visible R_ alias (filtered to GPR 0..31 in `by_alias`): DW_TAG_variable.
-- Precedence chain (per atom, per RR_<R_Name>):
-- (a) per-atom callsite atom_type(R_X, <T>): atom_views reg_type_overrides[atom_name][R_Name] — most specific; user explicit override for THIS atom only
-- (b) atom_ctx(<rbind_atom>): propagate THIS atom's declared rbind atom's Binds_* field types
-- (c) self-binding: atom.rbind.fields / regs (only when this atom IS the rbind atom for its Binds_*)
-- (d) atom_phase(<label>)-grouped: first atom in source-order that shares this label AND has its own atom.rbind provides the Binds_* field types
-- (e) enum-site atom_type(<T>) default: register_alias_registry[R_Name].default_type (per-alias fallback declared in lottes_tape.h)
-- (f) void* fallback: the void_chain_offset built in section 1b; gdb renders `(void *) 0x...` (hex)
-- An R_Name absent from the registry AND missed by all of (a..e) skips emission for that alias entirely.
local atom_view_ctx_fields = nil -- populated by step (b); map field_name -> field entry
local reg_to_field_ctx = nil -- populated by step (b); map GPR index -> field name
local atom_view_phase_fields = nil -- populated by step (d); map field_name -> field entry
local reg_to_field_phase = nil -- populated by step (d); map GPR index -> field name
-- atom-name -> atom lookup is precomputed once as atom_by_name_global.
local field_type_by_name = {}
if atom.rbind and atom.rbind.fields then
for _, f in ipairs(atom.rbind.fields) do
if f.type_name then
field_type_by_name[f.name] = f
end
end
end
local reg_to_field = {}
if atom.rbind and atom.rbind.regs then
for _, pair in ipairs(atom.rbind.regs) do
reg_to_field[pair.reg] = pair.field
end
end
local atom_view = (registries.atom_views or {})[atom.name]
-- Build the atom-name lookup table once (cheap; O(atom_table)) so step (b) and step (d) can resolve rbind_atom names.
local atom_by_name = atom_by_name or (function() local m = {}; for _, a in ipairs(atom_table) do if a.name then m[a.name] = a end end; return m end)()
-- step (b) inputs: this atom's `atom_ctx(<rbind_atom>)` (resolved from the registries' atom_ctxs)
local this_ctx = registries.atom_ctxs and registries.atom_ctxs[atom.name]
if this_ctx and this_ctx.rbind_atom then
local rbind = atom_by_name_global[this_ctx.rbind_atom]
if rbind and rbind.rbind and rbind.rbind.fields then
atom_view_ctx_fields = {}
for _, f in ipairs(rbind.rbind.fields) do atom_view_ctx_fields[f.name] = f end
if rbind.rbind.regs then
reg_to_field_ctx = {}
for _, pair in ipairs(rbind.rbind.regs) do reg_to_field_ctx[pair.reg] = pair.field end
end
end
end
-- step (d) inputs: this atom's `atom_phase(<label>)`. Find the FIRST atom in the same phase group that has its own rbind
-- and is in source-order (declared before this atom in any source file); locate it via the per-atom_info entry whose phase == this atom's name.
local my_phase_label = nil
for _, ai in ipairs(registries.atom_infos or {}) do
if ai.atom_name == atom.name and ai.phase then
my_phase_label = ai.phase
break
end
end
if my_phase_label then
local group = (registries.atom_phases or {})[my_phase_label]
if group and group.atoms then
for _, group_atom_name in ipairs(group.atoms) do
if group_atom_name ~= atom.name then
local cand = atom_by_name_global[group_atom_name]
if cand and cand.rbind and cand.rbind.fields then
atom_view_phase_fields = {}
for _, f in ipairs(cand.rbind.fields) do atom_view_phase_fields[f.name] = f end
if cand.rbind.regs then
reg_to_field_phase = {}
for _, pair in ipairs(cand.rbind.regs) do reg_to_field_phase[pair.reg] = pair.field end
end
break
end
end
end
end
end
-- 5-step precedence chain. The dispatch loop runs the first step that yields a non-nil offset; each step returns the type's section offset or nil.
-- Per-atom precomputed state is captured in upvalues: atom_view, reg_to_field_ctx, atom_view_ctx_fields,
-- reg_to_field_phase, atom_view_phase_fields, field_type_by_name, reg_to_field, alias, type_chain_offsets.
local PRECEDENCE_STEPS = {
-- (a) per-atom callsite atom_type(R_X, <T>): most specific; user explicit override for THIS atom only.
function(r_name, alias_code)
local override = atom_view and atom_view.reg_type_overrides and atom_view.reg_type_overrides[r_name]
if override and override.pointer_depth and override.pointer_depth > 0 then
return type_chain_offsets[override.type_name .. "|" .. override.pointer_depth]
end
end,
-- (b) atom_ctx(<rbind_atom>) propagation.
function(r_name, alias_code)
local ctx_field_name = reg_to_field_ctx and reg_to_field_ctx[alias_code]
local ctx_f = ctx_field_name and atom_view_ctx_fields and atom_view_ctx_fields[ctx_field_name]
if ctx_f and ctx_f.pointer_depth and ctx_f.pointer_depth > 0 then
return type_chain_offsets[ctx_f.type_name .. "|" .. ctx_f.pointer_depth]
end
end,
-- (c) self-binding (this atom IS the rbind atom for its Binds_*).
function(r_name, alias_code)
local field_name = reg_to_field[alias_code]
local f = field_name and field_type_by_name[field_name]
if f and f.pointer_depth and f.pointer_depth > 0 then
return type_chain_offsets[f.type_name .. "|" .. f.pointer_depth]
end
end,
-- (d) atom_phase(<label>)-grouped propagation.
function(r_name, alias_code)
local phase_field_name = reg_to_field_phase and reg_to_field_phase[alias_code]
local phase_f = phase_field_name and atom_view_phase_fields and atom_view_phase_fields[phase_field_name]
if phase_f and phase_f.pointer_depth and phase_f.pointer_depth > 0 then
return type_chain_offsets[phase_f.type_name .. "|" .. phase_f.pointer_depth]
end
end,
-- (e) enum-site atom_type(<T>) default on the registry entry.
function(r_name, alias_code)
if alias and alias.default_type and alias.default_depth and alias.default_depth > 0 then
return type_chain_offsets[alias.default_type .. "|" .. alias.default_depth]
end
end,
}
-- Iterate `by_alias` in sorted order; Lua's pairs() is non-deterministic, so sorting ensures byte-identical DWARF output across builds.
for _, r_name in ipairs(by_alias_order) do
local alias = by_alias[r_name]
local rr_name = "RR_" .. strip_r_prefix(r_name)
local alias_code = alias.code
emit(uleb128(ABBREV_VARIABLE))
emit(rr_name .. "\0") -- DW_FORM_string (DW_AT_name)
-- DW_FORM_exprloc: ULEB byte count + DW_OP_regN byte. DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f; DW_OP_reg15 is 0x5f.
-- `alias_code` is the MIPS GPR index (0..31) from the merged register_alias_registry.
emit(uleb128(1) .. string.char(DW_OP_reg0 + alias_code)) -- DW_FORM_exprloc (DW_OP_regN from registry code)
-- Precedence chain (a..e). Step (f) is the void* fallback initialized below; the chain overrides it when any step yields a non-nil offset.
local type_offset = type_chain_offsets["void|1"]
for _, step in ipairs(PRECEDENCE_STEPS) do
local candidate = step(r_name, alias_code)
if candidate then type_offset = candidate; break end
end
emit(elf_dwarf.write_u32_le(ref4_of(type_offset))) -- DW_FORM_ref4 → type
emit(string.char(0x01)) -- DW_AT_external=1 (visible at CU scope)
end
-- If rbind, emit bind_args variable with PC-ranged location list.
-- The loclist lives in .debug_loclists and is indexed by `DW_FORM_sec_offset` (4-byte section-relative offset).
-- Two PC ranges cover every field: [atom.addr, last_load+8) describes each field as tape memory (DW_OP_bregN + offset) piece,
-- and [last_load+8, atom.end) describes each field as a GPR (DW_OP_regN) piece.
if atom.rbind then
local binds_name = atom.rbind.binds
local loclists_offset = loclists_offsets[atom.name] or 0
emit(uleb128(ABBREV_BIND_VAR_LOCLIST))
emit("bind_args\0") -- DW_FORM_string (DW_AT_name)
emit(elf_dwarf.write_u32_le(loclists_offset)) -- DW_FORM_sec_offset → .debug_loclists
emit(elf_dwarf.write_u32_le(ref4_of(struct_section_offsets[binds_name]))) -- DW_FORM_ref4 → struct_type
end
-- Per-component invocation inlined_subroutine instances.
-- Each invocation covers a contiguous .word range [start_pos, end_pos] within the atom; we compute the PC range from
-- atom start + .word offsets × MIPS_BYTES_PER_WORD. `inv.call_file` resolves to the line-unit file index, preserving call-site attribution across source files.
-- A skipped invocation suppresses its synthetic inline frame entirely so source-level `step` cannot descend into it; the per-PC-range non-statement rows in .debug_line (emitted above) provide full skip semantics.
if atom.invocations and not atom.debug_skip then
for _, inv in ipairs(atom.invocations) do
if inv.debug_skip then
-- This invocation emits no inlined_subroutine DIE; the whole-PC-range non-statement rows in .debug_line provide full skip semantics.
-- Stepping from the preceding atom statement lands at the first unskipped row after this invocation's range.
else
local inv_low = atom.addr + inv.start_pos * MIPS_BYTES_PER_WORD
local inv_high = atom.addr + (inv.end_pos + 1) * MIPS_BYTES_PER_WORD
emit(uleb128(ABBREV_INLINED_SUBROUTINE))
emit(elf_dwarf.write_u32_le(ref4_of(abstract_offsets[inv.component_name]))) -- DW_FORM_ref4 → abstract_origin
emit(elf_dwarf.write_u32_le(inv_low)) -- DW_FORM_addr (DW_AT_low_pc)
emit(elf_dwarf.write_u32_le(inv_high)) -- DW_FORM_addr (DW_AT_high_pc)
emit(uleb128(resolve_provenance_file_index(inv.call_path))) -- DW_FORM_udata (DW_AT_call_file)
emit(uleb128(inv.call_line)) -- DW_FORM_udata (DW_AT_call_line)
end
end
end
emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of subprogram's children (DWARF5 §7.5.3)
end
-- Do not emit a final 0 here; build_debug_info_section preserves the root terminator byte.
return table.concat(S.bytes)
end
--- Build the new .debug_abbrev: existing + duplicate of the main CU's table + the new declarations 100..106 (with their terminating 0).
---
--- Why duplicate the main table: a CU can name only one abbrev table via its `debug_abbrev_offset` field.
--- The main CU currently points at the gcc-generated table (codes 1..60+); codes 100..106 are NEW, so they live in a different table.
--- To keep all main-CU abbreviation codes in one table we DUPLICATE the gcc-generated codes at a new offset and append our new declarations 100..106 immediately after (with a single shared terminator 0).
---
--- Result layout (0-based section offsets):
--- [0 .. #existing-1] existing .debug_abbrev (unchanged)
--- [#existing .. +#main_dup-1] duplicate of the main table (codes 1..60+, NO terminator)
--- [+#main_dup .. +#new_abbrevs-1] codes 100..106 + final 0
---
--- The MAIN CU's debug_abbrev_offset is patched to `#existing` (start of the duplicate table).
--- Codes 100..106 live inside the same table immediately after the duplicated gcc codes, so a single abbrev-table pointer is enough.
---
--- Fails safely by returning existing sections unchanged if the table walker can't find the table terminator (malformed input).
---
--- @param existing string -- existing .debug_abbrev bytes, byte-for-byte
--- @param main_abbrev_offset integer -- 0-based offset into `existing` of the main CU's abbrev table
--- @return string, integer -- (new_abbrev_bytes, offset_where_duplicate_table_starts = #existing)
local function build_debug_abbrev_section(existing, main_abbrev_offset)
local table_end = find_abbrev_table_end(existing, main_abbrev_offset)
if not table_end then return existing, nil end
-- Duplicate the main table declarations, excluding its terminating 0 byte.
-- (1-indexed sub: existing:sub(main_abbrev_offset + 1, table_end) reads bytes from 0-based [main_abbrev_offset .. table_end - 1].)
local main_table_dup = existing:sub(main_abbrev_offset + 1, table_end)
local new_abbrevs = build_new_abbrev() -- includes its own terminating 0
-- The MAIN CU's debug_abbrev_offset points to the duplicate's start (= #existing).
-- Codes 100..106 follow the duplicate's declarations inside that same table.
return existing .. main_table_dup .. new_abbrevs, #existing
end
--- Build the new .debug_str: existing strings + new strings appended.
--- @param existing string -- existing .debug_str bytes, byte-for-byte
--- @param atom_table table[]
--- @param registries table -- merged registries from collect_per_source_registries
--- @return string -- existing bytes plus the deterministic appended strings
local function build_debug_str_section(existing, atom_table, registries)
local new_strings = build_new_strings(atom_table, registries)
return existing .. new_strings
end
--- Build the new .debug_info: SPLICE inserted DIEs into the MAIN CU as children.
---
--- This implementation:
--- 1. Builds the inserted-children bytes (base_type, struct_types, subprograms with their RR_* + bind_args children) via build_inserted_children.
--- 2. Patches the main CU's `unit_length` field to account for the inserted bytes.
--- 3. Patches the main CU's `debug_abbrev_offset` field to point at the duplicate abbrev table (offset = #existing_abbrev_before_append).
--- 4. Preserves all original main CU DIE bytes.
--- 5. Replaces the main CU's final byte (the root children-terminator, 0) with: inserted_children_bytes + a single 0 byte (root terminator preserved).
---
--- The crt CU (everything before main_cu_start) is preserved.
--- @param existing string -- existing .debug_info section bytes
--- @param main_cu_start integer -- 0-based offset of the main CU's unit_length field
--- @param main_cu_end_excl integer -- 0-based offset of the first byte AFTER the main CU
--- @param new_abbrev_offset integer -- 0-based offset into the new .debug_abbrev of the duplicate main table
--- @param atom_table table[]
--- @param rbind_structs table -- {[binds_name] = {bytes, fields, atom_names}}
--- @param loclists_offsets table -- {[atom_name] = section-relative offset}
--- @param registries table -- merged registries from collect_per_source_registries
--- @return string -- the rebuilt .debug_info bytes
local function build_debug_info_section(existing, main_cu_start, main_cu_end_excl, new_abbrev_offset, atom_table, rbind_structs, loclists_offsets, registries)
-- 1) Build the inserted children bytes (just before the main CU's root terminator).
local inserted = build_inserted_children(main_cu_start, main_cu_end_excl, atom_table, rbind_structs, loclists_offsets, registries)
local inserted_len = #inserted
-- 2) Patch main CU's unit_length += inserted_len.
local old_unit_length = elf_dwarf.read_u32_le(existing, main_cu_start)
local new_unit_length = old_unit_length + inserted_len
local new_unit_length_bytes = elf_dwarf.write_u32_le(new_unit_length)
-- 3) Patch main CU's debug_abbrev_offset (bytes [main_cu_start + 8 .. + 11]).
local new_abbrev_offset_bytes = elf_dwarf.write_u32_le(new_abbrev_offset)
-- 4) Splice. All offsets below are 0-based; existing:sub is 1-indexed inclusive.
-- Byte ranges (0-based, inclusive):
-- [0 .. main_cu_start - 1] crt CU, unchanged
-- [main_cu_start + 0 .. + 3] unit_length (PATCHED)
-- [main_cu_start + 4 .. + 7] version + unit_type + address_size, unchanged
-- [main_cu_start + 8 .. + 11] debug_abbrev_offset (PATCHED)
-- [main_cu_start + 12 .. main_cu_end_excl - 2] existing DIE bytes, unchanged
-- [main_cu_end_excl - 1] root children-terminator, unchanged 0
local pre_end = main_cu_end_excl - 2 -- 0-based end of existing DIE bytes (inclusive)
local root_terminator = main_cu_end_excl - 1 -- 0-based position of the final 0 byte
return existing:sub(1, main_cu_start) -- crt CU
.. new_unit_length_bytes -- patched unit_length (4 bytes)
.. existing:sub(main_cu_start + 5, main_cu_start + 8) -- version(2) + unit_type(1) + address_size(1), unchanged
.. new_abbrev_offset_bytes -- patched debug_abbrev_offset (4 bytes)
.. existing:sub(main_cu_start + 13, pre_end + 1) -- existing DIE bytes, unchanged
.. inserted -- our inserted children
.. existing:sub(root_terminator + 1, main_cu_end_excl) -- root children-terminator, unchanged 0
end
--- Build the .debug_loc: just a terminator.
--- Atoms don't have stack frames. The .debug_loc section describes per-instruction location adjustments for call-frame-based variables;
--- we use DW_OP_regN which is register-based and needs no .debug_loc entries.
--- The section must stay non-empty or gdb may complain; the DW_LLE_end_of_list marker (per DWARF5 §7.7) is a single byte 0x00.
--
-- The actual .debug_loc emission is `string.char(DW_LLE_end_of_list)` inlined at the per-section writers below (the dispatch is in M.run, not a table).
-- Per-section output path resolver.
-- Each entry returns the on-disk path for that section's `.bin` blob.
local SECTION_WRITERS = {
debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end,
debug_aranges = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_aranges.bin" end,
debug_rnglists = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_rnglists.bin" end,
debug_abbrev = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_abbrev.bin" end,
debug_info = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_info.bin" end,
debug_str = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_str.bin" end,
debug_loc = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_loc.bin" end,
debug_loclists = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_loclists.bin" end,
}
-- Write a list of `{name, data}` section records to disk via SECTION_WRITERS.
-- @param results table[] -- list of `{name=, data=}` records to write
-- @param ctx PassCtx
-- @param basename string -- output file basename (e.g. "hello_gte")
-- @return table -- list of {name_bin = path} entries to append to M.run's outputs
local function write_sections(results, ctx, basename)
local outputs = {}
for _, r in ipairs(results) do
local path = SECTION_WRITERS[r.name](ctx.out_root, basename)
local f = io.open(path, "wb")
if not f then
io.stderr:write(string.format("[dwarf_injection] failed to open %s for write\n", path))
else
f:write(r.data); f:close()
outputs[#outputs + 1] = { [r.name .. "_bin"] = path }
end
end
return outputs
end
-- ════════════════════════════════════════════════════════════════════════════
-- Pass entry
-- ════════════════════════════════════════════════════════════════════════════
local M = {}
--- M.run — orchestrator entry.
--- @param ctx DwarfInjectionCtx
--- @return table
function M.run(ctx)
-- Guard: this pass is opt-in via --dwarf-injection (not run on --all).
if not (ctx.flags and ctx.flags.dwarf_injection) then
return { outputs = {}, errors = {}, warnings = {} }
end
-- Guard: --elf is required.
local elf_path = ctx.flags and ctx.flags.elf_path
if not elf_path or elf_path == "" then
io.stderr:write("[dwarf_injection] --elf flag missing\n")
return { outputs = {}, errors = {}, warnings = {} }
end
-- Resolve relative ELF path to absolute via `duffle.to_absolute_path`.
elf_path = duffle.to_absolute_path(elf_path)
-- Read the existing DWARF sections directly (no subprocess; io.open + manual ELF32 section-header walk).
-- We need all 8 sections: .debug_line / .debug_aranges / .debug_rnglists get extended (additional rows appended to the existing unit),
-- and .debug_info / .debug_abbrev / .debug_str / .debug_loc / .debug_loclists get spliced (the main CU's unit_length is patched; no new compile unit is appended; .debug_loc/.debug_loclists may not exist in the source ELF so we add-section them on splice).
-- The per-section dispatch is inlined in the writers loop below.
local existing_sections = elf_dwarf.read_elf_sections(elf_path, {
".debug_line", ".debug_aranges", ".debug_rnglists",
".debug_info", ".debug_abbrev", ".debug_str",
-- .debug_loc and .debug_loclists don't exist in the source ELF (we add-section them on splice);
-- reading them just returns "" which is the "missing" case the builder handles.
".debug_loc", ".debug_loclists",
})
-- Skip state lives in `corpus.atoms_by_name[*].debug_skip` (whole-atom) and `atom.paths.invocations[*].debug_skip` (per-invocation).
-- `corpus` is the sole canonical source projection.
local corpus = (ctx.shared and ctx.shared.corpus) or {}
local registries = collect_per_source_registries(corpus)
-- Read nm symbols (the ONLY disk-side input to the atom table) and join
-- them against `corpus.atoms_by_name` + `atom.paths` for word rows + invocation ancestry.
-- Disk source-map/provenance text is not consulted (those are diagnostic artifacts; semantic inputs are in memory).
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local atom_table = build_atom_table(corpus, addrs)
-- Detect rbind atoms + index Binds_* struct fields from the corpus.
-- The merged registries are threaded through so parse_body_load_pairs resolves R_<reg> via register_alias_registry.
local _, rbind_structs = parse_rbind_atoms(corpus, atom_table, registries)
-- Write the .bin files. The build_psyq.ps1 post-link hook splices these into a copy of the ELF via objcopy --update-section.
-- Build order:
-- 0. Validate .debug_info layout (crt CU + DWARF5 main CU + final 0 root terminator).
-- If validation fails, write existing sections unchanged and emit no synthetic data.
-- 1. Build new .debug_abbrev using the main CU's abbrev offset → returns the offset of the duplicate main table (= #existing_abbrev).
-- 2. Build new .debug_info by splicing inserted children into the main CU (patches main CU's unit_length + debug_abbrev_offset;
-- preserves all original DIE bytes; never appends a synthetic CU).
-- 3. Build .debug_line + .debug_aranges + .debug_rnglists.
-- 4. Build .debug_loc (just a terminator).
--
-- .debug_str also receives the new RR_<R_Name> entries (the merged registry drives the strings table to keep .debug_str and .debug_info in sync).
-- The per-section dispatch is inlined in the writers loop below.
local basename = ctx.basename or duffle.basename_no_ext(elf_path) or DEFAULT_BASENAME
if ctx.out_root and ctx.out_root ~= "" then
duffle.ensure_dir(ctx.out_root)
-- Step 0: layout validation. Bail out safely if the .debug_info layout doesn't match what we expect (crt CU + DWARF5 main CU + final 0 byte).
-- A layout mismatch means the gcc emission changed; the safest response is to leave existing sections unchanged and emit no synthetic data, so the build's debug-info step never silently produces broken DWARF.
local existing_info = existing_sections[".debug_info"] or ""
local existing_abbrev = existing_sections[".debug_abbrev"] or ""
local main_cu_start, main_cu_end_excl, main_abbrev_offset = find_main_cu_layout(existing_info)
if not main_cu_start then
io.stderr:write("[dwarf_injection] layout validation failed; writing existing sections unchanged\n")
local existing_str = existing_sections[".debug_str"] or ""
local results_safe = {
{ name = "debug_info", data = existing_info },
{ name = "debug_abbrev", data = existing_abbrev },
{ name = "debug_str", data = existing_str },
{ name = "debug_line", data = build_dwarf_line_section (existing_sections[".debug_line"] or "", atom_table) },
{ name = "debug_aranges", data = build_dwarf_aranges_section (existing_sections[".debug_aranges"] or "", atom_table) },
{ name = "debug_rnglists", data = build_dwarf_rnglists_section(existing_sections[".debug_rnglists"] or "", atom_table) },
{ name = "debug_loc", data = string.char(DW_LLE_end_of_list) },
}
local section_outputs = write_sections(results_safe, ctx, basename)
return { outputs = section_outputs, errors = {}, warnings = {} }
end
-- Step 1: duplicate main abbrev table + append new codes 100..109.
local new_abbrev, new_abbrev_offset = build_debug_abbrev_section(existing_abbrev, main_abbrev_offset)
if not new_abbrev_offset then
io.stderr:write("[dwarf_injection] main abbrev-table validation failed; refusing to emit malformed DWARF\n")
return { outputs = {}, errors = {}, warnings = {"main abbrev-table validation failed"} }
end
-- Step 1b: compute per-atom loclist offsets BEFORE building .debug_info
-- (the bind_args variable references the loclist via DW_FORM_sec_offset).
local loclists_offsets = compute_loclists_offsets(atom_table)
-- Step 2: splice inserted children into the main CU (patches unit_length + abbrev_offset; no synthetic CU).
local new_info = build_debug_info_section(existing_info, main_cu_start, main_cu_end_excl, new_abbrev_offset, atom_table, rbind_structs, loclists_offsets, registries)
-- Step 2b: rebuild .debug_str now that we know which RR_<R_Name> entries get emitted.
-- This aligns with build_debug_info_section's by_alias loop.
local new_str = build_debug_str_section(existing_sections[".debug_str"] or "", atom_table, registries)
-- Step 3-5: independent sections.
local results = {
{ name = "debug_abbrev", data = new_abbrev },
{ name = "debug_info", data = new_info },
{ name = "debug_str", data = new_str },
{ name = "debug_line", data = build_dwarf_line_section (existing_sections[".debug_line"] or "", atom_table) },
{ name = "debug_aranges", data = build_dwarf_aranges_section (existing_sections[".debug_aranges"] or "", atom_table) },
{ name = "debug_rnglists", data = build_dwarf_rnglists_section(existing_sections[".debug_rnglists"] or "", atom_table) },
{ name = "debug_loc", data = string.char(DW_LLE_end_of_list) },
{ name = "debug_loclists", data = build_debug_loclists_section(atom_table, registries) },
}
local section_outputs = write_sections(results, ctx, basename)
return { outputs = section_outputs, errors = {}, warnings = {} }
end
return { outputs = {}, errors = {}, warnings = {} }
end
-- Test-only exports expose the real emission + offset paths so tests exercise them end-to-end.
M.compute_loclists_offsets_for_test = compute_loclists_offsets
M.build_debug_loclists_section_for_test = build_debug_loclists_section
M.tape_piece_size_for_test = tape_piece_size
return M