--- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms. --- --- Reads the pre-scanned SourceScan payload (produced once upstream by `duffle.scan_source`) --- for `MipsAtom_(name)` (kind="atom"), `MipsAtomComp_` / `MipsAtomComp_Proc_` (kind="comp_*"), --- and `MipsCode code_` (kind="raw_atom") declarations. --- Walks each atom's pre-tokenized body (`{{tok=string, rel=integer}, ...}` from `duffle.tokenize_body`), --- counts per-token word contributions via `ctx.shared.word_counts`, and emits one --- `WORD N LINE L TEXT T` line per `.word` to `/.atoms.sourcemap.txt`. --- --- **Two output forms** (per the workspace's per-emission-form pattern from --- `guide_metaprogram_ssdl.md`): --- 1. **Canonical text form** — `/.atoms.sourcemap.txt`. --- Format-version-tagged for forward-compat. --- Lives in `/` (build/gen). --- Matches the convention used by `annotation.lua` (`/.errors.h`) + `static_analysis.lua` (`/.static_analysis.txt`). --- Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `/gen/`. --- 2. **gdb-runtime form** — `/gdb_tape_atoms_runtime.gdb` --- (pure gdb command script; addresses pre-computed via `nm`; the 9 user commands defined as `define ... end` blocks). --- Emitted ONLY when `ctx.flags.gdb_runtime` is true AND `ctx.flags.elf_path` points to an existing ELF. --- The gdb runtime form lets `gdb-multiarch --without-python` users (the common case on Windows MinGW builds) --- load the source-map data via `source ` — no Python/Tcl/Guile required. --- --- **Output format** (canonical text form): --- ``` --- # FORMAT_VERSION 1 --- # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT --- ATOM "" --- WORD 0 LINE 49 TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)), --- WORD 1 LINE 49 TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)), --- ... (one WORD line per .word emitted by the atom body) ... --- ENDATOM --- ATOM "" --- ... --- ENDATOM --- ``` --- --- Marker calls (`atom_label(...)`, `atom_offset(...)`) emit 0 `.word`s. --- They share the same walking convention as `passes/offsets.lua :: scan_atom_body`: --- markers do NOT advance the word-offset counter, but if a marker is bundled on the same token with a --- trailing instruction (e.g. `atom_label(foo) load_half_u(...)`), --- the trailing instruction's word count is added. This matches `offsets.lua :: count_marker_rest`. --- --- **Conventions:** tabs (1/level), EmmyLua annotations, no regex, --- Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ -- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` -- (works both standalone + when require'd). `duffle_paths.lua` sets package.path then returns `require("duffle")` -- at the bottom, so the dofile value IS the duffle module. local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local elf_dwarf = require("elf_dwarf") local word_count_eval = require("word_count_eval") local count_token_words = word_count_eval.count_token_words -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ -- Format version emitted as the first line. Bump + add a migration test if the format changes; -- the gdb runtime loader rejects mismatches (E2). local FORMAT_VERSION = 1 -- Marker-call identifiers (mirrors offsets.lua:33-34). local LABEL_MARKER = "atom_label" local OFFSET_MARKER = "atom_offset" -- ════════════════════════════════════════════════════════════════════════════ -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- @class AtomSourceMapCtx --- @field sources table[] -- SourceScan payload per source (from `ctx.sources`) --- @field shared table -- `ctx.shared` --- @field shared.word_counts table -- macro name -> word count (populated by word-counts + components passes) --- @field out_root string -- output root (e.g. "build/gen") --- @field dry_run boolean -- if true, compute but don't write --- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path` -- ════════════════════════════════════════════════════════════════════════════ -- Helpers -- ════════════════════════════════════════════════════════════════════════════ --- True iff the leading identifier of `tok` is a marker call (`atom_label` / `atom_offset`). --- Mirrors `passes/offsets.lua :: is_marker_token` (which is file-local there). --- @param tok string --- @return boolean local function is_marker_token(tok) local leading = duffle.read_ident(tok, 1) return leading == LABEL_MARKER or leading == OFFSET_MARKER end --- Count words contributed by the non-marker portion of `tok` (after the marker's closing `)`). --- Mirrors offsets.lua:182 `count_marker_rest`. --- Returns 0 if there's no trailing content after the marker call. --- @param tok string --- @param wc table --- @return integer local function count_marker_rest(tok, wc) local marker_end = duffle.find_marker_call_end(tok) if not marker_end or marker_end >= #tok then return 0 end local rest = duffle.trim(tok:sub(marker_end)) if rest == "" then return 0 end return count_token_words(rest, wc) end --- Compute per-word entries for an atom. --- Shared between the canonical text form and the gdb-runtime form. --- --- Returns a list of `{pos, line, text}` entries + the total word count. --- Markers contribute 0 entries (the marker call emits 0 `.word`s). --- @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 --- @return table[], integer local function compute_word_entries(atom, src, wc) 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 if words > 0 then -- Source line for THIS token = line containing byte offset `atom.body_off + rel`. -- `src.scan.line_of(...)` is O(log N) via LineIndex. local line = src.scan.line_of(atom.body_off + rel) -- Flatten newlines + tabs in TEXT to spaces so each WORD entry fits on one physical line. -- The gdb Python parser (or our pure-gdb parser) does line-based splits; multi-line TEXT would break it. local text = duffle.trim(tok):gsub("[\t\r\n]+", " ") for _ = 1, words do entries[#entries + 1] = { pos = pos, line = line, text = text } pos = pos + 1 end end end return entries, pos end -- ════════════════════════════════════════════════════════════════════════════ -- Provenance emission -- ════════════════════════════════════════════════════════════════════════════ -- 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, body_line}` entries + the total word count. --- `comp_name` is nil for RAW rows. `body_line` is the line of THIS specific word in the macro body (component source file, not the caller's source); --- it differs from `comp_line` (= the macro signature line) for every body word whose macro-body token is on a different physical line. --- `body_line` is `nil` for RAW rows and for component words whose component declaration could not be indexed (older pass combinations / external macros). --- --- The per-word body-line lookup mirrors `passes/dwarf_injection.lua :: compute_invocation_body_lines`: --- walk the component's pre-tokenized body in lockstep with `count_token_words` and attribute the source line via `src.scan.line_of(...)` to each emitted `.word`. --- Atom labels (`atom_label(...)`) emit 0 `.word`s and are skipped to stay aligned with the macro-side word-counting contract. --- @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=} --- @param comp_body_index table -- per-source component body index: bare_name -> {body_off, body_tokens, line_of} --- @return table[], integer local function compute_provenance_entries(atom, src, wc, comp, comp_body_index) 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 -- Per-word body lines: lazily allocate from the indexed component body the first time we see a `mac_X(...)` call to a given component. -- We allocate ONE full body_lines vector per (call) and consume it sequentially; -- if a single atom calls the same component more than once, each call refetches its own vector. -- (Today no atom calls the same `mac_X(...)` twice, but the refetch keeps the semantics correct even if that changes.) local body_lines = nil local function fetch_body_lines() if not (bare and comp_body_index) then return nil end local idx = comp_body_index[bare] if not (idx and idx.body_tokens and idx.line_of) then return nil end local lines = {} for _, bt in ipairs(idx.body_tokens) do local bt_tok = duffle.trim(bt.tok or "") if bt_tok ~= "" then local leading = duffle.read_ident(bt_tok, 1) local bt_words if leading == "atom_label" or leading == "atom_offset" then bt_words = 0 else bt_words = count_token_words(bt_tok, wc) end if bt_words > 0 then local body_line = idx.line_of(idx.body_off + bt.rel) for _ = 1, bt_words do lines[#lines + 1] = body_line end end end end return lines end if words > 0 then local line = src.scan.line_of(atom.body_off + rel) local text = duffle.trim(tok):gsub("[\t\r\n]+", " ") -- Fetch body_lines ONCE per token (one mac_X(...) call exhausts N body words). if comp_name then body_lines = fetch_body_lines() end for i = 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, body_line = body_lines and body_lines[i], } pos = pos + 1 end end end return entries, pos end --- Render one atom's provenance stanza. Format: --- `WORD N CALL : MACRO ":" [BODY ]` (for component words) --- `WORD N CALL : RAW` (for direct instructions) --- `BODY ` is the source line of THIS specific word within the macro body --- (lottes_tape.h:N where N is the per-word body line). --- Absent for RAW rows and for component rows whose component declaration could not be indexed (older pass combinations / external macros). --- Downstream consumers (dwarf_injection, tests) fall back to DefLine / comp_line when BODY is absent. --- Returns (lines, total_words). --- @param src table --- @param atom table --- @param wc table --- @param comp table -- shared.components map --- @param comp_body_index table -- per-source component body index: bare_name -> {body_off, body_tokens, line_of} --- @return string[], integer local function emit_provenance_stanza(src, atom, wc, comp, comp_body_index) local lines = {} local rel_path = src.path:gsub("\\", "/") local entries, total = compute_provenance_entries(atom, src, wc, comp, comp_body_index) -- 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 local body_suffix = "" if pe.body_line then body_suffix = " BODY " .. tostring(pe.body_line) end lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d"%s', pe.pos, rel_path, pe.line, pe.comp_name, pe.comp_path, pe.comp_line, body_suffix) 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 --- Build a per-source component body index keyed by the bare component name (e.g. `gte_load_tri_verts`). --- Each entry holds the data we need to map each emitted `.word` to its actual source line within the macro body: --- body_off -- byte offset of the `{` (start of body) in the component's source file. --- body_tokens -- list of {tok, rel} pairs; `rel` is the byte offset within the body. --- line_of -- closure resolving byte offsets in the component's source file to lines. --- Only `comp_bare` + `comp_proc` declarations contribute (a macro invocation can only resolve to one of those). --- First declaration wins (subsequent redeclarations would collide; today's sources declare each component exactly once). --- 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 --- @param comp_body_index table -- cross-source component body index (built once in M.run; may be empty) --- @return string local function render_provenance(src, wc, comp, comp_body_index) 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 + the per-word BODY line. Used by" lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word" lines[#lines + 1] = "# line program rows for native source-level step into component bodies." -- The cross-source component body index is passed in from M.run (one global lookup shared across every source's provenance file). -- A per-source lookup would miss every component whose declaration is in another source (e.g. `gte_load_tri_verts` is declared in `lottes_tape.h` but invoked from `hello_gte_tape.c`). for _, atom in ipairs(src.scan.atoms or {}) do local stanza = emit_provenance_stanza(src, atom, wc, comp, comp_body_index) 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, comp_body_index) 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 --- @param atom table --- @param wc table --- @return string[], integer local function emit_atom_stanza(src, atom, wc) local lines = {} local rel_path = src.path:gsub("\\", "/") local entries, total = compute_word_entries(atom, src, wc) -- 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 _, we in ipairs(entries) do lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s", we.pos, we.line, we.text) 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 source map file content for one source (one .atoms.sourcemap.txt per source). --- Mirrors offsets.lua's `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter. --- @param src table --- @param wc table --- @return string local function render_source_map(src, wc) local lines = {} lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" for _, atom in ipairs(src.scan.atoms or {}) do local stanza = emit_atom_stanza(src, atom, wc) 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_atom_stanza(src, atom, wc) for _, line in ipairs(stanza) do lines[#lines + 1] = line end end return table.concat(lines, "\n") .. "\n" end -- ════════════════════════════════════════════════════════════════════════════ -- gdb-runtime emission (post-link, addresses via nm) -- ════════════════════════════════════════════════════════════════════════════ --- Escape a string for embedding in a gdb `set $var = "..."` literal. --- gdb uses C-style escaping; we escape `\` and `"` (newlines were flattened earlier). --- @param s string --- @return string local function gdb_escape(s) return (s:gsub("\\", "\\\\"):gsub('"', '\\"')) end --- Build the list of atoms with addresses + word entries. --- Shared helper for the gdb-runtime file emission. --- @param ctx PassCtx --- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries} local function build_atom_table(ctx) local wc = (ctx.shared and ctx.shared.word_counts) or {} local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) local matched = {} for _, src in ipairs(ctx.sources) do if src.scan then local file_base = src.path:match("([^/\\]+)$") or src.path for _, atom in ipairs(src.scan.atoms or {}) do if atom.kind == nil or atom.kind == "atom" then local name = atom.raw_name or atom.name local info = addrs[name] if info then local entries, total = compute_word_entries(atom, src, wc) matched[#matched + 1] = { name = name, src_path = src.path, file_base = file_base, addr = info[1], size_bytes = info[2], words = total, entries = entries, } end end end for _, atom in ipairs(src.scan.raw_atoms or {}) do local name = atom.name local info = addrs[name] if info then local entries, total = compute_word_entries(atom, src, wc) matched[#matched + 1] = { name = name, src_path = src.path, file_base = file_base, addr = info[1], size_bytes = info[2], words = total, entries = entries, } end end end end -- Deterministic order: sort by address (matches `nm` output ordering). table.sort(matched, function(a, b) return a.addr < b.addr end) for i, a in ipairs(matched) do a.idx = i - 1 end return matched end --- Append the 9 gdb command definitions to `lines`. Pure gdb scripting no Python, no Tcl, no Guile required. --- **Fully hardcoded per-atom** because gdb doesn't do nested `$` substitution in var names --- `$__atom_name_$__i` inside a `while` loop is treated as one literal identifier, not a concat. --- --- Each command is a static sequence of `printf` / `tbreak` / `if ... end` blocks. --- The Lua pass emits N atoms' worth of lines — no runtime iteration. --- With 7 atoms + ~200 word entries, the runtime file is ~2000 lines, all auto-generated, no human edit ever. --- @param lines table -- output line buffer (mutated in place) --- @param matched table -- list of atom records from `build_atom_table` local function append_gdb_commands(lines, matched) -- ── tape_atoms ── -- Hardcoded one printf per atom. No loop. lines[#lines + 1] = "define tape_atoms" for _, a in ipairs(matched) do -- gdb 12.1 quirk: literals in printf args require an attached target. -- Use the per-atom convenience vars set above as printf args. lines[#lines + 1] = string.format(' printf " code_%%-32s @ 0x%%08x %%4d words\\n", $__atom_name_%d, $__atom_addr_%d, $__atom_words_%d', a.idx, a.idx, a.idx) end lines[#lines + 1] = "end" lines[#lines + 1] = "document tape_atoms" lines[#lines + 1] = " List every tape atom symbol in the loaded ELF (code_) with .rodata addr + word count." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── break_atom (generic) + per-atom break_atom_X ── lines[#lines + 1] = "define break_atom" lines[#lines + 1] = ' echo "Usage: break_atom_ (pick from the list below)"' for _, a in ipairs(matched) do lines[#lines + 1] = string.format(' printf " break_atom_%%-32s\\n", $__atom_name_%d', a.idx) end lines[#lines + 1] = "end" lines[#lines + 1] = "document break_atom" lines[#lines + 1] = " Generic help: lists the per-atom break_atom_ commands." lines[#lines + 1] = "end" lines[#lines + 1] = "" for _, a in ipairs(matched) do lines[#lines + 1] = string.format("define break_atom_%s", a.name) lines[#lines + 1] = string.format(" break *$__atom_addr_%d", a.idx) lines[#lines + 1] = string.format(' printf " Breakpoint set at code_%s (0x%%08x)\\n", $__atom_addr_%d', a.name, a.idx) lines[#lines + 1] = "end" lines[#lines + 1] = string.format("document break_atom_%s", a.name) lines[#lines + 1] = string.format(" Set a breakpoint at code_%s.", a.name) lines[#lines + 1] = "end" lines[#lines + 1] = "" end -- ── step_atom / next_atom ── -- Hardcoded one tbreak per atom. No loop. lines[#lines + 1] = "define step_atom" for _, a in ipairs(matched) do lines[#lines + 1] = string.format(" tbreak *$__atom_addr_%d", a.idx) end lines[#lines + 1] = " continue" lines[#lines + 1] = "end" lines[#lines + 1] = "document step_atom" lines[#lines + 1] = " Set one-shot BPs at every atom + continue. Stops at the next atom boundary." lines[#lines + 1] = "end" lines[#lines + 1] = "" lines[#lines + 1] = "define next_atom" lines[#lines + 1] = " step_atom" lines[#lines + 1] = "end" lines[#lines + 1] = "document next_atom" lines[#lines + 1] = " Alias for step_atom." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── where_in_atom ── -- Hardcoded one outer-if per atom; inside, one inner-if per WORD entry. lines[#lines + 1] = "define where_in_atom" lines[#lines + 1] = " set $__pc = (unsigned int)$pc" lines[#lines + 1] = " set $__matched = 0" for _, a in ipairs(matched) do -- Precompute end_addr (gdb 12.1's expression evaluator chokes on `addr + words*4`). lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx) lines[#lines + 1] = string.format(" if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx) lines[#lines + 1] = string.format(' printf "atom: code_%%s\\n", $__atom_name_%d', a.idx) lines[#lines + 1] = ' printf "addr: 0x%08x\\n", $__pc' lines[#lines + 1] = string.format(" set $__word = ($__pc - $__atom_addr_%d) / 4", a.idx) lines[#lines + 1] = string.format(' printf "word: %%d/%%d\\n", $__word, $__atom_words_%d', a.idx) -- One inner-if per WORD entry. Each word's line + text hardcoded. for _, we in ipairs(a.entries) do lines[#lines + 1] = string.format(" if $__word == %d", we.pos) -- Escape TEXT for printf format string. local escaped_text = we.text:gsub("%%", "%%%%"):gsub('"', '\\"') lines[#lines + 1] = string.format(' printf "source: %%s:%%d %%s\\n", $__atom_file_%d, %d, "%s"', a.idx, we.line, escaped_text) lines[#lines + 1] = " end" end -- Fallback for words beyond the source map (shouldn't happen if nm matches). local max_word = 0 if #a.entries > 0 then max_word = a.entries[#a.entries].pos end lines[#lines + 1] = string.format(' if $__word > %d', max_word) lines[#lines + 1] = ' printf "source: (no source-map entry for word %%d; map may be stale)\\n", $__word' lines[#lines + 1] = " end" lines[#lines + 1] = " set $__matched = 1" lines[#lines + 1] = " end" end lines[#lines + 1] = " if !$__matched" lines[#lines + 1] = ' echo PC is not inside any known atom (in .text or unmapped region).' lines[#lines + 1] = " end" lines[#lines + 1] = "end" lines[#lines + 1] = "document where_in_atom" lines[#lines + 1] = " Report current atom name, .rodata addr, word offset, and source line." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── stepi_inside_atom ── -- Hardcoded one if-containment-check per atom (no loop). -- Precompute end_addr in Lua so we don't ask gdb to evaluate `addr + words*4` inside the if condition -- (gdb 12.1's expression evaluator chokes on the `*` and emits a misleading 'function malloc' error in some gdb builds). lines[#lines + 1] = "define stepi_inside_atom" lines[#lines + 1] = " set $__in_atom = 0" lines[#lines + 1] = " set $__did_step = 0" lines[#lines + 1] = " set $__pc = (unsigned int)$pc" for _, a in ipairs(matched) do -- Precompute end_addr in the convenience var (single expression gdb handles). lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx) lines[#lines + 1] = string.format(" if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx) lines[#lines + 1] = " set $__in_atom = 1" lines[#lines + 1] = " stepi" lines[#lines + 1] = " set $__did_step = 1" lines[#lines + 1] = " end" end lines[#lines + 1] = " if !$__did_step" lines[#lines + 1] = ' echo [gdb_tape_atoms] stepi_inside_atom: PC is not inside any atom; refusing to step.' lines[#lines + 1] = " end" lines[#lines + 1] = " where_in_atom" lines[#lines + 1] = "end" lines[#lines + 1] = "document stepi_inside_atom" lines[#lines + 1] = " One MIPS-instruction step, then where_in_atom. The step-and-see-source-line workflow." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── show_c2 ── -- GTE data regs (COP2). pcsx-redux's gdb stub doesn't expose COP2 (only -- 72 regs: 32 GPR + COP0 + FPR). -- curl http://localhost:8080/api/v1/lua/gte -- We keep the command definition as a stub that points the user at the plugin. lines[#lines + 1] = "define show_c2" lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2: gdb stub does not expose COP2 in this build."' lines[#lines + 1] = ' echo "[gdb_tape_atoms] Use scripts/pcsx_debug_helper.zip + curl http://localhost:8080/api/v1/lua/gte"' lines[#lines + 1] = ' echo "[gdb_tape_atoms] (or pcsx-redux Debug > Registers window for a native view)"' lines[#lines + 1] = "end" lines[#lines + 1] = "document show_c2" lines[#lines + 1] = " Stub. The gdb stub in this pcsx-redux build does not expose COP2 regs." lines[#lines + 1] = " For GTE data + control state, use the pcsx_debug_helper Lua plugin or the" lines[#lines + 1] = " pcsx-redux Debug > Registers window." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── show_c2ctl ── lines[#lines + 1] = "define show_c2ctl" lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2ctl: see show_c2 for the same workaround."' lines[#lines + 1] = "end" lines[#lines + 1] = "document show_c2ctl" lines[#lines + 1] = " Stub. Same workaround as show_c2." lines[#lines + 1] = "end" lines[#lines + 1] = "" -- ── wave_ctx ── lines[#lines + 1] = "define wave_ctx" lines[#lines + 1] = ' printf "$t4 = R_FaceCursor 0x%08x\\n", $t4' lines[#lines + 1] = ' printf "$t5 = R_VertBase 0x%08x\\n", $t5' lines[#lines + 1] = ' printf "$t6 = R_OtBase 0x%08x\\n", $t6' lines[#lines + 1] = ' printf "$t7 = R_PrimCursor 0x%08x\\n", $t7' lines[#lines + 1] = "end" lines[#lines + 1] = "document wave_ctx" lines[#lines + 1] = " Pretty-print the 4 wave-context GPRs ($t4=R_FaceCursor, $t5=R_VertBase, $t6=R_OtBase, $t7=R_PrimCursor). Requires target attached." lines[#lines + 1] = "end" end --- Emit the gdb-runtime file (post-link). Pure gdb scripting — no Python. --- Reads ELF addresses via `mipsel-none-elf-nm -S`, embeds them in `/gdb_tape_atoms_runtime.gdb` --- so gdb loads the data via `set $var = ...` + `define ... end` blocks at source-time. --- @param ctx PassCtx local function emit_gdb_runtime(ctx) if not (ctx.flags and ctx.flags.gdb_runtime) then return end local elf_path = ctx.flags.elf_path if not elf_path or elf_path == "" then io.stderr:write("[atoms_source_map] --gdb-runtime requires --elf \n") return end if lfs.attributes(elf_path, "mode") ~= "file" then io.stderr:write(string.format( "[atoms_source_map] --gdb-runtime: ELF not found at %s\n", elf_path)) return end local matched = build_atom_table(ctx) if #matched == 0 then io.stderr:write("[atoms_source_map] --gdb-runtime: no atoms matched against nm symbols (stale scan?).\n") return end local lines = {} lines[#lines + 1] = "# Auto-generated by ps1_meta.lua (passes/atoms_source_map.lua)" lines[#lines + 1] = "# DO NOT EDIT — re-run ps1_meta.lua --atoms-source-map --gdb-runtime to regenerate" lines[#lines + 1] = "# Sourced by scripts/gdb/gdb_tape_atoms.gdb (the wrapper)." lines[#lines + 1] = "# Pure gdb scripting — no Python, no Tcl, no Guile required." lines[#lines + 1] = "# Commands are FULLY HARDCODED per-atom because gdb doesn't do nested" lines[#lines + 1] = "# `$` substitution in var names (`$foo_$i` is one literal identifier)." lines[#lines + 1] = "# Per-atom convenience vars ($__atom_name_ etc.) are set so gdb's" lines[#lines + 1] = "# `printf` has valid expression args (gdb 12.1 quirks: literals in" lines[#lines + 1] = "# printf args require an attached target; convenience-var args do not)." lines[#lines + 1] = string.format("# %d atoms from ELF: %s", #matched, elf_path) lines[#lines + 1] = "" -- Format version + count + ELF path (the latter is referenced by the load-line). lines[#lines + 1] = "set $__atom_format_version = " .. FORMAT_VERSION lines[#lines + 1] = string.format("set $__atom_count = %d", #matched) lines[#lines + 1] = string.format('set $__elf_path = "%s"', gdb_escape(elf_path)) lines[#lines + 1] = "" -- Per-atom convenience vars (used as printf args; literals aren't accepted -- without an attached target on gdb 12.1). for _, a in ipairs(matched) do lines[#lines + 1] = string.format('set $__atom_name_%d = "%s"', a.idx, gdb_escape(a.name)) lines[#lines + 1] = string.format("set $__atom_addr_%d = 0x%x", a.idx, a.addr) lines[#lines + 1] = string.format("set $__atom_words_%d = %d", a.idx, a.words) lines[#lines + 1] = string.format('set $__atom_file_%d = "%s"', a.idx, gdb_escape(a.file_base)) end lines[#lines + 1] = "" -- The 9 commands (each `define ... end` overrides the wrapper's stub). lines[#lines + 1] = "# ── 9 user commands (overrides wrapper stubs) ──" append_gdb_commands(lines, matched) lines[#lines + 1] = "" -- Confirmation line for the source operator. lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path' local out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb" if not ctx.dry_run then duffle.ensure_dir(duffle.dirname(out_path)) duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n") end io.stderr:write(string.format( "[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched)) end -- ════════════════════════════════════════════════════════════════════════════ -- M — module exports -- ════════════════════════════════════════════════════════════════════════════ local M = {} --- Build the cross-source component body index used by `render_provenance` to attribute each emitted `.word` to its actual line within the macro body. --- --- Components are declared in one source (the header that contains `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`) --- but invoked from many source files (every atom body that calls `mac_X(...)`). --- The body_offset + body_tokens + line_of live with the declaration source, so a per-source index would miss invocations from other sources. --- --- The cross-source index is keyed by the bare component name (`gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`) — `strip_mac_prefix_from_token` strips the `mac_` prefix --- from call-site identifiers and yields that exact bare name; matching it here keeps the lookup aligned with the `ctx.shared.components` map's keying convention. --- First declaration wins (subsequent redeclarations would collide; today's sources declare each component exactly once). --- @param ctx PassCtx --- @return table -- {[comp_name] = {body_off, body_tokens, line_of}} local function build_cross_source_component_body_index(ctx) local index = {} for _, src in ipairs(ctx.sources or {}) do if src.scan and src.scan.atoms then local line_of = src.scan.line_of for _, atom in ipairs(src.scan.atoms) do if atom.kind == "comp_bare" or atom.kind == "comp_proc" then -- Prefer `atom.name` (stripped of `ac_` prefix); fall back to `raw_name` -- only if the stripped name is absent (defensive — current scan-source always sets both). local name = atom.name or atom.raw_name if name and not index[name] then index[name] = { body_off = atom.body_off, body_tokens = atom.body_tokens, line_of = line_of, } end end end end end return index end --- 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`: --- per-.word provenance with `mac_X(...)` component resolution back to the component's definition file:line + the per-word body line. --- Optionally also emit `/gdb_tape_atoms_runtime.gdb` when `ctx.flags.gdb_runtime` is true. --- @param ctx PassCtx --- @return PassResult function M.run(ctx) local outputs = {} local errors = {} local warnings = {} -- word-counts + components passes must have populated shared.word_counts. -- If absent, the orchestrator wired the deps wrong — fail loud. local wc = (ctx.shared and ctx.shared.word_counts) or {} if not wc or not next(wc) then warnings[#warnings + 1] = { line = 0, msg = "atoms_source_map: ctx.shared.word_counts is empty; the word-counts + components passes may not have populated it. Check the PASSES dep edges.", } end -- 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 {} -- Cross-source component body index. -- Built ONCE so every source's provenance writer can resolve `mac_X(...)` invocations back to the macro's body tokens (regardless of which source declared the component). -- Per-source copies were insufficient — the atom file (`hello_gte_tape.c`) does not contain the `MipsAtomComp_(...)` declarations, -- so the body data would be missing for every component invocation the atom file emitted. local comp_body_index = build_cross_source_component_body_index(ctx) -- Always emit the canonical text form (per-source). for _, src in ipairs(ctx.sources) do if src.scan then local n_atoms = src.scan.atoms and #src.scan.atoms or 0 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) -- (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 — 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, comp_body_index) if not ctx.dry_run then 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 = sourcemap_path } outputs[#outputs + 1] = { kind = "report", path = prov_path } end end end -- Optionally emit the gdb-runtime form (post-link, one file per build). if ctx.flags and ctx.flags.gdb_runtime then emit_gdb_runtime(ctx) end return { outputs = outputs, errors = errors, warnings = warnings } end return M