2 Commits
12 changed files with 1219 additions and 177 deletions
+26
View File
@@ -76,6 +76,32 @@
"tbreak main",
"continue"
]
},
{
"name": "Debug: Hello GTE Psy-Q! (atoms debug — DWARF-injected)",
"type": "gdb",
"request": "attach",
"target": "localhost:3333",
"remote": true,
"cwd": "${workspaceRoot}",
"valuesFormatting": "parseText",
"stopAtConnect": true,
"gdbpath": "gdb-multiarch",
"windows": {
"gdbpath": "gdb-multiarch.exe"
},
"osx": {
"gdbpath": "gdb"
},
"executable": "${workspaceRoot}/build/hello_gte.dwarf-injected.elf",
"autorun": [
"monitor reset shellhalt",
"load build/hello_gte.dwarf-injected.elf",
"source scripts/gdb/gdb_tape_atoms.gdb",
"tbreak main",
"continue"
]
}
]
}
+20 -20
View File
@@ -27,10 +27,10 @@ internal MipsAtom_(rbind_cube_g4_face) atom_info(atom_bind(Binds_CubeTri)
, atom_writes(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)
){
/* Pop 4 arguments from the tape directly into the workspace registers */
load_word(R_PrimCursor, R_TapePtr, O_(Binds_CubeTri,PrimCursor)),
load_word(R_FaceCursor, R_TapePtr, O_(Binds_CubeTri,FaceCursor)),
load_word(R_VertBase, R_TapePtr, O_(Binds_CubeTri,VertBase)),
load_word(R_OtBase, R_TapePtr, O_(Binds_CubeTri,OtBase)),
load_word(R_PrimCursor, R_TapePtr, O_(Binds_CubeTri,PrimCursor)),
load_word(R_FaceCursor, R_TapePtr, O_(Binds_CubeTri,FaceCursor)),
load_word(R_VertBase, R_TapePtr, O_(Binds_CubeTri,VertBase)),
load_word(R_OtBase, R_TapePtr, O_(Binds_CubeTri,OtBase)),
add_ui_self( R_TapePtr, S_(Binds_CubeTri)),
mac_yield()
};
@@ -41,7 +41,7 @@ internal MipsAtom_(rbind_cube_g4_face) atom_info(atom_bind(Binds_CubeTri)
* Reads 4 indices from R_FaceCur (V4_S2 = 8 bytes), loads 4 vertices into
* the GTE, runs the PsyQ RotAverageNclip4 sequence, and renders a Poly_G4.
*/
internal
internal
MipsAtom_(cube_g4_face) atom_info(
atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase),
atom_writes(R_PrimCursor, R_FaceCursor)
@@ -94,21 +94,21 @@ typedef Struct_(Binds_FloorTri) {
V3_S2* VertBase;
U4* OtBase;
};
internal
internal
MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri)
, atom_reads(R_TapePtr)
, atom_writes(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)
){
/* Pop 4 arguments from the tape directly into the workspace registers */
load_word(R_PrimCursor, R_TapePtr, O_(Binds_FloorTri,PrimCursor)),
load_word(R_FaceCursor, R_TapePtr, O_(Binds_FloorTri,FaceCursor)),
load_word(R_VertBase, R_TapePtr, O_(Binds_FloorTri,VertBase)),
load_word(R_OtBase, R_TapePtr, O_(Binds_FloorTri,OtBase)),
load_word(R_PrimCursor, R_TapePtr, O_(Binds_FloorTri,PrimCursor)),
load_word(R_FaceCursor, R_TapePtr, O_(Binds_FloorTri,FaceCursor)),
load_word(R_VertBase, R_TapePtr, O_(Binds_FloorTri,VertBase)),
load_word(R_OtBase, R_TapePtr, O_(Binds_FloorTri,OtBase)),
add_ui_self( R_TapePtr, S_(Binds_FloorTri)),
mac_yield()
};
internal
internal
MipsAtom_(floor_f3_face) atom_info(
, atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)
, atom_writes(R_PrimCursor, R_FaceCursor)
@@ -117,7 +117,7 @@ MipsAtom_(floor_f3_face) atom_info(
mac_gte_load_tri_verts(R_T0, R_T1, R_T2),
nop2, gte_cmdw_rotate_translate_perspective_triple,
nop2, gte_cmdw_nclip,
/* Culling (Branch forward if Backface) */
nop2, gte_mv_from_data_r(R_T0, C2_MAC0),
nop,
@@ -125,21 +125,21 @@ MipsAtom_(floor_f3_face) atom_info(
/* Format Primitive */
mac_format_f3_color(0xFF, 0xFF, 0xFF), // RGB-form (R=FF, G=FF, B=FF = white)
mac_gte_store_f3_post_rtpt(),
/* Calculate Depth */
nop2, gte_avg_sort_z3,
nop2, gte_mv_from_data_r(R_T1, C2_OTZ),
nop2, gte_avg_sort_z3,
nop2, gte_mv_from_data_r(R_T1, C2_OTZ),
/* Bounds Check OTZ < 2048 (Branch forward to skip insertion) */
add_ui( R_AT, R_0, OrderingTbl_Len),
set_lt_u( R_AT, R_T1, R_AT),
branch_equal(R_AT, R_0, atom_offset(bounds_chk, floor_f3_face_exit)), nop,
add_ui( R_AT, R_0, OrderingTbl_Len),
set_lt_u( R_AT, R_T1, R_AT),
branch_equal(R_AT, R_0, atom_offset(bounds_chk, floor_f3_face_exit)), nop,
/* Insert into Ordering Table Linked List */
mac_insert_ot_tag_f3(),
add_ui_self(R_PrimCursor, S_(Poly_F3)), /* Advance Prim Cursor (5 words) */
// Note(Ed): No bounds checking, should be checked before atom runs.
/* Advance Input Cursor & Yield (Both branch targets land here) */
atom_label(floor_f3_face_exit)
atom_label(floor_f3_face_exit)
add_ui_self(R_FaceCursor, S_(S2) * 4), /* Advance Face Cursor (4 * S2 = 8 bytes) */
mac_yield()
};
@@ -150,7 +150,7 @@ internal MipsAtom_(sync_primitive_arena) atom_info(atom_bind(Binds_SyncPrimitive
, atom_writes(R_TapePtr)
){
load_word(R_AT, R_TapePtr, O_(Binds_SyncPrimitiveArena,used)),
load_word(R_T0, R_TapePtr, O_(Binds_SyncPrimitiveArena,cursor)),
load_word(R_T0, R_TapePtr, O_(Binds_SyncPrimitiveArena,cursor)),
add_ui_self( R_TapePtr, S_(Binds_SyncPrimitiveArena)),
/* Calculate byte offset and store directly back to RAM */
sub_u( R_T0, R_PrimCursor, R_T0), // R_T0 = R_PrimCursor - binds.cursor
View File
+7 -17
View File
@@ -168,28 +168,18 @@ if arg and arg[1] then
end
-- Accept either a directory or a file path. Directory args are
-- expanded via `dir /b *.lua` (Windows) or `ls *.lua` (Unix).
-- expanded via lfs.dir (native, no subprocess).
local lfs = require("lfs")
local function is_dir(p)
local f = io.open(p, "r")
if f then f:close() return false end
return true
return lfs.attributes(p, "mode") == "directory"
end
local function list_lua(dir)
local out = {}
local cmd
if package.config:sub(1, 1) == "\\" then
cmd = 'dir /b "' .. dir .. '\\*.lua" 2>nul'
else
cmd = 'ls -1 "' .. dir .. '"/*.lua 2>/dev/null'
end
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:match("%.lua$") then
out[#out + 1] = dir .. "/" .. line
end
if not is_dir(dir) then return out end
for entry in lfs.dir(dir) do
if entry:match("%.lua$") then
out[#out + 1] = dir .. "/" .. entry
end
p:close()
end
return out
end
+49
View File
@@ -394,6 +394,55 @@ function build-gte_hello {
-out_root (join-path $path_build 'gen') `
-passes @('--gdb-runtime') `
-extra_args @('--elf', $elf)
ps1-meta -sources $atom_sources -metadata $path_atom_metadata `
-out_root (join-path $path_build 'gen') `
-passes @('--dwarf-injection') `
-extra_args @('--elf', $elf)
# F' track: post-link DWARF injection. The new Lua pass writes
# build/gen/<basename>.dwarf_*.bin blobs; we splice them into a COPY
# of the ELF via objcopy --update-section (works fine from PowerShell).
# The un-injected $elf + $exe are unchanged (shipping binary).
$dwarfLineBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_line.bin'
$dwarfArangesBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_aranges.bin'
$dwarfRnglistsBin = Join-Path (Join-Path $path_build 'gen') 'hello_gte.dwarf_rnglists.bin'
$injectElf = Join-Path $path_build 'hello_gte.dwarf-injected.elf'
if ((Test-Path $dwarfLineBin) -and (Test-Path $dwarfArangesBin) -and (Test-Path $dwarfRnglistsBin)) {
Write-Host "[build] DWARF-injecting $elf -> $injectElf"
Copy-Item -LiteralPath $elf -Destination $injectElf
& $Objcopy --update-section ".debug_line=$dwarfLineBin" $injectElf
if ($LASTEXITCODE -ne 0) {
Write-Warning "[build] objcopy .debug_line update failed (exit $LASTEXITCODE); removing $injectElf"
Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue
} else {
& $Objcopy --update-section ".debug_aranges=$dwarfArangesBin" $injectElf
if ($LASTEXITCODE -ne 0) {
Write-Warning "[build] objcopy .debug_aranges update failed (exit $LASTEXITCODE); removing $injectElf"
Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue
} else {
& $Objcopy --update-section ".debug_rnglists=$dwarfRnglistsBin" $injectElf
if ($LASTEXITCODE -ne 0) {
Write-Warning "[build] objcopy .debug_rnglists update failed (exit $LASTEXITCODE); removing $injectElf"
Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue
} else {
# Baked atoms execute from RAM but are emitted as C data arrays, so
# their ELF sections lack SHF_EXECINSTR. GDB discards line rows for
# non-code sections. Mark only the debug-copy sections executable;
# the shipping ELF and PS-EXE remain byte/flag unchanged.
& $Objcopy `
--set-section-flags ".rodata=alloc,load,readonly,code,contents" `
--set-section-flags ".data=alloc,load,data,code,contents" `
$injectElf
if ($LASTEXITCODE -ne 0) {
Write-Warning "[build] objcopy atom-section flag update failed (exit $LASTEXITCODE); removing $injectElf"
Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue
} else {
Write-Host "[build] DWARF-injected ELF: $injectElf"
}
}
}
}
}
}
build-gte_hello
+93 -110
View File
@@ -41,96 +41,55 @@ local lfs = require("lfs")
-- ASCII byte constants
-- ════════════════════════════════════════════════════════════════════════════
local BYTE_SPACE = 32 -- ' '
local BYTE_TAB = 9 -- '\t'
local BYTE_NEWLINE = 10 -- '\n'
local BYTE_CR = 13 -- '\r'
local BYTE_VT = 11 -- '\v'
local BYTE_FF = 12 -- '\f'
local BYTE_SPACE = 0x20 -- ' '
local BYTE_TAB = 0x09 -- '\t'
local BYTE_NEWLINE = 0x0A -- '\n'
local BYTE_CR = 0x0D -- '\r'
local BYTE_VT = 0x0B -- '\v'
local BYTE_FF = 0x0C -- '\f'
local BYTE_UNDERSCORE = 95 -- '_'
local BYTE_DOT = 46 -- '.'
local BYTE_SLASH = 47 -- '/'
local BYTE_BACKSLASH = 92 -- '\\'
local BYTE_STAR = 42 -- '*'
local BYTE_DQUOTE = 34 -- '"'
local BYTE_SQUOTE = 39 -- '\''
local BYTE_COMMA = 44 -- ','
local BYTE_SEMI = 59 -- ';'
local BYTE_UNDERSCORE = 0x5F -- '_'
local BYTE_DOT = 0x2E -- '.'
local BYTE_SLASH = 0x2F -- '/'
local BYTE_BACKSLASH = 0x5C -- '\\'
local BYTE_STAR = 0x2A -- '*'
local BYTE_DQUOTE = 0x22 -- '"'
local BYTE_SQUOTE = 0x27 -- '\''
local BYTE_COMMA = 0x2C -- ','
local BYTE_SEMI = 0x3B -- ';'
local BYTE_OPEN_PAREN = 40 -- '('
local BYTE_OPEN_BRACE = 123 -- '{'
local BYTE_OPEN_BRACK = 91 -- '['
local BYTE_OPEN_PAREN = 0x28 -- '('
local BYTE_OPEN_BRACE = 0x7B -- '{'
local BYTE_OPEN_BRACK = 0x5B -- '['
local BYTE_LOWER_A = 97 -- 'a'
local BYTE_LOWER_Z = 122 -- 'z'
local BYTE_UPPER_A = 65 -- 'A'
local BYTE_UPPER_Z = 90 -- 'Z'
local BYTE_LOWER_A = 0x61 -- 'a'
local BYTE_LOWER_Z = 0x7A -- 'z'
local BYTE_UPPER_A = 0x41 -- 'A'
local BYTE_UPPER_Z = 0x5A -- 'Z'
local BYTE_DIGIT_0 = 48 -- '0'
local BYTE_DIGIT_9 = 57 -- '9'
local BYTE_DIGIT_0 = 0x30 -- '0'
local BYTE_DIGIT_9 = 0x39 -- '9'
-- ════════════════════════════════════════════════════════════════════════════
-- Section -1: Bootstrap (path-setup at module load)
-- ════════════════════════════════════════════════════════════════════════════
--
-- When duffle.lua is first loaded (via `dofile` from an entry script or via `require` from a passes script),
-- the code below sruns and sets `package.path` + `package.cpath` so subsequent `require`s resolve.
-- Idempotent: re-loads just re-set the same paths.
-- Path setup is done by `scripts/duffle_paths.lua`, which derives the repo
-- root from `debug.getinfo(1, "S").source` (NO subprocess, ~0ms) and
-- then calls `require("duffle")`. The prior `io.popen("git rev-parse ...")`
-- approach in this section was removed during F'' because:
--
-- **Entry scripts** trigger this with one line:
-- `local duffle = dofile(arg[0]:match("(.*[/\\])") .. "/../duffle.lua")` which runs this top-level + returns `M`.
-- 1. Every entry script + every passes script now uses
-- `dofile("duffle_paths.lua")` (14 call sites; verified via grep).
-- The `find_repo_root` / `setup_package_path` defined here was dead
-- code in practice.
-- 2. `git rev-parse` costs ~100-180ms per subprocess spawn on Windows.
-- `debug.getinfo` is <1ms. There's no reason to keep the slow path
-- even as a "fallback".
--
-- **Passes scripts** are loaded via `require("passes.X")` from the entry script; by the time they run,
-- the entry script has already triggered this bootstrap, so the paths are set.
--- Resolve the repo root via `git rev-parse --show-toplevel` (cached).
--- Returns a path with a trailing separator, or nil if not in a git repo.
--- @return string|nil
local function find_repo_root()
-- Cached in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one git call.
-- Without this, git rev-parse runs once per script load.
if package.loaded.__duffle_repo_root__ then return package.loaded.__duffle_repo_root__ end
local p = io.popen("git rev-parse --show-toplevel 2>nul")
local root
if p then
root = p:read("*l")
p:close()
end
if not root or root == "" then return nil end
if not root:match("[/\\]$") then root = root .. "/" end
package.loaded.__duffle_repo_root__ = root
return root
end
--- Set `package.path` (for `require("duffle")` + `require("passes.X")`)
--- and `package.cpath` (for `lpeg.dll` on Windows).
function M.setup_package_path()
local repo_root = find_repo_root()
if not repo_root then
io.stderr:write("[duffle] git rev-parse failed -- not in a git repo?\n")
os.exit(2)
end
-- From the repo root, derive both `scripts/` and `scripts/passes/` so `require("duffle")` AND `require("passes.annotation")` resolve.
local scripts_dir = repo_root .. "scripts/"
local passes_dir = repo_root .. "scripts/passes/"
package.path = scripts_dir .. "?.lua;"
.. scripts_dir .. "?/init.lua;"
.. passes_dir .. "?.lua;"
.. passes_dir .. "?/init.lua;"
.. package.path
-- cpath: only needed on Windows for the bundled lpeg.dll.
-- (LPeg is optional -- duffle.lua's `pcall(require, "lpeg")` falls back to hand-rolled scanners if the .dll isn't loadable.)
if package.config:sub(1, 1) == "\\" then
package.cpath = repo_root .. "toolchain/luajit-2.1/lib/lua/5.1/?.dll;"
.. package.cpath
end
end
-- NOTE: `M.setup_package_path()` is NOT auto-called here. The entry scripts explicitly `dofile("duffle_paths.lua")` first, which calls `M.setup_package_path()`.
-- The function exists for the helper to use (so the path-setup logic is centralized in duffle.lua).
-- If a future use case ever needs to load `duffle.lua` WITHOUT going
-- through `duffle_paths.lua`, set `package.path` manually before `require`.
-- See `docs/guide_metaprogram_ssdl.md` §"I/O primitives" for the pattern.
-- ════════════════════════════════════════════════════════════════════════════
-- Section 0: LPeg patterns (compiled once at module load)
@@ -173,7 +132,7 @@ local lpeg_block_cmt_pat = P("/*") * (P(1) - P("*/"))^0 * P("*/")
local lpeg_str_or_cmt_pat = lpeg_str_pat + lpeg_chr_pat + lpeg_line_cmt_pat + lpeg_block_cmt_pat
-- Whitespace + comment skipper: zero+ (whitespace run | string | comment).
local ws_pat = S(" \t\n\r\v\f")
local ws_pat = S(" \t\n\r\v\f")
local lpeg_ws_and_cmt_pat = (ws_pat + lpeg_str_or_cmt_pat)^0
-- Generic "skip until target, but step over balanced groups" matcher.
@@ -185,11 +144,9 @@ local lpeg_scan_to_target_pat = function(target) return (P(1) - P(target))^0 en
-- ════════════════════════════════════════════════════════════════════════════
-- Section 1: character classification (byte-based for hot loops)
-- ════════════════════════════════════════════════════════════════════════════
--
-- Two APIs:
-- is_space(c), is_alpha(c), etc. — accept a single-char STRING (legacy)
-- is_space_byte(b), is_alpha_byte(b), etc. — accept a single-byte INTEGER
--
-- The byte-based versions are 5-10x faster in tight loops because they avoid the string allocation per s:sub(pos, pos) call.
-- Whitespace characters per C locale.
@@ -209,8 +166,8 @@ function M.is_digit_byte(b) return b and b >= BYTE_DIGIT_0 and b <= BYTE_DIGIT_9
-- Letter OR digit OR underscore.
function M.is_alnum_byte(b) return M.is_alpha_byte(b) or M.is_digit_byte(b) end
-- String-based wrappers (kept for callers that already have a single-char
-- string; the byte versions are what the hot loops should call).
-- String-based wrappers (kept for callers that already have a single-char string;
-- the byte versions are what the hot loops should call).
function M.is_space(c)
if type(c) == "number" then return M.is_space_byte(c) end
return c == " " or c == "\t" or c == "\n" or c == "\r" or c == "\v" or c == "\f"
@@ -240,7 +197,6 @@ function M.trim(s)
end
-- Linear-search for a single-byte target in a string.
-- (Phase 3 retained this for places where LPeg is overkill.)
-- @param haystack string
-- @param target integer -- byte value
-- @param start integer -- optional 1-indexed start (default 1)
@@ -305,6 +261,25 @@ function M.write_file_lf(path, content)
f:write(content); f:close()
end
-- Return `{path, ...}` for files in `out_root` whose basename matches
-- `pattern` (Lua pattern, NOT regex — `%.` not `\.`). Empty list if
-- `out_root` doesn't exist or matches nothing.
--
-- **Cost:** ~2ms native (lfs.dir) vs ~56ms subprocess (`dir /b`).
-- @param out_root Path
-- @param pattern string -- Lua pattern matched against basename only
-- @return string[]
function M.list_dir(out_root, pattern)
local files = {}
if lfs.attributes(out_root, "mode") ~= "directory" then return files end
for entry in lfs.dir(out_root) do
if entry:match(pattern) then
files[#files + 1] = out_root .. "\\" .. entry
end
end
return files
end
-- Convert a (possibly relative) path to an absolute path, using CWD if needed.
-- Normalizes forward slashes to backslashes on Windows.
-- Used for byte-identical emit: the // Source: comment line uses the absolute path.
@@ -354,7 +329,7 @@ function M._reset_ensured_dirs() _ensured_dirs = {} end
-- Group a list of `SourceFile`-shaped records by their `dir` field.
-- Used by the annotation / static-analysis / report passes to partition sources into per-DIRECTORY (per-module) buckets
-- before emitting per-module reports. Insertion order preserved within each bucket (matches source order in `ctx.sources`).
-- @param sources table[] -- list of source records (each having a `dir` string field)
-- @param sources table[] -- list of source records (each having a `dir` string field)
-- @return table<string, table[]> -- map of `dir` -> sources in that dir
function M.group_sources_by_dir(sources)
local by_dir = {}
@@ -370,7 +345,7 @@ end
-- ════════════════════════════════════════════════════════════════════════════
-- Skip a string or C-style comment starting at position `pos`.
-- Returns the position just past the construct, or `pos` unchanged if no string/comment starts there. LPeg-backed.
-- Returns the position just past the construct, or `pos` unchanged if no string/comment starts there.
function M.skip_str_or_cmt(s, pos) return lpeg.match(lpeg_str_or_cmt_pat, s, pos) or pos end
-- Skip whitespace AND C-style comments starting at position `pos`.
@@ -378,7 +353,7 @@ function M.skip_str_or_cmt(s, pos) return lpeg.match(lpeg_str_or_cmt_pat, s, pos
function M.skip_ws_and_cmt(s, pos) return lpeg.match(lpeg_ws_and_cmt_pat, s, pos) or pos end
-- Read a C-style identifier (alpha followed by zero+ alnum) starting at position `pos`.
-- Returns the identifier string + the position just past it, or nil + pos if no identifier starts here. LPeg-backed.
-- Returns the identifier string + the position just past it, or nil + pos if no identifier starts here.
function M.read_ident(s, pos)
local result = lpeg.match(lpeg_ident_pat, s, pos)
if result then return result, pos + #result end
@@ -523,8 +498,8 @@ function M.split_top_level_commas(body)
while pos <= body_len do
local c = body:byte(pos)
if c == BYTE_OPEN_PAREN then local _, a = M.read_parens(body, pos); pos = a -- scan: ... ( <balanced> ...
elseif c == BYTE_OPEN_BRACE then local _, a = M.read_braces(body, pos); pos = a -- scan: ... { <balanced> ...
if c == BYTE_OPEN_PAREN then local _, a = M.read_parens(body, pos); pos = a -- scan: ... ( <balanced> ...
elseif c == BYTE_OPEN_BRACE then local _, a = M.read_braces(body, pos); pos = a -- scan: ... { <balanced> ...
elseif c == BYTE_OPEN_BRACK then local _, a = M.read_brackets(body, pos); pos = a -- scan: ... ( <balanced> ...
elseif c == BYTE_COMMA then
-- scan: ... <token> , <next> ...
@@ -587,13 +562,22 @@ function M.tokenize_body(body)
local scan = rel
while scan <= len do
local c = body:byte(scan)
if c == 44 then break end -- ','
if c == 10 then break end -- '\n'
if c == 59 then break end -- ';'
if c == 40 then local _, a = M.read_parens (body, scan); scan = a -- '('
elseif c == 123 then local _, a = M.read_braces (body, scan); scan = a -- '{'
elseif c == 91 then local _, a = M.read_brackets (body, scan); scan = a -- '['
elseif c == 34 or c == 39 then scan = M.skip_str_or_cmt(body, scan) + 1 -- '"' or '\''
-- Terminator bytes (delimit a token at the top level): ',' = 0x2C,
-- '\n' = 0x0A, ';' = 0x3B. These also appear as separators between
-- argument lists inside the parens/braces/brackets, so we stop the
-- scan when we hit any of them.
if c == BYTE_COMMA then break end
if c == BYTE_NEWLINE then break end
if c == BYTE_SEMI then break end
-- Group opener bytes (consume the balanced group via the matching reader):
-- '(' = 0x28, '{' = 0x7B, '[' = 0x5B.
if c == BYTE_OPEN_PAREN then local _, a = M.read_parens (body, scan); scan = a
elseif c == BYTE_OPEN_BRACE then local _, a = M.read_braces (body, scan); scan = a
elseif c == BYTE_OPEN_BRACK then local _, a = M.read_brackets (body, scan); scan = a
-- String-literal byte ('"' = 0x22 or '\'' = 0x27): skip past the
-- quoted region in one shot.
elseif c == BYTE_DQUOTE or c == BYTE_SQUOTE then
scan = M.skip_str_or_cmt(body, scan) + 1
else
scan = scan + 1
end
@@ -639,7 +623,9 @@ function M.build_body_line_index(body)
if pos > 1 then
index[pos] = newline_count + 1
end
if body:byte(pos) == 10 then
-- Newline byte = 0x0A (BYTE_NEWLINE). Counts line boundaries so the
-- index maps each source-byte offset → its 1-based line number.
if body:byte(pos) == BYTE_NEWLINE then
newline_count = newline_count + 1
end
end
@@ -667,11 +653,11 @@ end
-- ════════════════════════════════════════════════════════════════════════════
function M.load_word_counts(metadata_path)
local counts = {}
local content = M.read_file(metadata_path)
local len = #content
local pos = 1
local prefix = "WORD_COUNT("
local counts = {}
local content = M.read_file(metadata_path)
local len = #content
local pos = 1
local prefix = "WORD_COUNT("
while pos <= len do
local nl = M.find_byte(content, BYTE_NEWLINE, pos)
local line_end = nl or (len + 1)
@@ -691,8 +677,6 @@ function M.load_word_counts(metadata_path)
return counts
end
-- ════════════════════════════════════════════════════════════════════════════
-- ══════════════════════════════════════════════════
-- Section 6: LineIndex (perf fix — replaces the per-call rescan line_of)
-- ══════════════════════════════════════════════════
@@ -731,10 +715,9 @@ M.WAVE_CONTEXT_REGS = {
-- The annotation DSL has been reduced to a single annotation macro:
-- atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...))
-- All phase / region / cadence / async / resource / group tokens have
-- been dropped. They may be reintroduced later as optional sub-calls
-- of atom_info; for now, the parser only recognizes atom_info + its
-- three sub-calls (atom_bind, atom_reads, atom_writes).
-- All phase / region / cadence / async / resource / group tokens have been dropped.
-- They may be reintroduced later as optional sub-calls of atom_info;
-- for now, the parser only recognizes atom_info + its three sub-calls (atom_bind, atom_reads, atom_writes).
M.TAPE_ATOM_MACROS = {
["atom_info"] = { kind = "info", binds = false },
}
@@ -1010,7 +993,7 @@ M.INSTRUCTION_LATENCY = {
["atom_writes"] = 0,
}
-- Default cycle cost for unknown macros.
-- Default cycle cost for unknown macros.
-- The static-analysis pass adds 1 cycle per unknown token and emits a "new macro; update INSTRUCTION_LATENCY"
-- advisory so the cycle budget stays accurate as the codebase grows.
M.UNKNOWN_INSTRUCTION_CYCLES = 1
+527
View File
@@ -0,0 +1,527 @@
--- elf_dwarf.lua — ELF32 + DWARF + atoms source-map utilities for the F'' track.
---
--- All ELF32 + DWARF-specific code lives here.
---
--- **What this module contains:**
--- - **Format-constant tables** (
--- the byte-offset / opcode / size encyclopedias for ELF32, DWARF4 aranges, DWARF5 rnglists, DWARF line-program, MIPS).
--- Every constant carries a spec:` comment naming the spec section that defines it (convention established by F'').
--- - **I/O helpers**: little-endian byte read/write, ELF32 section walker, nm symbol reader,
--- source-map parser, native directory glob.
---
--- **Conventions:** tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Native dependencies
-- ════════════════════════════════════════════════════════════════════════════
-- lfs is wired into package.cpath by `duffle_paths.lua` (vendored under
-- `toolchain/lfs/lfs.dll`). Required here for native directory ops
-- (replaces the ~56ms `dir /b` subprocess with ~2ms native).
local lfs = require("lfs")
local M = {}
-- ════════════════════════════════════════════════════════════════════════════
-- Format-constant tables
-- ════════════════════════════════════════════════════════════════════════════
-- ----------------------------------------------------------------------------
-- MIPS sizes
-- ----------------------------------------------------------------------------
--- spec: MIPS o32 ABI §"Register Usage" — 32-bit general-purpose registers
M.MIPS_BYTES_PER_WORD = 0x04
-- ----------------------------------------------------------------------------
-- ELF32 (System V ABI gABI v1.2)
-- ----------------------------------------------------------------------------
--
-- All offsets are 1-INDEXED (matching Lua string.sub convention),
-- expressed in hex so they map directly to the wire-format byte positions in the binary file.
-- To compute the 0-indexed file offset, subtract 1.
--
-- Example: e_shoff_offset = 0x21 means the 4-byte e_shoff field
-- starts at string.sub byte 0x21 (= 33 in 1-indexed), i.e. file offset 0x20 (= 32).
--- spec: System V ABI gABI v1.2 §"ELF Header" (Table 1) + §"Section Header Table"
M.ELF32 = {
magic_offset = 0x01, -- 4-byte magic "\127ELF" at file offset 0x00
magic = "\127ELF",
class_offset = 0x05, -- 1-byte; 1 = ELF32, 2 = ELF64
class_elf32 = 1,
endian_offset = 0x06, -- 1-byte; 1 = little-endian, 2 = big-endian
endian_little = 1,
header_bytes = 0x34, -- spec: gABI v1.2 §"ELF Header" — ELF32 header is 52 bytes total
e_shoff_offset = 0x21, -- 4-byte LE; section-header table file offset
e_shentsize_offset = 0x2F, -- 2-byte LE; section-header entry size in bytes
e_shnum_offset = 0x31, -- 2-byte LE; number of section headers
e_shstrndx_offset = 0x33, -- 2-byte LE; index of section-name string table
sh_size_bytes = 0x28, -- spec: gABI v1.2 §"Section Header Table" — each entry is 40 bytes
sh_name_offset = 0x01, -- 4-byte LE; offset into .shstrtab
sh_type_offset = 0x05, -- 4-byte LE; section type (SHT_*)
sh_offset_offset = 0x11, -- 4-byte LE; section's file offset
sh_size_offset = 0x15, -- 4-byte LE; section's size in bytes
dw_dwarf32_terminator = 0xFFFFFFFF, -- spec: DWARF4 spec §7.4 — 32-bit DWARF initial-length terminator
}
-- ----------------------------------------------------------------------------
-- DWARF4 .debug_aranges (per DWARF5 spec §7.4 — Address Range Table)
-- ----------------------------------------------------------------------------
--
-- All offsets are 1-INDEXED (matching Lua string.sub convention), in hex.
--- spec: DWARF5 spec §7.4 (Address Range Table) — 32-bit DWARF form
M.DWARF4_ARANGES = {
unit_length_offset = 0x01, -- 4-byte LE; length of unit body (excludes these 4 bytes)
version_offset = 0x05, -- 2-byte LE; expected = 2
cu_offset_offset = 0x07, -- 4-byte LE; CU DIE offset in .debug_info
addr_size_offset = 0x0B, -- 1-byte; expected = 4 (32-bit MIPS)
seg_size_offset = 0x0C, -- 1-byte; expected = 0
entry_size = 0x08, -- 4-byte addr + 4-byte length (per §7.4)
terminator_size = 0x08, -- 8 zero bytes (per §7.4 end-of-list marker)
version_expected = 2,
addr_size_expected = 4,
seg_size_expected = 0,
}
-- ----------------------------------------------------------------------------
-- DWARF5 .debug_rnglists (per DWARF5 spec §2.17 + §7.21)
-- ----------------------------------------------------------------------------
--
-- All offsets are 1-INDEXED (matching Lua string.sub convention), in hex.
--- spec: DWARF5 spec §2.17 + §7.21 (Range List Table) — 32-bit DWARF form
M.DWARF5_RNGLISTS = {
unit_length_offset = 0x01, -- 4-byte LE
version_offset = 0x05, -- 2-byte LE; expected = 5
addr_size_offset = 0x07, -- 1-byte; expected = 4
seg_size_offset = 0x08, -- 1-byte; expected = 0
offset_count_offset = 0x09, -- 4-byte LE; expected = 0
first_entry_offset = 0x0D,
end_of_list = 0x00, -- spec: DWARF5 §7.7 — DW_RLE_end_of_list byte value
start_length = 0x07, -- spec: DWARF5 §7.7 — DW_RLE_start_length byte value
version_expected = 5,
addr_size_expected = 4,
seg_size_expected = 0,
offset_count_expected = 0,
}
-- ----------------------------------------------------------------------------
-- DWARF line-program opcodes (per DWARF5 spec §6.2.5)
-- ----------------------------------------------------------------------------
--
-- Opcode VALUES stay in decimal — they're identifiers (DW_LNS_copy = 1), not binary positions.
-- Compare to the *_offset fields above which are hex.
--- spec: DWARF5 spec §6.2.5 (Line Number Program Opcodes)
M.DWARF_LINE_OPS = {
-- Standard opcodes (§6.2.5.2)
DW_LNS_extended = 0, -- spec: §6.2.5.2 — extended opcode marker byte
DW_LNS_copy = 1,
DW_LNS_advance_pc = 2,
DW_LNS_advance_line = 3,
DW_LNS_set_file = 4,
-- 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
-- Standard opcode header (§6.2.5.1)
-- opcode_base + line_range are 1-byte header fields; hex so they map
-- directly to their position in the line-program header byte sequence.
-- line_base stays signed decimal (=-5) since 0xFB obscures the spec semantics.
opcode_base = 0x0D,
line_base = -5,
line_range = 0x0E,
-- Extended opcode payload sizes (include the sub-opcode byte; §6.2.5.3)
-- Hex so they match the byte positions in the line-program wire format.
end_sequence_payload_size = 0x01, -- size = sub_opcode only
set_address_payload_size = 0x05, -- size = sub_opcode(1) + addr(4)
}
-- ════════════════════════════════════════════════════════════════════════════
-- I/O helpers: little-endian byte read/write
-- ════════════════════════════════════════════════════════════════════════════
--- Read a 4-byte little-endian unsigned integer from `buf` at 1-indexed offset `off`.
--- Equivalent to `string.unpack("<I4", buf, off)` but avoids the table-return shape + works under LuaJIT 2.1
--- (which has partial `string.unpack` coverage).
---
--- **Convention:** offsets are 1-indexed (matching Lua `string.sub`).
---
--- **Byte weights** are written as `0x100`, `0x10000`, `0x1000000` (i.e.
--- 2^8, 2^16, 2^24) so the LE byte positions are visually explicit:
--- byte 0 contributes its value directly; byte 1 is shifted left by 8
--- (= 0x100); byte 2 by 16 (= 0x10000); byte 3 by 24 (= 0x1000000).
---
--- @param buf string
--- @param off integer -- 1-indexed
--- @return integer
function M.read_u32_le(buf, off)
return buf:byte(off)
+ buf:byte(off + 0x01) * 0x00000100
+ buf:byte(off + 0x02) * 0x00010000
+ buf:byte(off + 0x03) * 0x01000000
end
--- Read a 2-byte little-endian unsigned integer from `buf` at 1-indexed offset `off`.
--- (1-indexed convention; matches `M.read_u32_le`.)
--- @param buf string
--- @param off integer -- 1-indexed
--- @return integer
function M.read_u16_le(buf, off)
return buf:byte(off) + buf:byte(off + 0x01) * 0x00000100
end
--- Return a 4-byte little-endian byte string for `value`.
--- Caller concatenates with `..` if composing multi-word blobs.
---
--- **Byte weights** written as `0x100` etc. (see `M.read_u32_le` for rationale).
--- @param value integer -- 0 ≤ value ≤ 0xFFFFFFFF
--- @return string
function M.write_u32_le(value)
return string.char(
value % 0x00000100,
math.floor(value / 0x00000100) % 0x00000100,
math.floor(value / 0x00010000) % 0x00000100,
math.floor(value / 0x01000000) % 0x00000100)
end
--- Return a 2-byte little-endian byte string for `value`.
--- @param value integer -- 0 ≤ value ≤ 0xFFFF
--- @return string
function M.write_u16_le(value)
return string.char(value % 0x00000100, math.floor(value / 0x00000100) % 0x00000100)
end
-- ════════════════════════════════════════════════════════════════════════════
-- I/O helpers: ELF32 / DWARF / symbols
-- ════════════════════════════════════════════════════════════════════════════
--- Read the named sections from a post-link ELF32 by walking the ELF32 section-header table directly
--- (no subprocess; lfs only for the existence check). Returns `{[name] = bytes_or_empty_string, ...}`.
---
--- **Convention:** offsets from `M.ELF32` (1-indexed for string.sub).
--- Every requested name has an entry in the returned dict; missing sections have an empty string (NOT nil)
--- so callers can do `sections[".debug_x"] or ""` for the missing case.
---
--- **Cost:** one file open + one `f:seek` + one `f:read` per section header
--- (we walk all `e_shnum` headers regardless of how many names are requested, to find the .shstrtab first).
--- For frequent callers, pass the union of all needed sections in one call.
---
-- can add `.debug_info` + `.debug_loc` + `.debug_str_offsets` to the list without writing a 2nd ELF walker.
--- @param elf_path Path
--- @param section_names string[] -- list of section names to read
--- @return table<string, string>
function M.read_elf_sections(elf_path, section_names)
-- Initialize result with all requested names set to "" so callers can do `sections[X]
-- or ""` for missing sections without nil-checks.
local result = {}
for _, name in ipairs(section_names) do result[name] = "" end
-- O(1) lookup set.
local wanted = {}
for _, name in ipairs(section_names) do wanted[name] = true end
-- Existence check (lfs.attributes avoids an io.open-vs-fail race).
if lfs.attributes(elf_path, "mode") ~= "file" then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] ELF not found: %s\n", elf_path))
return result
end
local f = io.open(elf_path, "rb")
if not f then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] io.open failed: %s\n", elf_path))
return result
end
-- Read the ELF32 header.
local header = f:read(M.ELF32.header_bytes)
if not header or #header < M.ELF32.header_bytes then
io.stderr:write("[elf_dwarf.read_elf_sections] ELF too small for ELF32 header\n")
f:close()
return result
end
-- Sanity-check magic + class + endianness.
if header:sub(M.ELF32.magic_offset, M.ELF32.magic_offset + 0x03) ~= M.ELF32.magic then
io.stderr:write("[elf_dwarf.read_elf_sections] not an ELF file\n")
f:close()
return result
end
if header:byte(M.ELF32.class_offset) ~= M.ELF32.class_elf32 then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] not ELF32 (class=%d)\n", header:byte(M.ELF32.class_offset)))
f:close()
return result
end
if header:byte(M.ELF32.endian_offset) ~= M.ELF32.endian_little then
io.stderr:write("[elf_dwarf.read_elf_sections] not little-endian; unsupported\n")
f:close()
return result
end
-- Parse section-header table location + dimensions from the header.
local e_shoff = M.read_u32_le(header, M.ELF32.e_shoff_offset)
local e_shentsize = M.read_u16_le(header, M.ELF32.e_shentsize_offset)
local e_shnum = M.read_u16_le(header, M.ELF32.e_shnum_offset)
local e_shstrndx = M.read_u16_le(header, M.ELF32.e_shstrndx_offset)
-- Read the section-header string table (.shstrtab) so we can resolve section names from their `sh_name` offsets.
f:seek("set", e_shoff + e_shstrndx * e_shentsize)
local strtab_hdr = f:read(e_shentsize)
if not strtab_hdr or #strtab_hdr < e_shentsize then
io.stderr:write("[elf_dwarf.read_elf_sections] could not read .shstrtab header\n")
f:close()
return result
end
local strtab_offset = M.read_u32_le(strtab_hdr, M.ELF32.sh_offset_offset)
local strtab_size = M.read_u32_le(strtab_hdr, M.ELF32.sh_size_offset)
f:seek("set", strtab_offset)
local strtab = f:read(strtab_size) or ""
-- Walk all section headers; collect (offset, size) for the wanted names.
local function read_section_bytes(sh_offset, sh_size)
f:seek("set", sh_offset)
return f:read(sh_size) or ""
end
for sh_idx = 0, e_shnum - 1 do
f:seek("set", e_shoff + sh_idx * e_shentsize)
local sh = f:read(e_shentsize)
if not sh or #sh < e_shentsize then break end
local sh_name = M.read_u32_le(sh, M.ELF32.sh_name_offset)
local sh_offset = M.read_u32_le(sh, M.ELF32.sh_offset_offset)
local sh_size = M.read_u32_le(sh, M.ELF32.sh_size_offset)
-- Extract the name (null-terminated C string in strtab).
local name_end = strtab:find("\0", sh_name + 1, true) or (sh_name + 1)
local name = strtab:sub(sh_name + 1, name_end - 1)
if wanted[name] then
result[name] = read_section_bytes(sh_offset, sh_size)
end
end
f:close()
return result
end
--- Read ELF symbol addresses by walking the `.symtab` + `.strtab` sections
--- directly (no `nm` subprocess). Returns a map `{name -> {addr, size_bytes}}`
--- for every `code_<name>` symbol.
---
--- **Why direct parsing instead of `mipsel-none-elf-nm -S`?**
--- The `nm` subprocess costs ~50ms per spawn on Windows (cmd.exe + mipsel-none-elf-nm.exe). Parsing `.symtab` ourselves is ~0ms.
--- Same return shape, same `code_` prefix filter.
---
--- **Conventions:**
--- - ELF32 symtab entry = 16 bytes (`st_name:4 + st_value:4 + st_size:4 + st_info:1 + st_other:1 + st_shndx:2`). 1-indexed for Lua string.sub.
--- - We filter on STB_GLOBAL (high nibble of st_info = 1) to match `nm`'s default (external symbols only). STB_WEAK excluded.
--- - We strip the `code_` prefix to match the previous `read_nm` output.
--- - `st_size > 0` filter excludes undefined/imported symbols.
---
--- **Cost:** ~1ms (file open + 2 section reads + symtab iteration).
--- Previously: ~50ms (nm subprocess spawn).
---
--- @param elf_path Path
--- @return table<string, {integer, integer}>
function M.read_nm(elf_path)
local addrs = {}
-- Read .symtab + .strtab via the existing ELF walker (no subprocess).
local sections = M.read_elf_sections(elf_path, {".symtab", ".strtab"})
local symtab = sections[".symtab"]
local strtab = sections[".strtab"]
if not symtab or not strtab or #symtab == 0 or #strtab == 0 then
-- No symbol table (e.g. stripped ELF). Return empty.
return addrs
end
-- Iterate the 16-byte ELF32 symtab entries.
-- Each entry (1-indexed): st_name at 1, st_value at 5, st_size at 9,
-- st_info at 13, st_other at 14, st_shndx at 15.
local SYM_ENTRY_BYTES = 0x10
local SYM_ST_NAME = 0x01
local SYM_ST_VALUE = 0x05
local SYM_ST_SIZE = 0x09
local SYM_ST_INFO = 0x0D
local n_syms = #symtab / SYM_ENTRY_BYTES
for i = 0, n_syms - 1 do
local entry_off = i * SYM_ENTRY_BYTES + 1 -- 1-indexed
local st_info = symtab:byte(entry_off + SYM_ST_INFO - 1)
-- High nibble = binding (STB_LOCAL=0, STB_GLOBAL=1, STB_WEAK=2).
-- Use math.floor(/16) instead of bit.rshift for LuaJIT 2.1 compat
-- (LuaJIT's `>>` is 5.3+, but math.floor(x/16) works on all versions).
local binding = math.floor(st_info / 16)
if binding == 0 or binding == 1 then -- STB_LOCAL or STB_GLOBAL
local st_size = M.read_u32_le(symtab, entry_off + SYM_ST_SIZE - 1)
if st_size > 0 then
local st_name_off = M.read_u32_le(symtab, entry_off + SYM_ST_NAME - 1)
-- Extract the name from .strtab (null-terminated C string).
local name_end = strtab:find("\0", st_name_off + 1, true) or (st_name_off + 1)
local name = strtab:sub(st_name_off + 1, name_end - 1)
-- Filter: only `code_<name>` symbols (matches nm output).
local short = name:match("^code_(.+)$")
if short then
local st_value = M.read_u32_le(symtab, entry_off + SYM_ST_VALUE - 1)
addrs[short] = { st_value, st_size }
end
end
end
end
return addrs
end
-- ════════════════════════════════════════════════════════════════════════════
-- LEB128 encoders (Unsigned + Signed Little-Endian Base 128)
-- ════════════════════════════════════════════════════════════════════════════
--
-- DWARF uses LEB128 to encode variable-length integers in its wire format
-- (line-program opcodes, DW_AT values, etc.). Both encoders pack 7 bits
-- of data per byte + 1 bit of "more bytes follow" signaling.
--
-- Per-byte layout:
--
-- bit: 7 6 5 4 3 2 1 0
-- │ └───── 7-bit data ─────┘
-- └─ continuation flag (LEB_CONT_BIT = 0x80)
--
-- For SLEB128 (signed), bit 6 of the 7-bit data is the sign bit that the
-- decoder uses for sign extension:
-- bit 6 = 0 → value is positive (or zero); zero-extend on decode
-- bit 6 = 1 → value is negative; one-extend on decode
--
-- The signed encoder must emit the MINIMUM number of bytes whose final 7-bit
-- payload already has the correct sign bit set
-- (otherwise the decoder would round-trip to a different value).
--
-- Spec: DWARF5 §7.6 "Variable-Length Data" / Appendix C.
-- Top bit of each LEB128 byte. Set if more bytes follow in the encoding.
local LEB_CONT_BIT = 0x80
-- Low 7 bits of each LEB128 byte. The actual data payload.
local LEB_DATA_MASK = 0x7F
-- Bit 6 of the 7-bit data (i.e. 0x40). For SLEB128: the sign-bit position
-- used by the decoder for sign extension. Encoders MUST stop when the next
-- byte would be redundant AND the sign bit in the last byte matches the
-- value's sign.
local SLEB_SIGN_BIT = 0x40
--- ULEB128 (Unsigned Little-Endian Base 128) encoder. Returns the byte
--- string for the non-negative integer `n`.
---
--- Algorithm:
--- - Extract the low 7 bits of `n` (LEB_DATA_MASK = 0x7F).
--- - Shift `n` right by 7 bits.
--- - If more bytes remain, OR in the continuation flag (LEB_CONT_BIT).
--- - Repeat until `n` is fully consumed.
---
--- @param n integer -- non-negative
--- @return string
function M.uleb128(n)
assert(n >= 0, "uleb128 requires non-negative input")
local bytes = {}
repeat
local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits
n = (n - b) / (LEB_DATA_MASK + 1) -- shift right by 7 bits
if n > 0 then b = b + LEB_CONT_BIT end -- set continuation bit if more bytes follow
bytes[#bytes + 1] = string.char(b)
until n == 0
return table.concat(bytes)
end
--- SLEB128 (Signed Little-Endian Base 128) encoder. Returns the byte
--- string for the integer `n` (may be negative).
---
--- Algorithm differs from ULEB128 by the termination condition: stop when
--- the remaining bits can be inferred from the sign bit in the last byte's
--- 7-bit data payload.
--- - If `n == 0` (no more value bits) AND bit 6 of the data = 0 → positive terminator (sign bit says "zero-extend").
--- - If `n == -1` (sign-extended all-1s) AND bit 6 of the data = 1 → negative terminator (sign bit says "one-extend").
---
--- Without these checks, the decoder would round-trip to a different value
--- (e.g. encoding `0` as `0x80 0x00` decodes to `0` correctly but is 2
--- bytes long; the termination check picks the 1-byte `0x00` form).
---
--- @param n integer -- any integer (negative allowed)
--- @return string
function M.sleb128(n)
local bytes = {}
local more = true
while more do
local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits
n = (n - b) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7
-- Termination: remaining value bits fit in the sign bit of the last byte.
if n == 0 and b < SLEB_SIGN_BIT then more = false end -- positive terminator
if n == -1 and b >= SLEB_SIGN_BIT then more = false end -- negative terminator
if more then b = b + LEB_CONT_BIT end
bytes[#bytes + 1] = string.char(b)
end
return table.concat(bytes)
end
-- ════════════════════════════════════════════════════════════════════════════
-- I/O helpers: atoms source-map + native directory glob
-- ════════════════════════════════════════════════════════════════════════════
--- Parse a FORMAT_VERSION <expected_version> `*.atoms.sourcemap.txt` file.
--- Returns `{name -> {total = N, words = {{pos, line}, ...}}}`.
--- Returns `{}` on format-version mismatch (and logs to stderr).
---
--- **Wire format** (emitted by `passes/atoms_source_map.lua`):
--- ```
--- # FORMAT_VERSION <n>
--- ATOM <name> "<abs-source-path>" <total>
--- WORD <n> LINE <line> TEXT <text...>
--- ...
--- ENDATOM
--- ```
---
--- **Conventions:** the in-memory shape uses `{pos, line, text}`
--- (`atoms_source_map.lua:142`); the `.txt` file uses `WORD <n>` so the parser maps `n` → `pos` field name.
--- @param sm_path Path
--- @param expected_version integer -- expected FORMAT_VERSION line
--- @return table<string, table>
function M.parse_source_map_file(sm_path, expected_version)
local out = {}
local cur_name, cur_words = nil, {}
for raw in io.lines(sm_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_source_map_file] source-map version mismatch (got %s, expected %d) in %s\n",
ver, expected_version, sm_path))
return {}
end
-- skip other comments
elseif line:sub(1, 4) == "ATOM" then
-- ATOM <name> "<abs-source-path>" <total>
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
-- Update the recorded total from the entries count
-- (matches the `lines[1] = lines[1]:gsub(" 0$", " " .. total)` patch in atoms_source_map.lua:170).
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
-- WORD <n> LINE <line> TEXT <text...>
local _, n, _, src_line = line:find("WORD%s+(%d+)%s+LINE%s+(%d+)")
if n and src_line then
cur_words[#cur_words + 1] = { pos = tonumber(n), line = tonumber(src_line) }
end
end
end
return out
end
return M
+2 -22
View File
@@ -52,6 +52,7 @@
-- 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
@@ -202,27 +203,6 @@ 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
@@ -237,7 +217,7 @@ end
--- @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 addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local matched = {}
for _, src in ipairs(ctx.sources) do
+467
View File
@@ -0,0 +1,467 @@
--- passes/dwarf_injection.lua — Per-atom DWARF injection for tape-atom step-debug (F').
---
--- Reads the post-link ELF directly (lfs + io.open; walks the ELF32 section
--- header table to find `.debug_line` + `.debug_aranges` + `.debug_rnglists`),
--- APPENDS synthetic DWARF line-program sequences for every `code_<name>` atom,
--- EXTENDS the `.debug_aranges` and main-CU range tables with the atom ranges.
--- Writes the new section data to `<out_root>/<basename>.dwarf_*.bin`.
---
--- The `build_psyq.ps1` post-link hook then splices those `.bin` files into a copy of the ELF via:
--- mipsel-none-elf-objcopy --update-section .debug_line=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_aranges=<bin> <elf>
--- mipsel-none-elf-objcopy --update-section .debug_rnglists=<bin> <elf>
--- (Splice step runs from PowerShell — no Lua subprocess; no cmd /c parsing issues.
--- objcopy's --update-section works fine in PowerShell even though Lua's `os.execute`/`io.popen` would mangle the `=` on Windows.)
---
--- Result: VSCode's source gutter follows per-stepi inside atom bodies.
--- Native VSCode UX (gutter arrow + highlighted line + Run to Cursor + conditional BPs by source line).
--- No VSCode plugin, no Python, no pyelftools — pure Lua + objcopy.
---
--- **Conventions:** tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Bootstrap
-- ════════════════════════════════════════════════════════════════════════════
-- Load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
-- Sets package.path + package.cpath then returns duffle.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ELF32 / DWARF / atoms-source-map utilities (post-link debug-info injection).
-- Sister module to duffle.lua — contains the format-constant tables (ELF32 byte offsets, DWARF opcodes, etc.) and the I/O helpers
-- (read_elf_sections, nm, source-map parser, LE byte r/w). `list_dir` lives in duffle.lua as a general I/O primitive (lifted out during F'').
local elf_dwarf = require("elf_dwarf")
local lfs = require("lfs")
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- DWARF line-program opcodes + range-list entry encodings (per DWARF5 spec).
-- All values lifted from `elf_dwarf.DWARF_LINE_OPS` + `elf_dwarf.DWARF5_RNGLISTS`.
-- Local aliases preserve the F' code's readability
-- (e.g. `DW_LNS_copy` reads better than `elf_dwarf.DWARF_LINE_OPS.DW_LNS_copy` in an emitter body).
local DWARF_LINE_OPS = elf_dwarf.DWARF_LINE_OPS
local DWARF5_RNGLISTS = elf_dwarf.DWARF5_RNGLISTS
local MIPS_BYTES_PER_WORD = elf_dwarf.MIPS_BYTES_PER_WORD
local DW_LNS_copy = DWARF_LINE_OPS.DW_LNS_copy
local DW_LNS_advance_pc = DWARF_LINE_OPS.DW_LNS_advance_pc
local DW_LNS_advance_line = DWARF_LINE_OPS.DW_LNS_advance_line
local DW_LNS_set_file = DWARF_LINE_OPS.DW_LNS_set_file
local DW_LNS_extended = DWARF_LINE_OPS.DW_LNS_extended
local DW_LNE_end_sequence = DWARF_LINE_OPS.DW_LNE_end_sequence
local DW_LNE_set_address = DWARF_LINE_OPS.DW_LNE_set_address
local DW_RLE_end_of_list = DWARF5_RNGLISTS.end_of_list
local DW_RLE_start_length = DWARF5_RNGLISTS.start_length
-- File index 11 in the existing main line unit is hello_gte_tape.c.
-- The injector extends that unit rather than appending an unreferenced unit.
local ATOM_SOURCE_FILE_INDEX = 11
-- Path templates for the .bin outputs are now in SECTION_WRITERS (see below).
-- Default basename if not provided via ctx.
local DEFAULT_BASENAME = "hello_gte"
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class DwarfInjectionCtx
--- @field flags table -- ctx.flags; reads flags.elf_path + flags.dwarf_injection
--- @field out_root string -- output root (e.g. "build/gen")
--- @field basename string -- input ELF basename (default "hello_gte")
-- ════════════════════════════════════════════════════════════════════════════
-- LEB128 encoders
-- ════════════════════════════════════════════════════════════════════════════
--
-- Lifted to `elf_dwarf.uleb128` + `elf_dwarf.sleb128` (F'' refactor).
-- See those helpers for the bit-layout documentation + named constants
-- (LEB_CONT_BIT, LEB_DATA_MASK, SLEB_SIGN_BIT).
-- Local aliases so the line-program encoder (below) can keep its short names.
local uleb128 = elf_dwarf.uleb128
local sleb128 = elf_dwarf.sleb128
-- ════════════════════════════════════════════════════════════════════════════
-- DWARF line-program encoder
-- ════════════════════════════════════════════════════════════════════════════
--- 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:
--- DW_LNS_advance_pc(1 .word = 4 bytes)
--- DW_LNS_advance_line(line - prev_line)
--- DW_LNS_copy
--- DW_LNE_end_sequence
---
--- 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}
--- @return string
local function build_atom_sequence(atom)
local function set_address(addr)
-- Per DWARF5 §6.2.5.3:
-- marker(0) + size(ULEB128, includes sub_opcode byte) + sub_opcode + payload
-- For set_address: size = 1 (sub_opcode) + 4 (addr) = 5
local addr_bytes = elf_dwarf.write_u32_le(addr)
local sub_size = string.char(DW_LNE_set_address) .. addr_bytes
return string.char(DW_LNS_extended) .. uleb128(#sub_size) .. sub_size
end
local function copy_op() return string.char(DW_LNS_copy) end
local function set_file(file_index) return string.char(DW_LNS_set_file) .. uleb128(file_index) end
local function advance_pc(bytes_delta) return string.char(DW_LNS_advance_pc) .. uleb128(bytes_delta) end
local function advance_line(line_delta) return string.char(DW_LNS_advance_line) .. sleb128(line_delta) end
local function end_sequence()
-- size = 1 (just the sub_opcode byte, no payload)
return string.char(DW_LNS_extended)
.. string.char(DWARF_LINE_OPS.end_sequence_payload_size)
.. string.char(DW_LNE_end_sequence)
end
if not atom.entries or #atom.entries == 0 then return set_address(atom.addr) .. end_sequence() end
-- 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.
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
}
local prev_line = atom.entries[1].line
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
end
parts[#parts + 1] = end_sequence()
return table.concat(parts)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Helpers: nm + source-map.txt + atom table
-- ════════════════════════════════════════════════════════════════════════════
--- Build the atom table the section builders consume.
--- Cross-references nm symbols with source-map.txt entries; sorted by addr.
--- @param ctx DwarfInjectionCtx
--- @return table[] -- list of {name, addr, size_bytes, words, entries}
local function build_atom_table(ctx)
local basename = ctx.basename or DEFAULT_BASENAME
-- Source-map path: convention matches the α MVP's emission location.
-- writes `<out_root>/<basename>.atoms.sourcemap.txt` (e.g. `build/gen/hello_gte_tape.atoms.sourcemap.txt`).
-- But ctx.out_root is `build/gen` (the per-build output root) and basename defaults to `hello_gte`.
-- The actual file emitted today is per-source; we look for any `*.atoms.sourcemap.txt` in out_root.
local sm_files = duffle.list_dir(ctx.out_root, "%.atoms.sourcemap%.txt$")
if #sm_files == 0 then
io.stderr:write(string.format(
"[dwarf_injection] no *.atoms.sourcemap.txt in %s; need atoms-source-map pass first\n",
ctx.out_root))
return {}
end
-- Read nm + merge all source-map files.
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local merged = {}
for _, sm_path in ipairs(sm_files) do
local sm = elf_dwarf.parse_source_map_file(sm_path, 1)
for name, sm_data in pairs(sm) do
merged[name] = sm_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] = {
name = name,
addr = info[1],
size_bytes = info[2],
words = sm.total,
entries = sm.words,
}
end
end
table.sort(out, function(a, b) return a.addr < b.addr end)
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Section builders (single dispatch table — see guide_metaprogram_ssdl.md §11)
-- ════════════════════════════════════════════════════════════════════════════
--- Append per-atom line-program sequences to the existing main .debug_line unit
--- (the final unit, referenced by the main CU's DW_AT_stmt_list).
---
--- The old implementation appended a new Unit 3.
--- No compilation unit pointed at it through DW_AT_stmt_list, so gdb ignored it.
--- It also encoded byte 13 as the extended-opcode marker; byte 13 is actually the first special opcode.
--- The existing final unit already contains hello_gte_tape.c as file index 11 and ends with a valid end_sequence.
--- We preserve its bytes, append independent atom sequences, and increase only that unit's DWARF32 unit_length.
---
--- @param existing string -- existing section bytes (verbatim)
--- @param atom_table table -- list of {name, addr, size_bytes, words, entries}
--- @return string
local function build_dwarf_line_section(existing, atom_table)
if #atom_table == 0 then return existing end
-- Build the sequences.
local sequences = {}
for _, atom in ipairs(atom_table) do
sequences[#sequences + 1] = build_atom_sequence(atom)
end
local appended = table.concat(sequences)
-- Walk DWARF32 line units and retain the final unit's bounds.
-- The main C CU points at this final unit (DW_AT_stmt_list = 0x5b in today's ELF).
local unit_pos, last_pos, last_length, last_end = 1, nil, nil, nil
while unit_pos <= #existing do
if unit_pos + 3 > #existing then return existing end
local unit_length = elf_dwarf.read_u32_le(existing, unit_pos)
if unit_length == elf_dwarf.ELF32.dw_dwarf32_terminator then return existing end
local unit_end = unit_pos + 3 + unit_length
if unit_end > #existing then return existing end
last_pos, last_length, last_end = unit_pos, unit_length, unit_end
unit_pos = unit_end + 1
end
if unit_pos ~= #existing + 1 or not last_pos then return existing end
local new_length = last_length + #appended
local new_length_bytes = elf_dwarf.write_u32_le(new_length)
return existing:sub(1, last_pos - 1)
.. new_length_bytes
.. existing:sub(last_pos + 4, last_end)
.. appended
end
--- Extend .debug_aranges with one entry per atom, pointing at the same CU the existing entries point at
--- (read from the existing header's debug_info_offset field).
--- The 8-byte zero terminator is preserved.
---
--- Existing .debug_aranges layout (DWARF4 §6.1.1, 32-bit):
--- unit_length (4 bytes) -- size of the rest
--- version (2 bytes) -- = 2
--- debug_info_offset (4 bytes) -- CU DIE offset in .debug_info
--- address_size (1 byte) -- = 4 on MIPS
--- segment_size (1 byte) -- = 0
--- [entries...] -- address(4) + length(4) per entry
--- terminator -- address=0 + length=0 (8 zero bytes)
---
--- @param existing string
--- @param atom_table table
--- @return string
local function build_dwarf_aranges_section(existing, atom_table)
if #existing < 12 then return existing end -- header sanity
-- .debug_aranges can contain multiple compilation units (CUs).
-- gcc-mips-elf emits one CU per .text section TU.
-- We extend the LAST unit by replacing its 8-byte terminator with our atom entries followed by a new 8-byte terminator.
-- We bump the unit's length field accordingly.
--
-- Unit structure (DWARF4 §7.21):
-- unit_length (4)
-- version (2)
-- debug_info_offset (4) -- CU DIE offset in .debug_info
-- address_size (1)
-- segment_size (1)
-- entries... (4-byte addr + 4-byte length)
-- terminator (8 bytes: addr=0, length=0)
-- Walk all units and emit each one (preserving existing structure).
-- For the LAST unit, replace the terminator with my entries + new term.
local result = {}
local i = 1 -- 1-indexed
local is_last_unit = false
while i <= #existing do
-- Read this unit's length.
local ul = elf_dwarf.read_u32_le(existing, i)
if ul == elf_dwarf.ELF32.dw_dwarf32_terminator then
-- DWARF64 marker - not supported.
return existing
end
local unit_start = i
local unit_end = i + 3 + ul -- last byte of unit content
is_last_unit = (unit_end == #existing)
if is_last_unit then
-- The old terminator is replaced by entries + a new terminator, so
-- net section growth (and unit_length growth) is entries only.
local added_bytes = #atom_table * elf_dwarf.DWARF4_ARANGES.entry_size
local new_ul = ul + added_bytes
local new_ul_bytes = elf_dwarf.write_u32_le(new_ul)
-- Emit everything EXCEPT the last 8 bytes (terminator).
result[#result + 1] = new_ul_bytes
.. existing:sub(i + 4, unit_end - elf_dwarf.DWARF4_ARANGES.terminator_size)
-- Append my atom entries.
for _, atom in ipairs(atom_table) do
local a = atom.addr
local size = atom.size_bytes
result[#result + 1] = elf_dwarf.write_u32_le(a) .. elf_dwarf.write_u32_le(size)
end
-- Append a new terminator.
result[#result + 1] = string.rep("\0", elf_dwarf.DWARF4_ARANGES.terminator_size)
else
-- Emit this unit unchanged.
result[#result + 1] = existing:sub(unit_start, unit_end)
end
i = unit_end + 1
end
if not is_last_unit then
-- Malformed or no terminator found; append a new unit at the end.
-- For now, return existing unchanged to avoid making it worse.
return existing
end
return table.concat(result)
end
--- Extend the main CU's DWARF5 range list with one DW_RLE_start_length entry per atom.
--- GDB validates an address against DW_AT_ranges before consulting the CU's line program;
--- .debug_aranges alone is not sufficient.
---
--- Current section shape (one DWARF32 table):
--- unit_length(4), version=5(2), address_size=4(1), segment_size=0(1),
--- offset_entry_count=0(4), start_length entries..., end_of_list(1).
---
--- @param existing string
--- @param atom_table table
--- @return string
local function build_dwarf_rnglists_section(existing, atom_table)
if #existing < elf_dwarf.DWARF5_RNGLISTS.first_entry_offset or #atom_table == 0 then return existing end
local unit_length = elf_dwarf.read_u32_le(existing, elf_dwarf.DWARF5_RNGLISTS.unit_length_offset)
local version = elf_dwarf.read_u16_le(existing, elf_dwarf.DWARF5_RNGLISTS.version_offset)
local address_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.addr_size_offset)
local segment_size = existing:byte(elf_dwarf.DWARF5_RNGLISTS.seg_size_offset)
local offset_entry_count = elf_dwarf.read_u32_le(existing, elf_dwarf.DWARF5_RNGLISTS.offset_count_offset)
if unit_length + 4 ~= #existing
or version ~= elf_dwarf.DWARF5_RNGLISTS.version_expected
or address_size ~= elf_dwarf.DWARF5_RNGLISTS.addr_size_expected
or segment_size ~= elf_dwarf.DWARF5_RNGLISTS.seg_size_expected
or offset_entry_count ~= elf_dwarf.DWARF5_RNGLISTS.offset_count_expected
or existing:byte(#existing) ~= DW_RLE_end_of_list then
return existing
end
local entries = {}
for _, atom in ipairs(atom_table) do
entries[#entries + 1] = string.char(DW_RLE_start_length)
.. elf_dwarf.write_u32_le(atom.addr)
.. uleb128(atom.size_bytes)
end
local appended = table.concat(entries)
local new_length = unit_length + #appended
local new_length_bytes = elf_dwarf.write_u32_le(new_length)
return new_length_bytes
.. existing:sub(5, #existing - 1)
.. appended
.. string.char(DW_RLE_end_of_list)
end
local SECTION_BUILDERS = {
debug_line = build_dwarf_line_section,
debug_aranges = build_dwarf_aranges_section,
debug_rnglists = build_dwarf_rnglists_section,
}
-- Per-section output path resolver (mirrors SECTION_BUILDERS).
-- Returns the on-disk path for the section's `.bin` blob.
local SECTION_WRITERS = {
debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end,
debug_aranges = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_aranges.bin" end,
debug_rnglists = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_rnglists.bin" end,
}
-- ════════════════════════════════════════════════════════════════════════════
-- Pass entry
-- ════════════════════════════════════════════════════════════════════════════
local M = {}
--- M.run — orchestrator entry. Phase 1 Tasks 3-4 read + cross-ref. Phase 2
--- Tasks 5-7 fill in the section builders + .bin emission.
--- @param ctx DwarfInjectionCtx
--- @return table
function M.run(ctx)
-- Guard: this pass is opt-in via --dwarf-injection (not run on --all).
if not (ctx.flags and ctx.flags.dwarf_injection) then
return { outputs = {}, errors = {}, warnings = {} }
end
-- Guard: --elf is required.
local elf_path = ctx.flags and ctx.flags.elf_path
if not elf_path or elf_path == "" then
io.stderr:write("[dwarf_injection] --elf flag missing\n")
return { outputs = {}, errors = {}, warnings = {} }
end
-- Resolve relative ELF path to absolute via the canonical duffle helper.
elf_path = duffle.to_absolute_path(elf_path)
-- Read the existing DWARF sections directly (no subprocess; lfs + io.open + manual ELF32 section-header walk).
local existing_sections = elf_dwarf.read_elf_sections(elf_path, {".debug_line", ".debug_aranges", ".debug_rnglists"})
local existing_line = existing_sections[".debug_line"] or ""
local existing_aranges = existing_sections[".debug_aranges"] or ""
local existing_rnglists = existing_sections[".debug_rnglists"] or ""
io.stderr:write(string.format("[dwarf_injection] read .debug_line (%d) + .debug_aranges (%d) + .debug_rnglists (%d) bytes\n"
, #existing_line, #existing_aranges, #existing_rnglists))
-- Build the atom table (cross-ref nm symbols with source-map.txt entries).
local atom_table = build_atom_table(ctx)
io.stderr:write(string.format("[dwarf_injection] matched %d atoms between nm + source-map\n", #atom_table))
-- Write the .bin files. The build_psyq.ps1 post-link hook splices these into a copy of the ELF via objcopy --update-section.
local basename = ctx.basename or DEFAULT_BASENAME
if ctx.out_root and ctx.out_root ~= "" then
duffle.ensure_dir(ctx.out_root)
-- Build + write each section via the SECTION_BUILDERS / SECTION_WRITERS dispatch.
local outputs = {}
for name, builder in pairs(SECTION_BUILDERS) do
local existing = existing_sections["." .. name] or ""
local bytes = builder(existing, atom_table)
local path = SECTION_WRITERS[name](ctx.out_root, basename)
io.stderr:write(string.format("[dwarf_injection] new .%s = %d bytes (was %d, +%d atoms)\n"
, name, #bytes, #existing, #atom_table))
local f = io.open(path, "wb")
if not f then
io.stderr:write(string.format("[dwarf_injection] failed to open %s for write\n", path))
else
f:write(bytes); f:close()
outputs[#outputs + 1] = { [name .. "_bin"] = path }
end
end
return {
outputs = outputs,
errors = {},
warnings = {},
}
end
return { outputs = {}, errors = {}, warnings = {} }
end
return M
+1 -1
View File
@@ -116,7 +116,7 @@ local function scan_binds_fields(body)
local field_ident, field_end = duffle.read_ident(body, duffle.skip_ws_and_cmt(body, type_end))
if field_ident then
fields[#fields + 1] = { name = field_ident, offset = byte_off }
byte_off = byte_off + 4
byte_off = byte_off + 0x04 -- U4 field = 4 bytes (= 1 .word)
end
body_pos = field_end or (type_end + 1)
else
+1 -1
View File
@@ -476,7 +476,7 @@ local function check_abi_handoff(atom, pipe_ctx, findings)
atom = atom.name, line = atom.line,
check = "abi_handoff", kind = "error",
msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)",
atom.name, atom.line, binds_name, binds_name, binds.bytes, binds.bytes / 4),
atom.name, atom.line, binds_name, binds_name, binds.bytes, binds.bytes / 0x04),
}
end
end
+26 -6
View File
@@ -155,6 +155,17 @@ local PASSES = {
desc = "Emit gen/<basename>.atoms.sourcemap.txt (per-.word C source line map for gdb debugging)",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.atoms.sourcemap.txt" } },
},
["dwarf-injection"] = {
module = "passes.dwarf_injection",
kind = "shared",
deps = {"atoms-source-map"},
desc = "Inject per-atom .debug_line + .debug_aranges into the ELF (post-link; writes section .bin blobs for objcopy splice)",
out = {
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_line.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_aranges.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_rnglists.bin" },
},
},
report = {
module = "passes.report",
kind = "report",
@@ -180,6 +191,7 @@ local PASS_FLAG_TO_NAME = {
["--offsets"] = "offsets",
["--static-analysis"] = "static-analysis",
["--atoms-source-map"] = "atoms-source-map",
["--dwarf-injection"] = "dwarf-injection",
["--report"] = "report",
["--scan-source"] = "scan-source",
["--all"] = ALL_PASSES_SENTINEL,
@@ -187,7 +199,7 @@ local PASS_FLAG_TO_NAME = {
local ALL_PASS_NAMES = {
"scan-source", "word-counts", "components", "annotation",
"offsets", "static-analysis", "atoms-source-map", "report",
"offsets", "static-analysis", "atoms-source-map", "dwarf-injection", "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.
@@ -199,8 +211,8 @@ local function request_all_passes(args)
end
-- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it).
-- Returning nil + os.exit() handles termination flags (--help). This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep
-- and made the dispatch logic hard to scan.
-- Returning nil + os.exit() handles termination flags (--help).
-- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan.
local FLAG_HANDLERS = {}
-- ════════════════════════════════════════════════════════════════════════════
@@ -221,6 +233,7 @@ PASS_FLAGS (pick one or more, or use --all):
--validate Run atom annotation DSL validation
--offsets Generate <module>/gen/<basename>.offsets.h
--atoms-source-map Generate <basename>.atoms.sourcemap.txt per source
--dwarf-injection Inject per-atom .debug_line + .debug_aranges (post-link, requires --elf)
--static-analysis [FUTURE] GTE pipeline-fill, mac_yield uniformity
--report Render per-project summary
--all Equivalent to all 6 flags above (default)
@@ -231,7 +244,8 @@ COMMON_FLAGS:
--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 <out_root>/gdb_tape_atoms_runtime.gdb (post-link, requires --elf)
--elf PATH Path to linked .elf (for --gdb-runtime address resolution)
--dwarf-injection Opt in to DWARF injection (writes <basename>.dwarf_*.bin blobs for objcopy splice; requires --elf)
--elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection)
--dry-run Print dep order + ASCII graph; exit 0 without running
--verbose Print per-pass debug output
--help Show this help and exit
@@ -269,6 +283,12 @@ FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_roo
-- 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
-- F' track: enable DWARF injection (default OFF; opt-in via .vscode/launch.json or ps1_meta CLI).
FLAG_HANDLERS["--dwarf-injection"] = function(args)
args.flags = args.flags or {}
args.flags.dwarf_injection = true
args.requested_set[#args.requested_set + 1] = "dwarf-injection"
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)
@@ -582,8 +602,8 @@ local function render_dep_graph(passes, requested, closed)
add(" |<base>.macs.h | |<base>.errors | |<base>.offsets| |<base>.static |")
add(" | (header) | | .h | | .h | | _analysis |")
add(" +------+-------+ | +annot.txt | | (header) | | .txt |")
add(" | +------+-------+ +--------------+ +------+--------+")
add(" v v v")
add(" | +--+-----------+ +--------------+ +------+--------+")
add(" v v v")
add(" +------+----------------+ +------+-------+ |")
add(" |offsets|static-analysis| |report| |<--------------------+")
add(" | | | +------+-------+")