Files
pikuma_ps1/scripts/passes/dwarf_injection.lua
T

2638 lines
147 KiB
Lua
Raw 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 tape 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 "./" ---@type string
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") ---@type DuffleExport
-- 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") ---@type ElfDwarf
-- 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 ---@type fun(table_bytes: string, table_start: integer): integer|nil
-- Local DWARF opcode constants + length-prefixed integers (uleb128 + sleb128 encoders are in elf_dwarf.lua).
local uleb128 = elf_dwarf.uleb128 ---@type fun(n: integer): string
local sleb128 = elf_dwarf.sleb128 ---@type fun(n: integer): string
-- ════════════════════════════════════════════════════════════════════════════
-- 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 ---@type DwarfLineOps
local DWARF5_RNGLISTS = elf_dwarf.DWARF5_RNGLISTS ---@type Dwarf5Rnglists
local MIPS_BYTES_PER_WORD = elf_dwarf.MIPS_BYTES_PER_WORD ---@type integer
local DW_LNS_copy = DWARF_LINE_OPS.DW_LNS_copy ---@type integer
local DW_LNS_advance_pc = DWARF_LINE_OPS.DW_LNS_advance_pc ---@type integer
local DW_LNS_advance_line = DWARF_LINE_OPS.DW_LNS_advance_line ---@type integer
local DW_LNS_set_file = DWARF_LINE_OPS.DW_LNS_set_file ---@type integer
local DW_LNS_negate_stmt = DWARF_LINE_OPS.DW_LNS_negate_stmt ---@type integer
local DW_LNS_extended = DWARF_LINE_OPS.DW_LNS_extended ---@type integer
local DW_LNE_end_sequence = DWARF_LINE_OPS.DW_LNE_end_sequence ---@type integer
local DW_LNE_set_address = DWARF_LINE_OPS.DW_LNE_set_address ---@type integer
local DW_RLE_end_of_list = DWARF5_RNGLISTS.end_of_list ---@type integer
local DW_RLE_start_length = DWARF5_RNGLISTS.start_length ---@type integer
-- File-index lookup for the existing main line unit (Unit 2).
-- Populated at pass start by `init_file_index_lookup(elf_path)` from the runtime ELF (see `elf_dwarf.read_line_unit_file_table`).
local _file_index_by_basename = nil ---@type table<string, integer>|nil -- bag -- [basename] = 1-based line-table file index
local _file_path_by_index = nil ---@type table<integer, string>|nil -- bag -- [1-based index] = full source path (diagnostics / future consumers)
local _default_atom_source_index = nil ---@type integer -- any valid index used in opaque-row fallbacks
-- 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 ---@type integer -- 100: DW_TAG_compile_unit
local ABBREV_SUBPROGRAM = 0x65 ---@type integer -- 101: DW_TAG_subprogram
local ABBREV_VARIABLE = 0x66 ---@type integer -- 102: DW_TAG_variable with DW_AT_type = ref4 to U4
local ABBREV_STRUCT_TYPE = 0x67 ---@type integer -- 103: DW_TAG_structure_type with children (Binds_X mirror)
local ABBREV_MEMBER = 0x68 ---@type integer -- 104: DW_TAG_member no children (DW_AT_type = ref4 to U4 base)
local ABBREV_BIND_VAR = 0x69 ---@type integer -- 105: DW_TAG_variable no children + DW_AT_type = ref4 (the bind_args variable)
local ABBREV_BASE_TYPE = 0x6A ---@type integer -- 106: DW_TAG_base_type no children (U4)
-- Component step-into (DW_TAG_inlined_subroutine + abstract DW_TAG_subprogram).
local ABBREV_ABSTRACT_SUBPROGRAM = 0x6B ---@type integer -- 107: DW_TAG_subprogram (abstract — no low_pc/high_pc); for each unique mac_X component
local ABBREV_INLINED_SUBROUTINE = 0x6C ---@type integer -- 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 ---@type integer -- 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 ---@type integer -- 110: DW_TAG_pointer_type no children + DW_AT_type = ref4 (typed-view / U4 / void chain)
-- One row per DIE kind build_inserted_children emits.
-- attrs list form + the value key filled from the atom / registry / local table.
local DIE_SCHEMA = { ---@type table<string, DieSchema>
base_type = {
abbrev = ABBREV_BASE_TYPE,
attrs = {
{ form = "string", key = "name" },
{ form = "data1", key = "byte_size" },
{ form = "data1", key = "encoding" },
},
},
abstract_subprogram = {
abbrev = ABBREV_ABSTRACT_SUBPROGRAM,
attrs = {
{ form = "string", key = "name" },
{ form = "data1", key = "inline" },
{ form = "data1", key = "external" },
{ form = "udata", key = "decl_file" },
{ form = "udata", key = "decl_line" },
},
},
subprogram = {
abbrev = ABBREV_SUBPROGRAM,
attrs = {
{ form = "string", key = "name" },
{ form = "addr", key = "low_pc" },
{ form = "addr", key = "high_pc" },
{ form = "string", key = "linkage_name" },
},
},
variable = {
abbrev = ABBREV_VARIABLE,
attrs = {
{ form = "string", key = "name" },
{ form = "exprloc", key = "location" },
{ form = "ref4", key = "type" },
{ form = "data1", key = "external" },
},
},
structure_type = {
abbrev = ABBREV_STRUCT_TYPE,
attrs = {
{ form = "string", key = "name" },
{ form = "udata", key = "byte_size" },
},
},
member = {
abbrev = ABBREV_MEMBER,
attrs = {
{ form = "string", key = "name" },
{ form = "udata", key = "data_member_location" },
{ form = "ref4", key = "type" },
},
},
pointer_type = {
abbrev = ABBREV_TYPED_VIEW_POINTER,
attrs = {
{ form = "ref4", key = "type" },
},
},
bind_var_loclist = {
abbrev = ABBREV_BIND_VAR_LOCLIST,
attrs = {
{ form = "string", key = "name" },
{ form = "sec_offset", key = "location" },
{ form = "ref4", key = "type" },
},
},
inlined_subroutine = {
abbrev = ABBREV_INLINED_SUBROUTINE,
attrs = {
{ form = "ref4", key = "abstract_origin" },
{ form = "addr", key = "low_pc" },
{ form = "addr", key = "high_pc" },
{ form = "udata", key = "call_file" },
{ form = "udata", key = "call_line" },
},
},
}
-- DWARF5 §7.7.3 loclist opcodes.
local DW_LLE_end_of_list = 0x00 ---@type integer
local DW_LLE_start_length = 0x08 ---@type integer
local DW_OP_reg0 = 0x50 ---@type integer -- base reg op; regN = 0x50 + N
local DW_OP_breg0 = 0x70 ---@type integer -- base breg op; bregN = 0x70 + N (SLEB offset)
local DW_OP_piece = 0x93 ---@type integer
local MIPS_LOAD_DELAY_BYTES = 0x08 ---@type integer -- 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 ---@type integer
-- 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 ---@type integer
-- 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 ---@type integer
local DW_TAG_subprogram = 0x2E ---@type integer
local DW_TAG_variable = 0x34 ---@type integer
local DW_TAG_structure_type = 0x13 ---@type integer
local DW_TAG_member = 0x0D ---@type integer
local DW_TAG_base_type = 0x24 ---@type integer
local DW_TAG_pointer_type = 0x0F ---@type integer
-- Component step-into.
local DW_TAG_inlined_subroutine = 0x1D ---@type integer
local DW_AT_name = 0x03 ---@type integer
local DW_AT_low_pc = 0x11 ---@type integer
local DW_AT_high_pc = 0x12 ---@type integer
local DW_AT_language = 0x13 ---@type integer
local DW_AT_location = 0x02 ---@type integer
local DW_AT_comp_dir = 0x1B ---@type integer
local DW_AT_byte_size = 0x0B ---@type integer
local DW_AT_encoding = 0x3E ---@type integer -- DWARF5 §7.7.1: DW_AT_encoding for the DW_ATE_unsigned base type
local DW_AT_data_member_location = 0x38 ---@type integer
local DW_AT_type = 0x49 ---@type integer
local DW_AT_linkage_name = 0x6E ---@type integer -- DWARF5 §7.7.1: DW_AT_linkage_name with DW_FORM_string
local DW_AT_external = 0x3F ---@type integer -- marks a variable/function as externally visible
-- Inlined_subroutine + abstract_origin attributes.
local DW_AT_abstract_origin = 0x31 ---@type integer
local DW_AT_call_file = 0x58 ---@type integer
local DW_AT_call_line = 0x59 ---@type integer
local DW_AT_inline = 0x20 ---@type integer -- 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 ---@type integer -- DWARF5 §7.7.1: DW_AT_decl_file (1-based file index into the CU's file table)
local DW_AT_decl_line = 0x3B ---@type integer -- DWARF5 §7.7.1: DW_AT_decl_line
-- Replaced the hardcoded `ATOM_SOURCE_FILE_INDEX = 11` and the `PROVENANCE_BASENAME_TO_FILE_INDEX` table below with a runtime lookup
-- (`init_file_index_lookup` + `resolve_provenance_file_index`) that reads the actual `.debug_line` file table from the post-link ELF.
--- Populate the module-level file-index lookup table from the `.debug_line` section of the post-link ELF pointed at by `elf_path`.
--- This MUST be called exactly once at pass start (from `M.run`) before any `resolve_provenance_file_index` invocation;
--- downstream callers handle a nil table as "no file info available; fall back to errors".
---
--- The lookup uses `elf_dwarf.read_line_unit_file_table` (which parses both DWARF3 and DWARF5 line-program units —
--- the crt0.s assembler-side DWARF5 unit may emit non-standard form codes for paths and is intentionally skipped).
--- @param elf_path string|nil
--- @return nil
local function init_file_index_lookup(elf_path)
if not elf_path or elf_path == "" then return end
local b2i, _basenames, paths = elf_dwarf.read_line_unit_file_table(elf_path) ---@type table<string, integer>|nil, table<integer, string>|nil, table<integer, string>|nil
if type(b2i) ~= "table" or type(paths) ~= "table" then
io.stderr:write("[dwarf_injection] read_line_unit_file_table returned no file table for: " .. tostring(elf_path) .. "\n")
return
end
_file_index_by_basename = b2i
_file_path_by_index = paths
-- Pick any valid index for the opaque-row fallbacks at lines 466 + 570
-- (both sites legitimately want "any file index"; gdb resolves whatever index we emit to whatever that file's line happens to be).
for idx in pairs(paths) do ---@type integer|nil
_default_atom_source_index = idx
break
end
end
--- 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 runtime-computed file table populated by `init_file_index_lookup`.
---
--- Returns 0 (the DWARF `set_file(0)` "no file change" sentinel) when the basename is not in the file table.
--- This is a normal occurrence: the compiler only adds a file to the `.debug_line` file table when the file has line-numbered content (i.e., code).
--- Files containing only static-array data (e.g. `MipsAtomComp_` declarations in `gp.atom.c`, `psyq.atom.c`, `pad.atom.c` — the OT-tag inserts, etc.) produce no line numbers,
--- so gcc omits them from the file table.
--- The DWARF emitter then keeps the previous line-program file state instead of pointing at a file that has no entries to walk.
--- A stderr warning is emitted per-miss so the user can audit which files the compiler dropped.
--- @param path string -- absolute provenance path (mixed slashes accepted)
--- @return integer -- 1-based line-unit file index, or 0 on miss (DWARF no-change sentinel)
local function resolve_provenance_file_index(path)
if _file_index_by_basename == nil then
error("[dwarf_injection] resolve_provenance_file_index called before init_file_index_lookup. Is M.run being entered correctly (with --elf)?")
end
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).
local normalized = path:gsub("\\", "/") ---@type string
-- Take the last path component (the basename).
local basename = normalized:match("([^/]+)$") or normalized ---@type string
local idx = _file_index_by_basename[basename] ---@type integer|nil
if idx ~= nil then return idx end
-- Last-resort exact-path match (handles paths that don't reduce to a known basename).
for i, p in pairs(_file_path_by_index) do ---@type integer, string
if p and p:gsub("\\", "/") == normalized then return i end
end
-- File is in the corpus but gcc omitted it from the .debug_line file table (data-only content).
-- Return 0 = DWARF `set_file(0)` no-change sentinel so the line program keeps its prior file state.
io.stderr:write(string.format("[dwarf_injection] line-table miss: '%s' (basename '%s') not in .debug_line file table; "
.. "falling back to set_file(0)\n", path, basename))
return 0
end
local DW_FORM_addr = 0x01 ---@type integer
local DW_FORM_data1 = 0x0B ---@type integer
local DW_FORM_string = 0x08 ---@type integer -- inline null-terminated
local DW_FORM_strp = 0x0E ---@type integer -- 4-byte offset into .debug_str
local DW_FORM_exprloc = 0x18 ---@type integer -- length-prefixed (ULEB128) DW_OP bytes
local DW_FORM_ref4 = 0x13 ---@type integer -- 4-byte offset within the same .debug_info CU
local DW_FORM_udata = 0x0F ---@type integer -- ULEB128 (DW_AT_byte_size for struct_type, DW_AT_data_member_location for member)
local DW_FORM_implicit_const = 0x21 ---@type integer -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker)
local DW_FORM_sec_offset = 0x17 ---@type integer -- 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 ---@type integer -- 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 DwarfAtom[] -- list of atoms with .rbind set
-- @param registries DwarfRegistries -- 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"] ---@type AliasEntry|nil
local tape_reg = tape_alias_entry and tape_alias_entry.code ---@type integer
local parts = {} ---@type string[]
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
if atom.rbind and tape_reg then
local fields = atom.rbind.fields or {} ---@type TypeField[]
local regs = atom.rbind.regs or {} ---@type DwarfLoadPair[]
local n_fields = #fields ---@type integer
local last_load_pc = atom.addr + (n_fields - 1) * MIPS_BYTES_PER_WORD ---@type integer
local transition_pc = last_load_pc + MIPS_LOAD_DELAY_BYTES ---@type integer
local tape_pieces = {} ---@type string[]
for _, f in ipairs(fields) do ---@type integer, TypeField
local offset = f.offset or 0 ---@type integer
local offset_sleb = elf_dwarf.sleb128(offset) ---@type string
-- (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) ---@type string
local gpr_pieces = {} ---@type string[]
for _, pair in ipairs(regs) do ---@type integer, DwarfLoadPair
-- (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) ---@type string
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 ---@type integer
local body = table.concat(parts) ---@type string
local unit_length = LOCLIST_HEADER_SIZE - 4 + #body ---@type integer -- -4 because unit_length excludes itself
local header = elf_dwarf.write_u32_le(unit_length) ---@type string
.. 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 DwarfAtom[] -- list of atoms with .rbind set
-- @return table<string, integer> -- bag: atom name -> offset_in_section
local function compute_loclists_offsets(atom_table)
local LOCLIST_ENTRY_HEADER_SIZE = 1 + 4 + 1 ---@type integer -- DW_LLE_start_length(1) + addr(4) + uleb_length(1)
local offsets = {} ---@type table<string, integer> -- bag
-- 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 ---@type integer -- = 12
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
if atom.rbind then
offsets[atom.name] = cursor
local n_fields = #atom.rbind.fields ---@type integer
-- 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 ---@type integer
for _, f in ipairs(atom.rbind.fields or {}) do ---@type integer, TypeField
tape_pieces_size = tape_pieces_size + tape_piece_size(f.offset or 0)
end
local gpr_pieces_size = n_fields * 3 ---@type integer -- 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 ---@type integer
local gpr_entry = LOCLIST_ENTRY_HEADER_SIZE + gpr_pieces_size ---@type integer
local body_len = tape_entry + gpr_entry + 1 ---@type integer -- +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" ---@type string
local DEFAULT_CU_COMP_DIR = "." ---@type string
-- SECTION_WRITERS owns the .bin output path templates.
-- Default basename if not provided via ctx.
local DEFAULT_BASENAME = "hello_gte" ---@type string
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class DwarfInjectionCtx
--- @field flags PassFlags
--- @field sources SourceFile[]
--- @field out_root string
--- @field basename string
--- @field shared PassShared|nil
--- DieSchema / FORM_WRITERS / atom-table records live on this file.
--- SourceFile, Path, AtomName: see duffle.lua.
--- Corpus, PassCtx, PassResult, PassFlags, PassShared, PassFinding: see ps1_meta.lua.
--- AtomEntry, AliasEntry, TypeNameEntry, TypeField, AtomInfoEntry, BindsEntry,
--- AtomViewEntry, AtomCtxEntry, AtomPhaseGroup, SourceScan, RegTypeOverride: see scan_source.lua.
--- AtomPaths, WordEvent, BodyToken: see emission_model.lua.
--- InvocationRecord: see duffle_emit.lua.
--- NmAddr: see atoms_source_map.lua.
--- ElfDwarf, DwarfLineOps, Dwarf5Rnglists: see elf_dwarf.lua.
--- @class DieSchemaAttr
--- @field form string
--- @field key string
--- @class DieSchema
--- @field abbrev integer
--- @field attrs DieSchemaAttr[]
--- @class DieValues
--- @field name string|nil
--- @field byte_size integer|nil
--- @field encoding integer|nil
--- @field inline integer|nil
--- @field external integer|nil
--- @field decl_file integer|nil
--- @field decl_line integer|nil
--- @field low_pc integer|nil
--- @field high_pc integer|nil
--- @field linkage_name string|nil
--- @field location string|integer|nil
--- @field type integer|nil
--- @field data_member_location integer|nil
--- @field abstract_origin integer|nil
--- @field call_file integer|nil
--- @field call_line integer|nil
--- @alias DieFormWriter fun(emit: fun(s: string), v: string|integer)
--- @alias DwarfSectionPathWriter fun(out_root: string, basename: string): string
--- @alias DwarfPrecedenceStep fun(r_name: string, alias_code: integer): integer|nil
--- @class DwarfAtomWord
--- @field pos integer
--- @field line integer
--- @field text string
--- @class DwarfLoadPair
--- @field reg integer
--- @field field string
--- @class DwarfRbind
--- @field binds string
--- @field fields TypeField[]
--- @field bytes integer
--- @field regs DwarfLoadPair[]
--- @field info_line integer
--- @class DwarfAtom
--- @field name string
--- @field addr integer
--- @field size_bytes integer
--- @field words integer
--- @field entries DwarfAtomWord[]
--- @field invocations InvocationRecord[]
--- @field debug_skip boolean
--- @field src_path string
--- @field rbind DwarfRbind|nil
--- @class DwarfRegistries
--- @field register_alias_registry table<string, AliasEntry>
--- @field type_name_registry table<string, TypeNameEntry>
--- @field atom_views table<AtomName, AtomViewEntry>
--- @field atom_ctxs table<AtomName, AtomCtxEntry>
--- @field atom_phases table<string, AtomPhaseGroup>
--- @field atom_infos AtomInfoEntry[]
--- @class DwarfRbindStruct
--- @field bytes integer
--- @field fields TypeField[]
--- @field atom_names string[]
--- @class DwarfComponentSite
--- @field name string
--- @field def_file string
--- @field def_line integer
--- @class DwarfTypeLayoutMember
--- @field name string
--- @field offset integer
--- @field byte_size integer
--- @field type_name string|nil
--- @field pointer_depth integer
--- @class DwarfTypeLayout
--- @field byte_size integer
--- @field members DwarfTypeLayoutMember[]
--- @class DwarfEmitState
--- @field bytes string[]
--- @field next_offset integer
--- @field type_chain_offsets table<string, integer> -- bag: "T|depth" -> section offset
--- @field struct_section_offsets table<string, integer> -- bag: binds name -> section offset
--- @field abstract_offsets table<string, integer> -- bag: component name -> section offset
--- @field member_base_type_offsets table<string, integer> -- bag: "tn|size|enc" -> section offset
--- @field base_type_section_offset integer|nil
--- @class DwarfSectionBlob
--- @field name string
--- @field data string
--- @class DwarfInjectionPass
--- @field run fun(ctx: PassCtx): PassResult
--- @field compute_loclists_offsets_for_test fun(atom_table: DwarfAtom[]): table<string, integer>
--- @field build_debug_loclists_section_for_test fun(atom_table: DwarfAtom[], registries: DwarfRegistries): string
--- @field tape_piece_size_for_test fun(offset: integer): integer
--- @field build_atom_table_for_test fun(corpus: Corpus, addrs: table<string, NmAddr>): DwarfAtom[]
--- 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 Corpus -- the corpus from `ctx.shared.corpus`
--- @return DwarfRegistries -- {
--- 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 = {} ---@type AtomInfoEntry[]
for _, ai in ipairs((corpus and corpus.atom_infos) or {}) do ---@type integer, AtomInfoEntry
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 DwarfAtom -- {name, addr, size_bytes, words, entries, invocations, debug_skip, word_events}
--- @return string
local function build_atom_sequence(atom)
--- @param addr integer
--- @return string
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) ---@type string
local sub_size = string.char(DW_LNE_set_address) .. addr_bytes ---@type string
return string.char(DW_LNS_extended) .. uleb128(#sub_size) .. sub_size
end
--- @return string
local function copy_op() return string.char(DW_LNS_copy) end
--- @param file_index integer
--- @return string
local function set_file(file_index) return string.char(DW_LNS_set_file) .. uleb128(file_index) end
--- @param bytes_delta integer
--- @return string
local function advance_pc(bytes_delta) return string.char(DW_LNS_advance_pc) .. uleb128(bytes_delta) end
--- @param line_delta integer
--- @return string
local function advance_line(line_delta) return string.char(DW_LNS_advance_line) .. sleb128(line_delta) end
--- @return string
local function negate_stmt() return string.char(DW_LNS_negate_stmt) end
--- @return string
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(resolve_provenance_file_index(atom.src_path)),
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 {} ---@type InvocationRecord[]
local innermost_idx = {} ---@type table<integer, InvocationRecord|nil> -- bag
local ancestry_idx = {} ---@type table<integer, InvocationRecord[]> -- bag
for idx = 1, #atom.entries do ---@type integer
innermost_idx[idx] = nil
ancestry_idx[idx] = {}
local active = {} ---@type InvocationRecord[]
for _, inv in ipairs(invs) do ---@type integer, InvocationRecord
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).
--- @param a InvocationRecord
--- @param b InvocationRecord
--- @return boolean
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 = {} ---@type table<integer, integer> -- bag
for _, top_inv in ipairs(invs) do ---@type integer, InvocationRecord
local earliest_nested_call_line = nil ---@type integer
local earliest_nested_start_pos = nil ---@type integer
for _, cand in ipairs(invs) do ---@type integer, InvocationRecord
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 = { ---@type string[]
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 ---@type integer|nil -- line-state.file_idx (nil = uninitialized)
local cur_line = 1 ---@type integer -- line-state.line starts at 1 (per DWARF spec)
local is_stmt = true ---@type boolean -- main line unit default_is_stmt; every sequence ends restored
--- @param want_stmt boolean
--- @return nil
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.
--- @param file_idx integer
--- @param line integer
--- @param want_stmt boolean
--- @return nil
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 = resolve_provenance_file_index(atom.src_path) ---@type integer
-- --- Atom entry (idx 1) -------------------------------------------------
local entry_1 = atom.entries[1] ---@type DwarfAtomWord
local entry_1_ancestry = ancestry_idx[1] ---@type InvocationRecord[]
-- 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 ---@type integer, InvocationRecord
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) ---@type boolean
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 ---@type integer|nil
local entry = atom.entries[idx] ---@type DwarfAtomWord
local inv = innermost_idx[idx] ---@type InvocationRecord
-- 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] ---@type InvocationRecord[]
for ai, anc in ipairs(ancestry) do ---@type integer, InvocationRecord
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) ---@type boolean
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 ---@type integer
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 Corpus -- From `ctx.shared.corpus`
--- @param addrs table<string, NmAddr> -- ELF symbols keyed by atom name from `elf_dwarf.read_nm`
--- @return DwarfAtom[] -- 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 {} ---@type table<AtomName, AtomEntry> -- bag
-- Per-atom ingest. Returns nil if the atom is absent from the corpus; the caller skips it via the `if atom then ...` guard.
-- `src_path` is the absolute source path that declared this atom; the build_atom_table iteration below threads `src.path` through.
-- This is consumed by `build_atom_sequence::set_file(...)` for opaque-row fallbacks + raw-word rows (atoms where no invocation ancestry exists).
--- @param name string
--- @param info NmAddr
--- @param src_path string
--- @return DwarfAtom|nil
local function ingest_atom(name, info, src_path)
local atom_record = atoms_by_name[name] ---@type AtomEntry|nil
if not atom_record then return nil end
local paths = atom_record.paths or {} ---@type AtomPaths
local word_events = paths.word_events or {} ---@type WordEvent[]
local invocations_proj = paths.invocations or {} ---@type InvocationRecord[]
-- 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 = {} ---@type DwarfAtomWord[]
for idx, ev in ipairs(word_events) do ---@type integer, WordEvent
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 = { ---@type DwarfAtom
name = name,
addr = info[1],
size_bytes = info[2],
words = #word_events,
entries = entries,
debug_skip = atom_record.debug_skip == true,
src_path = src_path or "",
}
-- 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 ---@type integer, InvocationRecord
-- `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 = {} ---@type DwarfAtom[]
-- Walk every source's atom list (which preserves source order + per-source src_path).
-- Cross-ref with the nm symbol table; atoms absent from `addrs` are skipped
-- (an atom declared in source but not emitted as a symbol is a metaprogram or atom-info bug, not a source-correlation bug — emit_no_emit would catch it upstream).
for _, src in ipairs((corpus and corpus.source_order) or {}) do ---@type integer, SourceFile
local src_path = src.path or "" ---@type string
for _, atom_rec in ipairs(((src.scan or {}).atoms) or {}) do ---@type integer, AtomEntry
local info = addrs[atom_rec.name or atom_rec.raw_name] ---@type NmAddr|nil
if info then
local atom = ingest_atom(atom_rec.name or atom_rec.raw_name, info, src_path) ---@type DwarfAtom|nil
if atom then out[#out + 1] = atom end
end
end
for _, atom_rec in ipairs(((src.scan or {}).raw_atoms) or {}) do ---@type integer, AtomEntry
local info = addrs[atom_rec.name or atom_rec.raw_name] ---@type NmAddr|nil
if info then
local atom = ingest_atom(atom_rec.name or atom_rec.raw_name, info, src_path) ---@type DwarfAtom|nil
if atom then out[#out + 1] = atom end
end
end
end
--- @param a DwarfAtom
--- @param b DwarfAtom
--- @return boolean
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 DwarfAtom[]
--- @return table<string, DwarfComponentSite>
local function collect_component_defs(atom_table)
local out = {} ---@type table<string, DwarfComponentSite> -- bag
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
for _, inv in ipairs(atom.invocations or {}) do ---@type integer, InvocationRecord
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)),
--- ...
---
--- Also matches `load_half` / `load_half_u` / `load_byte` / `load_byte_u` (any MIPS load instruction with `(R_<reg>, R_<base>, O_(<Binds_X>, FieldName))` shape).
--- Every field's `byte_size` + `offset` determine which load to emit; this function only records the (reg, field) pair.
---
--- 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_*` call or other statement).
--- @param body_tokens BodyToken[] -- 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 DwarfRegistries -- Merged registries from collect_per_source_registries
--- @return DwarfLoadPair[] -- List of {reg = <MIPS index>, field = <field name>}
local function parse_body_load_pairs(body_tokens, binds_name, registries)
local pairs = {} ---@type DwarfLoadPair[]
local reg_index_by_name = (registries and registries.register_alias_registry) or {} ---@type table<string, AliasEntry> -- bag
-- One regex that matches any of: load_word, load_half, load_half_u, load_byte, load_byte_u, gte_lw, gte_lwc2.
-- The captured ident is `kind`; `inner` holds the parens body for arg parsing.
local load_pattern = "^(load_word|load_half|load_half_u|load_byte|load_byte_u|gte_lw|gte_lwc2)%s*%((.*)%)$" ---@type string
for _, t in ipairs(body_tokens or {}) do ---@type integer, BodyToken
local tok = duffle.trim(t.tok or "") ---@type string
local kind, inner = tok:match(load_pattern) ---@type string|nil, string|nil
if kind then
local args = duffle.split_top_level_commas(inner) ---@type string[]
-- Expected shape for an rbind piece-chain load: (R_<reg>, R_TapePtr, O_(Binds_<X>, FieldName))
-- The second arg MUST be R_TapePtr — loads from other bases (e.g. `load_byte_u(R_RawStatus, R_PadRaw, 0)`)
-- are field-derivative loads that read already-bound tape values; they're NOT a new piece-chain.
if #args >= 3 and duffle.trim(args[2]) == "R_TapePtr" then
local reg_name = duffle.trim(args[1]) ---@type string
local third_arg = duffle.trim(args[3]) ---@type string
-- Match O_(Binds_<X>, FieldName)
local b, f = third_arg:match("^O_%((Binds_[%w_]+)%s*,%s*(.-)%s*%)$") ---@type string|nil, string|nil
local alias_entry = reg_index_by_name[reg_name] ---@type AliasEntry|nil
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 Corpus -- From `ctx.shared.corpus`
--- @param atom_table DwarfAtom[] -- Cross-ref'd atom table from build_atom_table
--- @param registries DwarfRegistries -- Merged registries from collect_per_source_registries
--- @return table<string, DwarfRbind>, table<string, DwarfRbindStruct>
local function parse_rbind_atoms(corpus, atom_table, registries)
registries = registries or {}
local rbind_atoms = {} ---@type table<string, DwarfRbind> -- bag
local rbind_structs = {} ---@type table<string, DwarfRbindStruct> -- bag
-- 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 = {} ---@type table<string, BindsEntry> -- bag
for _, src in ipairs((corpus and corpus.source_order) or {}) do ---@type integer, SourceFile
local scan = src.scan ---@type SourceScan|nil
if scan then
for _, b in ipairs(scan.binds or {}) do ---@type integer, BindsEntry
binds_by_name[b.name] = b
end
end
end
for binds_name, b in pairs(binds_by_name) do ---@type string, BindsEntry
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 = {} ---@type table<string, BodyToken[]> -- bag
for _, src in ipairs((corpus and corpus.source_order) or {}) do ---@type integer, SourceFile
local scan = src.scan ---@type SourceScan|nil
if scan then
for _, atom in ipairs(scan.atoms or {}) do ---@type integer, AtomEntry
body_tokens_by_atom[atom.name] = atom.body_tokens
end
end
end
local ai_by_atom = {} ---@type table<string, AtomInfoEntry> -- bag
for _, src in ipairs((corpus and corpus.source_order) or {}) do ---@type integer, SourceFile
local scan = src.scan ---@type SourceScan|nil
if scan then
for _, ai in ipairs(scan.atom_infos or {}) do ---@type integer, AtomInfoEntry
ai_by_atom[ai.atom_name] = ai
end
end
end
for atom_name, ai in pairs(ai_by_atom) do ---@type string, AtomInfoEntry
if ai.binds then
local struct = rbind_structs[ai.binds] ---@type DwarfRbindStruct|nil
local body_toks = body_tokens_by_atom[atom_name] ---@type BodyToken[]|nil
if struct and body_toks then
local pairs = parse_body_load_pairs(body_toks, ai.binds, registries) ---@type DwarfLoadPair[]
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 ---@type integer, DwarfAtom
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 DwarfAtom[] -- 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 = {} ---@type string[]
for _, atom in ipairs(atom_table) do sequences[#sequences + 1] = build_atom_sequence(atom) end ---@type integer, DwarfAtom
local appended = table.concat(sequences) ---@type string
-- 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 ---@type integer, integer|nil, integer|nil, integer|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) ---@type integer
if unit_length == elf_dwarf.dw_dwarf32_terminator then return existing end
local unit_end_excl = unit_pos + 4 + unit_length ---@type integer
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 ---@type integer
local new_length_bytes = elf_dwarf.write_u32_le(new_length) ---@type string
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 DwarfAtom[]
--- @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 = {} ---@type string[]
local i = 0 ---@type integer -- zero-based wire offset
local is_last_unit = false ---@type boolean
while i < #existing do
-- Read this unit's length.
local ul = elf_dwarf.read_u32_le(existing, i) ---@type integer
if ul == elf_dwarf.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 ---@type integer
local unit_end_excl = i + 4 + ul ---@type integer
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 ---@type integer
local new_ul = ul + added_bytes ---@type integer
local new_ul_bytes = elf_dwarf.write_u32_le(new_ul) ---@type string
-- 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 ---@type integer, DwarfAtom
local a = atom.addr ---@type integer
local size = atom.size_bytes ---@type integer
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 DwarfAtom[]
--- @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) ---@type integer
local version = elf_dwarf.read_u16_le(existing, elf_dwarf.DWARF5_RNGLISTS.version_offset) ---@type integer
local address_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.addr_size_offset + 1) ---@type integer
local segment_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.seg_size_offset + 1) ---@type integer
local offset_entry_count = elf_dwarf.read_u32_le(existing, elf_dwarf.DWARF5_RNGLISTS.offset_count_offset) ---@type integer
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 = {} ---@type string[]
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
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) ---@type string
local new_length = unit_length + #appended ---@type integer
local new_length_bytes = elf_dwarf.write_u32_le(new_length) ---@type string
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 DwarfRbind -- {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 = {} ---@type string[]
local field_offset_by_name = {} ---@type table<string, integer> -- bag
for _, f in ipairs(rbind.fields) do ---@type integer, TypeField
field_offset_by_name[f.name] = f.offset
end
local next_offset = rbind.bytes ---@type integer
for i = #rbind.regs, 1, -1 do ---@type integer -- walk backwards to know each piece's size
local pair = rbind.regs[i] ---@type DwarfLoadPair
local off = field_offset_by_name[pair.field] or 0 ---@type integer
local size ---@type integer
if i == #rbind.regs then
size = next_offset - off
else
local next_off = field_offset_by_name[rbind.regs[i + 1].field] ---@type integer
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 = {} ---@type string[]
for i = #op_bytes, 1, -1 do rev[#rev + 1] = op_bytes[i] end ---@type integer
local op = table.concat(rev) ---@type string
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 ---@type integer
local DW_UT_compile = 0x01 ---@type integer
local DWARF32_TERMINATOR = 0xFFFFFFFF ---@type integer -- sentinel for DWARF64 marker
local CU_HEADER_SIZE = 12 ---@type integer -- 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 ---@type integer
if buf_len < CU_HEADER_SIZE then return nil end
local pos = 0 ---@type integer
local main_cu_start = nil ---@type integer
local main_cu_end_excl = nil ---@type integer
while pos + 4 <= buf_len do
local unit_length = elf_dwarf.read_u32_le(existing, pos) ---@type integer
if unit_length == DWARF32_TERMINATOR then return nil end
local unit_end_excl = pos + 4 + unit_length ---@type integer
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 ---@type integer
local version = elf_dwarf.read_u16_le(existing, hdr) ---@type integer
local unit_type = existing:byte(hdr + 2 + 1) ---@type integer
local address_size = existing:byte(hdr + 3 + 1) ---@type integer
local abbrev_off = elf_dwarf.read_u32_le(existing, hdr + 4) ---@type integer
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 ---@type integer
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()
--- @param name string
--- @param form integer
--- @return string
local function attr(name, form) return uleb128(name) .. uleb128(form) end
--- @param code integer
--- @param tag integer
--- @param has_children boolean
--- @param attrs string
--- @return string
local function abbrev(code, tag, has_children, attrs)
local children = has_children and 0x01 or 0x00 ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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; gdb resolves the subprogram, not the gcc global array
local abbrev_variable = abbrev(ABBREV_VARIABLE, DW_TAG_variable, false, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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, ---@type integer -- 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 DwarfAtom[] -- list of {name, addr, size_bytes, ...}
--- @param registries DwarfRegistries
--- @return string
--- @return table<string, integer> -- bag: name -> offset in the 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 = {} ---@type string[]
local map = {} ---@type table<string, integer> -- bag
-- 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] ---@type integer
-- 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] ---@type integer
-- Atom names (one per unique atom)
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
local name = atom.name ---@type string
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 = {} ---@type string[]
for r_name, alias in pairs(registries.register_alias_registry or {}) do ---@type string, AliasEntry|nil
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 ---@type integer, string
local rr_name = "RR_" .. strip_r_prefix(r_name) ---@type string
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 DwarfAtom[] -- atoms (with atom.rbind set if rbind; atom.invocations set if mac_X(...) calls)
--- @param rbind_structs table<string, DwarfRbindStruct> -- {[binds_name] = {bytes, fields, atom_names}}
--- @param loclists_offsets table<string, integer> -- {[atom_name] = section-relative offset into the new .debug_loclists}
--- @param registries DwarfRegistries -- 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 = {} ---@type table<string, AliasEntry> -- bag
for r_name, alias in pairs(registries.register_alias_registry or {}) do ---@type string, AliasEntry|nil
if alias.code and alias.code >= 0 and alias.code <= 31 then
by_alias[r_name] = alias
end
end
local by_alias_order = {} ---@type string[]
for r_name in pairs(by_alias) do by_alias_order[#by_alias_order + 1] = r_name end ---@type string
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.
--- @param atoms DwarfAtom[]
--- @return table<string, DwarfAtom>
local function build_atom_name_index(atoms)
local m = {} ---@type table<string, DwarfAtom> -- bag
for _, a in ipairs(atoms or {}) do ---@type integer, AliasEntry|nil
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) ---@type table<string, DwarfAtom> -- bag
-- 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 ---@type integer
-- Closure state: bytes + next_offset + the typed-view/structure/abstract offset caches.
-- All step-emitters mutate this state in place.
local S = { ---@type DwarfEmitState
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
}
--- @param s string
--- @return nil
local function emit(s)
S.bytes[#S.bytes + 1] = s
S.next_offset = S.next_offset + #s
end
local FORM_WRITERS = { ---@type table<string, DieFormWriter>
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
string = function(emit, v) emit(v .. "\0") end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
data1 = function(emit, v) emit(string.char(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
udata = function(emit, v) emit(uleb128(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
addr = function(emit, v) emit(elf_dwarf.write_u32_le(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
ref4 = function(emit, v) emit(elf_dwarf.write_u32_le(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
sec_offset = function(emit, v) emit(elf_dwarf.write_u32_le(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
data2 = function(emit, v) emit(elf_dwarf.write_u16_le(v)) end,
--- @param emit fun(s: string)
--- @param v string|integer
--- @return nil
exprloc = function(emit, v) emit(v) end,
}
--- @param schema_name string
--- @param values DieValues
--- @return nil
local function emit_die(schema_name, values)
local row = DIE_SCHEMA[schema_name] ---@type DieSchema
emit(uleb128(row.abbrev))
for _, attr in ipairs(row.attrs) do ---@type integer, DieSchemaAttr
local v = values[attr.key] ---@type string|integer
local w = FORM_WRITERS[attr.form] ---@type DieFormWriter
if not w then error("emit_die: unknown form " .. tostring(attr.form)) end
w(emit, v)
end
end
--- @param section_offset integer
--- @return integer
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 ---@type integer
emit_die("base_type", {
name = "unsigned int",
byte_size = 4,
encoding = DW_ATE_unsigned,
})
-- The function body below reads S.next_offset directly via the `next_offset` function;
-- this keeps offsets synchronized with emitted data.
--- @return integer
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 table<string, integer> -- bag -- {[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 table<string, integer> -- bag -- { [type_name] = max_depth }
for _, atom in ipairs(atom_table) do ---@type integer, DwarfAtom
if atom.rbind and atom.rbind.fields then
for _, f in ipairs(atom.rbind.fields) do ---@type integer, TypeField
if f.type_name and f.pointer_depth and f.pointer_depth > 0 then
local depth = used_typed_views[f.type_name] or 0 ---@type integer
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 = {} ---@type string[]
for tn in pairs(used_typed_views) do sorted_typed_types[#sorted_typed_types + 1] = tn end ---@type string
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.).
--
-- Layout for V* / Rect_* / Reg_* / Slice / … comes from corpus.type_name_registry.
-- scan_source parses Struct_() in math.h, math.atom.h, memory.h and fills field offset + byte_size.
-- U1U4 / S1S4 / B1B4 stay authored fundamentals (BUILTIN_BYTE_SIZES + base_type DIEs below).
local type_reg = registries.type_name_registry or {} ---@type table<string, TypeNameEntry> -- bag
--- @param tn string
--- @return DwarfTypeLayout|nil
local function layout_from_registry(tn)
local entry = type_reg[tn] ---@type TypeNameEntry|nil
if not entry or entry.kind ~= "struct" or not entry.fields or #entry.fields == 0 then
return nil
end
if entry.byte_size == nil then return nil end
local members = {} ---@type DwarfTypeLayoutMember[]
for _, f in ipairs(entry.fields) do ---@type integer, TypeField
if f.offset == nil or f.byte_size == nil then return nil end
members[#members + 1] = {
name = f.name,
offset = f.offset,
byte_size = f.byte_size,
type_name = f.type_name,
pointer_depth = f.pointer_depth or 0,
}
end
return { byte_size = entry.byte_size, members = members }
end
--- @param tn string
--- @return integer
local function encoding_for_type(tn)
if type(tn) == "string" and tn:match("^S[124]$") then return 5 end
return 7
end
local S2_TYPE_BYTE_SIZE = 2 ---@type integer
local S4_TYPE_BYTE_SIZE = 4 ---@type integer
-- 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 = {} ---@type table<string, integer> -- bag
--- @param tn string
--- @param byte_size integer
--- @param encoding integer
--- @return integer
local function ensure_member_base_type(tn, byte_size, encoding)
local key = tn .. "|" .. byte_size .. "|" .. encoding ---@type string
if member_base_type_offsets[key] then return member_base_type_offsets[key] end
local off = next_offset() ---@type integer
emit_die("base_type", {
name = tn,
byte_size = byte_size,
encoding = 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)
ensure_member_base_type("S4", S4_TYPE_BYTE_SIZE, 5)
local type_chain_offsets = {} ---@type table<string, integer> -- bag
local struct_die_offsets = {} ---@type table<string, integer> -- bag
--- @param tn string
--- @return integer|nil
local function emit_struct_layout(tn)
if struct_die_offsets[tn] then return struct_die_offsets[tn] end
local type_info = layout_from_registry(tn) ---@type DwarfTypeLayout|nil
if not type_info then return nil end
for _, m in ipairs(type_info.members) do ---@type integer, DwarfTypeLayoutMember
if (m.pointer_depth or 0) == 0 and layout_from_registry(m.type_name) then
emit_struct_layout(m.type_name)
end
end
local struct_offset = next_offset() ---@type integer
struct_die_offsets[tn] = struct_offset
emit_die("structure_type", {
name = tn,
byte_size = type_info.byte_size,
})
for _, m in ipairs(type_info.members) do ---@type integer, DwarfTypeLayoutMember
local member_type_off ---@type integer
if (m.pointer_depth or 0) > 0 then
member_type_off = type_chain_offsets[m.type_name .. "|" .. m.pointer_depth]
elseif layout_from_registry(m.type_name) then
member_type_off = struct_die_offsets[m.type_name]
else
member_type_off = ensure_member_base_type(
m.type_name, m.byte_size, encoding_for_type(m.type_name))
end
if not member_type_off then
member_type_off = ensure_member_base_type(
m.type_name or "U4", m.byte_size or U4_BYTE_SIZE, 7)
end
emit_die("member", {
name = m.name,
data_member_location = m.offset,
type = ref4_of(member_type_off),
})
end
emit(string.char(DIE_CHILDREN_TERMINATOR))
return struct_offset
end
for _, tn in ipairs(sorted_typed_types) do ---@type integer, string
if tn ~= "U4" then
local depth = used_typed_views[tn] ---@type integer
local struct_offset = emit_struct_layout(tn) ---@type integer
if not struct_offset then
local innermost_offset = next_offset() ---@type integer
emit_die("base_type", {
name = tn,
byte_size = U4_BYTE_SIZE,
encoding = DW_ATE_unsigned,
})
local outermost_offset = next_offset() ---@type integer
emit_die("pointer_type", { type = ref4_of(innermost_offset) })
type_chain_offsets[tn .. "|" .. depth] = outermost_offset
elseif depth == 1 then
local outermost_offset = next_offset() ---@type integer
emit_die("pointer_type", { type = ref4_of(struct_offset) })
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
-- 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() ---@type integer
emit_die("base_type", {
name = "void",
byte_size = 1,
encoding = DW_ATE_unsigned,
})
emit_die("pointer_type", { type = ref4_of(void_chain_offset) })
-- 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 integer
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_die("pointer_type", { type = ref4_of(base_type_section_offset) })
local u4_chain_offset = next_offset() - 5 ---@type integer -- 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 = {} ---@type table<string, integer> -- bag
local sorted_struct_names = {} ---@type string[]
for k in pairs(rbind_structs) do sorted_struct_names[#sorted_struct_names + 1] = k end ---@type string
table.sort(sorted_struct_names)
for _, binds_name in ipairs(sorted_struct_names) do ---@type integer, string
local struct = rbind_structs[binds_name] ---@type DwarfRbindStruct
struct_section_offsets[binds_name] = next_offset()
emit_die("structure_type", {
name = binds_name,
byte_size = struct.bytes,
})
for _, field in ipairs(struct.fields) do ---@type integer, TypeField
local field_type_offset ---@type integer
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_die("member", {
name = field.name,
data_member_location = field.offset,
type = ref4_of(field_type_offset),
})
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) ---@type table<string, DwarfComponentSite> -- bag
local abstract_offsets = {} ---@type table<string, integer> -- bag -- name -> section offset
local sorted_comp_names = {} ---@type string[]
for name in pairs(component_defs) do sorted_comp_names[#sorted_comp_names + 1] = name end ---@type string
table.sort(sorted_comp_names)
-- DW_INL_inlined (1) = "this subroutine was inlined" — accurate for the mac_* components.
local DW_INL_inlined = 0x01 ---@type integer
for _, comp_name in ipairs(sorted_comp_names) do ---@type integer, string
local def = component_defs[comp_name] ---@type DwarfComponentSite
abstract_offsets[comp_name] = next_offset()
emit_die("abstract_subprogram", {
name = "mac_" .. comp_name,
inline = DW_INL_inlined,
external = 0x01,
decl_file = resolve_provenance_file_index(def.def_file),
decl_line = def.def_line,
})
end
-- 4) Emit per-atom DW_TAG_subprograms (children of main CU).
-- Subprogram names match the written C ident (the ELF symbol).
-- 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 ---@type integer, DwarfAtom
emit_die("subprogram", {
name = atom.name,
low_pc = atom.addr,
high_pc = atom.addr + atom.size_bytes,
linkage_name = atom.name,
})
-- 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 ---@type table<string, TypeField>|nil -- bag -- populated by step (b); map field_name -> field entry
local reg_to_field_ctx = nil ---@type table<integer, string>|nil -- bag -- populated by step (b); map GPR index -> field name
local atom_view_phase_fields = nil ---@type table<string, TypeField>|nil -- bag -- populated by step (d); map field_name -> field entry
local reg_to_field_phase = nil ---@type table<integer, string>|nil -- bag -- 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 = {} ---@type table<string, TypeField> -- bag
if atom.rbind and atom.rbind.fields then
for _, f in ipairs(atom.rbind.fields) do ---@type integer, TypeField
if f.type_name then
field_type_by_name[f.name] = f
end
end
end
local reg_to_field = {} ---@type table<integer, string> -- bag
if atom.rbind and atom.rbind.regs then
for _, pair in ipairs(atom.rbind.regs) do ---@type integer, DwarfLoadPair
reg_to_field[pair.reg] = pair.field
end
end
local atom_view = (registries.atom_views or {})[atom.name] ---@type AtomViewEntry|nil
-- 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] ---@type AtomCtxEntry|nil
if this_ctx and this_ctx.rbind_atom then
local rbind = atom_by_name_global[this_ctx.rbind_atom] ---@type DwarfAtom|nil
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 ---@type integer, TypeField
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 ---@type integer, DwarfLoadPair
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 ---@type string|nil
for _, ai in ipairs(registries.atom_infos or {}) do ---@type integer, AtomInfoEntry
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] ---@type AtomPhaseGroup|nil
if group and group.atoms then
for _, group_atom_name in ipairs(group.atoms) do ---@type integer, string
if group_atom_name ~= atom.name then
local cand = atom_by_name_global[group_atom_name] ---@type DwarfAtom|nil
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 ---@type integer, TypeField
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 ---@type integer, DwarfLoadPair
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 = { ---@type DwarfPrecedenceStep[]
-- (a) per-atom callsite atom_type(R_X, <T>): most specific; user explicit override for THIS atom only.
--- @param r_name string
--- @param alias_code integer
--- @return integer|nil
function(r_name, alias_code)
local override = atom_view and atom_view.reg_type_overrides and atom_view.reg_type_overrides[r_name] ---@type RegTypeOverride|nil
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.
--- @param r_name string
--- @param alias_code integer
--- @return integer|nil
function(r_name, alias_code)
local ctx_field_name = reg_to_field_ctx and reg_to_field_ctx[alias_code] ---@type string|nil
local ctx_f = ctx_field_name and atom_view_ctx_fields and atom_view_ctx_fields[ctx_field_name] ---@type TypeField|nil
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_*).
--- @param r_name string
--- @param alias_code integer
--- @return integer|nil
function(r_name, alias_code)
local field_name = reg_to_field[alias_code] ---@type string|nil
local f = field_name and field_type_by_name[field_name] ---@type TypeField|nil
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.
--- @param r_name string
--- @param alias_code integer
--- @return integer|nil
function(r_name, alias_code)
local phase_field_name = reg_to_field_phase and reg_to_field_phase[alias_code] ---@type string|nil
local phase_f = phase_field_name and atom_view_phase_fields and atom_view_phase_fields[phase_field_name] ---@type TypeField|nil
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.
--- @param r_name string
--- @param alias_code integer
--- @return integer|nil
function(r_name, alias_code)
local a = by_alias[r_name] ---@type AliasEntry|nil
if a and a.default_type and a.default_depth and a.default_depth > 0 then
return type_chain_offsets[a.default_type .. "|" .. a.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 ---@type integer, string
local alias = by_alias[r_name] ---@type AliasEntry|nil
local rr_name = "RR_" .. strip_r_prefix(r_name) ---@type string
local alias_code = alias.code ---@type integer
local type_offset = type_chain_offsets["void|1"] ---@type integer
for _, step in ipairs(PRECEDENCE_STEPS) do ---@type integer, DwarfPrecedenceStep
local candidate = step(r_name, alias_code) ---@type integer
if candidate then type_offset = candidate; break end
end
emit_die("variable", {
name = rr_name,
location = uleb128(1) .. string.char(DW_OP_reg0 + alias_code),
type = ref4_of(type_offset),
external = 0x01,
})
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 ---@type string
local loclists_offset = loclists_offsets[atom.name] or 0 ---@type integer
emit_die("bind_var_loclist", {
name = "bind_args",
location = loclists_offset,
type = ref4_of(struct_section_offsets[binds_name]),
})
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 ---@type integer, InvocationRecord
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 ---@type integer
local inv_high = atom.addr + (inv.end_pos + 1) * MIPS_BYTES_PER_WORD ---@type integer
emit_die("inlined_subroutine", {
abstract_origin = ref4_of(abstract_offsets[inv.component_name]),
low_pc = inv_low,
high_pc = inv_high,
call_file = resolve_provenance_file_index(inv.call_path),
call_line = inv.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) ---@type integer
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) ---@type string
local new_abbrevs = build_new_abbrev() ---@type string -- 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 DwarfAtom[]
--- @param registries DwarfRegistries -- 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) ---@type string
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 DwarfAtom[]
--- @param rbind_structs table<string, DwarfRbindStruct>
--- @param loclists_offsets table<string, integer> -- bag: atom name -> section-relative offset
--- @param registries DwarfRegistries
--- @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) ---@type string
local inserted_len = #inserted ---@type integer
-- 2) Patch main CU's unit_length += inserted_len.
local old_unit_length = elf_dwarf.read_u32_le(existing, main_cu_start) ---@type integer
local new_unit_length = old_unit_length + inserted_len ---@type integer
local new_unit_length_bytes = elf_dwarf.write_u32_le(new_unit_length) ---@type string
-- 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) ---@type string
-- 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 ---@type integer -- 0-based end of existing DIE bytes (inclusive)
local root_terminator = main_cu_end_excl - 1 ---@type integer -- 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 = { ---@type table<string, DwarfSectionPathWriter>
--- @param out_root string
--- @param basename string
--- @return string
debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_aranges = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_aranges.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_rnglists = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_rnglists.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_abbrev = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_abbrev.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_info = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_info.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_str = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_str.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
debug_loc = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_loc.bin" end,
--- @param out_root string
--- @param basename string
--- @return string
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 DwarfSectionBlob[] -- list of `{name=, data=}` records to write
--- @param ctx PassCtx
--- @param basename string -- output file basename (e.g. "hello_gte")
--- @return table<string, string>[] -- bag: one <section>_bin -> path per row
local function write_sections(results, ctx, basename)
local outputs = {} ---@type table<string, string>[] -- bag: one <section>_bin -> path per row
for _, r in ipairs(results) do ---@type integer, DwarfSectionBlob
local path = SECTION_WRITERS[r.name](ctx.out_root, basename) ---@type string
local f = io.open(path, "wb") ---@type file*|nil
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 = {} ---@type DwarfInjectionPass
--- M.run — orchestrator entry.
--- @param ctx PassCtx
--- @return PassResult
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 ---@type string|nil
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, { ---@type table<string, string> -- bag
".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",
})
-- Resolve the per-file line-table indices from the same .debug_line bytes;
-- this MUST run before any atom sequence is emitted (build_atom_sequence below
-- calls resolve_provenance_file_index when populating call-site / body rows).
init_file_index_lookup(elf_path)
-- 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 {} ---@type Corpus
local registries = collect_per_source_registries(corpus) ---@type DwarfRegistries
-- 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) ---@type table<string, NmAddr> -- bag
local atom_table = build_atom_table(corpus, addrs) ---@type DwarfAtom[]
-- 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) ---@type table<string, DwarfRbind>, table<string, DwarfRbindStruct>
-- 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 ---@type string
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 "" ---@type string
local existing_abbrev = existing_sections[".debug_abbrev"] or "" ---@type string
local main_cu_start, main_cu_end_excl, main_abbrev_offset = find_main_cu_layout(existing_info) ---@type integer|nil, integer|nil, integer|nil
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 "" ---@type string
local results_safe = { ---@type DwarfSectionBlob[]
{ 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) ---@type table<string, string>[] -- bag
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) ---@type string, integer|nil
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) ---@type table<string, integer> -- bag
-- 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) ---@type string
-- 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) ---@type string
-- Step 3-5: independent sections.
local results = { ---@type DwarfSectionBlob[]
{ 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) ---@type table<string, string>[] -- bag
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
M.build_atom_table_for_test = build_atom_table
return M