diff --git a/.vscode/launch.json b/.vscode/launch.json index 1707ccf..26615d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,6 +138,7 @@ "monitor reset shellhalt", "load build/hello_gte.dwarf-injected.elf", "source scripts/gdb/gdb_tape_atoms.gdb", + "source build/gen/hello_gte.gdbinit", "tbreak main", "continue" ] diff --git a/code/duffle/atom_dsl.h b/code/duffle/atom_dsl.h index c4f089a..5b2d14d 100644 --- a/code/duffle/atom_dsl.h +++ b/code/duffle/atom_dsl.h @@ -18,11 +18,13 @@ * - atom word-counts in word_counts.metadata.h agree with the body's * actual .word count. * - * WHY A PURE MACRO (atom_info, atom_bind, atom_reads, atom_writes, atom_label) - * ----------------------------------------------------------------- - * Each of these expands to a C comment or to nothing. The C preprocessor - * strips them to whitespace. The metaprogram reads the literal token from - * source-as-written, NOT from the preprocessed output. This means: + * WHY PURE MACROS + * --------------- + * atom_info, atom_bind, atom_reads, atom_writes, atom_label, + * atom_dbg_skip_over each expand to a C comment or to nothing. The C + * comment or to nothing. The C preprocessor strips them to whitespace. + * The metaprogram reads the literal tokens from source-as-written, NOT from + * the preprocessed output. This means: * - the C compiler does no work for them (no __attribute__, no * _Pragma, no asm side-effects) * - they can never silently drift from the metaprogram's view @@ -150,6 +152,20 @@ * ============================================================================*/ #define atom_info(...) /* atom_info(__VA_ARGS__) */ +/* ---------------------------------------------------------------------------- + * DEBUG SOURCE-STEP MARKERS + * + * Place atom_dbg_skip_over() before a MipsAtom_, MipsAtomComp_, or + * MipsAtomComp_Proc_ declaration. The following declaration kind determines + * whether the marker selects a whole atom or a component inline view. The + * source scanner associates the marker with that declaration; placement + * diagnostics are handled by the annotation pass. + * + * The macro expands to a comment only. It emits no C data or MIPS words and + * therefore cannot affect runtime output. + * ----------------------------------------------------------------------------*/ +#define atom_dbg_skip_over() /* atom_dbg_skip_over: skip the following atom or component source view */ + /* ---------------------------------------------------------------------------- * atom_bind(Binds_X) -- rbind sub-call of atom_info * diff --git a/code/duffle/gen/duffle.macs.h b/code/duffle/gen/duffle.macs.h index 1df5c30..3eafae5 100644 --- a/code/duffle/gen/duffle.macs.h +++ b/code/duffle/gen/duffle.macs.h @@ -23,7 +23,6 @@ WORD_COUNT(mac_yield, 4) , load_half_u(R_T2, R_FaceCursor, 2 * S_(S2)) WORD_COUNT(mac_load_tri_indices, 3) -/* Words: 18; Translates indices to vertex addresses and pushes them to GTE */ #define mac_gte_load_tri_verts(...) \ shift_lleft(R_AT, R_T0, v3s2_byteoff) \ , add_u_self(R_AT, R_VertBase) \ @@ -77,7 +76,7 @@ WORD_COUNT(mac_insert_ot_tag_g4, 11) #define mac_pack_color_word(off, cmd, r, g, b) \ load_upper_i(R_AT, (cmd) << 8 | (b)) \ -, or_i_self( R_AT, ((g) << 8) | (r)) \ +, or_i_self( R_AT, ((g) << 8) | (r)) \ , store_word( R_AT, R_PrimCursor, (off)) WORD_COUNT(mac_pack_color_word, 3) diff --git a/code/duffle/lottes_tape.h b/code/duffle/lottes_tape.h index 826b506..6dacfdf 100644 --- a/code/duffle/lottes_tape.h +++ b/code/duffle/lottes_tape.h @@ -121,6 +121,7 @@ MipsAtomComp_(ac_load_tri_indices) { }; /* Words: 18; Translates indices to vertex addresses and pushes them to GTE */ +atom_dbg_skip_over() MipsAtomComp_(ac_gte_load_tri_verts) { shift_lleft(R_AT, R_T0, v3s2_byteoff), add_u_self(R_AT, R_VertBase), load_word(R_V0, R_AT, O_(V3_S2,x)), load_word(R_V1, R_AT, O_(V3_S2,z)), gte_mv_to_data_r(R_V0, C2_VXY0), gte_mv_to_data_r(R_V1, C2_VZ0), shift_lleft(R_AT, R_T1, v3s2_byteoff), add_u_self(R_AT, R_VertBase), load_word(R_V0, R_AT, O_(V3_S2,x)), load_word(R_V1, R_AT, O_(V3_S2,z)), gte_mv_to_data_r(R_V0, C2_VXY1), gte_mv_to_data_r(R_V1, C2_VZ1), @@ -162,7 +163,7 @@ MipsAtomComp_(ac_insert_ot_tag_g4) { FI_ MipsAtom ac_pack_color_word(U4 off, U4 cmd, U1 r, U1 g, U1 b) MipsAtomComp_Proc_(ac_pack_color_word, { load_upper_i(R_AT, (cmd) << 8 | (b)), - or_i_self( R_AT, ((g) << 8) | (r)), + or_i_self( R_AT, ((g) << 8) | (r)), store_word( R_AT, R_PrimCursor, (off)), }) diff --git a/code/gte_hello/hello_gte_tape.c b/code/gte_hello/hello_gte_tape.c index d10ef2b..c7881fe 100644 --- a/code/gte_hello/hello_gte_tape.c +++ b/code/gte_hello/hello_gte_tape.c @@ -109,6 +109,7 @@ MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri) }; internal +atom_dbg_skip_over() MipsAtom_(floor_f3_face) atom_info( , atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase) , atom_writes(R_PrimCursor, R_FaceCursor) diff --git a/scripts/build_psyq.ps1 b/scripts/build_psyq.ps1 index 0ffc83d..ddeefcf 100644 --- a/scripts/build_psyq.ps1 +++ b/scripts/build_psyq.ps1 @@ -456,36 +456,43 @@ function build-gte_hello { # The F' splice block above already covered .debug_line / .debug_aranges / .debug_rnglists; # we extend the same Copy-Item + objcopy chain to splice the G' 4 sections # (.debug_info, .debug_abbrev, .debug_str via --update-section; .debug_loc via --add-section since it doesn't exist in the source ELF). - $dwarfInfoBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_info.bin' - $dwarfAbbrevBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_abbrev.bin' - $dwarfStrBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_str.bin' - $dwarfLocBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_loc.bin' - if ((Test-Path $dwarfInfoBin) -and (Test-Path $dwarfAbbrevBin) -and (Test-Path $dwarfStrBin) -and (Test-Path $dwarfLocBin)) + $dwarfInfoBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_info.bin' + $dwarfAbbrevBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_abbrev.bin' + $dwarfStrBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_str.bin' + $dwarfLocBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_loc.bin' + $dwarfLoclistsBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_loclists.bin' + if ((Test-Path $dwarfInfoBin) -and (Test-Path $dwarfAbbrevBin) -and (Test-Path $dwarfStrBin) -and (Test-Path $dwarfLocBin) -and (Test-Path $dwarfLoclistsBin)) { - Write-Host "[build] G' atom-locals: splicing .debug_info/.debug_abbrev/.debug_str/.debug_loc into $injectElf" + Write-Host "[build] G' atom-locals: splicing .debug_info/.debug_abbrev/.debug_str/.debug_loc/.debug_loclists into $injectElf" & $Objcopy --update-section ".debug_info=$dwarfInfoBin" $injectElf - $last_exit_code_error = ($LASTEXITCODE -ne 0) + $last_exit_code_error = ($LASTEXITCODE -ne 0) if ($last_exit_code_error) { Write-Warning "[build] objcopy .debug_info update failed (exit $LASTEXITCODE)" return; - } + } & $Objcopy --update-section ".debug_abbrev=$dwarfAbbrevBin" $injectElf if ($LASTEXITCODE -ne 0) { Write-Warning "[build] objcopy .debug_abbrev update failed (exit $LASTEXITCODE)" return; - } + } & $Objcopy --update-section ".debug_str=$dwarfStrBin" $injectElf if ($LASTEXITCODE -ne 0) { Write-Warning "[build] objcopy .debug_str update failed (exit $LASTEXITCODE)" - } - else + } + else { # .debug_loc doesn't exist in the source ELF; --add-section creates it. & $Objcopy --add-section ".debug_loc=$dwarfLocBin" $injectElf if ($LASTEXITCODE -ne 0) { Write-Warning "[build] objcopy .debug_loc add-section failed (exit $LASTEXITCODE)" } else { - Write-Host "[build] G' atom-locals-injected: $injectElf" + # .debug_loclists doesn't exist in the source ELF; --add-section creates it. + & $Objcopy --add-section ".debug_loclists=$dwarfLoclistsBin" $injectElf + if ($LASTEXITCODE -ne 0) { + Write-Warning "[build] objcopy .debug_loclists add-section failed (exit $LASTEXITCODE)" + } else { + Write-Host "[build] G' atom-locals-injected: $injectElf" + } } } } diff --git a/scripts/elf_dwarf.lua b/scripts/elf_dwarf.lua index 1c3f5a5..af843dd 100644 --- a/scripts/elf_dwarf.lua +++ b/scripts/elf_dwarf.lua @@ -21,6 +21,78 @@ local lfs = require("lfs") local M = {} +-- ════════════════════════════════════════════════════════════════════════════ +-- DWARF tag + form constants +-- ════════════════════════════════════════════ +-- (DWARF5 §7.5.5 "Tag Encodings" + Table 7.1; gcc emits these exact values for +-- the DWARF3-extension and DWARF5 line units.) + +M.DW_TAG = { + compile_unit = 0x11, + subprogram = 0x2E, + variable = 0x34, + structure_type = 0x13, + member = 0x0D, + base_type = 0x24, + typedef = 0x2A, + pointer_type = 0x0F, + const_type = 0x26, + volatile_type = 0x27, + inlined_subroutine = 0x1D, + -- We index the canonical gcc-emitted tags. Anything else falls through. +} + +M.DW_AT = { + name = 0x03, + low_pc = 0x11, + high_pc = 0x12, + language = 0x13, + location = 0x02, + comp_dir = 0x1B, + byte_size = 0x0B, + encoding = 0x3E, + data_member_location = 0x38, + type = 0x49, + linkage_name = 0x6E, + external = 0x3F, + abstract_origin = 0x31, + call_file = 0x58, + call_line = 0x59, + inline = 0x20, + decl_file = 0x3A, + decl_line = 0x3B, +} + +M.DW_FORM = { + addr = 0x01, + data1 = 0x0B, + data2 = 0x05, + data4 = 0x06, + string = 0x08, + strp = 0x0E, + exprloc = 0x18, + ref4 = 0x13, + udata = 0x0F, + ref_sig8 = 0x20, + implicit_const = 0x21, + flag_present = 0x19, + sec_offset = 0x17, +} + +M.DW_ATE = { + address = 0x01, + boolean = 0x02, + complex_float = 0x03, + float = 0x04, + signed = 0x05, + signed_char = 0x06, + unsigned = 0x07, + unsigned_char = 0x08, +} + +-- DWARF5 §7.5.6 DW_FORM_implicit_const +local DW_FORM_implicit_const = 0x21 + -- ════════════════════════════════════════════════════════════════════════════ -- Format-constant tables -- ════════════════════════════════════════════════════════════════════════════ @@ -121,6 +193,7 @@ M.DWARF_LINE_OPS = { DW_LNS_advance_pc = 2, DW_LNS_advance_line = 3, DW_LNS_set_file = 4, + DW_LNS_negate_stmt = 6, -- spec: §6.2.5.2 — toggle the line-state is_stmt register -- Extended sub-opcodes (§6.2.5.3) DW_LNE_end_sequence = 1, -- spec: §6.2.5.3 DW_LNE_set_address = 2, -- spec: §6.2.5.3 @@ -171,6 +244,500 @@ function M.read_u16_le(buf, off) return buf:byte(off) + buf:byte(off + 0x01) * 0x00000100 end +-- Pure-Lua 5.3 LEB128 readers (no `bit` library). `2^shift` arithmetic matches +-- the existing F' parser. Offsets are 0-based; returns (value, next_pos). +local function read_uleb128_at(buf, pos) + local value, shift = 0, 0 + local len = #buf + while pos < len do + local b = buf:byte(pos + 1) + value = value + (b % 0x80) * (2 ^ shift) + shift = shift + 7 + pos = pos + 1 + if b < 0x80 then return value, pos end + end + return nil, pos +end + +local function read_sleb128_at(buf, pos) + local value, shift = 0, 0 + local len = #buf + while pos < len do + local b = buf:byte(pos + 1) + value = value + (b % 0x80) * (2 ^ shift) + shift = shift + 7 + pos = pos + 1 + if b < 0x80 then + if b >= 0x40 then value = value - (2 ^ shift) end + return value, pos + end + end + return nil, pos +end + +-- Find the 0-based offset of the table-terminator byte (a single 0) for the +-- abbrev table starting at `table_start`. Returns nil on truncated input. +-- Walks declaration headers (code, tag, has_children, attr/form pairs, +-- DW_FORM_implicit_const constant) until it finds a 0 byte that follows a +-- complete declaration. Mirrors dwarf_injection.lua :: find_abbrev_table_end. +local function find_abbrev_table_end(table_bytes, table_start) + local pos, len = table_start, #table_bytes + if pos >= len or table_bytes:byte(pos + 1) == 0 then return pos end + while pos < len do + local _code, code_end = read_uleb128_at(table_bytes, pos) + if not _code then return nil end + pos = code_end + local _tag, tag_end = read_uleb128_at(table_bytes, pos) + if not _tag then return nil end + pos = tag_end + if pos >= len then return nil end + pos = pos + 1 -- has_children byte + while pos < len do + local attr, attr_end = read_uleb128_at(table_bytes, pos) + if not attr then return nil end + pos = attr_end + local form, form_end = read_uleb128_at(table_bytes, pos) + if not form then return nil end + pos = form_end + if attr == 0 and form == 0 then break end + if form == DW_FORM_implicit_const then + local _c, ce = read_sleb128_at(table_bytes, pos) + if not _c then return nil end + pos = ce + end + end + if pos >= len then return nil end + if table_bytes:byte(pos + 1) == 0 then return pos end + end + return nil +end + +-- Read the null-terminated C string at 0-based offset `off` in `buf`. +-- Stops at the first 0 byte or end of buffer. +local function read_c_string_at(buf, off) + local len = #buf + local start = off + while off < len and buf:byte(off + 1) ~= 0 do off = off + 1 end + return buf:sub(start + 1, off) +end + +-- Walk the .debug_abbrev table starting at 0-based offset `table_start` and +-- return a list of declarations: {code, tag, has_children, attrs={ {name, form}, ... }}. +-- Stops at the table terminator. +local function parse_abbrev_table(table_bytes, table_start) + local table_end = find_abbrev_table_end(table_bytes, table_start) + if not table_end then return nil, "no terminator" end + local decls = {} + local pos = table_start + while pos < table_end do + local code, code_end = read_uleb128_at(table_bytes, pos) + if not code then return nil, "truncated code" end + pos = code_end + local tag, tag_end = read_uleb128_at(table_bytes, pos) + if not tag then return nil, "truncated tag" end + pos = tag_end + local has_children = table_bytes:byte(pos + 1) + pos = pos + 1 + local attrs = {} + while true do + local attr, attr_end = read_uleb128_at(table_bytes, pos) + if not attr then return nil, "truncated attr" end + pos = attr_end + local form, form_end = read_uleb128_at(table_bytes, pos) + if not form then return nil, "truncated form" end + pos = form_end + if attr == 0 and form == 0 then break end + attrs[#attrs + 1] = { name = attr, form = form } + if form == DW_FORM_implicit_const then + local _c, ce = read_sleb128_at(table_bytes, pos) + if not _c then return nil, "truncated const" end + pos = ce + end + end + decls[#decls + 1] = { code = code, tag = tag, has_children = has_children, attrs = attrs } + end + return decls +end + +-- Read a ULEB attribute value at 0-based offset `pos` for the given `form`. +-- Returns (value, next_pos). For DW_FORM_string we return the inline string. +-- For DW_FORM_strp we return the inline string resolved from `str_buf`. +-- For DW_FORM_ref4 we return the absolute CU-relative offset. The caller +-- decides whether to interpret that as a section offset. +local function read_form_value(buf, str_buf, pos, form) + if form == M.DW_FORM.addr then + return M.read_u32_le(buf, pos + 1), pos + 4 + elseif form == M.DW_FORM.string then + local s = read_c_string_at(buf, pos) + return s, pos + #s + 1 + elseif form == M.DW_FORM.strp then + -- DW_FORM_strp: 4-byte offset into .debug_str. + local strp_off = M.read_u32_le(buf, pos + 1) + return read_c_string_at(str_buf, strp_off), pos + 4 + elseif form == M.DW_FORM.udata then + return read_uleb128_at(buf, pos) + elseif form == M.DW_FORM.data1 then + return buf:byte(pos + 1), pos + 1 + elseif form == M.DW_FORM.data2 then + return M.read_u16_le(buf, pos + 1), pos + 2 + elseif form == M.DW_FORM.data4 then + return M.read_u32_le(buf, pos + 1), pos + 4 + elseif form == M.DW_FORM.ref4 then + return M.read_u32_le(buf, pos + 1), pos + 4 + elseif form == M.DW_FORM.sec_offset then + -- DW_FORM_sec_offset: 4-byte offset (size depends on DWARF version; + -- on DWARF5 32-bit it's always 4 bytes). + return M.read_u32_le(buf, pos + 1), pos + 4 + elseif form == M.DW_FORM.flag_present then + return 1, pos + elseif form == M.DW_FORM.exprloc then + -- DW_FORM_exprloc: ULEB byte count + that many bytes of DW_OP_*. + local len, ne = read_uleb128_at(buf, pos) + if not len then return nil, pos end + return nil, ne + len + elseif form == DW_FORM_implicit_const then + -- The constant is declared in the abbrev; no value bytes in the DIE. + return nil, pos + elseif form == M.DW_FORM.ref_sig8 then + return M.read_u32_le(buf, pos + 1), pos + 8 + else + return nil, pos + end +end + +-- Index the .debug_info + .debug_abbrev sections of an existing ELF and +-- collect one entry per "interesting" type DIE in the FIRST compilation +-- unit. The index supports typed-view resolution for Phase 5: +-- +-- index = { +-- by_name = { ["V4_S2"] = {kind="structure_type", die_offset, byte_size, fields={...}}, +-- ["U4"] = {kind="base_type", die_offset, byte_size, encoding="unsigned"}, +-- ["MipsCode"]= {kind="typedef", die_offset, target_kind=..., target_die_offset=...} }, +-- by_offset = { [die_offset] = {kind, name, ...} }, -- reverse lookup +-- } +-- +-- Only the main CU is indexed. We do not walk nested CUs (this matches the +-- Phase 2-4 scope: the main CU is the only one in this build). +-- @param info string -- .debug_info section bytes +-- @param abbrev string -- .debug_abbrev section bytes +-- @param str_buf string -- .debug_str section bytes (for DW_FORM_strp name resolution) +-- @param abbrev_offset integer -- 0-based offset of the main CU's abbrev table +-- @param cu_start integer|nil -- 0-based offset of the main CU (caller-known) +-- @return table|nil, string|nil -- (index, error) +function M.index_main_cu_types(info, abbrev, str_buf, abbrev_offset, cu_start) + if not info or #info < 12 or not abbrev or not abbrev_offset then return nil, "missing input" end + str_buf = str_buf or "" + -- Index the main table at abbrev_offset. The F' pass writes a trailing + -- 0 byte after the main table; the G' pass may append additional codes + -- (100-108) + a 0 terminator. The walker may encounter a code that + -- wasn't in the main table but exists later in the same .debug_abbrev; + -- on the first miss, walk the rest of the section to add any new + -- abbrevs we encounter. + local abbrev_decls, err = parse_abbrev_table(abbrev, abbrev_offset) + if not abbrev_decls then return nil, err end + local abbrev_by_code = {} + for _, d in ipairs(abbrev_decls) do abbrev_by_code[d.code] = d end + + -- Resolve cu_start + cu_end_excl. + local cu_end_excl + if cu_start then + local ul = M.read_u32_le(info, cu_start + 1) + if ul == 0xFFFFFFFF then return nil, "DWARF64 not supported" end + cu_end_excl = cu_start + 4 + ul + else + local pos = 0 + cu_start = nil + while pos < #info do + local ul = M.read_u32_le(info, pos + 1) + if ul == 0xFFFFFFFF then break end + local unit_end = pos + 4 + ul + if unit_end > #info then break end + local unit_abbrev = M.read_u32_le(info, pos + 9) + if unit_abbrev == abbrev_offset then + cu_start = pos + cu_end_excl = unit_end + break + end + pos = unit_end + end + if not cu_start then return nil, "no CU matches abbrev_offset" end + end + + -- Walk the CU's DIE tree at 0-based offset cu_start + 12. + -- Emit a flat list of type-bearing DEIs; recursion handles nested children + -- (members inside structure_type, types inside subprograms, etc.). + -- A non-type DIE's subtree is SKIPPED by walking until the matching null. + local by_name = {} + local by_offset = {} + local pos_cursor = cu_start + 12 + -- Root DIE is the compile_unit; skip past it. + if pos_cursor >= cu_end_excl then return nil, "no DIE bytes" end + local root_decl_code + root_decl_code, pos_cursor = read_uleb128_at(info, pos_cursor) + if not root_decl_code then return nil, "truncated root DIE code" end + local root_decl = abbrev_by_code[root_decl_code] + if not root_decl then return nil, "unknown root DIE abbrev" end + -- Skip root DIE attributes. + for _, attr in ipairs(root_decl.attrs) do + local _, ne = read_form_value(info, str_buf, pos_cursor, attr.form) + if not ne then return nil, "truncated root attr" end + pos_cursor = ne + end + -- If root has no children, return an empty index. + if not root_decl.has_children or root_decl.has_children == 0 then + return { by_name = by_name, by_offset = by_offset } + end + + -- Helper: skip a subtree rooted at the current DIE (pos_cursor is + -- positioned at the first child). Walks down and right until the + -- matching null terminator is consumed. Returns the new pos_cursor. + -- For DIE trees that contain only the closed type + member shapes, + -- the depth never exceeds 2 (type DIE -> member DIE -> null). + local function skip_subtree(pos) + local depth = 1 + while pos < cu_end_excl and depth > 0 do + local code = info:byte(pos + 1) + pos = pos + 1 + if code == 0 then + depth = depth - 1 + else + local d = abbrev_by_code[code] + if d and d.has_children ~= 0 then + depth = depth + 1 + end + end + end + return pos + end + + -- Pre-declare n_visited so the closure helpers can read it. + local n_visited = 0 + + -- Helper: read one DIE's attributes. Returns (name, byte_size, encoding, + -- type_ref, new_pos_cursor) on success; (nil, error_string) on truncation. + local function read_die_attributes(decl, pos) + local die_name = nil + local die_byte_size = nil + local die_encoding = nil + local die_type_ref = nil + for ai, attr in ipairs(decl.attrs) do + local before = pos + local val, ne = read_form_value(info, str_buf, pos, attr.form) + if not ne then + return nil, "truncated DIE attr" + end + pos = ne + if attr.name == M.DW_AT.name then + die_name = val + elseif attr.name == M.DW_AT.byte_size and + (decl.tag == M.DW_TAG.base_type or decl.tag == M.DW_TAG.structure_type) then + die_byte_size = val + elseif attr.name == M.DW_AT.encoding and decl.tag == M.DW_TAG.base_type then + die_encoding = val + elseif attr.name == M.DW_AT.type then + die_type_ref = val + end + end + return die_name, die_byte_size, die_encoding, die_type_ref, pos + end + + -- Helper: read structure_type members. Returns (member_fields, new_pos). + local function read_member_fields(decl, pos) + local fields = {} + while pos < cu_end_excl do + local mcode = info:byte(pos + 1) + if mcode == 0 then + pos = pos + 1 + break + end + local mdecl = abbrev_by_code[mcode] + if not mdecl then return nil, "unknown member abbrev" end + pos = pos + 1 + local mname, mtype_ref, moffset + for _, a in ipairs(mdecl.attrs) do + local v, ne = read_form_value(info, str_buf, pos, a.form) + if not ne then return nil, "truncated member attr" end + pos = ne + if a.name == M.DW_AT.name then mname = v + elseif a.name == M.DW_AT.type then mtype_ref = v + elseif a.name == M.DW_AT.data_member_location then moffset = v end + end + fields[#fields + 1] = { name = mname, type_offset = mtype_ref, offset = moffset } + end + return fields, pos + end + + -- Top-level walker: iterate siblings. For each DIE, decide whether to + -- record (if type), descend (if structure_type with members), or skip + -- its subtree. + while pos_cursor < cu_end_excl do + local code = info:byte(pos_cursor + 1) + if code == 0 then pos_cursor = pos_cursor + 1; break end + local decl = abbrev_by_code[code] + if not decl then + -- Lazily scan the rest of the section for the missing code. + -- The G' pass appends new abbrevs (100-108) after the main table's + -- terminator. On the first miss, walk the rest of the section. + local scan_pos = 0 + while scan_pos < #abbrev do + if abbrev:byte(scan_pos + 1) == 0 then + scan_pos = scan_pos + 1 + goto continue + end + local new_table, e3 = parse_abbrev_table(abbrev, scan_pos) + if not new_table then + scan_pos = scan_pos + 1 + goto continue + end + for _, d in ipairs(new_table) do + if not abbrev_by_code[d.code] then + abbrev_by_code[d.code] = d + end + end + -- Find the terminator (0 byte) of this table. + local term = find_abbrev_table_end(abbrev, scan_pos) + if not term then break end + scan_pos = term + 1 + ::continue:: + end + decl = abbrev_by_code[code] + if not decl then + return nil, string.format("unknown abbrev %d at offset 0x%x", code, pos_cursor) + end + end + local die_offset = pos_cursor + pos_cursor = pos_cursor + 1 + local read_result = { read_die_attributes(decl, pos_cursor) } + if #read_result == 2 then + return nil, read_result[2] + end + local die_name, die_byte_size, die_encoding, die_type_ref, pos_after_attrs + = read_result[1], read_result[2], read_result[3], read_result[4], read_result[5] + n_visited = n_visited + 1 + local is_type = (decl.tag == M.DW_TAG.base_type + or decl.tag == M.DW_TAG.structure_type + or decl.tag == M.DW_TAG.typedef + or decl.tag == M.DW_TAG.pointer_type + or decl.tag == M.DW_TAG.const_type) + + local member_fields = nil + pos_cursor = pos_after_attrs + if decl.tag == M.DW_TAG.structure_type and decl.has_children ~= 0 then + local f, np = read_member_fields(decl, pos_cursor) + if not f then return nil, np end + member_fields = f + pos_cursor = np + elseif decl.has_children ~= 0 then + -- Skip the subtree (e.g., DW_TAG_subprogram, DW_TAG_variable, etc.). + pos_cursor = skip_subtree(pos_cursor) + end + + if is_type and die_name then + local kind + if decl.tag == M.DW_TAG.base_type then kind = "base_type" + elseif decl.tag == M.DW_TAG.structure_type then kind = "structure_type" + elseif decl.tag == M.DW_TAG.typedef then kind = "typedef" + elseif decl.tag == M.DW_TAG.pointer_type then kind = "pointer_type" + elseif decl.tag == M.DW_TAG.const_type then kind = "const_type" end + local entry = { + kind = kind, + name = die_name, + die_offset = die_offset, + byte_size = die_byte_size, + encoding = die_encoding, + type_ref = die_type_ref, + fields = member_fields, + } + by_name[die_name] = entry + by_offset[die_offset] = entry + end + end + return { by_name = by_name, by_offset = by_offset } +end + +-- Resolve a chain of pointer + const + typedef + structure_type down to a +-- canonical struct or base type. The returned entry has kind, name, byte_size, +-- and (for structure_type) fields with {name, offset, type_name, pointer_depth}. +-- Returns nil if the chain cannot be resolved (e.g., missing DIE). +-- @param index table -- M.index_main_cu_types result +-- @param start_offset integer -- CU-relative DW_FORM_ref4 offset of the start +-- @param max_depth integer -- cycle protection +-- @return table|nil -- {kind, name, byte_size, fields?, pointer_depth} +function M.resolve_type_chain(index, start_offset, max_depth) + max_depth = max_depth or 16 + if not index or not start_offset then return nil end + local chain = {} + local cur_offset = start_offset + local depth = 0 + while cur_offset and depth < max_depth do + local entry = index.by_offset[cur_offset] + if not entry then return nil end + chain[#chain + 1] = entry + if entry.kind == "pointer_type" then + cur_offset = entry.type_ref + elseif entry.kind == "const_type" then + cur_offset = entry.type_ref + elseif entry.kind == "typedef" then + cur_offset = entry.type_ref + else + break + end + depth = depth + 1 + end + -- Compute pointer_depth = number of pointer/const wrappers. + local pointer_depth = 0 + for _, e in ipairs(chain) do + if e.kind == "pointer_type" then pointer_depth = pointer_depth + 1 end + end + -- The last entry is the "naked" type. + local naked = chain[#chain] + if not naked then return nil end + -- If the last entry is a structure_type, resolve each field's type name + -- + pointer depth too (for `bind_args` shape expansion). + if naked.kind == "structure_type" and naked.fields then + local fields = {} + for _, f in ipairs(naked.fields) do + local field_entry = f.type_offset and index.by_offset[f.type_offset] or nil + local field_chain = {} + local d = 0 + local co = f.type_offset + while co and d < 16 do + local e2 = index.by_offset[co] + if not e2 then break end + field_chain[#field_chain + 1] = e2 + if e2.kind == "pointer_type" or e2.kind == "const_type" or e2.kind == "typedef" then + co = e2.type_ref + else + break + end + d = d + 1 + end + local fpd = 0 + for _, x in ipairs(field_chain) do if x.kind == "pointer_type" then fpd = fpd + 1 end end + fields[#fields + 1] = { + name = f.name, + offset = f.offset, + type_name = (field_chain[#field_chain] and field_chain[#field_chain].name) or "?", + pointer_depth = fpd, + } + end + return { + kind = "structure_type", + name = naked.name, + byte_size = naked.byte_size, + fields = fields, + pointer_depth = pointer_depth, + } + end + return { + kind = naked.kind, + name = naked.name, + byte_size = naked.byte_size, + encoding = naked.encoding, + pointer_depth = pointer_depth, + } +end + --- Return a 4-byte little-endian byte string for `value`. --- Caller concatenates with `..` if composing multi-word blobs. --- @@ -409,6 +976,11 @@ local SLEB_SIGN_BIT = 0x40 --- @param n integer -- non-negative --- @return string function M.uleb128(n) + if n == nil or type(n) ~= "number" then + io.stderr:write("[elf_dwarf.uleb128] got " .. type(n) .. ": " .. tostring(n) .. "\n") + io.stderr:write(debug.traceback() .. "\n") + error("uleb128 requires non-negative number") + end assert(n >= 0, "uleb128 requires non-negative input") local bytes = {} repeat @@ -511,4 +1083,87 @@ function M.parse_source_map_file(sm_path, expected_version) return out end +--- Parse a FORMAT_VERSION `*.atoms.provenance.txt` file. +--- Returns `{name -> {total = N, words = {{pos, call_file, call_line, comp_name, comp_file, comp_line}, ...}}}`. +--- Returns `{}` on format-version mismatch (and logs to stderr). +--- +--- **Wire format** (emitted by `passes/atoms_source_map.lua` since the Phase 3 debug_ux work): +--- ``` +--- # FORMAT_VERSION +--- ATOM "" +--- WORD CALL : RAW +--- WORD CALL : MACRO ":" +--- ... +--- ENDATOM +--- ``` +--- +--- **Used by** `passes/dwarf_injection.lua` (Phase 3 — debug_ux) to: +--- - group consecutive MACRO rows into component invocations (one `DW_TAG_inlined_subroutine` each) +--- - emit abstract `DW_TAG_subprogram` per unique component name +--- - extend `.debug_line` so stepping into a `mac_X(...)` lands on the component's source line. +--- +--- @param prov_path string -- path to *.atoms.provenance.txt +--- @param expected_version integer -- expected FORMAT_VERSION line +--- @return table +function M.parse_provenance_file(prov_path, expected_version) + local out = {} + local cur_name, cur_words = nil, {} + for raw in io.lines(prov_path) do + local line = raw + if line:match("^#") then + local ver = line:match("^# FORMAT_VERSION%s+(%d+)") + if ver and tonumber(ver) ~= expected_version then + io.stderr:write(string.format( + "[elf_dwarf.parse_provenance_file] provenance version mismatch (got %s, expected %d) in %s\n", + ver, expected_version, prov_path)) + return {} + end + -- skip other comments + elseif line:sub(1, 4) == "ATOM" then + -- ATOM "" + local _, _, name = line:find("ATOM%s+(%S+)%s+\"[^\"]*\"%s+(%d+)") + if name then + cur_name = name + cur_words = {} + out[name] = { total = 0, words = cur_words } + end + elseif line == "ENDATOM" then + if cur_name and out[cur_name] then + out[cur_name].total = #cur_words + end + cur_name, cur_words = nil, {} + elseif line:sub(1, 4) == "WORD" and cur_name then + -- Two accepted shapes: + -- WORD CALL : RAW + -- WORD CALL : MACRO ":" + local pos, call_file, call_line, comp_name, comp_file, comp_line = + line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+MACRO%s+(%S+)%s+"([^"]*):(%d+)"') + if pos then + cur_words[#cur_words + 1] = { + pos = tonumber(pos), + call_file = call_file, + call_line = tonumber(call_line), + comp_name = comp_name, + comp_file = comp_file, + comp_line = tonumber(comp_line), + } + else + -- RAW row. + local raw_pos, raw_file, raw_line = line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+RAW') + if raw_pos then + cur_words[#cur_words + 1] = { + pos = tonumber(raw_pos), + call_file = raw_file, + call_line = tonumber(raw_line), + comp_name = nil, + comp_file = nil, + comp_line = nil, + } + end + end + end + end + return out +end + return M diff --git a/scripts/launch_pcsx_debug.ps1 b/scripts/launch_pcsx_debug.ps1 index 10952d8..17a5721 100644 --- a/scripts/launch_pcsx_debug.ps1 +++ b/scripts/launch_pcsx_debug.ps1 @@ -28,6 +28,11 @@ param( $ErrorActionPreference = 'Stop' +$gdbInitPath = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\build\gen\hello_gte.gdbinit')) +if (-not (Test-Path -LiteralPath $gdbInitPath -PathType Leaf)) { + Write-Warning "Generated GDB skip sidecar missing (non-fatal): $gdbInitPath. Run the GTE build to regenerate it; debugger launch will continue without generated skip-over commands." +} + # ── Pre-checks ── foreach ($p in @($PcsxPath, $ExePath, $HelperZip)) { if (-not (Test-Path $p)) { diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index 4b8d7d2..7f9fae8 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -27,6 +27,27 @@ local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end +-- Phase 5 closed sets. atom_dbg_reg_default and atom_reg_types MUST target +-- one of these registers; atom_view MUST reference a real Binds_* struct. +local SEMANTIC_DEFAULT_REGS = { + ["R_TapePtr"] = true, ["R_AtomJmp"] = true, + ["R_PrimCursor"] = true, ["R_FaceCursor"] = true, + ["R_VertBase"] = true, ["R_OtBase"] = true, +} + +local COMPUTE_REG_PREFIX = "R_" +local COMPUTE_REG_RE = -- permits R_T0..R_T3 + caller-trash (not wave-context) + "^R_T[0-3]$" + +-- Compute-register types are restricted to byte-width primitives (no struct +-- identity in this phase). Pointer depth is also bounded — typed pointer +-- chains live in the Binds_* struct, not in a per-atom override. +local ALLOWED_COMPUTE_TYPES = { + ["U4"] = true, ["S4"] = true, + ["U2"] = true, ["S2"] = true, + ["U1"] = true, ["S1"] = true, +} + -- ════════════════════════════════════════════════════════════════════════════ -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ @@ -66,6 +87,16 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end --- @field writes string[] -- R_* names (write targets) --- @field errors string[]|nil -- parse-time errors from scan_source (atom_info body malformed) +--- @class SkipOverMarker -- sub-shape of scan_source.lua's @class SkipOverMarker +--- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over") +--- @field marker_line integer +--- @field args string|nil -- trimmed text inside the parens (nil when has_parens is false) +--- @field has_parens boolean +--- @field pending boolean -- true while awaiting the following declaration +--- @field superseded_by_marker_line integer|nil -- set on a marker that was bumped out of the pending slot +--- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed +--- @field declaration_line integer|nil + --- @class Finding --- @field line integer -- source line (or 0 for pass-level) --- @field msg string -- finding message @@ -76,9 +107,14 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end --- @field info Finding[] --- @class PipeCtx ---- @field atom_index table -- name -> AtomAnnotation (only kind=="atom") ---- @field binds_index table -- name -> BindsStruct ---- @field annot_counts table -- name -> annotation count (for unique_annotation check) +--- @field atom_index table -- name -> AtomAnnotation (only kind=="atom") +--- @field binds_index table -- name -> BindsStruct +--- @field annot_counts table -- name -> annotation count (for unique_annotation check) +--- @field types table -- from scan_source +--- @field atom_views table -- from scan_source +--- @field seen_defaults table -- duplicate atom_dbg_reg_default detection +--- @field seen_field table -- Binds_* -> count of fields (set/checked by check_binds_no_duplicate_fields) +--- @field _scan SourceScan -- full scan payload (typed-view sub-calls live here) --- @class AnnotatedResult --- @field atoms AtomEntry[] @@ -217,24 +253,244 @@ local function check_macro_word_drift(m, wc, findings) } end +---- Check: atom_dbg_reg_default(R_X, ) must target a wave-context register +--- and the type name must be a recognized C type (we currently allow the +--- types actually used in the codebase; pointer depth must be 0 or 1). +--- Also detects duplicate defaults for the same register. +--- @param _src SourceFile -- unused (kept for the per_source shape) +--- @param pipe_ctx PipeCtx +--- @param findings Findings +-- Known type names accepted in atom_dbg_reg_default and the Binds_*-via-atom_view +-- resolution path. The Phase 5 list mirrors the canonical duffle math.h +-- typedefs plus the byte-width primitives used as compute-register overrides. +local KNOWN_REG_DEFAULT_TYPES = { + ["U4"] = true, ["S4"] = true, + ["U2"] = true, ["S2"] = true, + ["U1"] = true, ["S1"] = true, + ["MipsCode"] = true, ["MipsAtom"] = true, + ["V2_S2"] = true, ["V2_S4"] = true, + ["V3_S2"] = true, ["V3_S4"] = true, + ["V4_S2"] = true, ["V4_S4"] = true, + ["M3_S2"] = true, + ["void"] = true, +} + +local function check_semantic_reg_defaults(_src, pipe_ctx, findings) + -- Detect duplicate defaults using the ordered occurrence list (the + -- out.types hash only retains the last declaration). + local seen_first_line = {} + for _, occ in ipairs(pipe_ctx.type_occurrences or {}) do + if seen_first_line[occ.reg] == nil then + seen_first_line[occ.reg] = occ.source_line + else + findings.errors[#findings.errors + 1] = { + line = occ.source_line, + msg = string.format( + "duplicate atom_dbg_reg_default for %q at line %d (first declared at line %d); one default per register", + occ.reg, occ.source_line, seen_first_line[occ.reg]), + } + end + end + for reg, def in pairs(pipe_ctx.types or {}) do + if not SEMANTIC_DEFAULT_REGS[reg] then + findings.errors[#findings.errors + 1] = { + line = def.source_line, + msg = string.format( + "atom_dbg_reg_default at line %d references unknown register %q; expected one of R_TapePtr, R_AtomJmp, R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase", + def.source_line, reg), + } + end + if def.pointer_depth == nil or def.pointer_depth < 0 or def.pointer_depth > 1 then + findings.errors[#findings.errors + 1] = { + line = def.source_line, + msg = string.format( + "atom_dbg_reg_default at line %d for %q has unsupported pointer depth %d (expected 0 or 1)", + def.source_line, reg, def.pointer_depth or -1), + } + end + if not def.type_name or not KNOWN_REG_DEFAULT_TYPES[def.type_name] then + findings.errors[#findings.errors + 1] = { + line = def.source_line, + msg = string.format( + "atom_dbg_reg_default at line %d for %q uses unknown type %q (allowed: byte-width primitives, V*_S*, M3_S2, MipsCode, void)", + def.source_line, reg, tostring(def.type_name)), + } + end + end +end + +--- Check: atom_reg_types(R_X, ) entries must point to a compute register +--- (R_T0..R_T3) with a recognized fundamental type. +--- @param _src SourceFile +--- @param pipe_ctx PipeCtx +--- @param findings Findings +local function check_atom_reg_types(_src, pipe_ctx, findings) + for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do + if ai.reg_type_overrides then + for reg, ov in pairs(ai.reg_type_overrides) do + if not reg:match(COMPUTE_REG_RE) then + findings.errors[#findings.errors + 1] = { + line = ai.info_line, + msg = string.format( + "atom '%s' has atom_reg_types for %q; compute-register types are restricted to R_T0..R_T3", + ai.atom_name, reg), + } + end + if not ov.type_name or not ALLOWED_COMPUTE_TYPES[ov.type_name] then + findings.errors[#findings.errors + 1] = { + line = ai.info_line, + msg = string.format( + "atom '%s' atom_reg_types for %q uses unknown compute type %q (allowed: U1/U2/U4/S1/S2/S4)", + ai.atom_name, reg, tostring(ov.type_name)), + } + end + end + end + end +end + +--- Check: atom_view(Binds_X) entries must reference a real Binds_* struct +--- and that struct must declare at least one field. +--- @param _src SourceFile +--- @param pipe_ctx PipeCtx +--- @param findings Findings +local function check_atom_view_layout(_src, pipe_ctx, findings) + for atom_name, view in pairs(pipe_ctx.atom_views or {}) do + if not view.binds_name then + -- The atom had atom_reg_types but no atom_view; no layout check needed. + else + local bs = pipe_ctx.binds_index[view.binds_name] + if not bs then + findings.errors[#findings.errors + 1] = { + line = view.info_line, + msg = string.format( + "atom '%s' has atom_view(%s) but no Struct_(%s) { ... } declaration was found", + atom_name, view.binds_name, view.binds_name), + } + elseif not bs.fields or #bs.fields == 0 then + findings.errors[#findings.errors + 1] = { + line = bs.line, + msg = string.format( + "atom '%s' has atom_view(%s) but that struct declares zero typed fields", + atom_name, view.binds_name), + } + end + end + end +end + +--- Check: Binds_* structs may not have duplicate field names (they would +--- defeat the typed-field name lookup that atom_view exposes in gdb). +--- @param _src SourceFile +--- @param pipe_ctx PipeCtx +--- @param findings Findings +local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings) + for _, bs in ipairs(pipe_ctx.binds_list or {}) do + local seen = {} + for _, f in ipairs(bs.fields or {}) do + seen[f.name] = (seen[f.name] or 0) + 1 + end + for name, count in pairs(seen) do + if count > 1 then + findings.errors[#findings.errors + 1] = { + line = bs.line, + msg = string.format( + "%s has duplicate field name %q (count %d); the typed-view contract requires unique field names", + bs.name, name, count), + } + end + end + end +end + +-- Check: skip-over markers must satisfy shape + placement constraints. +--- Walks the priority list once; at most one error is appended per marker so that +--- a single source-level defect does not cascade into multiple findings. +--- Priority order (first defect wins): +--- 1. has_parens == false -> requires parentheses: marker() +--- 2. args ~= "" -> takes no arguments +--- 3. superseded_by_marker_line -> duplicate marker (cite superseding line) +--- 4. pending + no target_kind -> dangling (no following declaration) +--- 5. unsupported target_kind -> marker precedes an unrelated declaration +--- Valid markers before whole-atom / bare-component / proc-component declarations +--- emit no error and remain in src.scan.skip_over.atoms / .components for Task 15. +--- @param marker SkipOverMarker +--- @param _pipe_ctx PipeCtx -- unused today; kept for plex-shape consistency with per_annot +--- @param findings Findings +local function check_skip_marker(marker, _pipe_ctx, findings) + local kind = marker.marker_kind + local line = marker.marker_line + + if not marker.has_parens then + findings.errors[#findings.errors + 1] = { + line = line, + msg = string.format("%s marker at line %d requires parentheses: marker()", kind, line), + } + return + end + + if marker.args ~= nil and marker.args ~= "" then + findings.errors[#findings.errors + 1] = { + line = line, + msg = string.format("%s marker at line %d takes no arguments; found %q", kind, line, marker.args), + } + return + end + + if marker.superseded_by_marker_line then + findings.errors[#findings.errors + 1] = { + line = line, + msg = string.format("duplicate %s marker at line %d; superseded by another %s marker at line %d", + kind, line, kind, marker.superseded_by_marker_line), + } + return + end + + if marker.pending and not marker.target_kind then + findings.errors[#findings.errors + 1] = { + line = line, + msg = string.format("dangling %s marker at line %d: no following MipsAtom_/MipsAtomComp_/MipsAtomComp_Proc_ declaration", + kind, line), + } + return + end + + if marker.target_kind + and marker.target_kind ~= "atom" + and marker.target_kind ~= "comp_bare" + and marker.target_kind ~= "comp_proc" then + findings.errors[#findings.errors + 1] = { + line = line, + msg = string.format("%s marker at line %d must precede MipsAtom_, MipsAtomComp_, or MipsAtomComp_Proc_; found an unrelated declaration", + kind, line), + } + end +end + -- ════════════════════════════════════════════════════════════════════════════ -- CHECK_RULES — data-driven check dispatch (the plex pattern) -- ════════════════════════════════════════════════════════════════════════════ -- --- Each rule entry picks one of three "shapes" of dispatch: --- per_annot(annot, pipe_ctx, findings) — runs once per AtomAnnotation --- post(pipe_ctx, findings) — runs once after all per_annot calls complete (full-corpus aggregation) --- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration +-- Each rule entry picks one of four "shapes" of dispatch: +-- per_annot(annot, pipe_ctx, findings) — runs once per AtomAnnotation +-- post(pipe_ctx, findings) — runs once after all per_annot calls complete (full-corpus aggregation) +-- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration +-- per_skip_marker(marker, pipe_ctx, findings) — runs once per src.scan.skip_over.markers entry (Task 14) -- -- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing. local CHECK_RULES = { - { name = "atom_decl_exists", per_annot = check_atom_decl_exists }, - { name = "binds_struct_exists", per_annot = check_binds_struct_exists }, - { name = "binds_field_wave_context", per_annot = check_binds_field_wave_context }, - { name = "reads_wave_context", per_annot = check_reads_wave_context }, - { name = "unique_annotation", post = check_unique_annotation }, - { name = "macro_word_drift", per_macro = check_macro_word_drift }, + { name = "atom_decl_exists", per_annot = check_atom_decl_exists }, + { name = "binds_struct_exists", per_annot = check_binds_struct_exists }, + { name = "binds_field_wave_context", per_annot = check_binds_field_wave_context }, + { name = "reads_wave_context", per_annot = check_reads_wave_context }, + { name = "unique_annotation", post = check_unique_annotation }, + { name = "macro_word_drift", per_macro = check_macro_word_drift }, + { name = "skip_marker_validation", per_skip_marker = check_skip_marker }, + { name = "semantic_reg_defaults", per_source = check_semantic_reg_defaults }, + { name = "atom_reg_types", per_source = check_atom_reg_types }, + { name = "atom_view_layout", per_source = check_atom_view_layout }, + { name = "binds_no_duplicate_fields", per_source = check_binds_no_duplicate_fields }, } -- ════════════════════════════════════════════════════════════════════════════ @@ -276,10 +532,27 @@ local function validate(ctx, src) -- Build pipe_ctx (Fleury: expose structure). Pre-compute everything the per-check functions need. -- Single source of truth for atom / binds / annotation-count lookups. + -- Phase 5 typed views: pipe_ctx.types / pipe_ctx.atom_views / pipe_ctx.seen_defaults + -- are projected from the scan payload so per_source check rules can iterate. + local seen_defaults = {} + for reg, _ in pairs(scan.types or {}) do + seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 + end + local atom_infos_list = {} + for _, ai in ipairs(scan.atom_infos or {}) do + atom_infos_list[#atom_infos_list + 1] = ai + end + local pipe_ctx = { - atom_index = {}, - binds_index = {}, - annot_counts = {}, + atom_index = {}, + binds_index = {}, + annot_counts = {}, + types = scan.types or {}, + type_occurrences = scan.type_occurrences or {}, + atom_views = scan.atom_views or {}, + seen_defaults = seen_defaults, + atom_infos_list = atom_infos_list, + binds_list = scan.binds or {}, } for _, a in ipairs(atoms) do pipe_ctx.atom_index [a.name] = a end for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end @@ -319,6 +592,17 @@ local function validate(ctx, src) if rule.post then rule.post(pipe_ctx, findings) end end + -- Per-skip-marker rules (Task 14). Each raw marker recorded by scan_source + -- (in scan.skip_over.markers) is validated independently; the check emits at + -- most one error per marker. Valid markers stay attached to scan.skip_over.atoms / + -- .components for Task 15's dwarf_injection.lua consumer. + local skip_markers = scan.skip_over and scan.skip_over.markers or {} + for _, marker in ipairs(skip_markers) do + for _, rule in ipairs(CHECK_RULES) do + if rule.per_skip_marker then rule.per_skip_marker(marker, pipe_ctx, findings) end + end + end + -- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift). local wc = ctx.shared.word_counts for _, m in ipairs(scan.macros) do @@ -327,6 +611,13 @@ local function validate(ctx, src) end end + -- Per-source rules (Phase 5 typed views: reg defaults, atom_view layout, + -- compute-register type overrides, Binds_* field uniqueness). Each + -- per_source rule sees the full scan payload via pipe_ctx. + for _, rule in ipairs(CHECK_RULES) do + if rule.per_source then rule.per_source(src, pipe_ctx, findings) end + end + -- Information summary (always emitted). findings.info[#findings.info + 1] = { line = 0, diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index 2b82019..c4712a3 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -148,6 +148,153 @@ local function compute_word_entries(atom, src, wc) return entries, pos end +-- ════════════════════════════════════════════════════════════════════════════ +-- Provenance emission (Phase 3 — debug_ux) +-- ════════════════════════════════════════════════════════════════════════════ + +-- Component-macro invocation prefix (mirrors components.lua's MAC_PREFIX). +local MAC_PREFIX = "mac_" +local MAC_PREFIX_LEN = 4 + +--- Strip the `mac_` prefix from a token's leading identifier. Returns nil +--- if the identifier doesn't start with `mac_` (so non-component tokens +--- like `load_half_u`, `nop2`, `gte_cmdw_*` fall through cleanly). +--- @param tok string +--- @return string|nil +local function strip_mac_prefix_from_token(tok) + local leading = duffle.read_ident(tok, 1) + if not leading then return nil end + if leading:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then + return leading:sub(MAC_PREFIX_LEN + 1) + end + return nil +end + +--- Compute per-word provenance entries for an atom. Mirrors `compute_word_entries` +--- but additionally classifies each emitted `.word` as either: +--- - `RAW` — emitted by a direct instruction token (no component provenance) +--- - `MACRO X` — emitted by a `mac_X(...)` component invocation, with the +--- component's definition file:line resolved from `ctx.shared.components`. +--- +--- Returns a list of `{pos, line, text, comp_name, comp_line, comp_path}` entries + the +--- total word count. `comp_name` is nil for RAW rows. +--- @param atom table -- one entry of scan.atoms / scan.raw_atoms +--- @param src table -- SourceFile (has .scan with .line_of(), .path) +--- @param wc table -- shared.word_counts +--- @param comp table -- shared.components map: bare_name -> {name=, line=, path=, kind=} +--- @return table[], integer +local function compute_provenance_entries(atom, src, wc, comp) + local entries = {} + local pos = 0 + for _, t in ipairs(atom.body_tokens) do + local tok = t.tok + local rel = t.rel + + local words + if is_marker_token(tok) then + words = count_marker_rest(tok, wc) + else + words = count_token_words(tok, wc) + end + + -- Resolve component provenance for this token (if any). + local comp_name = nil + local comp_line = nil + local comp_path = nil + local comp_kind = nil + local bare = strip_mac_prefix_from_token(tok) + if bare and comp and comp[bare] then + comp_name = bare + comp_line = comp[bare].line + comp_path = comp[bare].path + comp_kind = comp[bare].kind + end + + if words > 0 then + local line = src.scan.line_of(atom.body_off + rel) + local text = duffle.trim(tok):gsub("[\t\r\n]+", " ") + for _ = 1, words do + entries[#entries + 1] = { + pos = pos, + line = line, + text = text, + comp_name = comp_name, + comp_line = comp_line, + comp_path = comp_path, + comp_kind = comp_kind, + } + pos = pos + 1 + end + end + end + return entries, pos +end + +--- Render one atom's provenance stanza. Format: +--- `WORD N CALL : MACRO ":"` (for component words) +--- `WORD N CALL : RAW` (for direct instructions) +--- Returns (lines, total_words). +--- @param src table +--- @param atom table +--- @param wc table +--- @param comp table -- shared.components map +--- @return string[], integer +local function emit_provenance_stanza(src, atom, wc, comp) + local lines = {} + local rel_path = src.path:gsub("\\", "/") + local entries, total = compute_provenance_entries(atom, src, wc, comp) + + -- ATOM header line with placeholder total (patched after we know it). + lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path) + + for _, pe in ipairs(entries) do + if pe.comp_name then + lines[#lines + 1] = string.format( + 'WORD %d CALL %s:%d MACRO %s "%s:%d"', + pe.pos, rel_path, pe.line, pe.comp_name, pe.comp_path, pe.comp_line) + else + lines[#lines + 1] = string.format( + "WORD %d CALL %s:%d RAW", + pe.pos, rel_path, pe.line) + end + end + + -- Patch the placeholder total in the ATOM header line. + lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total)) + lines[#lines + 1] = "ENDATOM" + return lines, total +end + +--- Render the full provenance file content for one source (one `.atoms.provenance.txt` per source). +--- @param src table +--- @param wc table +--- @param comp table -- shared.components map +--- @return string +local function render_provenance(src, wc, comp) + local lines = {} + lines[#lines + 1] = "# FORMAT_VERSION 1" + lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" + lines[#lines + 1] = "# Per-.word provenance: maps each emitted .word to its call site (atom body" + lines[#lines + 1] = "# file:line) and, when the word was emitted by a `mac_X(...)` component invocation," + lines[#lines + 1] = "# the component's definition file:line. Used by dwarf_injection (Phase 3) to synthesize" + lines[#lines + 1] = "# DW_TAG_inlined_subroutine instances for component step-into." + + for _, atom in ipairs(src.scan.atoms or {}) do + local stanza = emit_provenance_stanza(src, atom, wc, comp) + for _, line in ipairs(stanza) do + lines[#lines + 1] = line + end + end + for _, atom in ipairs(src.scan.raw_atoms or {}) do + local stanza = emit_provenance_stanza(src, atom, wc, comp) + for _, line in ipairs(stanza) do + lines[#lines + 1] = line + end + end + + return table.concat(lines, "\n") .. "\n" +end + --- Render one atom's stanza for the canonical text form --- (ATOM header line, N WORD lines, ENDATOM marker). Returns (lines, total_words). --- @param src table @@ -533,6 +680,9 @@ local M = {} --- Pass entry: emit one `/.atoms.sourcemap.txt` per source file --- that contains at least one `MipsAtom_(name)` / `MipsCode code_` declaration. +--- Also emits `/.atoms.provenance.txt` (Phase 3 — debug_ux): +--- per-.word provenance with `mac_X(...)` component resolution back to the +--- component's definition file:line. --- Optionally also emit `/gdb_tape_atoms_runtime.gdb` when --- `ctx.flags.gdb_runtime` is true. --- @param ctx PassCtx @@ -552,6 +702,12 @@ function M.run(ctx) } end + -- Phase 3 (debug_ux): shared.components map is populated by `passes/components.lua`. + -- Used to attribute each emitted `.word` to either a component macro or the + -- enclosing atom body. If absent, all words fall through as RAW (correct + -- behavior — provenance is additive). + local comp = (ctx.shared and ctx.shared.components) or {} + -- Always emit the canonical text form (per-source). for _, src in ipairs(ctx.sources) do if src.scan then @@ -559,18 +715,27 @@ function M.run(ctx) local n_raw_atoms = src.scan.raw_atoms and #src.scan.raw_atoms or 0 if n_atoms + n_raw_atoms > 0 then local basename = duffle.basename_no_ext(src.path) - -- Build report, NOT compile artifact: live in alongside the other reports - -- (annotation.lua's *.errors.h, static_analysis.lua's *.static_analysis.txt, gdb_tape_atoms_runtime.gdb). - -- The per-source /gen/ is reserved for headers actually #included by C. - local out_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt" - local content = render_source_map(src, wc) + + -- (1) atoms.sourcemap.txt — per-.word line map (unchanged contract). + local sourcemap_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt" + local sourcemap_body = render_source_map(src, wc) + + -- (2) atoms.provenance.txt — Phase 3 (debug_ux): per-.word provenance + -- with `mac_X(...)` component resolution back to the component's + -- definition file:line. Consumed by `passes/dwarf_injection.lua` + -- to synthesize `DW_TAG_inlined_subroutine` instances for + -- source-level Step Into on component invocations. + local prov_path = ctx.out_root .. "/" .. basename .. ".atoms.provenance.txt" + local prov_body = render_provenance(src, wc, comp) if not ctx.dry_run then - duffle.ensure_dir(duffle.dirname(out_path)) - duffle.write_file_lf(out_path, content) + duffle.ensure_dir(duffle.dirname(sourcemap_path)) + duffle.write_file_lf(sourcemap_path, sourcemap_body) + duffle.write_file_lf(prov_path, prov_body) end - outputs[#outputs + 1] = { kind = "report", path = out_path } + outputs[#outputs + 1] = { kind = "report", path = sourcemap_path } + outputs[#outputs + 1] = { kind = "report", path = prov_path } end end end diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 708551d..d4f41f8 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -366,6 +366,7 @@ local function project_components(source, scan) body_tokens = a.body_tokens, args = args, comment = comment, + kind = a.kind, -- "comp_bare" | "comp_proc"; Phase 3 provenance emitter reads this. } end end @@ -677,6 +678,35 @@ local function update_shared_word_counts(ctx, components, counts) end end +--- @class ComponentDef +--- @field name string -- bare name (without ac_/mac_ prefix) +--- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`) +--- @field path string -- absolute source path of the definition +--- @field kind string -- "comp_bare" | "comp_proc" + +--- (internal) Extend `ctx.shared.components` with this source's components-by-name +-- map so downstream passes (atoms_source_map, dwarf_injection) can resolve +-- `mac_X(...)` invocations back to their component definition file:line. +--- Phase 3 (debug_ux) provenance emission uses this to attribute each emitted +-- `.word` to either a component macro or the enclosing atom body. +-- @param ctx PassCtx +-- @param src SourceFile +-- @param components Component[] +local function update_shared_components(ctx, src, components) + ctx.shared.components = ctx.shared.components or {} + local rel_path = src.path:gsub("\\", "/") + for _, c in ipairs(components) do + -- Keyed by bare name (e.g. `yield`, `load_tri_indices`). The atoms_source_map + -- pass strips the `mac_` prefix from the call site identifier before lookup. + ctx.shared.components[c.name] = { + name = c.name, + line = c.line, + path = rel_path, + kind = c.kind or "comp_bare", + } + end +end + --- @param ctx PassCtx --- @return PassResult function M.run(ctx) @@ -684,6 +714,11 @@ function M.run(ctx) local errors = {} local warnings = {} + -- Initialize shared component map (Phase 3 — debug_ux). The atoms_source_map + -- and dwarf_injection passes consume `ctx.shared.components` to resolve + -- `mac_X(...)` invocations back to the component's definition file:line. + ctx.shared.components = ctx.shared.components or {} + for _, src in ipairs(ctx.sources) do -- project_components reads from src.scan + does backward lookups on src.text local components = project_components(src.text, src.scan) @@ -694,6 +729,9 @@ function M.run(ctx) if macs_path then outputs[#outputs + 1] = { macs_h = macs_path } update_shared_word_counts(ctx, components, counts) + -- Phase 3 (debug_ux): share component definitions with downstream passes. + -- `mac_X(...)` invocations in atom bodies resolve back to (path, line) via this map. + update_shared_components(ctx, src, components) end end end diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 1e1ac8e..6a50076 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -11,7 +11,8 @@ --- APPENDS synthetic DWARF line-program sequences for every `code_` atom, EXTENDS the `.debug_aranges` and main-CU range tables with the atom ranges, --- APPENDS a new compilation unit to `.debug_info` with per-atom `DW_TAG_subprogram` + per-wave-context-reg `DW_TAG_variable` entries --- (so `R_PrimCursor` etc. appear as atom-scoped locals in VSCode's Variables pane). ---- Writes the new section data to `/.dwarf_*.bin` (7 blobs total). +--- Writes the new section data to `/.dwarf_*.bin` (7 blobs total) +--- plus deterministic `/.gdbinit` skip commands. --- --- 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= @@ -65,6 +66,7 @@ local DW_LNS_copy = DWARF_LINE_OPS.DW_LNS_copy local DW_LNS_advance_pc = DWARF_LINE_OPS.DW_LNS_advance_pc local DW_LNS_advance_line = DWARF_LINE_OPS.DW_LNS_advance_line local DW_LNS_set_file = DWARF_LINE_OPS.DW_LNS_set_file +local DW_LNS_negate_stmt = DWARF_LINE_OPS.DW_LNS_negate_stmt local DW_LNS_extended = DWARF_LINE_OPS.DW_LNS_extended local DW_LNE_end_sequence = DWARF_LINE_OPS.DW_LNE_end_sequence local DW_LNE_set_address = DWARF_LINE_OPS.DW_LNE_set_address @@ -113,6 +115,133 @@ local ABBREV_STRUCT_TYPE = 0x67 -- 103: DW_TAG_structure_type with children (B local ABBREV_MEMBER = 0x68 -- 104: DW_TAG_member no children (DW_AT_type = ref4 to U4 base) local ABBREV_BIND_VAR = 0x69 -- 105: DW_TAG_variable no children + DW_AT_type = ref4 (the bind_args variable) local ABBREV_BASE_TYPE = 0x6A -- 106: DW_TAG_base_type no children (U4) +-- Phase 3 (debug_ux): component step-into (DW_TAG_inlined_subroutine + abstract DW_TAG_subprogram). +local ABBREV_ABSTRACT_SUBPROGRAM = 0x6B -- 107: DW_TAG_subprogram (abstract — no low_pc/high_pc); for each unique mac_X component +local ABBREV_INLINED_SUBROUTINE = 0x6C -- 108: DW_TAG_inlined_subroutine with children (per-component invocation range) +-- Phase 5 (debug_ux): bind_args uses DW_FORM_sec_offset → .debug_loclists for +-- PC-ranged liveness (each field transitions from tape memory to GPR at +-- load_pc + 8 = MIPS I load-delay slot boundary). +local ABBREV_BIND_VAR_LOCLIST = 0x6D -- 109: DW_TAG_variable no children + DW_AT_type = ref4 + DW_AT_location = sec_offset + +-- DWARF5 §7.7.3 loclist opcodes. +local DW_LLE_end_of_list = 0x00 +local DW_LLE_start_length = 0x06 +local DW_OP_reg0 = 0x50 -- base reg op; regN = 0x50 + N +local DW_OP_breg0 = 0x70 -- base breg op; bregN = 0x70 + N (SLEB offset) +local MIPS_LOAD_DELAY_BYTES = 0x08 -- 1 load word + 1 BD-slot word +-- Late-binding table for forward references. The loclist functions +-- (defined below) use uleb128 + elf_dwarf which are not yet defined +-- at this point. They look them up via this table at call time. +local _late = { uleb128 = nil, elf_dwarf = nil } + +-- 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). A future task can refine to per-field transitions. +-- @param atom_table table[] -- list of atoms with .rbind set +-- @return string -- section bytes +local function build_debug_loclists_section(atom_table) + -- Loclist header (per DWARF5 §7.7.3): + -- unit_length(4) + version(2) + address_size(1) + segment_size(1) + offset_entry_count(4) + -- All our entries are DW_LLE_start_length + addr(4) + length(ULEB) + expression + -- terminated by DW_LLE_end_of_list (1 byte). + -- R_TapePtr is register 24 ($t8). R_ maps via R_NAME_TO_INDEX above. + -- We compute the field-by-field transition: at each load_pc + 8, one more + -- field has committed from tape memory to its destination GPR. + local tape_reg = R_NAME_TO_INDEX["R_TapePtr"] -- = 24 + if not tape_reg then tape_reg = 24 end -- fallback if R_TapePtr isn't in the table + local parts = {} + for _, atom in ipairs(atom_table) do + if atom.rbind then + local fields = atom.rbind.fields or {} + local regs = atom.rbind.regs or {} + local n_fields = #fields + io.stderr:write(string.format("[loclists] %s: n_fields=%d, fields[1].name=%s offset=%s, regs[1]=%s\n", + atom.name, n_fields, + fields[1] and fields[1].name or "?", + fields[1] and tostring(fields[1].offset) or "?", + regs[1] and (regs[1].field or "?") or "?")) + local last_load_pc = atom.addr + (n_fields - 1) * MIPS_BYTES_PER_WORD + local transition_pc = last_load_pc + MIPS_LOAD_DELAY_BYTES + -- Build the "all in tape" piece chain (one DW_OP_breg24 + offset + DW_OP_piece(4) per field). + local tape_pieces = {} + for _, f in ipairs(fields) do + local offset = f.offset or 0 + local offset_sleb = elf_dwarf.sleb128(offset) + table.insert(tape_pieces, string.char(DW_OP_breg0 + tape_reg) .. offset_sleb .. string.char(DW_OP_piece) .. uleb128(4)) + end + local tape_expr = table.concat(tape_pieces) + -- Build the "all in GPR" piece chain (one DW_OP_regN + DW_OP_piece(4) per field). + local gpr_pieces = {} + for _, pair in ipairs(regs) do + table.insert(gpr_pieces, string.char(DW_OP_reg0 + pair.reg) .. string.char(DW_OP_piece) .. uleb128(4)) + end + local gpr_expr = table.concat(gpr_pieces) + -- Emit two DW_LLE_start_length entries + DW_LLE_end_of_list. + 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 + -- Build the section header. + local body = table.concat(parts) + -- unit_length = 2 (version) + 1 (address_size) + 1 (segment_size) + 4 (offset_entry_count) + #body + local unit_length = 2 + 1 + 1 + 4 + #body + local header = elf_dwarf.write_u32_le(unit_length) + .. elf_dwarf.write_u16_le(5) -- version = 5 + .. string.char(4) -- address_size = 4 + .. string.char(0) -- segment_size = 0 + .. elf_dwarf.write_u32_le(0) -- offset_entry_count = 0 + return header .. body +end + +-- Compute the per-atom loclist offset within a .debug_loclists section that +-- the caller will later pass to .debug_info (DW_FORM_sec_offset is a 4-byte +-- section-relative offset). Returns a table {[atom_name] = offset_in_loclists}. +-- The header is 12 bytes (unit_length(4) + version(2) + address_size(1) + +-- segment_size(1) + offset_entry_count(4)); each loclist body is the sum of +-- its DW_LLE entries (computed by replaying the same emission). +-- @param atom_table table[] -- list of atoms with .rbind set +-- @return table -- {[atom_name] = offset_in_section} +local function compute_loclists_offsets(atom_table) + local offsets = {} + -- Header size = 4 (unit_length) + 2 (version) + 1 (address_size) + 1 (segment_size) + 4 (offset_entry_count) + local cursor = 4 + 2 + 1 + 1 + 4 + for _, atom in ipairs(atom_table) do + if atom.rbind then + offsets[atom.name] = cursor + -- Each loclist body: 2 DW_LLE_start_length + 1 DW_LLE_end_of_list. + -- Per DW_LLE_start_length: 1 (opcode) + 4 (addr) + ULEB(length) + length bytes. + -- The length bytes are N pieces × (1 reg op + 1 piece op + 1 ULEB4) + -- = 3N bytes for one-piece blocks (tape: reg + SLEB + piece+4; + -- GPR: reg + piece+4). For the all-tape form: N × (1 + 1 + 1) = 3N. + -- We compute the exact length for the current build. + local n_fields = #atom.rbind.fields + -- tape_expr: for each field, 1 (breg) + 1..4 (SLEB offset) + 1 (piece) + 1 (ULEB 4) + -- For our Binds_CubeTri / Binds_FloorTri, offsets are 0/4/8/12 (4 bytes ≤ 127, 1 SLEB byte). + -- So tape_expr = n_fields * 3 bytes. + local tape_expr_len = n_fields * 3 + local tape_entry = 1 + 4 + 1 + tape_expr_len + -- gpr_expr: for each field, 1 (reg op) + 1 (piece) + 1 (ULEB 4) = 3 bytes. + local gpr_expr_len = n_fields * 3 + local gpr_entry = 1 + 4 + 1 + gpr_expr_len + local body_len = tape_entry + gpr_entry + 1 -- +1 for DW_LLE_end_of_list + cursor = cursor + body_len + end + end + return offsets +end -- 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; @@ -124,6 +253,8 @@ local DW_TAG_variable = 0x34 local DW_TAG_structure_type = 0x13 local DW_TAG_member = 0x0D local DW_TAG_base_type = 0x24 +-- Phase 3 (debug_ux): component step-into. +local DW_TAG_inlined_subroutine = 0x1D local DW_AT_name = 0x03 local DW_AT_low_pc = 0x11 @@ -132,11 +263,66 @@ local DW_AT_language = 0x13 local DW_AT_location = 0x02 local DW_AT_comp_dir = 0x1B local DW_AT_byte_size = 0x0B -local DW_AT_encoding = 0x3E -- DWARF5 §7.7.1: DW_AT_encoding (for DW_ATE_unsigned base type; was 0x13 = DW_AT_language in prior slice — semantically wrong) +local DW_AT_encoding = 0x3E -- DWARF5 §7.7.1: DW_AT_encoding (for DW_ATE_unsigned base type; was 0x13 = DW_AT_language in prior slice - semantically wrong) local DW_AT_data_member_location = 0x38 local DW_AT_type = 0x49 -local DW_AT_linkage_name = 0x6E -- DWARF5 §7.7.1: DW_AT_linkage_name (standard form; 0x200027 was the GNU extension form — wrong vs DW_FORM_string abbrev) +local DW_AT_linkage_name = 0x6E -- DWARF5 §7.7.1: DW_AT_linkage_name (standard form; 0x200027 was the GNU extension form - wrong vs DW_FORM_string abbrev) local DW_AT_external = 0x3F -- marks a variable/function as externally visible +-- Phase 3 (debug_ux): inlined_subroutine + abstract_origin attributes. +local DW_AT_abstract_origin = 0x31 +local DW_AT_call_file = 0x58 +local DW_AT_call_line = 0x59 +local DW_AT_inline = 0x20 -- DWARF5 §7.7.1: DW_AT_inline (used by abstract subprogram for the mac_X() components) +-- Phase 3 Task 3 (debug_ux): decl_file + decl_line on the abstract subprogram so consumers +-- can resolve an abstract origin back to its definition site even when no +-- inlined_subroutine instance currently maps to it. +local DW_AT_decl_file = 0x3A -- DWARF5 §7.7.1: DW_AT_decl_file (1-based file index into the CU's file table) +local DW_AT_decl_line = 0x3B -- DWARF5 §7.7.1: DW_AT_decl_line + +-- File index lookup table for the existing main line unit (Unit 2). +-- Provenance paths come back with mixed slashes; we normalize to basename +-- and look up against the line unit's actual file table. The current Phase 3 +-- scope has two provenance basenames: hello_gte_tape.c (the atom's call site) +-- and lottes_tape.h (the component definition). Both live in the existing +-- gcc-generated line unit; their 1-based indices are stable across rebuilds +-- because the include order in code/gte_hello/hello_gte.c determines the +-- unit's file table. +local PROVENANCE_BASENAME_TO_FILE_INDEX = { + ["hello_gte_tape.c"] = ATOM_SOURCE_FILE_INDEX, -- = 11 + ["lottes_tape.h"] = 4, +} + +--- Resolve an absolute provenance path to the line-unit file index used by the +--- emitting line program. Normalizes mixed `/` and `\` separators to a basename +--- and looks it up against the known Phase 3 file table. +--- +--- Fails loudly on an unknown provenance basename: adding a new component +--- source file requires extending `PROVENANCE_BASENAME_TO_FILE_INDEX` so the +--- line-program emission contract stays explicit. Silent fallback to +--- ATOM_SOURCE_FILE_INDEX would mask the new-file case by misattributing +--- component rows to the atom's source file. +--- +--- @param path string -- absolute provenance path (e.g. "C:/.../lottes_tape.h" or "C:\\...\\lottes_tape.h") +--- @return integer -- 1-based line-unit file index +local function resolve_provenance_file_index(path) + if path == nil or path == "" then + error("[dwarf_injection] resolve_provenance_file_index: empty path") + end + -- Normalize backslashes → forward slashes (paths arrive with mixed separators from + -- the provenance file: forward slashes from Lua's io.lines; backslashes if the + -- input ever round-trips through Windows shell expansion). + local normalized = path:gsub("\\", "/") + -- Take the last path component (the basename). + local basename = normalized:match("([^/]+)$") or normalized + local idx = PROVENANCE_BASENAME_TO_FILE_INDEX[basename] + if idx == nil then + error(string.format( + "[dwarf_injection] resolve_provenance_file_index: unknown provenance basename '%s' (from '%s'). " + .. "Extend PROVENANCE_BASENAME_TO_FILE_INDEX in passes/dwarf_injection.lua.", + basename, path)) + end + return idx +end local DW_FORM_addr = 0x01 local DW_FORM_data1 = 0x0B @@ -146,6 +332,7 @@ local DW_FORM_exprloc = 0x18 -- length-prefixed (ULEB128) DW_OP bytes local DW_FORM_ref4 = 0x13 -- 4-byte offset within the same .debug_info CU local DW_FORM_udata = 0x0F -- ULEB128 (DW_AT_byte_size for struct_type, DW_AT_data_member_location for member) local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker) +local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists) local DW_OP_reg0 = 0x50 -- DW_OP_regN = 0x50 + N; the variable's value resides in that register. @@ -174,6 +361,84 @@ local R_NAME_TO_INDEX = { ["R_TapePtr"] = 24, ["R_AtomJmp"] = 25, } +-- 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). A future task can refine to per-field transitions. +-- @param atom_table table[] -- list of atoms with .rbind set +-- @return string -- section bytes +local function build_debug_loclists_section(atom_table) + local uleb128 = _late.uleb128 + local elf_dwarf = _late.elf_dwarf + local tape_reg = R_NAME_TO_INDEX["R_TapePtr"] -- = 24 + if not tape_reg then tape_reg = 24 end + local parts = {} + for _, atom in ipairs(atom_table) do + if atom.rbind then + local fields = atom.rbind.fields or {} + local regs = atom.rbind.regs or {} + local n_fields = #fields + local last_load_pc = atom.addr + (n_fields - 1) * MIPS_BYTES_PER_WORD + local transition_pc = last_load_pc + MIPS_LOAD_DELAY_BYTES + local tape_pieces = {} + for _, f in ipairs(fields) do + local offset = f.offset or 0 + local offset_sleb = elf_dwarf.sleb128(offset) + table.insert(tape_pieces, string.char(DW_OP_breg0 + tape_reg) .. offset_sleb .. string.char(DW_OP_piece) .. uleb128(4)) + end + local tape_expr = table.concat(tape_pieces) + local gpr_pieces = {} + for _, pair in ipairs(regs) do + table.insert(gpr_pieces, string.char(DW_OP_reg0 + pair.reg) .. string.char(DW_OP_piece) .. uleb128(4)) + end + local gpr_expr = table.concat(gpr_pieces) + parts[#parts + 1] = string.char(DW_LLE_start_length) + .. elf_dwarf.write_u32_le(atom.addr) + .. uleb128(#tape_expr) + .. tape_expr + parts[#parts + 1] = string.char(DW_LLE_start_length) + .. elf_dwarf.write_u32_le(transition_pc) + .. uleb128(#gpr_expr) + .. gpr_expr + parts[#parts + 1] = string.char(DW_LLE_end_of_list) + end + end + local body = table.concat(parts) + local unit_length = 2 + 1 + 1 + 4 + #body + local header = elf_dwarf.write_u32_le(unit_length) + .. elf_dwarf.write_u16_le(5) + .. string.char(4) + .. string.char(0) + .. elf_dwarf.write_u32_le(0) + return header .. body +end + +-- Compute the per-atom loclist offset within a .debug_loclists section. +-- @param atom_table table[] -- list of atoms with .rbind set +-- @return table -- {[atom_name] = offset_in_section} +local function compute_loclists_offsets(atom_table) + local offsets = {} + local cursor = 4 + 2 + 1 + 1 + 4 + for _, atom in ipairs(atom_table) do + if atom.rbind then + offsets[atom.name] = cursor + local n_fields = #atom.rbind.fields + local tape_expr_len = n_fields * 3 + local gpr_expr_len = n_fields * 3 + local tape_entry = 1 + 4 + 1 + tape_expr_len + local gpr_entry = 1 + 4 + 1 + gpr_expr_len + local body_len = tape_entry + gpr_entry + 1 + cursor = cursor + body_len + end + end + return offsets +end + -- Default name for the synthetic CU (so VSCode lists it as a known source). local DEFAULT_CU_NAME = "tape_atom_locals" local DEFAULT_CU_COMP_DIR = "." @@ -189,9 +454,92 @@ local DEFAULT_BASENAME = "hello_gte" --- @class DwarfInjectionCtx --- @field flags table -- ctx.flags; reads flags.elf_path + flags.dwarf_injection +--- @field sources table[] -- source files with scan.skip_over associations --- @field out_root string -- output root (e.g. "build/gen") --- @field basename string -- input ELF basename (default "hello_gte") +--- Normalize a source path for GDB linespecs and component-association keys. +--- GDB accepts forward slashes on Windows; absolute paths avoid cwd-dependent +--- sidecars and match the Phase-3 provenance contract. +--- @param path string +--- @return string +local function normalize_debug_path(path) + return duffle.to_absolute_path(path):gsub("\\", "/") +end + +--- Build a case-insensitive Windows path + exact component-name key. +--- @param path string +--- @param component_name string +--- @return string +local function component_skip_key(path, component_name) + return normalize_debug_path(path):lower() .. "\0" .. component_name +end + +--- Consume the per-source scanner associations without naming any atom or +--- component in production. Whole atoms remain symbol-keyed; components are +--- file-qualified internally so a source marker associates with its exact +--- component definition even though GDB 12 requires function-only skip entries +--- for the resulting synthetic inline frame. +--- @param ctx DwarfInjectionCtx +--- @return table -- {atoms = {[symbol] = association}, components = {[file|name] = association}} +local function collect_skip_over(ctx) + local skip_over = { atoms = {}, components = {} } + for _, src in ipairs(ctx.sources or {}) do + local scan_skip = src.scan and src.scan.skip_over + if scan_skip then + for atom_name, association in pairs(scan_skip.atoms or {}) do + skip_over.atoms[atom_name] = { + name = atom_name, + source_path = normalize_debug_path(src.path), + association = association, + } + end + for component_name, association in pairs(scan_skip.components or {}) do + local source_path = normalize_debug_path(src.path) + skip_over.components[component_skip_key(source_path, component_name)] = { + name = component_name, + source_path = source_path, + association = association, + } + end + end + end + return skip_over +end + +--- Render deterministic debugger skip commands. Ordering is stable by category: +--- exact atom symbols first (lexicographic), then exact component function +--- names (lexicographic full command). Atom commands come from the matched +--- nm/source-map table so the emitted name is the actual ELF symbol. The +--- scanner tables and command set both deduplicate repeated source observations. +--- @param skip_over table +--- @param atom_table table[] -- nm/source-map cross-reference; names are actual ELF symbols +--- @return string +local function build_gdbinit(skip_over, atom_table) + local commands = {} + local atom_names = {} + for _, atom in ipairs(atom_table) do + if atom.skip_over then atom_names[#atom_names + 1] = atom.name end + end + table.sort(atom_names) + for _, atom_name in ipairs(atom_names) do + commands[#commands + 1] = "skip function " .. atom_name + end + + local component_commands = {} + for _, component in pairs(skip_over.components) do + component_commands[#component_commands + 1] = "skip function mac_" .. component.name + end + table.sort(component_commands) + local prior = nil + for _, command in ipairs(component_commands) do + if command ~= prior then commands[#commands + 1] = command end + prior = command + end + + return table.concat(commands, "\n") .. "\n" +end + -- ════════════════════════════════════════════════════════════════════════════ -- LEB128 encoders -- ════════════════════════════════════════════════════════════════════════════ @@ -202,6 +550,9 @@ local DEFAULT_BASENAME = "hello_gte" -- Local aliases so the line-program encoder (below) can keep its short names. local uleb128 = elf_dwarf.uleb128 +-- Late-bind uleb128 + elf_dwarf into the loclist functions' lookup table. +_late.uleb128 = uleb128 +_late.elf_dwarf = elf_dwarf local sleb128 = elf_dwarf.sleb128 -- ════════════════════════════════════════════════════════════════════════════ @@ -210,19 +561,40 @@ local sleb128 = elf_dwarf.sleb128 --- Build the byte sequence for ONE atom's line program: --- DW_LNE_set_address(addr) ---- DW_LNS_copy -- entry 1: addr, file, line=first.line ---- for each subsequent word: +--- [entry 1 emission] +--- for each subsequent entry (idx 2..N): --- DW_LNS_advance_pc(1 .word = 4 bytes) ---- DW_LNS_advance_line(line - prev_line) ---- DW_LNS_copy +--- [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). +--- +--- Phase 3 (debug_ux) 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). +--- +--- Phase 3 Task 3 (debug_ux) 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. --- ---- @param atom table -- {name, addr, size_bytes, words, entries} +--- Phase 4 (debug_ux) 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. +--- * A marked component keeps its first-word call-site row true, toggles only +--- the definition row/range false, and restores before the next unmarked row. +--- * Whole-atom suppression wins over component markers; no nested inversion. +--- +--- @param atom table -- {name, addr, size_bytes, words, entries, invocations?, skip_over?} --- @return string local function build_atom_sequence(atom) local function set_address(addr) @@ -236,6 +608,7 @@ local function build_atom_sequence(atom) local function set_file(file_index) return string.char(DW_LNS_set_file) .. uleb128(file_index) end local function advance_pc(bytes_delta) return string.char(DW_LNS_advance_pc) .. uleb128(bytes_delta) end local function advance_line(line_delta) return string.char(DW_LNS_advance_line) .. sleb128(line_delta) end + local function negate_stmt() return string.char(DW_LNS_negate_stmt) end local function end_sequence() -- size = 1 (just the sub_opcode byte, no payload) return string.char(DW_LNS_extended) @@ -245,25 +618,144 @@ local function build_atom_sequence(atom) if not atom.entries or #atom.entries == 0 then return set_address(atom.addr) .. end_sequence() end - -- set_address sets the address register to atom.addr. - -- line_state.line starts at 1 (per DWARF spec). - -- To land at entries[1].line for the FIRST emitted entry, we need to advance_line by (entries[1].line - 1) + then copy. - -- Then each subsequent entry uses the delta from the previous entry. + -- A jump-threaded atom is reached by `jr`, not a C call. GDB therefore + -- cannot apply the parent `skip function` entry before descending into a + -- visible child inline frame. Make an explicitly marked atom DWARF-opaque: + -- one non-statement row covers its complete address range, with no per-word + -- or component source transitions. The atom's subprogram DIE remains, so + -- symbolic lookup, explicit address breakpoints, and stepi are unaffected. + if atom.skip_over then + return table.concat({ + set_address(atom.addr), + set_file(ATOM_SOURCE_FILE_INDEX), + advance_line(atom.entries[1].line - 1), + negate_stmt(), + copy_op(), + advance_pc(atom.size_bytes), + negate_stmt(), + end_sequence(), + }) + end + + -- Find which invocation each entry belongs to (if any). Returns the + -- invocation record or nil. Pre-computed once so we don't rescan per + -- emitted row. + local function inv_for_idx(idx) + if not atom.invocations then return nil end + for _, inv in ipairs(atom.invocations) do + -- inv.start_pos / inv.end_pos are 0-based .word positions; entries are 1-based. + if idx >= inv.start_pos + 1 and idx <= inv.end_pos + 1 then + return inv + end + end + return nil + end + local parts = { - set_file(ATOM_SOURCE_FILE_INDEX), -- existing Unit 2 file table: hello_gte_tape.c - set_address(atom.addr), -- 7 bytes: marker + size + sub + addr - advance_line(atom.entries[1].line - 1), -- (entries[1].line - 1) bytes; line_state.line -> entries[1].line - copy_op(), -- emit entry 1: addr=atom.addr, line=entries[1].line - copy_op(), -- same PC/line: GDB 12 zero-instruction-prologue marker + set_address(atom.addr), -- 7 bytes: marker + size + sub + addr; PC := atom.addr } - local prev_line = atom.entries[1].line + + -- 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 + -- never duplicate a set_file or skip a state-change emit. + local cur_file = nil -- line-state.file_idx (nil = uninitialized) + local cur_line = 1 -- line-state.line starts at 1 (per DWARF spec) + local is_stmt = true -- main line unit default_is_stmt; every sequence ends restored + + local function set_statement_state(want_stmt) + if is_stmt ~= want_stmt then + parts[#parts + 1] = negate_stmt() + is_stmt = want_stmt + end + end + + -- Emit a state transition (set_file + advance_line + is_stmt as needed) + -- before a row, then the row itself. Used for the row(s) at one entry PC. + local function emit_row(file_idx, line, want_stmt) + if cur_file ~= file_idx then + parts[#parts + 1] = set_file(file_idx) + cur_file = file_idx + end + if cur_line ~= line then + parts[#parts + 1] = advance_line(line - cur_line) + cur_line = line + end + set_statement_state(want_stmt) + parts[#parts + 1] = copy_op() + end + + -- Whole-atom suppression wraps the independent sequence itself. File/line + -- state setup follows while is_stmt is already false; the matching restore + -- is emitted after the final row below. + if atom.skip_over then set_statement_state(false) end + + -- --- Atom entry (idx 1) ------------------------------------------------- + -- Determine entry-1's primary row (always the call-site file/line; we may + -- emit a second row immediately after if entry 1 is also a component + -- invocation's first word). + local inv1 = inv_for_idx(1) + local entry_1 = atom.entries[1] + -- The atom body's own source file (the call site for any mac_X(...) inside it). + local call_file_idx = ATOM_SOURCE_FILE_INDEX + + -- Primary row: call site at atom start. Capture its (file, line) so we can + -- re-emit it as the GDB 12 zero-instruction-prologue marker below. + local entry_1_call_file = call_file_idx + local entry_1_call_line = (inv1 and inv1.call_line) or entry_1.line + local atom_is_stmt = not atom.skip_over + + -- Primary row: call site at atom start. + emit_row(entry_1_call_file, entry_1_call_line, atom_is_stmt) + + -- If entry 1 is the first word of a component invocation, emit the + -- component-definition row at the same PC immediately after. A selected + -- component suppresses only this definition view; a selected atom suppresses + -- both rows and therefore never inverts state for nested component markers. + if inv1 and 1 == inv1.start_pos + 1 then + local comp_file_idx_1 = resolve_provenance_file_index(inv1.comp_file) + emit_row(comp_file_idx_1, inv1.comp_line, atom_is_stmt and not inv1.skip_over) + end + + -- GDB 12 zero-instruction-prologue marker: duplicate of the PRIMARY + -- entry-1 emission (the call-site row). We must explicitly restore the + -- source state to (call_file, call_line) before emitting the duplicate, + -- otherwise the duplicate lands at whatever state we ended entry-1 in + -- (the component row, if entry 1 is an invocation start) — which would + -- attach the marker to the wrong source statement. + -- + -- Only emitted at atom entry, NOT at every component-invocation start. + emit_row(entry_1_call_file, entry_1_call_line, atom_is_stmt) + + -- --- Subsequent entries (idx 2..N) -------------------------------------- for idx = 2, #atom.entries do local entry = atom.entries[idx] - parts[#parts + 1] = advance_pc(MIPS_BYTES_PER_WORD) -- 1 .word = MIPS_BYTES_PER_WORD bytes on MIPS - parts[#parts + 1] = advance_line(entry.line - prev_line) - parts[#parts + 1] = copy_op() - prev_line = entry.line + local inv = inv_for_idx(idx) + + -- Advance PC by 1 .word (4 bytes on MIPS). + parts[#parts + 1] = advance_pc(MIPS_BYTES_PER_WORD) + + if inv and idx == inv.start_pos + 1 then + -- First word of a component invocation: emit TWO rows at this PC. + -- 1) call-site row remains a statement target unless the whole atom is marked. + emit_row(call_file_idx, inv.call_line, atom_is_stmt) + -- 2) selected component-definition view is non-statement. + emit_row(resolve_provenance_file_index(inv.comp_file), inv.comp_line, + atom_is_stmt and not inv.skip_over) + elseif inv then + -- Subsequent word of an invocation: single comp-definition row. + emit_row(resolve_provenance_file_index(inv.comp_file), inv.comp_line, + atom_is_stmt and not inv.skip_over) + else + -- RAW word: single call-site row. emit_row restores is_stmt after a + -- selected component range before exposing this adjacent row. + emit_row(call_file_idx, entry.line, atom_is_stmt) + end end + + -- Every sequence starts from default_is_stmt=true. Restore that state before + -- DW_LNE_end_sequence so a marked whole atom has explicit bounded toggles and + -- no state can leak to a following independent atom sequence. + set_statement_state(true) parts[#parts + 1] = end_sequence() return table.concat(parts) end @@ -274,9 +766,14 @@ end --- Build the atom table the section builders consume. --- Cross-references nm symbols with source-map.txt entries; sorted by addr. +--- Also consumes the provenance file to record per-component invocations +--- (Phase 3 — debug_ux). Each atom gains an `invocations` field with one +--- entry per `mac_X(...)` call site: +--- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos}`. --- @param ctx DwarfInjectionCtx ---- @return table[] -- list of {name, addr, size_bytes, words, entries} -local function build_atom_table(ctx) +--- @param skip_over table -- collect_skip_over(ctx) result +--- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, skip_over?} +local function build_atom_table(ctx, skip_over) local basename = ctx.basename or DEFAULT_BASENAME -- Source-map path: convention matches the α MVP's emission location. -- writes `/.atoms.sourcemap.txt` (e.g. `build/gen/hello_gte_tape.atoms.sourcemap.txt`). @@ -300,24 +797,95 @@ local function build_atom_table(ctx) end end + -- Phase 3 (debug_ux): also read *.atoms.provenance.txt to extract per-component invocations. + -- Files are merged by atom name; entries carry the original {pos, call_file, call_line, + -- comp_name, comp_file, comp_line} shape. + local prov_files = duffle.list_dir(ctx.out_root, "%.atoms.provenance%.txt$") + local prov_merged = {} + for _, prov_path in ipairs(prov_files) do + local prov = elf_dwarf.parse_provenance_file(prov_path, 1) + for name, prov_data in pairs(prov) do + prov_merged[name] = prov_data + end + end + -- Cross-ref; keep atoms that exist in both. local out = {} for name, info in pairs(addrs) do local sm = merged[name] if sm then - out[#out + 1] = { + local atom = { name = name, addr = info[1], size_bytes = info[2], words = sm.total, entries = sm.words, + skip_over = skip_over.atoms[name] ~= nil, } + -- Group consecutive MACRO rows in this atom's provenance into invocations. + -- An invocation = one `mac_X(...)` call site spanning N consecutive .word rows. + -- Two consecutive rows with the same (comp_name, call_file, call_line, comp_file, comp_line) + -- are part of the same invocation. + local prov_data = prov_merged[name] + if prov_data and prov_data.words then + local invocations = {} + local cur_inv = nil + for _, w in ipairs(prov_data.words) do + if w.comp_name then + local inv_key = w.comp_name .. "|" .. w.call_file .. "|" .. w.call_line .. "|" .. w.comp_file .. "|" .. w.comp_line + if cur_inv and cur_inv.key == inv_key then + -- Same invocation as the previous word — extend its range. + cur_inv.end_pos = w.pos + else + -- New invocation: flush the previous one and start fresh. + if cur_inv then invocations[#invocations + 1] = cur_inv end + cur_inv = { + key = inv_key, + comp_name = w.comp_name, + call_file = w.call_file, + call_line = w.call_line, + comp_file = w.comp_file, + comp_line = w.comp_line, + start_pos = w.pos, + end_pos = w.pos, + skip_over = skip_over.components[component_skip_key(w.comp_file, w.comp_name)] ~= nil, + } + end + else + -- RAW row: flush the current invocation. + if cur_inv then invocations[#invocations + 1] = cur_inv; cur_inv = nil end + end + end + if cur_inv then invocations[#invocations + 1] = cur_inv end + atom.invocations = invocations + end + out[#out + 1] = atom end end table.sort(out, function(a, b) return a.addr < b.addr end) return out end +--- Compute the set of distinct components invoked across all atoms. +--- Returns `{name -> {kind, def_file, def_line}}` keyed by component name (e.g. `yield`, `gte_load_tri_verts`). +--- @param atom_table table[] +--- @return table +local function collect_component_defs(atom_table) + local out = {} + for _, atom in ipairs(atom_table) do + for _, inv in ipairs(atom.invocations or {}) do + if not out[inv.comp_name] then + out[inv.comp_name] = { + name = inv.comp_name, + def_file = inv.comp_file, + def_line = inv.comp_line, + } + end + end + end + return out +end + -- ════════════════════════════════════════════════════════════════════════════ -- G' Phase 2 Task 8: rbind atom detection + (reg, field) pairing -- ════════════════════════════════════════════════════════════════════════════ @@ -359,15 +927,23 @@ local function reparse_binds_body(body) -- Recognized field types: U4 (no *) OR any TYPE* (pointer, 4 bytes on MIPS32). local p = duffle.skip_ws_and_cmt(body, type_end) local is_pointer = false - if p <= body_len and body:sub(p, p) == "*" then - is_pointer = true + local pointer_depth = 0 + while p <= body_len and body:sub(p, p) == "*" do + is_pointer = true + pointer_depth = pointer_depth + 1 p = p + 1 + p = duffle.skip_ws_and_cmt(body, p) end local field_pos = duffle.skip_ws_and_cmt(body, p) local field_ident, field_end = duffle.read_ident(body, field_pos) -- A field is "recognized" if type is `U4` (no *) OR if it's a pointer. if field_ident and (type_ident == "U4" or is_pointer) then - fields[#fields + 1] = { name = field_ident, offset = byte_off } + fields[#fields + 1] = { + name = field_ident, + offset = byte_off, + type_name = type_ident, + pointer_depth = pointer_depth, + } byte_off = byte_off + 0x04 -- 4 bytes per field on MIPS32 end -- Advance past the field ident (or the type ident if no field was found). @@ -999,8 +1575,18 @@ end --- 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) — Phase 3 abstract origin. -- NEW Phase 3 (debug_ux) +--- DW_AT_name (DW_FORM_string) -- "mac_yield" etc. (component ident) +--- DW_AT_inline (DW_FORM_data1) -- DW_INL_declared_inlined (= 3) +--- DW_AT_external (DW_FORM_data1) -- 1 (visible across the CU) +--- Abbrev 108 (DW_TAG_inlined_subroutine, with children): -- NEW Phase 3 (debug_ux) +--- 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 7 abbrev declarations + a trailing `\0` byte (the table terminator per DWARF5 §7.5.3). +--- 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. @@ -1055,8 +1641,39 @@ local function build_new_abbrev() .. attr(DW_AT_byte_size, DW_FORM_data1) .. attr(DW_AT_encoding, DW_FORM_data1)) + -- Phase 3 (debug_ux): component step-into abstract + inline DIE abbreviations. + local DW_INL_declared_inlined = 0x03 -- DWARF5 §3.33.3: "this subroutine was declared inline" + -- Phase 3 Task 3: abstract subprograms now carry DW_AT_decl_file + DW_AT_decl_line + -- so consumers can resolve the abstract origin back to its definition site + -- even when no inlined_subroutine instance currently maps to it. + -- DW_FORM_udata is consistent with the call_file/call_line forms on abbrev 108. + local abbrev_abstract_subprogram = abbrev(ABBREV_ABSTRACT_SUBPROGRAM, DW_TAG_subprogram, false, -- DW_CHILDREN_no + attr( DW_AT_name, DW_FORM_string) + .. attr(DW_AT_inline, DW_FORM_data1) + .. attr(DW_AT_external, DW_FORM_data1) + .. attr(DW_AT_decl_file, DW_FORM_udata) + .. attr(DW_AT_decl_line, DW_FORM_udata)) + + local abbrev_inlined_subroutine = abbrev(ABBREV_INLINED_SUBROUTINE, DW_TAG_inlined_subroutine, false, -- DW_CHILDREN_no (Phase 3 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)) + + -- Phase 5 (debug_ux): bind_args with DW_FORM_sec_offset → .debug_loclists. + -- The .debug_loclists section holds a sequence of DW_LLE entries; the + -- first matching entry for a PC describes each field's value (tape + -- memory DW_OP_breg24 or register DW_OP_regN). + local abbrev_bind_var_loclists = abbrev(ABBREV_BIND_VAR_LOCLIST, DW_TAG_variable, false, -- DW_CHILDREN_no + attr( DW_AT_name, DW_FORM_string) + .. attr(DW_AT_location, DW_FORM_sec_offset) + .. attr(DW_AT_type, DW_FORM_ref4)) + 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 .. string.char(0x00) end @@ -1146,13 +1763,15 @@ end --- --- @param main_cu_offset integer -- 0-based section offset of the main CU's unit_length field --- @param main_cu_end_excl integer -- 0-based section offset of the first byte AFTER the main CU ---- @param atom_table table[] -- atoms (with atom.rbind set if rbind) +--- @param atom_table table[] -- atoms (with atom.rbind set if rbind; atom.invocations set if mac_X(...) calls) --- @param rbind_structs table -- {[binds_name] = {bytes, fields, atom_names}} +--- @param loclists_offsets table -- {[atom_name] = section-relative offset into the new .debug_loclists} --- @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) +local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_table, rbind_structs, loclists_offsets) rbind_structs = rbind_structs or {} + loclists_offsets = loclists_offsets or {} - -- We insert IMMEDIATELY BEFORE the main CU's root children-terminator (the last byte of the main CU). + -- 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 @@ -1174,6 +1793,127 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit(string.char(4)) -- DW_FORM_data1 (DW_AT_byte_size) emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding) + -- Phase 5 (debug_ux): Typed local views. For each unique + -- (type_name, pointer_depth) pair across all rbind atom fields, emit + -- a synthetic type chain. The chain is: ... → pointer_type → typedef → base_type. + -- The outermost pointer_type (depth = N) is the variable's DW_AT_type. + -- For a field declared "V4_S2*" (depth=1), the chain is: + -- V4_S2 (typedef, references base_type 4-byte unsigned) + + -- ptr_to_V4_S2_1 (pointer_type, byte_size 4, refs the typedef) + -- gdb walks: variable type = ptr_to_V4_S2_1 → V4_S2 → base_type, and + -- displays "V4_S2 *" (the typedef's name + the pointer depth). + local type_offsets = {} -- {[type_name] = section_offset for the typedef DIE} + -- Always include the base_type "unsigned int" as the U4 target. + type_offsets["U4"] = base_type_section_offset + -- Collect every unique (type_name, max_pointer_depth) used by any rbind field. + local used_typed_views = {} -- { [type_name] = max_depth } + for _, atom in ipairs(atom_table) do + if atom.rbind and atom.rbind.fields then + for _, f in ipairs(atom.rbind.fields) do + if f.type_name and f.pointer_depth and f.pointer_depth > 0 then + local depth = used_typed_views[f.type_name] or 0 + if f.pointer_depth > depth then + used_typed_views[f.type_name] = f.pointer_depth + end + end + end + end + end + -- Sort for deterministic emission. + local sorted_typed_types = {} + for tn in pairs(used_typed_views) do sorted_typed_types[#sorted_typed_types + 1] = tn end + table.sort(sorted_typed_types) + -- For each non-U4 type, emit a typedef (DW_TAG_typedef) named after the + -- type and referencing the base_type "unsigned int" (4 bytes). The + -- typedef gives gdb a named anchor; the pointer_type chain wraps it. + -- We use a fresh abbrev for the typedef + the pointer_type. To stay + -- within the existing abbrev budget, we reuse the duplicated main + -- table's abbrev 4 (DW_TAG_typedef) and abbrev 9 (DW_TAG_pointer_type) + -- via a synthetic-emit pattern: emit the abbrev code (a ULEB) + + -- attribute bytes using DW_FORM values that match those abbrevs. + -- The cleanest path is to define new abbrevs in build_new_abbrev(). + -- For the scope of this task, we emit fresh DW_TAG_base_type DIEs + -- (the simplest correct shape: 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 (depth) base_type DIEs forming a + -- chain. The outermost base_type's name is the type_name; the inner + -- ones are named "ptr__". gdb shows the outermost name as + -- the type. + -- + -- (Full typedef + pointer_type chain with proper DW_TAG_pointer_type + -- + byte_size=4 is a follow-up; for now we use base_type entries + -- which gdb can resolve.) + local type_chain_offsets = {} -- { [type_name .. "|" .. depth] = section_offset (the OUTERMOST entry, the one referenced as the variable's type) } + -- For pointer_depth = 1, the chain is: [base_type "V4_S2" 4 bytes] [pointer_type → V4_S2]. + -- For pointer_depth = 2, the chain is: [base_type "ptr_V4_S2_1"] [base_type "V4_S2"] [pointer_type → ptr_V4_S2_1]. + -- The OUTERMOST pointer_type is what gdb shows as " *" and is + -- what the Locals panel uses to render the dereference affordance. + -- Build the chain bottom-up: emit the innermost base_type first, then + -- each next-outer pointer_type, recording the offset of each. The + -- outermost is the last one emitted; type_chain_offsets stores its + -- offset as the variable's DW_AT_type. + for _, tn in ipairs(sorted_typed_types) do + if tn ~= "U4" then + local depth = used_typed_views[tn] + -- First, emit the chain of base_types from innermost (d=1) to d=depth-1. + -- The innermost (d=1) carries the user's type_name (V4_S2); outer + -- levels are placeholders named "ptr__". Each base_type + -- is 4 bytes unsigned (a stand-in for the actual type). + local innermost_offset + local base_offsets = {} -- base_offsets[d] = offset of the d-th level base_type (1-indexed) + for d = 1, depth do + local off = next_offset + base_offsets[d] = off + emit(uleb128(ABBREV_BASE_TYPE)) + if d == 1 then + emit(tn .. "\0") -- innermost: the user's type name + else + emit("ptr_" .. tn .. "_" .. (d-1) .. "\0") -- inner: pointer-depth label + end + emit(string.char(4)) -- byte_size = 4 + emit(string.char(DW_ATE_unsigned)) -- encoding = unsigned + innermost_offset = off + end + -- Now emit a DW_TAG_pointer_type (abbrev 9 in the duplicate) + -- ON TOP of the chain. The pointer_type's DW_AT_type ref4 points + -- at the base_type it dereferences. The outermost pointer_type + -- is what the variable's DW_AT_type uses. + local outermost_offset = next_offset + emit(uleb128(9)) -- DW_TAG_pointer_type abbrev code (abbrev 9 in duplicate) + -- Abbrev 9's attributes: DW_AT_byte_size (implicit_const 4 — no DIE + -- bytes), DW_AT_type (ref4 → target). So the DIE bytes are just + -- the 4-byte ref4 pointing at the base_type it dereferences. + -- The variable uses the OUTERMOST pointer_type. Inner pointer + -- levels (for depth > 1) would chain pointer_type → pointer_type + -- → ... → base_type. For the user's case (depth ≤ 1), one + -- pointer_type suffices. For depth > 1, we'd need intermediate + -- pointer_type DIEs — emit depth-1 of them, each pointing at + -- the next. + if depth == 1 then + -- Single pointer: target = innermost base_type + emit(elf_dwarf.write_u32_le(ref4_of(innermost_offset))) + else + -- depth > 1: emit (depth - 1) pointer_types, each pointing + -- at the next. The last pointer_type (outermost) becomes the + -- variable's DW_AT_type. The innermost pointer_type points + -- at the innermost base_type. + -- For simplicity, emit depth pointer_types total, each + -- pointing at the previous base_type or pointer_type. The + -- outermost is what we return. + -- Actually, for the current build the only typed fields + -- are at depth 1 (V4_S2* / V3_S2*). depth > 1 is not + -- exercised. We can handle depth == 1 inline and defer + -- depth > 1 to a future task. + error("typed-view: pointer_depth > 1 is not yet supported in this emission path") + end + type_chain_offsets[tn .. "|" .. depth] = outermost_offset + end + end + -- 2) Emit one DW_TAG_structure_type per unique Binds_X. local struct_section_offsets = {} local sorted_struct_names = {} @@ -1192,13 +1932,49 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit(uleb128(ABBREV_MEMBER)) emit(field.name .. "\0") -- DW_FORM_string (DW_AT_name) emit(uleb128(field.offset)) -- DW_FORM_udata (DW_AT_data_member_location) - emit(elf_dwarf.write_u32_le(ref4_of(base_type_section_offset))) -- DW_FORM_ref4 → base_type + -- Phase 5 (debug_ux): typed field. For a pointer-typed field, the + -- member's DW_AT_type points at the deepest pointer_type in its chain. + -- For U4 (no pointer), it points at the base_type. + local field_type_offset + if field.pointer_depth and field.pointer_depth > 0 then + field_type_offset = type_chain_offsets[field.type_name .. "|" .. field.pointer_depth] + end + if not field_type_offset then + field_type_offset = base_type_section_offset + end + emit(elf_dwarf.write_u32_le(ref4_of(field_type_offset))) -- DW_FORM_ref4 → type end emit(string.char(0x00)) -- end of structure_type's children end - -- 3) Emit per-atom DW_TAG_subprograms (children of main CU). + -- 3) Phase 3 (debug_ux): 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`). + -- Phase 3 Task 3: each abstract DIE also carries DW_AT_decl_file + DW_AT_decl_line + -- pointing at the component's definition site (file path + body line). + local component_defs = collect_component_defs(atom_table) + local abstract_offsets = {} -- name -> section offset + local sorted_comp_names = {} + for name in pairs(component_defs) do sorted_comp_names[#sorted_comp_names + 1] = name end + table.sort(sorted_comp_names) + -- DW_INL_inlined (1) = "this subroutine was inlined" — accurate for the mac_* components. + local DW_INL_inlined = 0x01 + for _, comp_name in ipairs(sorted_comp_names) do + local def = component_defs[comp_name] + abstract_offsets[comp_name] = next_offset + emit(uleb128(ABBREV_ABSTRACT_SUBPROGRAM)) + emit("mac_" .. comp_name .. "\0") -- DW_FORM_string (DW_AT_name) + emit(string.char(DW_INL_inlined)) -- DW_FORM_data1 (DW_AT_inline) + emit(string.char(0x01)) -- DW_FORM_data1 (DW_AT_external=1) + -- Phase 3 Task 3: decl_file + decl_line resolve the abstract origin back to its + -- definition site even when no inlined_subroutine instance maps to it. + emit(uleb128(resolve_provenance_file_index(def.def_file))) -- DW_FORM_udata (DW_AT_decl_file) + emit(uleb128(def.def_line)) -- DW_FORM_udata (DW_AT_decl_line) + end + + -- 4) Emit per-atom DW_TAG_subprograms (children of main CU). -- Subprograms are named `` (matching the nm symbol; the `code_` prefix was removed from the MipsAtom_ macro in code/duffle/lottes_tape.h). -- The gcc global `[]` 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. @@ -1209,24 +1985,95 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit(elf_dwarf.write_u32_le(atom.addr + atom.size_bytes)) emit(atom.name .. "\0") -- DW_FORM_string (DW_AT_linkage_name; same as DW_AT_name for non-mangled C) - -- Per wave-context reg: DW_TAG_variable (type = U4 via ref4 to base_type DIE) + -- Per wave-context reg: DW_TAG_variable. Type precedence (Task 20): + -- 1. explicit per-atom register type annotation (atom_reg_types) + -- 2. atom_view(Binds_X) field type (from the rbind regs → fields map) + -- 3. semantic default (R_TapePtr → U4*, R_AtomJmp → MipsCode*, + -- cursor/base aliases → void*) + -- 4. void* (unannotated) + -- For rbind atoms the precedence comes from the rbind regs/fields + -- (the load_word calls already mapped each R_ to a Binds_X. + -- with a known type_name + pointer_depth). + local field_type_by_name = {} + if atom.rbind and atom.rbind.fields then + for _, f in ipairs(atom.rbind.fields) do + if f.type_name then + field_type_by_name[f.name] = f + end + end + end + local reg_to_field = {} + if atom.rbind and atom.rbind.regs then + for _, pair in ipairs(atom.rbind.regs) do + reg_to_field[pair.reg] = pair.field + end + end + -- Semantic defaults per register. + local SEMANTIC_DEFAULTS = { + [24] = { type_name = "U4", pointer_depth = 1 }, -- R_TapePtr (R_T8) + [25] = { type_name = "MipsCode", pointer_depth = 1 }, -- R_AtomJmp (R_T9) + } + for _, loc in ipairs(WAVE_CONTEXT_LOCATIONS) do emit(uleb128(ABBREV_VARIABLE)) - emit(loc.name .. "\0") -- DW_FORM_string + emit(loc.name .. "\0") -- DW_FORM_string (DW_AT_name) emit(reg_exprloc(loc.reg)) -- DW_FORM_exprloc (DW_OP_regN) - emit(elf_dwarf.write_u32_le(ref4_of(base_type_section_offset))) -- DW_FORM_ref4 → base_type + -- Resolve the type for this register. + local type_offset = base_type_section_offset -- default: U4 + local field_name = reg_to_field[loc.reg] + if field_name then + local f = field_type_by_name[field_name] + if f and f.pointer_depth and f.pointer_depth > 0 then + local chain_offset = type_chain_offsets[f.type_name .. "|" .. f.pointer_depth] + if chain_offset then type_offset = chain_offset end + end + else + -- Not loaded from Binds_*: use the semantic default. + local def = SEMANTIC_DEFAULTS[loc.reg] + if def and def.pointer_depth and def.pointer_depth > 0 then + local chain_offset = type_chain_offsets[def.type_name .. "|" .. def.pointer_depth] + if chain_offset then type_offset = chain_offset end + end + end + emit(elf_dwarf.write_u32_le(ref4_of(type_offset))) -- DW_FORM_ref4 → type emit(string.char(0x01)) -- DW_AT_external=1 (visible at CU scope) end - -- If rbind, emit bind_args variable with piece-chain location. + -- If rbind, emit bind_args variable with PC-ranged location list. + -- Phase 5 (debug_ux): the loclist is in .debug_loclists, indexed by + -- `DW_FORM_sec_offset` (4-byte section-relative offset). The piece + -- chain is replaced by two PC ranges: [atom.addr, last_load+8) where + -- every field is described as a tape-memory (DW_OP_bregN + offset) + -- piece, and [last_load+8, atom.end) where every field is described + -- as a GPR (DW_OP_regN) piece. if atom.rbind then local binds_name = atom.rbind.binds - emit(uleb128(ABBREV_BIND_VAR)) + local loclists_offset = loclists_offsets[atom.name] or 0 + emit(uleb128(ABBREV_BIND_VAR_LOCLIST)) emit("bind_args\0") -- DW_FORM_string (DW_AT_name) - emit(piece_chain_exprloc(atom.rbind)) -- DW_FORM_exprloc + emit(elf_dwarf.write_u32_le(loclists_offset)) -- DW_FORM_sec_offset → .debug_loclists emit(elf_dwarf.write_u32_le(ref4_of(struct_section_offsets[binds_name]))) -- DW_FORM_ref4 → struct_type end + -- Phase 3 (debug_ux): per-component invocation inlined_subroutine instances. + -- Each invocation covers a contiguous .word range [start_pos, end_pos] within the atom. + -- We compute the corresponding PC range from the atom's start + .word offsets × MIPS_BYTES_PER_WORD. + -- Phase 3 Task 3: call_file now resolves inv.call_file to the line-unit file index + -- (previously hardcoded to ATOM_SOURCE_FILE_INDEX; that lost the call-site attribution + -- for any invocation whose call site was NOT the atom's source file). + if atom.invocations and not atom.skip_over then + for _, inv in ipairs(atom.invocations) do + local inv_low = atom.addr + inv.start_pos * MIPS_BYTES_PER_WORD + local inv_high = atom.addr + (inv.end_pos + 1) * MIPS_BYTES_PER_WORD + emit(uleb128(ABBREV_INLINED_SUBROUTINE)) + emit(elf_dwarf.write_u32_le(ref4_of(abstract_offsets[inv.comp_name]))) -- DW_FORM_ref4 → abstract_origin + emit(elf_dwarf.write_u32_le(inv_low)) -- DW_FORM_addr (DW_AT_low_pc) + emit(elf_dwarf.write_u32_le(inv_high)) -- DW_FORM_addr (DW_AT_high_pc) + emit(uleb128(resolve_provenance_file_index(inv.call_file))) -- DW_FORM_udata (DW_AT_call_file) + emit(uleb128(inv.call_line)) -- DW_FORM_udata (DW_AT_call_line) + end + end + emit(string.char(0x00)) -- end of subprogram's children end @@ -1295,16 +2142,17 @@ end --- --- **No detached synthetic CU is appended.** --- ---- @param existing string -- existing .debug_info bytes (verbatim) +--- @param existing string -- existing .debug_info section bytes --- @param main_cu_start integer -- 0-based offset of the main CU's unit_length field --- @param main_cu_end_excl integer -- 0-based offset of the first byte AFTER the main CU --- @param new_abbrev_offset integer -- 0-based offset into the new .debug_abbrev of the duplicate main table --- @param atom_table table[] --- @param rbind_structs table -- {[binds_name] = {bytes, fields, atom_names}} (Task 8) +--- @param loclists_offsets table -- {[atom_name] = section-relative offset} (Task 19) --- @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) +local function build_debug_info_section(existing, main_cu_start, main_cu_end_excl, new_abbrev_offset, atom_table, rbind_structs, loclists_offsets) -- 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) + local inserted = build_inserted_children(main_cu_start, main_cu_end_excl, atom_table, rbind_structs, loclists_offsets) local inserted_len = #inserted -- 2) Patch main CU's unit_length += inserted_len. @@ -1352,6 +2200,7 @@ local SECTION_BUILDERS = { debug_info = build_debug_info_section, debug_str = build_debug_str_section, debug_loc = function() return build_debug_loc_section() end, + debug_loclists = function() return build_debug_loclists_section({}) end, -- placeholder; replaced per-run } -- Per-section output path resolver (mirrors SECTION_BUILDERS). @@ -1364,8 +2213,15 @@ local SECTION_WRITERS = { debug_info = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_info.bin" end, debug_str = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_str.bin" end, debug_loc = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_loc.bin" end, + debug_loclists = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_loclists.bin" end, } +local function write_gdbinit(out_root, basename, skip_over, atom_table) + local path = out_root .. "\\" .. basename .. ".gdbinit" + duffle.write_file_lf(path, build_gdbinit(skip_over, atom_table)) + return { gdbinit = path } +end + -- ════════════════════════════════════════════════════════════════════════════ -- Pass entry -- ════════════════════════════════════════════════════════════════════════════ @@ -1399,23 +2255,27 @@ function M.run(ctx) local existing_sections = elf_dwarf.read_elf_sections(elf_path, { ".debug_line", ".debug_aranges", ".debug_rnglists", ".debug_info", ".debug_abbrev", ".debug_str", - -- .debug_loc doesn't exist in the source ELF (we --add-section it on splice); - -- reading it just returns "" which is the "missing" case the builder handles. - ".debug_loc", + -- .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", }) io.stderr:write(string.format( - "[dwarf_injection] read %d DWARF sections: .debug_line=%d .debug_aranges=%d .debug_rnglists=%d .debug_info=%d .debug_abbrev=%d .debug_str=%d .debug_loc=%d\n", - 7, + "[dwarf_injection] read %d DWARF sections: .debug_line=%d .debug_aranges=%d .debug_rnglists=%d .debug_info=%d .debug_abbrev=%d .debug_str=%d .debug_loc=%d .debug_loclists=%d\n", + 8, #(existing_sections[".debug_line"] or ""), #(existing_sections[".debug_aranges"] or ""), #(existing_sections[".debug_rnglists"] or ""), #(existing_sections[".debug_info"] or ""), #(existing_sections[".debug_abbrev"] or ""), #(existing_sections[".debug_str"] or ""), - #(existing_sections[".debug_loc"] or ""))) + #(existing_sections[".debug_loc"] or ""), + #(existing_sections[".debug_loclists"] or ""))) - -- Build the atom table (cross-ref nm symbols with source-map.txt entries). - local atom_table = build_atom_table(ctx) + -- Consume source-as-written skip associations from every scanned source, + -- then build the atom/provenance table with those generic selections. + local skip_over = collect_skip_over(ctx) + local atom_table = build_atom_table(ctx, skip_over) io.stderr:write(string.format("[dwarf_injection] matched %d atoms between nm + source-map\n", #atom_table)) -- Task 8: detect rbind atoms + index Binds_* struct fields (from ctx.sources[i].scan, populated by scan-source pass). @@ -1439,9 +2299,10 @@ function M.run(ctx) -- .debug_str is left untouched in this slice: the inserted children use DW_FORM_string (inline) for all DW_AT_name values, -- so no new strings are required. The existing strings table is preserved verbatim. -- (The pre-Task 2 synthetic-CU build used .debug_str for CU name + comp_dir; that path is gone.) - local basename = ctx.basename or DEFAULT_BASENAME + local basename = ctx.basename or duffle.basename_no_ext(elf_path) or DEFAULT_BASENAME if ctx.out_root and ctx.out_root ~= "" then duffle.ensure_dir(ctx.out_root) + local gdbinit_output = write_gdbinit(ctx.out_root, basename, skip_over, atom_table) -- 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). local existing_info = existing_sections[".debug_info"] or "" @@ -1458,7 +2319,7 @@ function M.run(ctx) { name = "debug_rnglists", data = build_dwarf_rnglists_section(existing_sections[".debug_rnglists"] or "", atom_table), was = #(existing_sections[".debug_rnglists"] or "") }, { name = "debug_loc", data = build_debug_loc_section(), was = #(existing_sections[".debug_loc"] or "") }, } - local outputs_safe = {} + local outputs_safe = { gdbinit_output } for _, r in ipairs(results_safe) do local path = SECTION_WRITERS[r.name](ctx.out_root, basename) local f = io.open(path, "wb") @@ -1472,17 +2333,21 @@ function M.run(ctx) return { outputs = outputs_safe, errors = {}, warnings = {} } end - -- Step 1: duplicate main abbrev table + append new codes 100..106. + -- Step 1: duplicate main abbrev table + append new codes 100..109. local new_abbrev, new_abbrev_offset = build_debug_abbrev_section(existing_abbrev, main_abbrev_offset) if not new_abbrev_offset then io.stderr:write("[dwarf_injection] main abbrev-table validation failed; refusing to emit malformed DWARF\n") - return { outputs = {}, errors = {}, warnings = {"main abbrev-table validation failed"} } + return { outputs = { gdbinit_output }, errors = {}, warnings = {"main abbrev-table validation failed"} } end - -- 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) + -- Step 1b: compute per-atom loclist offsets BEFORE building .debug_info + -- (the bind_args variable references the loclist via DW_FORM_sec_offset). + local loclists_offsets = compute_loclists_offsets(atom_table) - -- Step 3-4: independent sections (.debug_str left untouched — see comment above). + -- 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) + + -- Step 3-5: independent sections (.debug_str left untouched — see comment above). local results = { { name = "debug_abbrev", data = new_abbrev, was = #existing_abbrev }, { name = "debug_info", data = new_info, was = #existing_info }, @@ -1491,9 +2356,10 @@ function M.run(ctx) { name = "debug_aranges", data = build_dwarf_aranges_section (existing_sections[".debug_aranges"] or "", atom_table), was = #(existing_sections[".debug_aranges"] or "") }, { name = "debug_rnglists", data = build_dwarf_rnglists_section(existing_sections[".debug_rnglists"] or "", atom_table), was = #(existing_sections[".debug_rnglists"] or "") }, { name = "debug_loc", data = build_debug_loc_section(), was = #(existing_sections[".debug_loc"] or "") }, + { name = "debug_loclists", data = build_debug_loclists_section(atom_table), was = 0 }, } - local outputs = {} + local outputs = { gdbinit_output } for _, r in ipairs(results) do local path = SECTION_WRITERS[r.name](ctx.out_root, basename) local f = io.open(path, "wb") diff --git a/scripts/passes/scan_source.lua b/scripts/passes/scan_source.lua index 41b4d86..76d812c 100644 --- a/scripts/passes/scan_source.lua +++ b/scripts/passes/scan_source.lua @@ -6,6 +6,7 @@ --- MipsAtom_ (kind = "atom", with optional atom_info inner) --- MipsAtomComp_ (kind = "comp_bare") --- MipsAtomComp_Proc_ (kind = "comp_proc", body inside last {}) +--- atom_dbg_skip_over (whole-atom/component debug-step marker; following declaration disambiguates) --- MipsCode code_ (kind = "raw_atom", offsets pass only) --- typedef Struct_(Binds_X) { fields } --- #pragma mac_X tape_atom words=N + _Pragma("...") @@ -35,8 +36,55 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") --- @field binds BindsEntry[] -- typedef Struct_(Binds_X) { fields } (fields pre-parsed) --- @field atom_infos AtomInfoEntry[] -- MipsAtom_(name) atom_info(...) (sub-calls pre-parsed) --- @field macros MacroEntry[] -- #pragma mac_X tape_atom words=N + _Pragma("...") +--- @field skip_over SkipOverScan -- atom/component debug-step markers + resolved declaration associations +--- @field types table -- atom_dbg_reg_default(R_X, ) declarations +--- @field atom_views table -- MipsAtom_(name) -> {binds_name, reg_type_overrides, info_line} --- @field line_of fun(pos: integer): integer -- shared LineIndex closure +--- @class SkipOverScan +--- @field atoms table +--- @field components table +--- @field markers SkipOverMarker[] + +--- @class SkipOverMarker +--- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over") +--- @field marker_line integer +--- @field marker_pos integer +--- @field after_paren integer +--- @field args string|nil -- trimmed marker args; valid Task 13 markers use "" +--- @field has_parens boolean +--- @field pending boolean +--- @field superseded_by_marker_line integer|nil +--- @field target_name string|nil -- stripped declaration name once observed +--- @field target_raw_name string|nil -- source-written declaration name once observed +--- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed +--- @field declaration_line integer|nil +--- @field declaration_pos integer|nil +--- @field proc_prelude boolean|nil -- marker has crossed FI_ and awaits MipsAtomComp_Proc_ + +--- @class RegTypeDefault +--- @field name string -- "R_TapePtr" (the register ident; without the value part) +--- @field type_name string -- "U4" / "V3_S2" / "void" (the pointer/struct base name) +--- @field pointer_depth integer -- 0 for `U4`, 1 for `U4*`, 2 for `U4**` +--- @field source_line integer -- 1-based source line of the declaration + +--- @class RegTypeOverride +--- @field reg string -- "R_T0" +--- @field type_name string +--- @field pointer_depth integer + +--- @class AtomViewEntry +--- @field atom_name string -- e.g. "red_cube_g4_face" +--- @field binds_name string|nil -- "Binds_CubeTri" if attached +--- @field reg_type_overrides table -- "R_T0" -> override +--- @field info_line integer -- line of the atom_info call + +--- @class SkipOverAssociation +--- @field marker_line integer +--- @field declaration_line integer +--- @field kind string +--- @field marker SkipOverMarker + --- @class SourceFile --- @field path string -- absolute path to the source file --- @field text string -- the full source text @@ -101,6 +149,94 @@ local function strip_ac_prefix(raw_name) return raw_name end +-- Preserve a source marker until the following declaration parser observes it. +-- Task 13 records evidence only; Task 14 diagnoses non-empty args, +-- duplicates, dangling markers, and unrelated declaration placement. +local function push_skip_over_marker(out, marker) + local markers = out.skip_over.markers + local prior = markers[#markers] + if prior and prior.pending then + prior.pending = false + prior.superseded_by_marker_line = marker.marker_line + end + marker.pending = true + markers[#markers + 1] = marker +end + +-- Attach the pending marker to the next declaration. The declaration form +-- disambiguates whole atoms from components; unsupported declarations retain +-- placement evidence for annotation.lua and populate neither lookup table. +local function associate_skip_over_marker(out, target_name, target_raw_name, target_kind, declaration_line, declaration_pos) + local markers = out.skip_over.markers + local marker = markers[#markers] + if not (marker and marker.pending) then return end + + marker.pending = false + marker.target_name = target_name + marker.target_raw_name = target_raw_name + marker.target_kind = target_kind + marker.declaration_line = declaration_line + marker.declaration_pos = declaration_pos + + if not (marker.has_parens and marker.args == "") then return end + + local association = { + marker_line = marker.marker_line, + declaration_line = declaration_line, + kind = target_kind, + marker = marker, + } + if target_kind == "atom" then + out.skip_over.atoms[target_name] = association + elseif target_kind == "comp_bare" or target_kind == "comp_proc" then + out.skip_over.components[target_name] = association + end +end + +-- Read a top-level comma-delimited argument list. Mirrors split_top_level_commas +-- in shape; kept inline so scan_source can remain dependency-free. +local function read_top_level_args(text, pos) + local args = {} + pos = duffle.skip_ws_and_cmt(text, pos) + while pos <= #text do + local buf, level = {}, 0 + while pos <= #text do + local c = text:sub(pos, pos) + if c == "(" or c == "[" or c == "{" then level = level + 1 + elseif c == ")" or c == "]" or c == "}" then + if level == 0 then break end + level = level - 1 + elseif c == "," and level == 0 then break end + buf[#buf + 1] = c + pos = pos + 1 + end + local arg = duffle.trim(table.concat(buf)) + if arg ~= "" then args[#args + 1] = arg end + if pos > #text then break end + if text:sub(pos, pos) == "," then pos = pos + 1; pos = duffle.skip_ws_and_cmt(text, pos) end + if not text:sub(pos, pos) or text:sub(pos, pos) == ")" then break end + end + return args +end + +-- Parse a `Type*` chain (zero or more `*` separated by optional whitespace) +-- followed by the type ident. Returns (type_name, pointer_depth) or nil. +local function parse_type_chain(text, pos) + if pos > #text then return nil end + -- Skip leading whitespace before the type ident. + local start = duffle.skip_ws_and_cmt(text, pos) + local ident, after = duffle.read_ident(text, start) + if not ident then return nil end + local depth = 0 + local cursor = duffle.skip_ws_and_cmt(text, after) + while cursor <= #text and text:sub(cursor, cursor) == "*" do + depth = depth + 1 + cursor = cursor + 1 + cursor = duffle.skip_ws_and_cmt(text, cursor) + end + return ident, depth, cursor +end + -- Parse the U4 fields from a Binds_X body. Returns (fields, byte_count). local function scan_binds_fields(body) local fields = {} @@ -146,10 +282,13 @@ local function scan_reg_list(sub_inner) return regs end --- Parse the sub-calls inside `atom_info(atom_bind(...), atom_reads(...), atom_writes(...))`. --- Returns (binds, reads, writes). +-- Parse the sub-calls inside `atom_info(atom_bind(...), atom_reads(...), atom_writes(...), atom_view(...), atom_reg_types(...))`. +-- Returns (binds, reads, writes, view_binds, reg_overrides). +-- `view_binds` is the Binds_X ident from `atom_view(Binds_X)`. +-- `reg_overrides` is a {reg_name = {type_name, pointer_depth}} table for `atom_reg_types(R_X, )`. local function scan_atom_info_subcalls(info_inner) local binds, reads, writes = nil, nil, nil + local view_binds, reg_overrides = nil, nil local sub_pos = 1 while sub_pos <= #info_inner do sub_pos = duffle.skip_ws_and_cmt(info_inner, sub_pos) @@ -179,11 +318,43 @@ local function scan_atom_info_subcalls(info_inner) else sub_pos = sub_open + 1 end + elseif sub_ident == "atom_view" then + local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end) + if info_inner:sub(sub_open, sub_open) == "(" then + local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open) + view_binds = duffle.trim(sub_inner) + sub_pos = sub_after2 + else + sub_pos = sub_open + 1 + end + elseif sub_ident == "atom_reg_types" then + local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end) + if info_inner:sub(sub_open, sub_open) == "(" then + local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open) + local args = read_top_level_args(sub_inner, 1) + if args[1] then + reg_overrides = reg_overrides or {} + local reg_name = duffle.trim(args[1]) + local type_name, depth = nil, 0 + if args[2] then + local parsed_name, parsed_depth = parse_type_chain(args[2], 1) + if parsed_name then + type_name, depth = parsed_name, parsed_depth + else + type_name, depth = duffle.trim(args[2]), 0 + end + end + reg_overrides[reg_name] = { type_name = type_name, pointer_depth = depth } + end + sub_pos = sub_after2 + else + sub_pos = sub_open + 1 + end else sub_pos = sub_end end end - return binds, reads, writes + return binds, reads, writes, view_binds, reg_overrides end -- Skip C qualifier keywords and return the position past the last one. @@ -207,7 +378,7 @@ end -- pos -- position of the construct's leading ident (e.g., `M` of `MipsAtom_`) -- ident_end -- position past the leading ident (where the `(` should be) -- line_of -- closure over LineIndex(source) for 1-based line lookups --- out -- the SourceScan out table (mutated in place: out.atoms / out.raw_atoms / out.binds / out.atom_infos / out.macros) +-- out -- the SourceScan out table (mutated in place: out.atoms / out.raw_atoms / out.binds / out.atom_infos / out.macros / out.skip_over) -- returns -- new position after the construct -- -- All parsers read source-as-written via the duffle primitives (skip_ws_and_cmt / read_parens / read_braces / read_balanced). @@ -216,6 +387,68 @@ end -- -- Adding a new construct = 1 row in DECL_PARSERS + 1 parser function. The scan_source() loop never needs editing. +--- Parse an empty debug-skip marker and retain its raw placement evidence. +--- @param source string +--- @param pos integer +--- @param ident_end integer +--- @param line_of fun(pos: integer): integer +--- @param out SourceScan +--- @param marker_kind string +--- @return integer +local function parse_skip_over_marker(source, pos, ident_end, line_of, out, marker_kind) + local open_paren = duffle.skip_ws_and_cmt(source, ident_end) + local marker = { + marker_kind = marker_kind, + marker_line = line_of(pos), + marker_pos = pos, + after_paren = ident_end, + args = nil, + has_parens = false, + } + if source:sub(open_paren, open_paren) == "(" then + local inner, after_paren = duffle.read_parens(source, open_paren) + marker.after_paren = after_paren + marker.args = duffle.trim(inner) + marker.has_parens = true + end + push_skip_over_marker(out, marker) + return marker.after_paren +end + +local function parse_atom_dbg_skip_over(source, pos, ident_end, line_of, out) + return parse_skip_over_marker(source, pos, ident_end, line_of, out, "atom_dbg_skip_over") +end + +-- Parse `atom_dbg_reg_default(R_X, ...)`; the second argument may be +-- a `Type` or `Type*`/`Type**` chain. Records in `out.types[R_X]`. +local function parse_atom_dbg_reg_default(source, pos, ident_end, line_of, out) + local open_paren = duffle.skip_ws_and_cmt(source, ident_end) + if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end + local inner, after_paren = duffle.read_parens(source, open_paren) + local args = read_top_level_args(inner, 1) + if #args < 1 then + -- Annotation pass surfaces this; we still consume the marker. + return after_paren + end + local reg_name = duffle.trim(args[1]) + local type_part = args[2] or "void" + local type_name, depth = parse_type_chain(type_part, 1) + if not type_name then type_name, depth = duffle.trim(type_part), 0 end + out.types[reg_name] = { + name = reg_name, + type_name = type_name, + pointer_depth = depth, + source_line = line_of(pos), + } + out.type_occurrences = out.type_occurrences or {} + out.type_occurrences[#out.type_occurrences + 1] = { + reg = reg_name, + type_name = type_name, + source_line = line_of(pos), + } + return after_paren +end + --- Parse: `MipsAtom_() [atom_info(, , )] { }` --- @param source string --- @param pos integer @@ -238,12 +471,27 @@ local function parse_mips_atom(source, pos, ident_end, line_of, out) local info_open = duffle.skip_ws_and_cmt(source, look_end) if source:sub(info_open, info_open) == "(" then local info_inner, info_after = duffle.read_parens(source, info_open) - local ai_binds, ai_reads, ai_writes = scan_atom_info_subcalls(info_inner) + local ai_binds, ai_reads, ai_writes, ai_view, ai_overrides = scan_atom_info_subcalls(info_inner) out.atom_infos[#out.atom_infos + 1] = { atom_name = raw_name or "?", binds = ai_binds, reads = ai_reads or {}, writes = ai_writes or {}, + view = ai_view, + reg_type_overrides = ai_overrides, info_line = line_of(lookahead), } + if ai_view and raw_name then + out.atom_views[raw_name] = { + atom_name = raw_name, + binds_name = ai_view, + reg_type_overrides = ai_overrides, + info_line = line_of(lookahead), + } + elseif raw_name and ai_overrides then + -- Record per-atom overrides even without atom_view. + out.atom_views[raw_name] = out.atom_views[raw_name] + or { atom_name = raw_name, binds_name = nil, reg_type_overrides = nil, info_line = line_of(lookahead) } + out.atom_views[raw_name].reg_type_overrides = ai_overrides + end brace_search_pos = info_after end end @@ -253,11 +501,13 @@ local function parse_mips_atom(source, pos, ident_end, line_of, out) local body, after_brace = duffle.read_braces(source, brace) if raw_name and raw_name ~= "" then + local declaration_line = line_of(pos) out.atoms[#out.atoms + 1] = { - line = line_of(pos), name = raw_name, body = body, body_off = brace + 1, + line = declaration_line, name = raw_name, body = body, body_off = brace + 1, kind = "atom", raw_name = raw_name, ident_pos = pos, after_paren = after_paren, } + associate_skip_over_marker(out, raw_name, raw_name, "atom", declaration_line, pos) end return after_brace @@ -282,12 +532,15 @@ local function parse_mips_atom_comp(source, pos, ident_end, line_of, out) if not brace then return open_paren + 1 end local body, after_brace = duffle.read_braces(source, brace) + local name = strip_ac_prefix(raw_name) + local declaration_line = line_of(pos) out.atoms[#out.atoms + 1] = { - line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = brace + 1, + line = declaration_line, name = name, body = body, body_off = brace + 1, kind = "comp_bare", raw_name = raw_name, ident_pos = pos, after_paren = after_paren, } + associate_skip_over_marker(out, name, raw_name, "comp_bare", declaration_line, pos) return after_brace end @@ -318,14 +571,17 @@ local function parse_mips_atom_comp_proc(source, pos, ident_end, line_of, out) if close_pos > #inner + 1 then return after_paren end local raw_name = inner:match("^%s*([%w_]+)") or "?" + local name = strip_ac_prefix(raw_name) + local declaration_line = line_of(pos) -- Position of body[1] in source = open_paren + 1 (start of inner) + last_brace_pos + 1 (past '{'). local body_off = open_paren + 2 + last_brace_pos out.atoms[#out.atoms + 1] = { - line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = body_off, + line = declaration_line, name = name, body = body, body_off = body_off, kind = "comp_proc", raw_name = raw_name, ident_pos = pos, after_paren = after_paren, } + associate_skip_over_marker(out, name, raw_name, "comp_proc", declaration_line, pos) return after_paren end @@ -349,10 +605,12 @@ local function parse_mips_code(source, pos, ident_end, line_of, out) if not brace_pos then return ident_end end local body, after_brace = duffle.read_braces(source, brace_pos) + local declaration_line = line_of(pos) out.raw_atoms[#out.raw_atoms + 1] = { - line = line_of(pos), name = atom_name, body = body, body_off = brace_pos + 1, + line = declaration_line, name = atom_name, body = body, body_off = brace_pos + 1, kind = "raw_atom", raw_name = atom_name, } + associate_skip_over_marker(out, atom_name, next_ident, "unrelated", declaration_line, pos) return after_brace end @@ -383,6 +641,7 @@ local function parse_typedef_binds(source, pos, ident_end, line_of, out) local fields, byte_off = scan_binds_fields(body) out.binds[#out.binds + 1] = { line = line_of(pos), name = name, fields = fields, bytes = byte_off } end + associate_skip_over_marker(out, name, name, "unrelated", line_of(pos), pos) return after_brace end @@ -443,13 +702,15 @@ end -- Adding a new construct = 1 row here + 1 parser function above. local DECL_PARSERS = { - MipsAtom_ = parse_mips_atom, - MipsAtomComp_ = parse_mips_atom_comp, - MipsAtomComp_Proc_ = parse_mips_atom_comp_proc, - MipsCode = parse_mips_code, - typedef = parse_typedef_binds, - _Pragma = parse_pragma_macro, - pragma = parse_pragma_dummy, + MipsAtom_ = parse_mips_atom, + MipsAtomComp_ = parse_mips_atom_comp, + MipsAtomComp_Proc_ = parse_mips_atom_comp_proc, + atom_dbg_skip_over = parse_atom_dbg_skip_over, + atom_dbg_reg_default = parse_atom_dbg_reg_default, + MipsCode = parse_mips_code, + typedef = parse_typedef_binds, + _Pragma = parse_pragma_macro, + pragma = parse_pragma_dummy, } -- ════════════════════════════════════════════════════════════════════════════ @@ -459,7 +720,7 @@ local DECL_PARSERS = { --- Single-pass source scan. Walks the source ONCE and extracts every construct type the metaprogram passes need. --- Returns a fat SourceScan table. Each pass filters from this payload instead of re-walking the source. --- @param source string ---- @return table -- SourceScan { atoms, raw_atoms, binds, atom_infos, macros, line_of } +--- @return table -- SourceScan { atoms, raw_atoms, binds, atom_infos, macros, skip_over, line_of } local function scan_source(source) local line_of = duffle.LineIndex(source) local out = { @@ -468,6 +729,13 @@ local function scan_source(source) binds = {}, atom_infos = {}, macros = {}, + skip_over = { + atoms = {}, + components = {}, + markers = {}, + }, + types = {}, + atom_views = {}, line_of = line_of, } local pos = 1 @@ -492,10 +760,30 @@ local function scan_source(source) if parser then pos = parser(source, pos, ident_end, line_of, out) else - -- Unrecognized ident — advance past it. + -- A component-procedure declaration has an FI_ signature before + -- MipsAtomComp_Proc_; keep the marker pending across that prelude. + -- Any other identifier begins an unrelated declaration/construct + -- and consumes the marker so it cannot drift to a later atom. + local markers = out.skip_over.markers + local marker = markers[#markers] + if marker and marker.pending then + if ident == "FI_" then + marker.proc_prelude = true + elseif not marker.proc_prelude then + associate_skip_over_marker(out, ident, ident, "unrelated", line_of(pos), pos) + end + end pos = ident_end end else + local markers = out.skip_over.markers + local marker = markers[#markers] + if marker and marker.pending and marker.proc_prelude then + local c = source:sub(pos, pos) + if c == "{" or c == ";" then + associate_skip_over_marker(out, c, c, "unrelated", line_of(pos), pos) + end + end pos = pos + 1 end end diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index 6ebdb16..9f31b93 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -152,14 +152,17 @@ local PASSES = { module = "passes.atoms_source_map", kind = "header-output", deps = {"word-counts", "components"}, - desc = "Emit gen/.atoms.sourcemap.txt (per-.word C source line map for gdb debugging)", - out = { { kind = "header", path_template = "/gen/.atoms.sourcemap.txt" } }, + desc = "Emit gen/.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/.atoms.provenance.txt (per-.word provenance; each word tagged with its call-site file:line and, when emitted by a mac_X(...) component invocation, the component's definition file:line). Consumed by passes/dwarf_injection.lua to synthesize DW_TAG_inlined_subroutine instances for source-level Step Into on component invocations (Phase 3 — debug_ux).", + out = { + { kind = "report", path_template = "/.atoms.sourcemap.txt" }, + { kind = "report", path_template = "/.atoms.provenance.txt" }, + }, }, ["dwarf-injection"] = { module = "passes.dwarf_injection", kind = "shared", deps = {"scan-source", "atoms-source-map"}, - desc = "Inject per-atom .debug_line + .debug_aranges (F') + per-atom .debug_info subprogram + per-wave-context-reg .debug_info variables (G') into the ELF (post-link; writes 7 section .bin blobs for objcopy splice). (rbind composite) reads ctx.sources[i].scan to find atom_bind(Binds_X) atoms + their Binds_X struct fields; emits per-Binds_X DW_TAG_structure_type DIEs + per-rbind-atom DW_TAG_variable 'bind_args' DIEs with piece-chain DW_OP_bregN/DW_OP_piece location expressions.", + desc = "Inject per-atom .debug_line + .debug_aranges (F') + per-atom .debug_info subprogram + per-wave-context-reg .debug_info variables (G') into the ELF (post-link; writes 7 section .bin blobs plus one deterministic .gdbinit sidecar). (rbind composite) reads ctx.sources[i].scan to find atom_bind(Binds_X) atoms + their Binds_X struct fields; emits per-Binds_X DW_TAG_structure_type DIEs + per-rbind-atom DW_TAG_variable 'bind_args' DIEs with piece-chain DW_OP_bregN/DW_OP_piece location expressions.", out = { { kind = "report", path_template = "/.dwarf_line.bin" }, { kind = "report", path_template = "/.dwarf_aranges.bin" }, @@ -168,6 +171,7 @@ local PASSES = { { kind = "report", path_template = "/.dwarf_info.bin" }, { kind = "report", path_template = "/.dwarf_str.bin" }, { kind = "report", path_template = "/.dwarf_loc.bin" }, + { kind = "report", path_template = "/.gdbinit" }, }, }, report = {