From 904889b483a42adf87d0df4fe4524c487475f3ea Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 12 Jul 2026 15:14:59 -0400 Subject: [PATCH] general review post-dwarf_injection.lua working --- scratch/pcsx_out.log | 0 scripts/audit_lua_nesting.lua | 24 +- scripts/duffle.lua | 203 +++++------ scripts/elf_dwarf.lua | 527 ++++++++++++++++++++++++++++ scripts/passes/atoms_source_map.lua | 24 +- scripts/passes/dwarf_injection.lua | 520 ++++++--------------------- scripts/passes/scan_source.lua | 2 +- scripts/passes/static_analysis.lua | 2 +- scripts/ps1_meta.lua | 4 +- 9 files changed, 738 insertions(+), 568 deletions(-) create mode 100644 scratch/pcsx_out.log create mode 100644 scripts/elf_dwarf.lua diff --git a/scratch/pcsx_out.log b/scratch/pcsx_out.log new file mode 100644 index 0000000..e69de29 diff --git a/scripts/audit_lua_nesting.lua b/scripts/audit_lua_nesting.lua index 9a6e458..5bf58a8 100644 --- a/scripts/audit_lua_nesting.lua +++ b/scripts/audit_lua_nesting.lua @@ -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 diff --git a/scripts/duffle.lua b/scripts/duffle.lua index d4e9896..15e3330 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -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 -- 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: ... ( ... - elseif c == BYTE_OPEN_BRACE then local _, a = M.read_braces(body, pos); pos = a -- scan: ... { ... + if c == BYTE_OPEN_PAREN then local _, a = M.read_parens(body, pos); pos = a -- scan: ... ( ... + elseif c == BYTE_OPEN_BRACE then local _, a = M.read_braces(body, pos); pos = a -- scan: ... { ... elseif c == BYTE_OPEN_BRACK then local _, a = M.read_brackets(body, pos); pos = a -- scan: ... ( ... elseif c == BYTE_COMMA then -- scan: ... , ... @@ -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 diff --git a/scripts/elf_dwarf.lua b/scripts/elf_dwarf.lua new file mode 100644 index 0000000..f1f2d9e --- /dev/null +++ b/scripts/elf_dwarf.lua @@ -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(" +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_` 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 +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_` 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 `*.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 +--- ATOM "" +--- WORD LINE TEXT +--- ... +--- ENDATOM +--- ``` +--- +--- **Conventions:** the in-memory shape uses `{pos, line, text}` +--- (`atoms_source_map.lua:142`); the `.txt` file uses `WORD ` so the parser maps `n` → `pos` field name. +--- @param sm_path Path +--- @param expected_version integer -- expected FORMAT_VERSION line +--- @return 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 "" + 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 LINE 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 diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index 4990456..2b82019 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -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 diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 4ff566b..4d00474 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -24,52 +24,46 @@ -- 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. +-- 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") --- Native `lfs` for file/directory operations. +-- 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 (per raddebugger's `dwarf_writer.c`). --- Standard opcodes 1-12 are single-byte + payload. -local DW_LNS_copy = 1 -local DW_LNS_advance_pc = 2 -local DW_LNS_advance_line = 3 -local DW_LNS_set_file = 4 +-- 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 --- Byte 0 introduces every extended opcode. `opcode_base` is instead the first --- special opcode and must never be used as the extended marker. -local DW_LNS_extended = 0 +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 --- Extended sub-opcodes (encoded as a single byte AFTER the ULEB128 size). -local DW_LNE_end_sequence = 1 -local DW_LNE_set_address = 2 +local DW_RLE_end_of_list = DWARF5_RNGLISTS.end_of_list +local DW_RLE_start_length = DWARF5_RNGLISTS.start_length --- DWARF5 range-list entry encodings used by the main CU. -local DW_RLE_end_of_list = 0 -local DW_RLE_start_length = 7 - --- 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. +-- 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. -local function dwarf_line_path(out_root, basename) - return out_root .. "\\" .. basename .. ".dwarf_line.bin" -end -local function dwarf_aranges_path(out_root, basename) - return out_root .. "\\" .. basename .. ".dwarf_aranges.bin" -end -local function dwarf_rnglists_path(out_root, basename) - return out_root .. "\\" .. basename .. ".dwarf_rnglists.bin" -end +-- 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" @@ -86,38 +80,14 @@ local DEFAULT_BASENAME = "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). ---- ULEB128 encoder. Returns the byte string. ---- @param n integer -- non-negative ---- @return string -local function uleb128(n) - assert(n >= 0, "uleb128 requires non-negative input") - local bytes = {} - repeat - local b = n % 128 - n = (n - b) / 128 - if n > 0 then b = b + 128 end - bytes[#bytes + 1] = string.char(b) - until n == 0 - return table.concat(bytes) -end - ---- SLEB128 encoder. Returns the byte string. ---- @param n integer ---- @return string -local function sleb128(n) - local bytes = {} - local more = true - while more do - local b = n % 128 - n = (n - b) / 128 - if n == 0 and (b % 128) < 64 then more = false end - if n == -1 and (b % 128) >= 64 then more = false end - if more then b = b + 128 end - bytes[#bytes + 1] = string.char(b) - end - return table.concat(bytes) -end +-- 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 @@ -144,34 +114,22 @@ local function build_atom_sequence(atom) -- 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 = string.char( - addr % 256, - math.floor(addr / 256) % 256, - math.floor(addr / 65536) % 256, - math.floor(addr / 16777216) % 256) + 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 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(1) .. string.char(DW_LNE_end_sequence) + 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 + 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). @@ -186,7 +144,7 @@ local function build_atom_sequence(atom) local prev_line = atom.entries[1].line for idx = 2, #atom.entries do local entry = atom.entries[idx] - parts[#parts + 1] = advance_pc(4) -- 1 .word = 4 bytes on MIPS + 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 @@ -195,217 +153,10 @@ local function build_atom_sequence(atom) return table.concat(parts) end --- ════════════════════════════════════════════════════════════════════════════ --- ELF32 direct reader --- ════════════════════════════════════════════════════════════════════════════ - ---- Read the existing `.debug_line` + `.debug_aranges` + `.debug_rnglists` bytes by walking the ---- ELF32 section-header table directly (no subprocess). ---- lfs is used only for the existence check; io.open + manual byte arithmetic does the rest. ---- ---- ELF32 header layout (52 bytes): ---- off 0: magic \x7fELF ---- off 4: class (1 = ELF32), endianness (1 = LE, 2 = BE) ---- ... ---- off 16: e_type (2), e_machine (2), e_version (4) ---- off 24: e_entry (4), e_phoff (4) ---- off 32: e_shoff (4) <-- section-header table file offset ---- off 36: e_flags (4), e_ehsize (2) ---- off 42: e_phentsize (2), e_phnum (2) ---- off 46: e_shentsize (2) <-- section-header entry size (40 for ELF32) ---- off 48: e_shnum (2) <-- number of section headers ---- off 50: e_shstrndx (2) <-- index of section-name string table ---- ---- Section header (40 bytes for ELF32): ---- off 0: sh_name (4) ---- off 4: sh_type (4) ---- off 8: sh_flags (4) ---- off 12: sh_addr (4) ---- off 16: sh_offset (4) <-- file offset of section data ---- off 20: sh_size (4) <-- size of section data ---- off 24: sh_link (4) ---- off 28: sh_info (4) ---- off 32: sh_addralign (4) ---- off 36: sh_entsize (4) ---- ---- Returns the three section byte strings (any may be empty if absent). ---- @param elf_path string ---- @return string, string, string -local function read_elf_dwarf_sections(elf_path) - -- Existence check (lfs.attributes avoids an io.open-vs-fail race). - if lfs.attributes(elf_path, "mode") ~= "file" then - io.stderr:write(string.format("[dwarf_injection] ELF not found: %s\n", elf_path)) - return "", "", "" - end - - local f = io.open(elf_path, "rb") - if not f then - io.stderr:write(string.format("[dwarf_injection] io.open failed: %s\n", elf_path)) - return "", "", "" - end - - -- Read the ELF32 header (52 bytes). - local header = f:read(52) - if not header or #header < 52 then - io.stderr:write("[dwarf_injection] ELF too small for ELF32 header\n") - f:close() - return "", "", "" - end - - -- Sanity-check magic + class + endianness. - if header:sub(1, 4) ~= "\127ELF" then - io.stderr:write("[dwarf_injection] not an ELF file\n") - f:close() - return "", "", "" - end - local class = header:byte(5) - local endian = header:byte(6) - if class ~= 1 then - io.stderr:write(string.format("[dwarf_injection] not ELF32 (class=%d)\n", class)) - f:close() - return "", "", "" - end - if endian ~= 1 then - io.stderr:write(string.format("[dwarf_injection] not little-endian (endian=%d); unsupported\n", endian)) - f:close() - return "", "", "" - end - - -- Parse e_shoff (off 32, 4 bytes LE), e_shentsize - -- (off 46, 2 bytes LE), e_shnum (off 48, 2 bytes LE), e_shstrndx (off 50, 2 bytes LE). - local function u32_le(buf, off) - return buf:byte(off) + buf:byte(off + 1) * 256 + buf:byte(off + 2) * 65536 + buf:byte(off + 3) * 16777216 - end - local function u16_le(buf, off) - return buf:byte(off) + buf:byte(off + 1) * 256 - end - - local e_shoff = u32_le(header, 33) - local e_shentsize = u16_le(header, 47) - local e_shnum = u16_le(header, 49) - local e_shstrndx = u16_le(header, 51) - - -- Read the section-header string table (.shstrtab). - -- Walk to section header index `e_shstrndx`, read its sh_offset + sh_size. - 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("[dwarf_injection] could not read .shstrtab header\n") - f:close() - return "", "", "" - end - local strtab_offset = u32_le(strtab_hdr, 17) - local strtab_size = u32_le(strtab_hdr, 21) - f:seek("set", strtab_offset) - local strtab = f:read(strtab_size) or "" - - -- Walk all section headers; collect names + (offset, size) for the - -- sections we care about. - local function read_section_bytes(sh_offset, sh_size) - f:seek("set", sh_offset) - return f:read(sh_size) or "" - end - - local debug_line_bytes = "" - local debug_aranges_bytes = "" - local debug_rnglists_bytes = "" - - 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 = u32_le(sh, 1) - local sh_offset = u32_le(sh, 17) - local sh_size = u32_le(sh, 21) - - -- 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 name == ".debug_line" then debug_line_bytes = read_section_bytes(sh_offset, sh_size) - elseif name == ".debug_aranges" then debug_aranges_bytes = read_section_bytes(sh_offset, sh_size) - elseif name == ".debug_rnglists" then debug_rnglists_bytes = read_section_bytes(sh_offset, sh_size) - end - end - - f:close() - return debug_line_bytes, debug_aranges_bytes, debug_rnglists_bytes -end - -- ════════════════════════════════════════════════════════════════════════════ -- Helpers: nm + source-map.txt + atom table -- ════════════════════════════════════════════════════════════════════════════ ---- Read ELF symbol addresses via `mipsel-none-elf-nm -S`. ---- Returns `{name -> {addr, size_bytes}}` for every `code_` symbol. ---- Duplicated from passes/atoms_source_map.lua ---- (lift decision in scratch/phase1_helpers_audit.md: keep duplication until 3+ callers). ---- @param elf_path string ---- @return table -local function read_nm(elf_path) - local addrs = {} - local f = io.popen(string.format('mipsel-none-elf-nm -S "%s" 2>nul', elf_path)) - if not f then return addrs end - for line in f:lines() do - 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 - ---- Parse FORMAT_VERSION 1 atoms.sourcemap.txt. ---- Returns `{name -> {total = N, words = {{n, line}, ...}}}`. ---- Matches the in-memory shape ---- (atoms_source_map.lua:142 uses `{pos, line, text}`; the `.txt` file uses `WORD ` so the parser maps `n` -> `pos`). ---- @param sm_path string ---- @return table -local function read_source_map(sm_path) - local out = {} - local cur_name, cur_total, cur_words = nil, 0, {} - for raw in io.lines(sm_path) do - local line = raw - if line:match("^#") then - if line:match("^# FORMAT_VERSION%s+(%d+)") then - local ver = tonumber(line:match("^# FORMAT_VERSION%s+(%d+)")) - if ver ~= 1 then - io.stderr:write(string.format( - "[dwarf_injection] source-map version mismatch (got %d, expected 1)\n", ver)) - return {} - end - end - -- skip other comments - elseif line:sub(1, 4) == "ATOM" then - -- ATOM "" - -- The path is quoted. Format per emission: string.format('ATOM %s "%s" 0', name, rel_path) - -- then patched: .gsub(" 0$", " " .. total). - -- So the wire format is: ATOM "" . - local _, _, name = line:find("ATOM%s+(%S+)%s+\"[^\"]*\"%s+(%d+)") - if name then - cur_name = name - cur_total = 0 - 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_total, cur_words = nil, 0, {} - elseif line:sub(1, 4) == "WORD" and cur_name then - -- WORD LINE 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] = { n = tonumber(n), line = tonumber(src_line) } - end - end - end - return out -end - --- Build the atom table the section builders consume. --- Cross-references nm symbols with source-map.txt entries; sorted by addr. --- @param ctx DwarfInjectionCtx @@ -413,20 +164,10 @@ end local function build_atom_table(ctx) local basename = ctx.basename or DEFAULT_BASENAME -- Source-map path: convention matches the α MVP's emission location. - -- writes `/.atoms.sourcemap.txt` (e.g. `build/gen/hello_gte_tape.atoms.sourcemap.txt`). + -- writes `/.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 = {} - if duffle and duffle.list_dir then - sm_files = duffle.list_dir(ctx.out_root, "%.atoms.sourcemap%.txt$") - elseif lfs then - -- Manual walk via lfs. - for entry in lfs.dir(ctx.out_root) do - if entry:match("%.atoms.sourcemap%.txt$") then - sm_files[#sm_files + 1] = ctx.out_root .. "\\" .. entry - end - end - end + 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", @@ -435,10 +176,10 @@ local function build_atom_table(ctx) end -- Read nm + merge all source-map files. - local addrs = read_nm(ctx.flags.elf_path) + local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) local merged = {} for _, sm_path in ipairs(sm_files) do - local sm = read_source_map(sm_path) + local sm = elf_dwarf.parse_source_map_file(sm_path, 1) for name, sm_data in pairs(sm) do merged[name] = sm_data end @@ -493,11 +234,8 @@ local function build_dwarf_line_section(existing, atom_table) 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 = existing:byte(unit_pos) - + existing:byte(unit_pos + 1) * 256 - + existing:byte(unit_pos + 2) * 65536 - + existing:byte(unit_pos + 3) * 16777216 - if unit_length == 0xFFFFFFFF 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 @@ -506,11 +244,7 @@ local function build_dwarf_line_section(existing, atom_table) if unit_pos ~= #existing + 1 or not last_pos then return existing end local new_length = last_length + #appended - local new_length_bytes = string.char( - new_length % 256, - math.floor(new_length / 256) % 256, - math.floor(new_length / 65536) % 256, - math.floor(new_length / 16777216) % 256) + local new_length_bytes = elf_dwarf.write_u32_le(new_length) return existing:sub(1, last_pos - 1) .. new_length_bytes @@ -559,11 +293,8 @@ local function build_dwarf_aranges_section(existing, atom_table) while i <= #existing do -- Read this unit's length. - local ul = existing:byte(i) - + existing:byte(i + 1) * 256 - + existing:byte(i + 2) * 65536 - + existing:byte(i + 3) * 16777216 - if ul == 0xFFFFFFFF then + 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 @@ -575,32 +306,20 @@ local function build_dwarf_aranges_section(existing, atom_table) 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 * 8 - local new_ul = ul + added_bytes - local new_ul_bytes = string.char( - new_ul % 256, - math.floor(new_ul / 256) % 256, - math.floor(new_ul / 65536) % 256, - math.floor(new_ul / 16777216) % 256) + 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 - 8) + .. 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] = string.char( - a % 256, - math.floor(a / 256) % 256, - math.floor(a / 65536) % 256, - math.floor(a / 16777216) % 256, - size % 256, - math.floor(size / 256) % 256, - math.floor(size / 65536) % 256, - math.floor(size / 16777216) % 256) + 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", 8) + 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) @@ -630,46 +349,32 @@ end --- @param atom_table table --- @return string local function build_dwarf_rnglists_section(existing, atom_table) - if #existing < 13 or #atom_table == 0 then return existing end + if #existing < elf_dwarf.DWARF5_RNGLISTS.first_entry_offset or #atom_table == 0 then return existing end - local unit_length = existing:byte(1) - + existing:byte(2) * 256 - + existing:byte(3) * 65536 - + existing:byte(4) * 16777216 - local version = existing:byte(5) + existing:byte(6) * 256 - local address_size = existing:byte(7) - local segment_size = existing:byte(8) - local offset_entry_count = existing:byte(9) - + existing:byte(10) * 256 - + existing:byte(11) * 65536 - + existing:byte(12) * 16777216 + 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 ~= 5 - or address_size ~= 4 - or segment_size ~= 0 - or offset_entry_count ~= 0 + 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 - local addr = atom.addr - entries[#entries + 1] = string.char(DW_RLE_start_length, - addr % 256, - math.floor(addr / 256) % 256, - math.floor(addr / 65536) % 256, - math.floor(addr / 16777216) % 256) + 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 = string.char( - new_length % 256, - math.floor(new_length / 256) % 256, - math.floor(new_length / 65536) % 256, - math.floor(new_length / 16777216) % 256) + local new_length_bytes = elf_dwarf.write_u32_le(new_length) return new_length_bytes .. existing:sub(5, #existing - 1) @@ -683,6 +388,14 @@ local SECTION_BUILDERS = { 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 -- ════════════════════════════════════════════════════════════════════════════ @@ -706,16 +419,14 @@ function M.run(ctx) return { outputs = {}, errors = {}, warnings = {} } end - -- Resolve relative ELF path to absolute (io.open inherits the calling - -- process's CWD which is reliable, but absolute paths are safer when - -- this module is required from another directory). - if not elf_path:match("^[%a]:[\\/]") and not elf_path:match("^[/\\]") then - local cwd = lfs.currentdir() - elf_path = cwd:gsub("[\\/]$", "") .. "\\" .. elf_path - 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_line, existing_aranges, existing_rnglists = read_elf_dwarf_sections(elf_path) + 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)) @@ -723,52 +434,31 @@ function M.run(ctx) local atom_table = build_atom_table(ctx) io.stderr:write(string.format("[dwarf_injection] matched %d atoms between nm + source-map\n", #atom_table)) - -- Build the new sections via the SECTION_BUILDERS dispatch. - local new_line = build_dwarf_line_section (existing_line, atom_table) - local new_aranges = build_dwarf_aranges_section (existing_aranges, atom_table) - local new_rnglists = build_dwarf_rnglists_section(existing_rnglists, atom_table) - io.stderr:write(string.format("[dwarf_injection] new .debug_line = %d bytes (was %d, +%d atoms)\n" - , #new_line, #existing_line, #atom_table)) - io.stderr:write(string.format("[dwarf_injection] new .debug_aranges = %d bytes (was %d, +%d atoms)\n" - , #new_aranges, #existing_aranges, #atom_table)) - io.stderr:write(string.format("[dwarf_injection] new .debug_rnglists = %d bytes (was %d, +%d atoms)\n" - , #new_rnglists, #existing_rnglists, #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 - if lfs.attributes(ctx.out_root, "mode") ~= "directory" then - lfs.mkdir(ctx.out_root) + 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 - local line_path = dwarf_line_path (ctx.out_root, basename) - local aranges_path = dwarf_aranges_path (ctx.out_root, basename) - local rnglists_path = dwarf_rnglists_path(ctx.out_root, basename) - local f1 = io.open(line_path, "wb") - if not f1 then - io.stderr:write(string.format("[dwarf_injection] failed to open %s for write\n", line_path)) - else - f1:write(new_line); f1:close() - end - local f2 = io.open(aranges_path, "wb") - if not f2 then - io.stderr:write(string.format("[dwarf_injection] failed to open %s for write\n", aranges_path)) - else - f2:write(new_aranges); f2:close() - end - local f3 = io.open(rnglists_path, "wb") - if not f3 then - io.stderr:write(string.format("[dwarf_injection] failed to open %s for write\n", rnglists_path)) - else - f3:write(new_rnglists); f3:close() - end - io.stderr:write(string.format("[dwarf_injection] wrote %s + %s + %s\n", line_path, aranges_path, rnglists_path)) return { - outputs = { - { dwarf_line_bin = line_path }, - { dwarf_aranges_bin = aranges_path }, - { dwarf_rnglists_bin = rnglists_path }, - }, - errors = {}, warnings = {}, + outputs = outputs, + errors = {}, + warnings = {}, } end return { outputs = {}, errors = {}, warnings = {} } diff --git a/scripts/passes/scan_source.lua b/scripts/passes/scan_source.lua index 0e0eebd..41b4d86 100644 --- a/scripts/passes/scan_source.lua +++ b/scripts/passes/scan_source.lua @@ -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 diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index cb09b2c..da960d2 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -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 diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index 5be671d..ca74a17 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -211,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 = {} -- ════════════════════════════════════════════════════════════════════════════