From 8b0fb1d4e455f02a57629b9585bd6b4f3da00eaf Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 11 Jul 2026 21:02:34 -0400 Subject: [PATCH] exploring gdb support for the atom asm dsl. --- scripts/gdb/gdb_tape_atoms.gdb | 119 ++++++ scripts/launch_debug_session.ps1 | 95 +++++ scripts/passes/atoms_source_map.lua | 615 ++++++++++++++++++++++++++++ scripts/ps1_meta.lua | 37 +- 4 files changed, 856 insertions(+), 10 deletions(-) create mode 100644 scripts/gdb/gdb_tape_atoms.gdb create mode 100644 scripts/launch_debug_session.ps1 create mode 100644 scripts/passes/atoms_source_map.lua diff --git a/scripts/gdb/gdb_tape_atoms.gdb b/scripts/gdb/gdb_tape_atoms.gdb new file mode 100644 index 0000000..070bc70 --- /dev/null +++ b/scripts/gdb/gdb_tape_atoms.gdb @@ -0,0 +1,119 @@ +# scripts/gdb/gdb_tape_atoms.gdb +# +# Wrapper for the tape-atom step-debug helpers. The 9 user commands are defined +# here as STUBS (degraded-state messages). The real implementations + the +# per-atom data tables are emitted by `passes/atoms_source_map.lua` (post-link +# invocation: `ps1_meta.lua --atoms-source-map --gdb-runtime --elf `) into +# `build/gen/gdb_tape_atoms_runtime.gdb`. Sourcing that file RE-DEFINES the +# commands with real implementations. +# +# If `build/gen/gdb_tape_atoms_runtime.gdb` is missing or stale, the stubs +# remain (E1: no source map). The user just needs to re-run `build_psyq.ps1` +# to regenerate. No exceptions; no crashes. +# +# Why a wrapper + separate runtime file? +# - The runtime file is auto-generated per-build; not in git. +# - The wrapper is checked into git; always works. +# - This split keeps the script trivial and the data plumbing out of git. +# +# Compatible with every gdb build (no Python, no Tcl, no Guile required) — +# pure gdb command scripting + `set $var = val` + `define ... end`. +# +# Generated by track gdb_tape_atom_debugging_20260711 — see +# C:\projects\Pikuma\ps1-ai\docs\gdb_tape_atom_debugging.md for the manual. + +# ── Stub commands (defined here so they're always present, even if the +# runtime file is missing). The runtime file overrides these if sourced. ── + +define tape_atoms + echo "[gdb_tape_atoms] STUB: runtime file build/gen/gdb_tape_atoms_runtime.gdb not found." + echo "[gdb_tape_atoms] STUB: run .\\build_psyq.ps1 to regenerate, then re-source this file." +end +document tape_atoms + List every tape atom symbol in the loaded ELF (code_) with its .rodata address and word count. + STUB state: runtime file not sourced. Run build_psyq.ps1 to regenerate. +end + +define break_atom + echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." +end +document break_atom + Set a breakpoint at the start of tape atom . STUB state. +end + +define step_atom + echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." +end +document step_atom + Resume execution until the next atom boundary. STUB state. +end + +define next_atom + echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." +end +document next_atom + Alias for step_atom. STUB state. +end + +define where_in_atom + echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." +end +document where_in_atom + Report current atom name, .rodata addr, word offset, and source line (if known). STUB state. +end + +define stepi_inside_atom + echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." +end +document stepi_inside_atom + One MIPS-instruction step, then where_in_atom. STUB state. +end + +define show_c2 + printf "C2[ 0] 0x%08x\n", $c2_data[0] + printf "C2[ 7] 0x%08x [otz]\n", $c2_data[7] + printf "C2[12] 0x%08x [sxy0]\n", $c2_data[12] + printf "C2[13] 0x%08x [sxy1]\n", $c2_data[13] + printf "C2[14] 0x%08x [sxy2]\n", $c2_data[14] + printf "C2[24] 0x%08x [mac0]\n", $c2_data[24] + printf "...\n" + echo "(STUB state: only 7 representative regs shown. Run build_psyq.ps1 for full dump.)" +end +document show_c2 + Pretty-print all 32 C2 data registers as hex + named alias. STUB state (7 reg subset). +end + +define show_c2ctl + printf "C2CTL[ 0] 0x%08x\n", $c2_control[0] + printf "...\n" + echo "(STUB state: only 1 reg shown. Run build_psyq.ps1 for full dump.)" +end +document show_c2ctl + Pretty-print all 32 C2 control registers. STUB state (1 reg subset). +end + +define wave_ctx + printf "$t4 = R_FaceCursor 0x%08x\n", $t4 + printf "$t5 = R_VertBase 0x%08x\n", $t5 + printf "$t6 = R_OtBase 0x%08x\n", $t6 + printf "$t7 = R_PrimCursor 0x%08x\n", $t7 +end +document wave_ctx + Pretty-print the 4 wave-context GPRs ($t4..$t7). (wave_ctx works in stub state too.) +end + + +# ── Source the runtime file (re-defines commands with real impls + data). ── + +# Try to source from project-root-relative path first (the typical case). +# If the user is in a different CWD, the source will fail and stubs remain. +# The runtime file path is computed relative to the ELF's source map convention +# (build/gen/gdb_tape_atoms_runtime.gdb). +echo [gdb_tape_atoms] Wrapper loaded. Sourcing runtime file... +# Suppress the "Redefine command" prompts that would otherwise appear when the +# runtime file overrides the 9 stub commands defined above. The runtime's +# `define` blocks are intended to overwrite — there's no ambiguity to confirm. +set confirm off +source build/gen/gdb_tape_atoms_runtime.gdb +set confirm on +echo [gdb_tape_atoms] Runtime sourced successfully (9 commands now have real implementations). \ No newline at end of file diff --git a/scripts/launch_debug_session.ps1 b/scripts/launch_debug_session.ps1 new file mode 100644 index 0000000..a0b8c7b --- /dev/null +++ b/scripts/launch_debug_session.ps1 @@ -0,0 +1,95 @@ +# scripts/launch_debug_session.ps1 +# +# Wrapper around pcsx-redux.exe + gdb-multiarch that auto-launches the +# gdb_tape_atoms.gdb helper and (optionally) hot-reloads the .ps-exe. +# +# Generated by track gdb_tape_atom_debugging_20260711 — see +# C:\projects\Pikuma\ps1-ai\docs\gdb_tape_atom_debugging.md for the manual. +# +# usage: +# .\launch_debug_session.ps1 # bare gdb attach (assumes pcsx-redux is up) +# .\launch_debug_session.ps1 -AutoLoadAtoms # also source gdb_tape_atoms.gdb on startup +# .\launch_debug_session.ps1 -AutoResetEmu # hot-reload the .ps-exe before attaching +# .\launch_debug_session.ps1 -AutoStartEmu # launch pcsx-redux.exe if not running +# +# All three flags can be combined: -AutoStartEmu -AutoResetEmu -AutoLoadAtoms + +[CmdletBinding()] +param( + [switch]$AutoLoadAtoms, + [switch]$AutoResetEmu, + [switch]$AutoStartEmu, + [string]$ExePath = (Join-Path $PSScriptRoot '..\build\hello_gte.ps-exe'), + [string]$ElfPath = (Join-Path $PSScriptRoot '..\build\hello_gte.elf') +) + +$ErrorActionPreference = 'Stop' + +# ── Pre-checks (E10: pcsx-redux on :3333, E11: gdb-multiarch in PATH) ── + +function Test-Prerequisites { + # E11: gdb-multiarch must be in PATH. + if (-not (Get-Command gdb-multiarch -ErrorAction SilentlyContinue)) { + Write-Error "gdb-multiarch not found in PATH. Install via scoop or .\update_deps.ps1." -ErrorAction Stop + exit 1 + } + + # E10: pcsx-redux must be reachable on :3333 (or we auto-start). + $tcp = New-Object System.Net.Sockets.TcpClient + try { + $async = $tcp.BeginConnect('localhost', 3333, $null, $null) + $connected = $async.AsyncWaitHandle.WaitOne(2000, $false) -and -not $async.IsFaulted + } catch { + $connected = $false + } + $tcp.Close() + + if (-not $connected) { + if ($AutoStartEmu) { + Write-Host "pcsx-redux not running; auto-starting..." -ForegroundColor Cyan + $pcsxExe = (Join-Path $PSScriptRoot '..\toolchain\pcsx-redux\vsprojects\x64\Release\pcsx-redux.exe') + if (-not (Test-Path $pcsxExe)) { + Write-Error "pcsx-redux.exe not found at $pcsxExe. Build it first, or pass -ExePath + start pcsx-redux manually." -ErrorAction Stop + exit 1 + } + Start-Process -FilePath $pcsxExe -PassThru | Out-Null + Start-Sleep -Seconds 3 + } else { + Write-Error "Cannot connect to pcsx-redux at localhost:3333. Start pcsx-redux first (or pass -AutoStartEmu)." -ErrorAction Stop + exit 1 + } + } +} + +# ── Optional: hot-reload .ps-exe via pcsx-redux's web server (port 8080) ── + +function Invoke-HotReload { + $absolutePath = [System.IO.Path]::GetFullPath($ExePath) + $uri = 'http://localhost:8080/api/v1/load-exec' + $body = @{ filename = $absolutePath } | ConvertTo-Json + try { + Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType 'application/json' | Out-Null + Write-Host "Hot-reloaded $absolutePath into pcsx-redux." -ForegroundColor Green + } catch { + Write-Warning "Could not hot-reload via pcsx-redux web server (port 8080): $_" + } +} + +# ── Main ── + +Test-Prerequisites +if ($AutoResetEmu) { Invoke-HotReload } + +$gdbScript = Join-Path $PSScriptRoot 'gdb\gdb_tape_atoms.gdb' + +$gdbArgs = @( + '-q' + '-ex', "file $ElfPath" + '-ex', 'target remote localhost:3333' +) +if ($AutoLoadAtoms) { + $gdbArgs += @('-ex', "source $gdbScript") +} + +Write-Host "Launching gdb-multiarch (script: $gdbScript, elf: $ElfPath)..." -ForegroundColor Cyan +& gdb-multiarch @gdbArgs \ No newline at end of file diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua new file mode 100644 index 0000000..3feb5c1 --- /dev/null +++ b/scripts/passes/atoms_source_map.lua @@ -0,0 +1,615 @@ +--- 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 `gen/.atoms.sourcemap.txt`. +--- +--- **Two output forms** (per the workspace's per-emission-form pattern from +--- `guide_metaprogram_ssdl.md`): +--- 1. **Canonical text form** — `/gen/.atoms.sourcemap.txt`. +--- Always emitted. Format-version-tagged for forward-compat. +--- 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. See "lua.md" in the ps1-ai styleguides. +--- +--- Provenance: generated 2026-07-11 by track gdb_tape_atom_debugging_20260711. + +-- ════════════════════════════════════════════════════════════════════════════ +-- 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 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" + +-- gdb-runtime file header + atom-count default for C2 alias table (32 entries). +local C2_DATA_ALIASES = { + "v0.xy", "v0.z", "v1.xy", "v1.z", "v2.xy", "v2.z", + "rgb", "otz", + "ir0", "ir1", "ir2", "ir3", + "sxy0", "sxy1", "sxy2", "sxyp", + "sz0", "sz1", "sz2", "sz3", + "rgb_fifo", "mac0_in", + "?", "?", "?", "?", + "mac0", "mac1", "mac2", "mac3", + "mac4", "mac5", "mac6", "mac7", +} + +-- ════════════════════════════════════════════════════════════════════════════ +-- 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 +--- (per-source `.atoms.sourcemap.txt`) and the gdb-runtime form +--- (`gdb_tape_atoms_runtime.gdb`). +--- +--- 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 + +--- 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) +-- ════════════════════════════════════════════════════════════════════════════ + +--- Read ELF symbol addresses via `mipsel-none-elf-nm -S`. Returns map: name -> {addr, size_bytes}. +--- @param elf_path string +--- @return table +local function read_nm(elf_path) + local addrs = {} + -- Use io.popen (subprocess spawn ~50ms on Windows). For frequent calls, + -- we'd memoize via package.loaded — but this only runs in the post-link pass. + local f = io.popen('mipsel-none-elf-nm -S "' .. elf_path .. '" 2>nul') + if not f then return addrs end + for line in f:lines() do + -- Format: "80015eac 0000015c r code_cube_g4_face" + -- The symbol-type letter (`r` for read-only .rodata) sits between size and name. + local addr_hex, size_hex, name = line:match("^(%x+)%s+(%x+)%s+%a%s+code_(%S+)") + if addr_hex and size_hex and name then + addrs[name] = { tonumber(addr_hex, 16), tonumber(size_hex, 16) } + end + end + f:close() + return addrs +end + +--- 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 = 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 + local end_addr = a.addr + a.words * 4 + lines[#lines + 1] = string.format( + " if $__pc >= $__atom_addr_%d && $__pc < $__atom_addr_%d + $__atom_words_%d * 4", + a.idx, 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). + lines[#lines + 1] = "define stepi_inside_atom" + lines[#lines + 1] = " set $__in_atom = 0" + lines[#lines + 1] = " set $__did_step = 0" + for _, a in ipairs(matched) do + local end_addr = a.addr + a.words * 4 + lines[#lines + 1] = string.format( + " if $pc >= $__atom_addr_%d && $pc < $__atom_addr_%d + $__atom_words_%d * 4", + a.idx, 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 (32 entries) ── + -- Hardcoded with `$c2_data[N]` (COP2 data regs). Requires target attached. + lines[#lines + 1] = "define show_c2" + for i = 0, 31 do + local alias = C2_DATA_ALIASES[i + 1] or "?" + lines[#lines + 1] = string.format( + ' printf "C2[%%2d] 0x%%08x [%%s]\\n", %d, $c2_data[%d], "%s"', + i, i, alias) + end + lines[#lines + 1] = "end" + lines[#lines + 1] = "document show_c2" + lines[#lines + 1] = " Pretty-print all 32 C2 data registers as hex + named alias. Requires target attached." + lines[#lines + 1] = "end" + lines[#lines + 1] = "" + + -- ── show_c2ctl (32 entries) ── + lines[#lines + 1] = "define show_c2ctl" + for i = 0, 31 do + lines[#lines + 1] = string.format( + ' printf "C2CTL[%%2d] 0x%%08x\\n", %d, $c2_control[%d]', i, i) + end + lines[#lines + 1] = "end" + lines[#lines + 1] = "document show_c2ctl" + lines[#lines + 1] = " Pretty-print all 32 C2 control registers as hex. Requires target attached." + 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 = {} + +--- Pass entry: emit one `gen/.atoms.sourcemap.txt` per source file +--- that contains at least one `MipsAtom_(name)` / `MipsCode code_` declaration. +--- 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 + + -- 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) + local out_path = src.dir .. "/gen/" .. basename .. ".atoms.sourcemap.txt" + local content = render_source_map(src, wc) + + if not ctx.dry_run then + duffle.ensure_dir(duffle.dirname(out_path)) + duffle.write_file_lf(out_path, content) + end + + outputs[#outputs + 1] = { kind = "report", path = out_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 \ No newline at end of file diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index d633afa..0a73f3a 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -148,6 +148,13 @@ local PASSES = { desc = "[FUTURE] GTE pipeline-fill, mac_yield uniformity, etc.", out = { { kind = "report", path_template = "/.static_analysis.txt" } }, }, + ["atoms-source-map"] = { + 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" } }, + }, report = { module = "passes.report", kind = "report", @@ -167,19 +174,20 @@ local PASS_KIND_STOP_ON_ERROR = { -- Closed set of CLI flags -> pass names. local PASS_FLAG_TO_NAME = { - ["--word-counts"] = "word-counts", - ["--components"] = "components", - ["--validate"] = "annotation", - ["--offsets"] = "offsets", - ["--static-analysis"] = "static-analysis", - ["--report"] = "report", - ["--scan-source"] = "scan-source", - ["--all"] = ALL_PASSES_SENTINEL, + ["--word-counts"] = "word-counts", + ["--components"] = "components", + ["--validate"] = "annotation", + ["--offsets"] = "offsets", + ["--static-analysis"] = "static-analysis", + ["--atoms-source-map"] = "atoms-source-map", + ["--report"] = "report", + ["--scan-source"] = "scan-source", + ["--all"] = ALL_PASSES_SENTINEL, } local ALL_PASS_NAMES = { "scan-source", "word-counts", "components", "annotation", - "offsets", "static-analysis", "report", + "offsets", "static-analysis", "atoms-source-map", "report", } --- Append every pass name to args.requested_set. Used by --all and by the "default to --all if no pass flags were given" fallback. @@ -212,6 +220,7 @@ PASS_FLAGS (pick one or more, or use --all): --components Generate /gen/.macs.h --validate Run atom annotation DSL validation --offsets Generate /gen/.offsets.h + --atoms-source-map Generate .atoms.sourcemap.txt per source --static-analysis [FUTURE] GTE pipeline-fill, mac_yield uniformity --report Render per-project summary --all Equivalent to all 6 flags above (default) @@ -221,6 +230,8 @@ COMMON_FLAGS: --metadata PATH Path to metadata.h (required) --out-root DIR Output root for reports (default: build/gen) --project-root DIR Project root for .macs.h scan (default: dirname(metadata)) + --gdb-runtime Also emit /gdb_tape_atoms_runtime.gdb (post-link, requires --elf) + --elf PATH Path to linked .elf (for --gdb-runtime address resolution) --dry-run Print dep order + ASCII graph; exit 0 without running --verbose Print per-pass debug output --help Show this help and exit @@ -253,6 +264,12 @@ FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) args.metadata FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) args.out_root = argv[arg_idx + 1]; return arg_idx + 1 end FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_root = argv[arg_idx + 1]; return arg_idx + 1 end +-- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the +-- post-link gdb-runtime emission. Same shape as the existing per-flag handlers: +-- mutates `args.flags` (which propagates into `ctx.flags`). +FLAG_HANDLERS["--gdb-runtime"] = function(args) args.flags = args.flags or {}; args.flags.gdb_runtime = true end +FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx) args.flags = args.flags or {}; args.flags.elf_path = argv[arg_idx + 1]; return arg_idx + 1 end + -- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set. Single-statement, no nesting. FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a) local name = PASS_FLAG_TO_NAME[a] @@ -368,7 +385,7 @@ local function build_ctx(args) upstream = {}, out_root = args.out_root, project_root = args.project_root, - flags = {}, + flags = args.flags or {}, dry_run = args.dry_run, verbose = args.verbose, }