diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 2e004f1..b8b1165 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -138,16 +138,13 @@ end -- Section 0: LPeg patterns (compiled once at module load) -- ════════════════════════════════════════════════════════════════════════════ -- --- LPeg is a required dependency (PEG library, no regex). It's loaded --- via `package.cpath` (configured by `duffle_paths.lua` to find --- `toolchain/lpeg/lpeg.dll`). There's no hand-rolled fallback — the --- original two-tier design added complexity for a 5-10x speedup that's --- only relevant at the high-level scanner stage; the byte-by-byte --- helpers in Section 1 are sufficient for the classification primitives. +-- LPeg is a required dependency (PEG library, no regex). +-- It's loaded via `package.cpath` (configured by `duffle_paths.lua` to find `toolchain/lpeg/lpeg.dll`). +-- There's no hand-rolled fallback. The original two-tier design added complexity for a 5-10x speedup that's +-- only relevant at the high-level scanner stage; the byte-by-byte helpers in Section 1 are sufficient for the classification primitives. -- --- If the require fails, fail loud with an actionable message (per --- lua.md §9). The build script (`update_deps.ps1`) builds lpeg.dll --- into `toolchain/lpeg/`; if it's missing, run `update_deps.ps1`. +-- If the require fails, fail loud with an actionable message. The build script (`update_deps.ps1`) builds lpeg.dll into `toolchain/lpeg/`; +-- if it's missing, run `update_deps.ps1`. local lpeg_ok, lpeg = pcall(require, "lpeg") if not lpeg_ok then io.stderr:write("[duffle] require('lpeg') failed: ", lpeg, "\n") @@ -195,8 +192,7 @@ local lpeg_scan_to_target_pat = function(target) return (P(1) - P(target))^0 en -- 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. +-- 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. function M.is_space_byte(b) return b == BYTE_SPACE or b == BYTE_TAB or b == BYTE_NEWLINE or b == BYTE_CR or b == BYTE_VT or b == BYTE_FF end @@ -301,11 +297,10 @@ function M.write_file(path, content) f:write(content); f:close() end --- Cache of directories already verified to exist in this process. Each --- ensure_dir() call may otherwise spawn a `cmd.exe mkdir` (50-100ms --- per call on Windows) — calling it inside per-source loops added 1.5+ --- seconds to the report pass. Cache makes ensure_dir idempotent within --- the process lifetime (safe across passes; the dir state doesn't change). +-- Cache of directories already verified to exist in this process. +-- Each ensure_dir() call may otherwise spawn a `cmd.exe mkdir` (50-100ms per call on Windows) — calling it inside per-source loops added 1.5+ +-- seconds to the report pass. Cache makes ensure_dir idempotent within the process lifetime. +-- (safe across passes; the dir state doesn't change). local _ensured_dirs = {} function M.ensure_dir(path) @@ -324,8 +319,7 @@ function M._reset_ensured_dirs() _ensured_dirs = {} 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. LPeg-backed. function M.skip_str_or_cmt(s, pos) return lpeg.match(lpeg_str_or_cmt_pat, s, pos) or pos end @@ -336,21 +330,17 @@ 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. +-- 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. function M.read_ident(s, pos) local result = lpeg.match(lpeg_ident_pat, s, pos) if result then return result, pos + #result end return nil, pos end --- Read a balanced-delimited group (parens, braces, or brackets) starting --- at position `pos`. Returns the inner content (between the delimiters) + --- the position just past the closing delimiter, or nil + pos if `s[pos]` --- isn't `open_char`. --- --- (Hand-rolled; the depth counting makes pure LPeg awkward here.) +-- Read a balanced-delimited group (parens, braces, or brackets) starting at position `pos`. +-- Returns the inner content (between the delimiters) + the position +-- just past the closing delimiter, or nil + pos if `s[pos]` isn't `open_char`. function M.read_balanced(s, open_char, close_char, pos) local open_byte = open_char:byte() if s:byte(pos) ~= open_byte then return nil, pos end @@ -380,8 +370,8 @@ M.read_parens = function(s, pos) return M.read_balanced(s, "(", ")", pos) end M.read_braces = function(s, pos) return M.read_balanced(s, "{", "}", pos) end M.read_brackets = function(s, pos) return M.read_balanced(s, "[", "]", pos) end --- Scan forward from position `start` until we find a specific single byte --- `target`, transparently stepping over balanced parens/braces/brackets. +-- Scan forward from position `start` until we find a specific single byte `target`, +-- transparently stepping over balanced parens/braces/brackets. -- Returns the position of `target`, or nil if not found. function M.scan_to_char(s, target, start) local target_byte = target:byte() @@ -403,23 +393,18 @@ end -- Split a brace-body into top-level comma-separated tokens. Honors nested -- parens/braces/brackets and skips strings/comments. -- --- FIX (2026-07-09): split at top-level NEWLINES and SEMICOLONS too, AND --- emit a token break after a top-level comment/string. Previous behavior --- glued the macro call after a comment into the same token, so --- `word_count_of_token` only saw the leading ident (often nil after --- stripping the comment), undercounting the body. See Phase 1 of the --- branch-offset regression investigation. Pure-comment / pure-string --- chunks (which now appear between real statements) are filtered out so --- they contribute 0 words instead of 1. +-- FIX (2026-07-09): split at top-level NEWLINES and SEMICOLONS too, AND emit a token break after a top-level comment/string. +-- Previous behavior glued the macro call after a comment into the same token, so `word_count_of_token` only saw the +-- leading ident (often nil after stripping the comment), undercounting the body. See Phase 1 of the branch-offset regression investigation. +-- Pure-comment / pure-string chunks (which now appear between real statements) are filtered out so they contribute 0 words instead of 1. function M.split_top_level_commas(body) local tokens = {} local pos = 1 local body_len = #body local token_start = 1 - -- True iff `chunk` contains any non-whitespace, non-comment, non-string - -- content (i.e., real token material). Walks through ws + comments - -- individually so a chunk like " /* trailing */ shift_lleft(...)" + -- True iff `chunk` contains any non-whitespace, non-comment, non-string content + -- (i.e., real token material). Walks through ws + comments individually so a chunk like " /* trailing */ shift_lleft(...)" -- is correctly classified as having real content (the macro call). local function has_real_content(chunk) local scan = 1 @@ -447,14 +432,13 @@ function M.split_top_level_commas(body) tokens[#tokens + 1] = chunk elseif #tokens > 0 then -- Pure comment/string chunk at top level (no preceding instruction content within this chunk). - -- APPEND it to the LAST token so emit-context callers (components.lua build_component_lines) + -- APPEND it to the LAST token so emit-context callers (components.lua build_component_lines) -- can convert `// trailing comment` to `/* */` and emit it with the macro body. - -- For word counting, count_token_words only inspects the leading ident, so a trailing comment doesn't - -- affect the count. + -- For word counting, count_token_words only inspects the leading ident, so a trailing comment doesn't affect the count. -- - -- This is the second-half fix to commit 98e27c2: the first fix correctly broke top-level comments + -- This is the second-half fix to commit 98e27c2: the first fix correctly broke top-level comments -- off from the NEXT statement (fixing macro-call word counts); - -- this fix preserves them on the PREVIOUS statement (restoring the comments in the emitted .macs.h output). + -- This fix preserves them on the PREVIOUS statement (restoring the comments in the emitted .macs.h output). tokens[#tokens] = tokens[#tokens] .. chunk end end @@ -540,7 +524,7 @@ function M.LineIndex(source) end -- (internal) Binary-search for the line number containing `query_pos`. local function line_of(query_pos) - local lo, hi = 1, n + local lo, hi = 1, n while lo <= hi do local mid = math.floor((lo + hi) / 2) if positions[mid] <= query_pos then @@ -577,34 +561,23 @@ M.TAPE_ATOM_MACROS = { -- GTE pipeline-fill latency table (static-analysis Phase 1). -- --- For each `gte_cmdw_*` macro in code/duffle/gte.h, the minimum number --- of consecutive COP2 "nop" words that MUST appear before any other --- COP2 read or non-nop instruction (so the GTE pipeline latency is --- fully retired). Latencies are sourced from the doxygen comments --- in gte.h (e.g. `* @brief Rotate, Translate and Perspective Triple --- (23 cycles)` with body `Two nop words fill the COP2 pipeline --- latency`). +-- For each `gte_cmdw_*` macro in code/duffle/gte.h, the minimum number of consecutive COP2 "nop" words that MUST appear +-- before any other COP2 read or non-nop instruction (so the GTE pipeline latency is fully retired). +-- Latencies are sourced from the doxygen comments in gte.h +-- (e.g. `* @brief Rotate, Translate and Perspective Triple (23 cycles)` with body `Two nop words fill the COP2 pipeline latency`). -- --- The check (`scripts/passes/static_analysis.lua :: --- check_gte_pipeline_fill`) walks each atom body, counts the --- consecutive nop words after every `gte_cmdw_*` invocation, and --- reports a finding if the count is below this minimum. Aliases --- are dereferenced before lookup (gté_cmdw_rtps_alias -> --- gte_cmdw_rtps -> 2). --- --- Values verified against PSX-SPX gte.txt (rtpt 23cy / 8cy per divide --- => 2 nops; nclip 8cy => 2 nops; avsz3/avsz4 14cy => 2 nops; op --- single-cycle atomic => 0 nops; mvmva 8cy matrix-vector => 2 nops). +-- The check (`scripts/passes/static_analysis.lua :: check_gte_pipeline_fill`) walks each atom body, +-- counts the consecutive nop words after every `gte_cmdw_*` invocation, and reports a finding if the count is below this minimum. +-- Aliases are dereferenced before lookup (gté_cmdw_rtps_alias -> gte_cmdw_rtps -> 2). +-- +-- Values verified against PSX-SPX gte.txt (rtpt 23cy / 8cy per divide => 2 nops; nclip 8cy => 2 nops; avsz3/avsz4 14cy => 2 nops; +-- op single-cycle atomic => 0 nops; mvmva 8cy matrix-vector => 2 nops). M.GTE_PIPELINE_LATENCY = { - -- Minimum number of consecutive `nop` words that must appear - -- IMMEDIATELY BEFORE a `gte_cmdw_` invocation -- to retire - -- any preceding `lwc2` / `swc2` / pre-existing C2 state writes - -- before the GTE pipeline starts reading from V0/V1/V2 or - -- MAC0..3 / OTZ / IR0..3 at the command's issue cycle. - -- - -- Values are from the doxygen comments in code/duffle/gte.h and - -- cross-checked against PSX-SPX `geometrytransformationenginegte.md`: + -- Minimum number of consecutive `nop` words that must appear IMMEDIATELY BEFORE a `gte_cmdw_` invocation + -- to retire any preceding `lwc2` / `swc2` / pre-existing C2 state writes before the GTE pipeline starts reading + -- from V0/V1/V2 or MAC0..3 / OTZ / IR0..3 at the command's issue cycle. -- + -- Values are from the doxygen comments in code/duffle/gte.h and cross-checked against PSX-SPX `geometrytransformationenginegte.md`: -- cmd cycles min pre-nops rationale -- rtps 14 2 8c per perspective divide + 6c for IR1..4 + mac write -- rptt 22 2 3x rtps worth of pipeline depth @@ -612,24 +585,18 @@ M.GTE_PIPELINE_LATENCY = { -- avsz3 14 2 14c to compute average + write OTZ -- avsz4 16 2 avsz3 + 2c extra for avg over 4 -- mvmva 8 2 IR1..4 write + matrix work - -- op 5 0 output to MAC0 only (atomic 5c calc) + -- op 5 0 output to MAC0 only (atomic 5c calc) -- - -- The `gte_rtpt()` / `gte_nclip()` / `gte_avsz3()` wrapper macros in - -- gte.h emit the pre-cmd nops internally (asm_words(nop, nop, ...)), - -- but THOSE WRAPPERS ARE NOT USED INSIDE ATOM BODIES in this - -- codebase. Every MipsAtom_(name) body uses raw `nop2, - -- gte_cmdw_, ...` form instead -- that `nop2,` is the pre-fill - -- this check validates. So values here must reflect the source-level - -- convention, NOT the wrapper-internal pre-fill (which is invisible - -- at the source level). + -- The `gte_rtpt()` / `gte_nclip()` / `gte_avsz3()` wrapper macros in gte.h emit the pre-cmd nops internally (asm_words(nop, nop, ...)), + -- but THOSE WRAPPERS ARE NOT USED INSIDE ATOM BODIES in this codebase. + -- Every MipsAtom_(name) body uses raw `nop2, gte_cmdw_, ...` form instead -- that `nop2,` is the pre-fill this check validates. + -- So values here must reflect the source-level convention, NOT the wrapper-internal pre-fill (which is invisible at the source level). -- - -- Existing clean-atom bodies (cube_g4_face, floor_f3_face, - -- diag_gte) all emit `nop2,` before every `gte_cmdw_` (which - -- matches values >= 2). The check passes them all. + -- Existing clean-atom bodies (cube_g4_face, floor_f3_face, diag_gte) all emit `nop2,` before every `gte_cmdw_` (which matches values >= 2). + -- The check passes them all. -- - -- Aliases are listed separately because source code may use either - -- the alias or the canonical name. The check looks up the EXACT - -- macro text, so both forms must be in the table. + -- Aliases are listed separately because source code may use either the alias or the canonical name. + -- The check looks up the EXACT macro text, so both forms must be in the table. -- Canonical macros (from code/duffle/gte.h) ["gte_cmdw_rtps"] = 2, @@ -646,7 +613,7 @@ M.GTE_PIPELINE_LATENCY = { ["gte_cmdw_avg_sort_z4"] = 2, -- Outer product aliases (same canonical op, 0 pre-fill nops). - -- gte_cmdw_op = canonical GTE-internal short form + -- gte_cmdw_op = canonical GTE-internal short form -- gte_cmdw_outer_product = NOCASH / SDK-readable form -- gte_cmdw_wedge = geometric-algebra (exterior-product) form ["gte_cmdw_outer_product"] = 0, @@ -656,13 +623,13 @@ M.GTE_PIPELINE_LATENCY = { -- GP0 packet sizes (total words including the 1-word tag) per GP0 cmd byte. -- Verified against code/duffle/gp.h struct sizes + the set_poly_* macros -- (which encode "len" = "words after tag"): --- set_poly_f3(p) -> set_len(p, 4) -> 5 total GP0 0x20 --- set_poly_ft3(p) -> set_len(p, 7) -> 8 total GP0 0x24 --- set_poly_f4(p) -> set_len(p, 5) -> 6 total GP0 0x28 +-- set_poly_f3(p) -> set_len(p, 4) -> 5 total GP0 0x20 +-- set_poly_ft3(p) -> set_len(p, 7) -> 8 total GP0 0x24 +-- set_poly_f4(p) -> set_len(p, 5) -> 6 total GP0 0x28 -- set_poly_ft4(p) -> set_len(p, 9) -> 10 total GP0 0x2C --- set_poly_g3(p) -> set_len(p, 6) -> 7 total GP0 0x30 +-- set_poly_g3(p) -> set_len(p, 6) -> 7 total GP0 0x30 -- set_poly_gt3(p) -> set_len(p, 9) -> 10 total GP0 0x34 --- set_poly_g4(p) -> set_len(p, 8) -> 9 total GP0 0x38 +-- set_poly_g4(p) -> set_len(p, 8) -> 9 total GP0 0x38 -- set_poly_gt4(p) -> set_len(p, 12) -> 13 total GP0 0x3C M.GP0_CMD_SIZE = { [0x20] = 5, -- Poly_F3 @@ -676,8 +643,7 @@ M.GP0_CMD_SIZE = { } -- Shape suffix (after `ac_format_` / `mac_format_` prefix) -> GP0 cmd byte. --- Lets the static-analysis check derive the cmd byte from a macro name --- like `mac_format_g4_color` -> `g4` -> 0x38 -> 9 expected words. +-- Lets the static-analysis check derive the cmd byte from a macro name like `mac_format_g4_color` -> `g4` -> 0x38 -> 9 expected words. M.GP0_CMD_BY_SHAPE = { ["f3"] = 0x20, ["ft3"] = 0x24, ["f4"] = 0x28, ["ft4"] = 0x2C, @@ -685,11 +651,9 @@ M.GP0_CMD_BY_SHAPE = { ["g4"] = 0x38, ["gt4"] = 0x3C, } --- Per-macro prim-buffer contribution (NOT .text instruction count -- --- this is "how many 32-bit words does this macro write to the primitive --- being built in main RAM"). Sum across `mac_format_X_color` + --- `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X` calls in an atom body --- must equal GP0_CMD_SIZE[GP0_CMD_BY_SHAPE[shape]]. +-- Per-macro prim-buffer contribution +-- (NOT .text instruction count this is "how many 32-bit words does this macro write to the primitive being built in main RAM"). +-- Sum across `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X` calls in an atom body must equal GP0_CMD_SIZE[GP0_CMD_BY_SHAPE[shape]]. M.GP0_MACRO_CONTRIB = { ["mac_format_f3_color"] = 1, ["mac_format_g3_color"] = 3, @@ -702,33 +666,28 @@ M.GP0_MACRO_CONTRIB = { ["mac_insert_ot_tag_g4"] = 1, } --- Per-macro cycle cost (best-case, no stalls). Used by the static-analysis --- `count_atom_cycles` pass (Phase 3) to emit per-atom cycle budgets. The --- counts cover the EXPANDED instruction sequence the macro emits (NOT just --- the token it appears as in source). For example: --- +-- Per-macro cycle cost (best-case, no stalls). Used by the static-analysis `count_atom_cycles` pass (Phase 3) to emit per-atom cycle budgets. +-- The counts cover the EXPANDED instruction sequence the macro emits (NOT just the token it appears as in source). +-- For example: -- mac_pack_color_word(off, cmd, r, g, b) emits: -- load_upper_i(R_AT, (cmd << 8) | b) -- 1 cycle -- or_i_self(R_AT, (g << 8) | r) -- 1 cycle -- store_word(R_AT, R_PrimCursor, off) -- 1 cycle -- = 3 cycles total -- --- mac_yield emits a control-transfer sequence (load_word, add_ui_self, --- jump_reg, nop) which "yields control" -- the atom body's cycle budget --- doesn't include the yield's cost (we model it as 0; runtime cost --- becomes part of the NEXT atom's prologue). +-- mac_yield emits a control-transfer sequence (load_word, add_ui_self, jump_reg, nop) +-- which "yields control" the atom body's cycle budget doesn't include the yield's cost (we model it as 0; +-- runtime cost becomes part of the NEXT atom's prologue). -- --- GTE command values are the GTE instruction's intrinsic cycles (the --- latency AFTER any pre-cmd `nop2` has retired). When the source emits --- `nop2, gte_cmdw_X` the nops' cycles are added separately (1+1) plus --- the gte_cmdw_X value here: --- rtpt = 21 + 2 nops = 23 total cycles (matches PSX-SPX) --- rtps = 12 + 2 nops = 14 total --- nclip = 6 + 2 nops = 8 total +-- GTE command values are the GTE instruction's intrinsic cycles (the latency AFTER any pre-cmd `nop2` has retired). +-- When the source emits `nop2, gte_cmdw_X` the nops' cycles are added separately (1+1) plus the gte_cmdw_X value here: +-- rtpt = 21 + 2 nops = 23 total cycles (matches PSX-SPX) +-- rtps = 12 + 2 nops = 14 total +-- nclip = 6 + 2 nops = 8 total -- avsz3 = 12 + 2 nops = 14 total -- avsz4 = 14 + 2 nops = 16 total --- mvmva = 6 + 2 nops = 8 total --- op = 5 (no pre-cmd nops required; single-cycle atomic) +-- mvmva = 6 + 2 nops = 8 total +-- op = 5 (no pre-cmd nops required; single-cycle atomic) M.INSTRUCTION_LATENCY = { -- CPU ALU (single-cycle R3000A ops) ["nop"] = 1, @@ -794,27 +753,27 @@ M.INSTRUCTION_LATENCY = { -- COP2 commands (intrinsic cycles, EXCLUDING the 2 pre-cmd nops that -- the source typically emits as `nop2, gte_cmdw_X`; those nops are -- counted separately via the `nop2` entry above) - ["gte_cmdw_rtpt"] = 21, - ["gte_cmdw_rtps"] = 12, - ["gte_cmdw_nclip"] = 6, - ["gte_cmdw_avsz3"] = 12, - ["gte_cmdw_avsz4"] = 14, - ["gte_cmdw_mvmva"] = 6, - ["gte_cmdw_op"] = 5, + ["gte_cmdw_rtpt"] = 21, + ["gte_cmdw_rtps"] = 12, + ["gte_cmdw_nclip"] = 6, + ["gte_cmdw_avsz3"] = 12, + ["gte_cmdw_avsz4"] = 14, + ["gte_cmdw_mvmva"] = 6, + ["gte_cmdw_op"] = 5, ["gte_cmdw_outer_product"] = 5, - ["gte_cmdw_wedge"] = 5, + ["gte_cmdw_wedge"] = 5, -- Long-form aliases (same cost as canonical) ["gte_cmdw_rotate_translate_perspective_single"] = 12, -- alias for rtps ["gte_cmdw_rotate_translate_perspective_triple"] = 21, -- alias for rtpt - ["gte_cmdw_avg_sort_z4"] = 14, -- alias for avsz4 + ["gte_cmdw_avg_sort_z4"] = 14, -- alias for avsz4 -- Non-cmdw aliases from gte.h (these are `#define gte_X gte_cmdw_Y`): - ["gte_avg_sort_z3"] = 12, -- alias for avsz3 - ["gte_avg_sort_z4"] = 14, -- alias for avsz4 - ["gte_rtps"] = 12, -- alias for rtps - ["gte_rtpt"] = 21, -- alias for rtpt - ["gte_nclip"] = 6, -- alias for nclip - ["gte_avsz3"] = 12, - ["gte_avsz4"] = 14, + ["gte_avg_sort_z3"] = 12, -- alias for avsz3 + ["gte_avg_sort_z4"] = 14, -- alias for avsz4 + ["gte_rtps"] = 12, -- alias for rtps + ["gte_rtpt"] = 21, -- alias for rtpt + ["gte_nclip"] = 6, -- alias for nclip + ["gte_avsz3"] = 12, + ["gte_avsz4"] = 14, -- Legacy single-cycle store helpers (gte_stotz, gte_stsxy3 are 1 cycle) ["gte_stotz"] = 1, ["gte_stsxy3"] = 1, @@ -847,8 +806,8 @@ M.INSTRUCTION_LATENCY = { ["atom_writes"] = 0, } --- Default cycle cost for unknown macros. The static-analysis pass adds 1 --- cycle per unknown token and emits a "new macro; update INSTRUCTION_LATENCY" +-- 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/passes/annotation.lua b/scripts/passes/annotation.lua index 53efd45..3867838 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -187,7 +187,6 @@ local function split_csv_top(s) end --- Split a string into whitespace-separated tokens. ---- Hand-rolled (no regex patterns). --- @param s string --- @return string[] local function split_ws(s) @@ -212,10 +211,8 @@ end -- Parse TAPE_ATOM_ANNOT(...) calls -- ════════════════════════════════════════════════════════════════════════════ --- Recognize a `atom_bind(...)`, `atom_reads(...)`, or `atom_writes(...)` --- sub-call embedded inside an atom_info arg list. --- Returns the kind ("atom_bind" / "atom_reads" / "atom_writes") and the inner content, --- or nil if the token isn't a recognized sub-call form. +-- Recognize a `atom_bind(...)`, `atom_reads(...)`, or `atom_writes(...)` sub-call embedded inside an atom_info arg list. +-- Returns the kind ("atom_bind" / "atom_reads" / "atom_writes") and the inner content, or nil if the token isn't a recognized sub-call form. -- Flattened via a prefix lookup instead of a nested if/elseif chain. local REGS_CALL_PREFIX = { ["atom_writes("] = { kind = "atom_writes", inner_offset = 13 }, @@ -451,9 +448,8 @@ local function parse_binds_body(body) end --- Try to parse a `typedef Struct_(Binds_X) { ... };` declaration. ---- Returns the parsed BindsStruct (if the form matched) and the new ---- source position. If the form didn't match, returns nil + a position ---- to continue scanning from. +--- Returns the parsed BindsStruct (if the form matched) and the new source position. +--- If the form didn't match, returns nil + a position to continue scanning from. --- @param source string --- @param ident_pos integer -- position of the `typedef` ident start --- @param after_typedef integer -- position just past `typedef` @@ -527,10 +523,9 @@ end -- Find every MipsAtom_(name) { ... } declaration in source -- ════════════════════════════════════════════════════════════════════════════ ---- Read the next identifier token from `s` starting at `pos`, where the ---- identifier is a contiguous run of `[a-zA-Z0-9_]` characters (no ---- underscore-starting alpha-only constraint). Returns the ident + the ---- position just past it, or nil + pos if no identifier starts there. +--- Read the next identifier token from `s` starting at `pos`, where the identifier is a contiguous run of `[a-zA-Z0-9_]` +--- characters (no underscore-starting alpha-only constraint). +--- Returns the ident + the position just past it, or nil + pos if no identifier starts there. --- @param s string --- @param pos integer --- @return string|nil, integer @@ -541,8 +536,8 @@ local function read_alnum_ident(s, pos) return s:sub(start, pos - 1), pos end ---- Find every `MipsAtom_(name)` declaration in source. (Just the name + ---- source line; the body is parsed separately by `parse_mips_atom`.) +--- Find every `MipsAtom_(name)` declaration in source. +--- (Just the name + source line; the body is parsed separately by `parse_mips_atom`.) --- @param source string --- @return Atom[] local function find_atom_names(source) @@ -648,9 +643,8 @@ local function parse_atom_info_call(source, atom_name, after_mipsatom_paren, lin end --- Find every `MipsAtom_(name) atom_info(...) { ... };` annotation in source. ---- Returns a list of annotation entries. Atoms without a following ---- `atom_info(...)` call produce NO entry (atoms without annotations are ---- valid in the new minimal shape). +--- Returns a list of annotation entries. Atoms without a following `atom_info(...)` call produce NO entry +--- (atoms without annotations are valid in the new minimal shape). --- @param source string --- @return AtomAnnotation[] local function find_atom_annotations(source) @@ -861,16 +855,13 @@ end -- Per-DIRECTORY (per-module) output: errors.h + annotations.txt -- ════════════════════════════════════════════════════════════════════════════ -- --- Per-source reports were the old behavior; each source in the same --- directory produced its own .errors.h + .annotations.txt, --- which flooded build/gen/ with one report per header. The new behavior --- aggregates per-DIRECTORY (one errors.h + one annotations.txt per module --- basename). Directories with zero atoms/annotations are skipped (no --- file emitted). +-- Per-source reports were the old behavior; each source in the same directory produced its own .errors.h + .annotations.txt, +-- which flooded build/gen/ with one report per header. +-- Aggregates per-DIRECTORY (one errors.h + one annotations.txt per module basename). +-- Directories with zero atoms/annotations are skipped (no file emitted). ---- Render `.errors.h` with `#error` directives for every ---- error found across all sources in the directory. Empty directories ---- (no errors, no atoms) produce no file. +--- Render `.errors.h` with `#error` directives for every error found across all sources in the directory. +--- Empty directories (no errors, no atoms) produce no file. local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources) if ctx.dry_run then return nil end if atoms_count == 0 and #errors == 0 then @@ -878,7 +869,7 @@ local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sour return nil end local out_path = ctx.out_root .. "/" .. dir_basename .. ".errors.h" - local lines = { + local lines = { "// Auto-generated by ps1_meta.lua (passes/annotation.lua) — DO NOT EDIT", string.format("// Module: %s Sources: %d", dir_basename, #sources), "#pragma once", @@ -923,10 +914,8 @@ end local M = {} --- Expose `validate` for downstream passes (e.g. report.lua) that need --- to re-render the per-source results into a per-MODULE report. Keeping --- it as a single shared function avoids the duplication that an --- earlier version of report.lua had. +-- Expose `validate` for downstream passes (e.g. report.lua) that need to re-render the per-source results into a per-MODULE report. +-- Keeping it as a single shared function avoids the duplication that an earlier version of report.lua had. M.validate = validate --- @param ctx PassCtx @@ -936,11 +925,9 @@ function M.run(ctx) local errors = {} local warnings = {} - -- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`, - -- validate every source in the dir, then emit ONE errors.h per dir - -- (skipping dirs with no atoms AND no errors). The actual - -- annotations.txt is rendered by passes/report.lua from the stashed - -- per-module results below. + -- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`, validate every source in the dir, then emit ONE errors.h per dir + -- (skipping dirs with no atoms AND no errors). + -- The actual annotations.txt is rendered by passes/report.lua from the stashed per-module results below. local by_dir = {} for _, src in ipairs(ctx.sources) do by_dir[src.dir] = by_dir[src.dir] or {} diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 6ae8279..d1e10b7 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -1,8 +1,7 @@ --- passes/components.lua — Component-macro header generator. --- ---- Walks every source for `MipsAtomComp_(ac_X) { body }` ---- (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations ---- and emits a per-directory `.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` +--- Walks every source for `MipsAtomComp_(ac_X) { body }` (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations and +--- emits a per-directory `.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` --- entries for downstream offset computation. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, @@ -116,8 +115,7 @@ local function to_absolute_path(path) local cwd = p:read("*l") p:close() if not cwd then return path end - -- Normalize forward slashes to backslashes (Windows convention) on - -- both the cwd AND the relative path tail, so the join is uniform. + -- Normalize forward slashes to backslashes (Windows convention) on both the cwd AND the relative path tail, so the join is uniform. cwd = cwd:gsub("/", "\\") local tail = (path:gsub("/", "\\")) return cwd .. "\\" .. tail @@ -149,16 +147,13 @@ local function find_last_name_open_paren(source, name, before_pos) return last_idx end ---- Find the args of the function declaration that immediately precedes ---- a `MipsAtomComp_Proc_` invocation of the given name. Returns the ---- args string (e.g., `"U4 off, U4 code, U1 r, U1 g, U1 b"`) or nil ---- if no function declaration is found. +--- Find the args of the function declaration that immediately precedes a `MipsAtomComp_Proc_` invocation of the given name. +--- Returns the args string (e.g., `"U4 off, U4 code, U1 r, U1 g, U1 b"`) or nil if no function declaration is found. --- --- Convention: function form is --- `FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })` ---- We find the LAST occurrence of `"ac_X("` before `before_pos` and ---- extract the args from inside the parens. We then verify the ---- preceding context ends with `MipsAtom` (the function-decl keyword +--- We find the LAST occurrence of `"ac_X("` before `before_pos` and extract the args from inside the parens. +--- We then verify the preceding context ends with `MipsAtom` (the function-decl keyword --- with possible qualifiers between). --- --- @param source string @@ -169,8 +164,7 @@ local function find_function_args_for(source, name, before_pos) local last_idx = find_last_name_open_paren(source, name, before_pos) if not last_idx then return nil end - -- Verify the preceding context ends with "MipsAtom" (with - -- possible qualifiers between). + -- Verify the preceding context ends with "MipsAtom" (with possible qualifiers between). local before = source:sub(1, last_idx - 1) local trimmed = duffle.trim(before) if trimmed:sub(-#MIPS_ATOM) ~= MIPS_ATOM then @@ -188,8 +182,7 @@ end -- Preceding-comment-block extraction -- ════════════════════════════════════════════════════════════════════════════ --- Skip whitespace (space/tab/newline/CR) backward from `pos`, returning --- the position of the first non-whitespace char. +-- Skip whitespace (space/tab/newline/CR) backward from `pos`, returning the position of the first non-whitespace char. -- @param source string -- @param pos integer -- @return integer @@ -223,8 +216,7 @@ local function find_block_comment_open(source, close_pos) return open_at end --- Walk back from `open_at` over leading spaces + tabs to include the --- indentation before the `/*` in the captured comment. +-- Walk back from `open_at` over leading spaces + tabs to include the indentation before the `/*` in the captured comment. -- @param source string -- @param open_at integer -- @return integer @@ -241,8 +233,7 @@ local function extend_left_over_indent(source, open_at) return start end --- Walk back from `line_end` to the start of the source line (the most --- recent `\n` or position 1). +-- Walk back from `line_end` to the start of the source line (the most recent `\n` or position 1). -- @param source string -- @param line_end integer -- @return integer @@ -255,9 +246,8 @@ local function find_line_start(source, line_end) end -- (internal) Capture one `/* ... */` block comment whose closing `*/` --- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where --- `new_scan_pos` is where to continue scanning for more comments, or --- nil if no block comment was found. +-- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where `new_scan_pos` +-- is where to continue scanning for more comments, or nil if no block comment was found. local function capture_block_comment(source, close_end_pos) local open_at = find_block_comment_open(source, close_end_pos) if not open_at then return nil end @@ -276,15 +266,11 @@ local function capture_line_comment(source, line_end_pos) return nil end ---- Find the contiguous comment block immediately preceding `pos` in ---- `source`. Returns the comment text (with the `/* */` or `//` markers ---- preserved) or an empty string if no comment is adjacent. +--- Find the contiguous comment block immediately preceding `pos` in `source`. +--- Returns the comment text (with the `/* */` or `//` markers preserved) or an empty string if no comment is adjacent. --- ---- Used to copy signature comments from the source declaration ---- (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) over to the ---- generated `mac_X` macro, so LSP/IntelliSense displays the args doc. ---- ---- No regex (per the no_regex constraint). +--- Used to copy signature comments from the source declaration (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) +--- over to the generated `mac_X` macro, so LSP/IntelliSense displays the args doc. --- --- @param source string --- @param pos integer @@ -321,8 +307,7 @@ end -- Argument-name extraction -- ════════════════════════════════════════════════════════════════════════════ --- Walk `trimmed` backward from `pos` over trailing whitespace / --- asterisks / brackets, returning the position of the first +-- Walk `trimmed` backward from `pos` over trailing whitespace / asterisks / brackets, returning the position of the first -- non-trailer character (i.e. the end of the identifier). -- @param trimmed string -- @param pos integer @@ -358,8 +343,7 @@ local function trim_ident_back(trimmed, pos) return back end ---- Extract just the parameter NAMES from a function-args string ---- (stripping type annotations). E.g., +--- Extract just the parameter NAMES from a function-args string (stripping type annotations). E.g., --- `"U4 off, U4 code, U1 r, U1 g, U1 b"` -> `{"off", "code", "r", "g", "b"}` --- `"U4 *ptr"` -> `{"ptr"}` --- `""` -> nil @@ -389,9 +373,8 @@ end -- Component scanner (bare + function forms) -- ════════════════════════════════════════════════════════════════════════════ --- Parse the inner content of an `AtomComp_(name, ...)` call. Returns --- (name, body_or_nil) — `body_or_nil` is non-nil iff this is the --- function-form `MipsAtomComp_Proc_(name, { body })` invocation. +-- Parse the inner content of an `AtomComp_(name, ...)` call. +-- Returns (name, body_or_nil) — `body_or_nil` is non-nil iff this is the function-form `MipsAtomComp_Proc_(name, { body })` invocation. -- @param inner string -- the content between ( and ) of the AtomComp_ call --- @return string|nil, string|nil local function parse_atomcomp_inner(inner) @@ -414,8 +397,7 @@ local function parse_atomcomp_inner(inner) end -- (internal) Try to extract a bare-form `MipsAtomComp_(ac_X)` declaration. --- Bare form: `MipsAtomComp_(ac_X) { body }` — body comes from the brace block --- AFTER the parens. +-- Bare form: `MipsAtomComp_(ac_X) { body }` — body comes from the brace block AFTER the parens. -- @param source string -- @param name string -- the `ac_X` ident from the parens --- @param ident_pos integer -- position of the `MipsAtomComp_` ident start @@ -507,13 +489,11 @@ end -- Convert `//` line comments to `/* */` block comments in a token. -- --- C macros use `\` line-continuations; a `//` comment before `\` would --- consume the continuation, breaking the macro. We convert `//` to --- `/* */` so the multi-line macro structure is preserved. +-- C macros use `\` line-continuations; a `//` comment before `\` would consume the continuation, +-- breaking the macro. We convert `//` to `/* */` so the multi-line macro structure is preserved. -- -- Skips `//` sequences that are inside string or character literals --- (a rough heuristic — sufficient for component bodies which don't --- have those constructs). +-- (a rough heuristic — sufficient for component bodies which don't have those constructs). -- --- @param s string --- @return string @@ -551,10 +531,8 @@ end -- Word-count computation (memoized recursive lookup) -- ════════════════════════════════════════════════════════════════════════════ --- Strip the `mac_` prefix from a component-call ident so we can look it --- up against the components-by-name table. Returns the ident unchanged --- if it doesn't start with the prefix (so a non-component ident like --- `mask_upper` falls through to the wc-table branch). +-- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table. Returns the ident unchanged +-- if it doesn't start with the prefix (so a non-component ident like `mask_upper` falls through to the wc-table branch). -- @param ident string|nil -- @return string|nil local function strip_mac_prefix(ident) @@ -565,10 +543,9 @@ local function strip_mac_prefix(ident) return ident end --- (internal) Recursive word-count lookup. `cache` is the memoization table --- across all calls to `compute_component_word_count`; the in-progress --- -1 sentinel detects cycles (A -> B -> A). --- @param name string -- the component name (without `mac_`) +-- (internal) Recursive word-count lookup. `cache` is the memoization table across all calls to `compute_component_word_count`; +-- the in-progress -1 sentinel detects cycles (A -> B -> A). +-- @param name string -- the component name (without `mac_`) -- @param comp_by_name table -- @param wc table -- @param cache table @@ -604,17 +581,15 @@ local function word_count_rec(name, comp_by_name, wc, cache) return n end ---- Compute the word count of a component body, accounting for macro ---- expansion. Each comma-separated entry in the body is a "slot" that ---- contributes its own word count. For most entries (regular MIPS ---- instructions) the count is 1. For `mac_Y(...)` calls, the count is ---- the word count of `mac_Y` (recursive lookup through `components`). +--- Compute the word count of a component body, accounting for macro expansion. +--- Each comma-separated entry in the body is a "slot" that contributes its own word count. +--- For most entries (regular MIPS instructions) the count is 1. +--- For `mac_Y(...)` calls, the count is the word count of `mac_Y` (recursive lookup through `components`). --- For encoding macros with a known multi-word count (e.g. `mask_upper` = 2), --- the count is taken from `word_counts`. --- ---- The lookup is memoized via `word_count_rec` to avoid infinite recursion ---- (e.g. if two components referenced each other). This is the same ---- algorithm as the original `tape_atom_annotation_pass.lua` (commit 7d20a4d). +--- The lookup is memoized via `word_count_rec` to avoid infinite recursion (e.g. if two components referenced each other). +--- This is the same algorithm as the original `tape_atom_annotation_pass.lua` (commit 7d20a4d). --- --- @param c Component --- @param components Component[] @@ -663,8 +638,7 @@ local function tokens_from_body(body) return out end ---- Determine the macro signature: function-args list (function form) ---- or variadic-ignored (bare form). +--- Determine the macro signature: function-args list (function form) or variadic-ignored (bare form). --- @param args_str string|nil --- @return string local function signature_from_args(args_str) @@ -675,8 +649,8 @@ local function signature_from_args(args_str) return "..." end ---- Strip the trailing `" \"` (space + backslash) line continuation ---- from the last body line. The last 2 chars are always that pair. +--- Strip the trailing `" \"` (space + backslash) line continuation from the last body line. +--- The last 2 chars are always that pair. local function strip_trailing_continuation(lines) local last = lines[#lines] if last:sub(-2) == " \\" then @@ -684,9 +658,8 @@ local function strip_trailing_continuation(lines) end end ---- Emit the `#define mac_X(sig) \\t \,\t ...` ---- block. Converts `//` line comments to `/* */` block comments in ---- each token so they don't break the C macro `\` line continuations. +--- Emit the `#define mac_X(sig) \\t \,\t ...` block. +--- Converts `//` line comments to `/* */` block comments in each token so they don't break the C macro `\` line continuations. local function emit_macro_body(lines, c, sig, tokens) for tok_idx = 1, #tokens do tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx]) @@ -699,9 +672,8 @@ local function emit_macro_body(lines, c, sig, tokens) strip_trailing_continuation(lines) end ---- Build the list of lines for one component (signature comment, ---- `#define mac_X(...)` line with backslash-continued tokens, then ---- `WORD_COUNT(mac_X, N)` entry). +--- Build the list of lines for one component +--- (signature comment, `#define mac_X(...)` line with backslash-continued tokens, then `WORD_COUNT(mac_X, N)` entry). --- @param c Component --- @param components Component[] --- @param wc table @@ -734,17 +706,14 @@ end -- Per-source emit logic -- ════════════════════════════════════════════════════════════════════════════ --- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES` --- block, the `// Auto-generated` comment, the `// Source:` line, and the --- self-contained `WORD_COUNT` macro definition). +-- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES` block, +-- the `// Auto-generated` comment, the `// Source:` line, and the self-contained `WORD_COUNT` macro definition). -- @param src SourceFile -- @return string[] local function header_boilerplate(src) return { - -- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching - -- the convention in lottes_tape.h. The build does manual unity - -- includes (the user controls include order), so the pragma - -- is only active for IDE/tooling. + -- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h. + -- The build does manual unity includes (the user controls include order), so the pragma is only active for IDE/tooling. "#ifdef INTELLISENSE_DIRECTIVES", "#pragma once", "#endif", @@ -753,8 +722,7 @@ local function header_boilerplate(src) "// Component atoms (MipsAtomComp_(ac_*)) -> macro variants (mac_*)", "", -- Self-contained: define WORD_COUNT if not already defined. - -- We use the same definition here so the auto-generated - -- entries below expand to compile-time constants whether + -- We use the same definition here so the auto-generated entries below expand to compile-time constants whether -- the metadata file is included first or not. "#ifndef WORD_COUNT", "#define WORD_COUNT(name, count) enum { words_##name = (count) };", @@ -764,10 +732,9 @@ local function header_boilerplate(src) end -- Compute the output path for one source's `.macs.h` file. --- The pre-rework convention uses the *directory* basename (not the --- source file basename) — e.g. `code/duffle/lottes_tape.h` produces --- `code/duffle/gen/duffle.macs.h`. This matches what the C codebase --- #includes. +-- The pre-rework convention uses the *directory* basename +-- (not the source file basename) — e.g. `code/duffle/lottes_tape.h` produces `code/duffle/gen/duffle.macs.h`. +-- This matches what the C codebase #includes. -- @param src SourceFile -- @return string -- the output directory -- @return string -- the full output path @@ -777,13 +744,10 @@ local function compute_macs_h_path(src) return out_dir, out_path end ---- Emit a per-source `.macs.h` header with the `mac_X` macros + ---- `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are ---- preserved (the git blob is LF; Windows text-mode would emit CRLF and ---- break the byte-identical diff). +--- Emit a per-source `.macs.h` header with the `mac_X` macros + `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are +--- preserved (the git blob is LF; Windows text-mode would emit CRLF and break the byte-identical diff). --- ---- Honors `ctx.dry_run`: prints the intended path but does not write ---- the file. +--- Honors `ctx.dry_run`: prints the intended path but does not write the file. --- --- @param ctx PassCtx --- @param src SourceFile @@ -793,7 +757,7 @@ local function emit_component_macros_h(ctx, src, components) if #components == 0 then return nil end local out_dir, out_path = compute_macs_h_path(src) - local lines = header_boilerplate(src) + local lines = header_boilerplate(src) local wc = ctx.shared.word_counts for _, c in ipairs(components) do @@ -819,8 +783,8 @@ end -- Pass entry -- ════════════════════════════════════════════════════════════════════════════ --- (internal) Extend `ctx.shared.word_counts` with this source's component --- macros so offsets sees them without re-reading the file. +-- (internal) Extend `ctx.shared.word_counts` with this source's component macros +-- so offsets sees them without re-reading the file. -- @param ctx PassCtx -- @param components Component[] local function update_shared_word_counts(ctx, components) diff --git a/scripts/passes/offsets.lua b/scripts/passes/offsets.lua index 01acc7e..99454fa 100644 --- a/scripts/passes/offsets.lua +++ b/scripts/passes/offsets.lua @@ -1,26 +1,19 @@ --- passes/offsets.lua — Branch-offset generator. --- ---- Scans every source for `MipsAtom_(name) { ... }` (and the raw ---- `MipsCode code_ { ... }` form) declarations, computes the ---- word offset from each `atom_offset(F, T)` marker to its target ---- `atom_label(T)` declaration, and emits `.offsets.h` ---- with one `#define _atom_offset_F_T = N` per branch. +--- Scans every source for `MipsAtom_(name) { ... }` (and the raw `MipsCode code_ { ... }` form) declarations, +--- computes the word offset from each `atom_offset(F, T)` marker to its target `atom_label(T)` declaration, +--- and emits `.offsets.h` with one `#define _atom_offset_F_T = N` per branch. --- ---- The offset is `target_word - branch_word - 1` (the standard MIPS ---- branch-immediate encoding: branch_offset = relative_pc_in_words - 1). +--- The offset is `target_word - branch_word - 1` (the standard MIPS branch-immediate encoding: branch_offset = relative_pc_in_words - 1). --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. See ---- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`. +--- Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ --- Resolve `arg[0]` to an absolute-ish script directory so that --- `require("duffle")` resolves against `scripts/` regardless of CWD. --- Note: this boilerplate is duplicated in 6 other entry scripts; a --- Phase-6 extraction target (`duffle.setup_package_path()`). +-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD. -- Bootstrap: see `ps1_meta.lua` for the rationale. -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Uses `debug.getinfo` to find this file's own directory, so it works @@ -145,10 +138,8 @@ end -- Marker-call helpers -- ════════════════════════════════════════════════════════════════════════════ --- Extract comma-separated identifier args from a parenthesized group --- after a function-like macro call. Returns (args, after_paren) where --- `after_paren` is the position just past the closing `)`, or nil if --- `token` did not start with `(`. +-- Extract comma-separated identifier args from a parenthesized group after a function-like macro call. +-- Returns (args, after_paren) where `after_paren` is the position just past the closing `)`, or nil if `token` did not start with `(`. -- @param token string -- @param after_ident integer -- @return string[], integer|nil @@ -157,8 +148,8 @@ local function extract_ident_args(token, after_ident) if token:sub(arg_start, arg_start) ~= "(" then return {}, nil end local inner, after_paren = duffle.read_parens(token, arg_start) - local args = {} - local pos = 1 + local args = {} + local pos = 1 local inner_len = #inner while pos <= inner_len do pos = duffle.skip_ws_and_cmt(inner, pos) @@ -177,8 +168,7 @@ local function extract_ident_args(token, after_ident) return args, after_paren end --- (internal) Record a `atom_label(name)` marker — `at_pos` is the --- branch-free word position within the atom body. +-- (internal) Record a `atom_label(name)` marker — `at_pos` is the branch-free word position within the atom body. -- @param labels table -- @param args string[] -- @param at_pos integer @@ -196,8 +186,7 @@ local function record_offset_marker(branches, args, at_pos) end end ---- Scan a single token for atom_label/atom_offset markers, walking through ---- balanced groups transparently (so nested calls are found). +--- Scan a single token for atom_label/atom_offset markers, walking through balanced groups transparently (so nested calls are found). --- @param token string --- @param at_pos integer -- the branch-free word position of this token in the body --- @param labels table @@ -229,8 +218,7 @@ local function scan_for_atom_markers(token, at_pos, labels, branches) end end ---- Find the end position (just past the closing ')') of the first ---- atom_label/atom_offset call in `tok`. Returns 0 if no such call. +--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call. --- @param tok string --- @return integer -- 0 if no marker call found; otherwise end-1 (just past ')') local function find_marker_call_end(tok) @@ -266,8 +254,7 @@ end -- Atom scanner -- ════════════════════════════════════════════════════════════════════════════ ---- Skip C qualifier keywords (`static`, `const`, etc.) and return the ---- position past the last qualifier. +--- Skip C qualifier keywords (`static`, `const`, etc.) and return the position past the last qualifier. --- @param source string --- @param pos integer --- @return integer @@ -281,8 +268,7 @@ local function skip_qualifiers(source, pos) end -- (internal) Try to parse the wrapped atom form: `MipsAtom_() { ... }`. --- Returns the parsed Atom (name + body + position past body), or nil if --- the form didn't match. +-- Returns the parsed Atom (name + body + position past body), or nil if the form didn't match. -- @param source_text string -- @param after_pos integer -- position just past `MipsAtom_` -- @return Atom|nil @@ -300,9 +286,9 @@ local function try_wrapped_atom(source_text, after_pos) name_end = name_end + 1 end local name = inner:sub(name_start, name_end - 1) - if name == "" then return nil end + if name == "" then return nil end - local brace_pos = duffle.scan_to_char(source_text, "{", after_paren) + local brace_pos = duffle.scan_to_char(source_text, "{", after_paren) if not brace_pos then return nil end local body, after_brace = duffle.read_braces(source_text, brace_pos) return { name = name, body = body, after_brace = after_brace } @@ -325,8 +311,7 @@ local function try_raw_atom(source_text, after_pos) return { name = atom_name, body = body, after_brace = after_brace } end ---- Find every `MipsAtom_(name) { ... }` (or raw `MipsCode code_ { ... }`) ---- declaration in a source. +--- Find every `MipsAtom_(name) { ... }` (or raw `MipsCode code_ { ... }`) declaration in a source. --- @param source_text string --- @return Atom[] local function find_atoms(source_text) @@ -368,10 +353,10 @@ end -- Per-atom body scan -- ════════════════════════════════════════════════════════════════════════════ --- (internal) Count words emitted by the rest of `tok` after a marker call --- (the marker call itself emits 0 words, but the source pattern may bundle --- the marker with the next instruction on the same line, separated by no --- top-level comma). Returns the word count contributed by that rest. +-- (internal) Count words emitted by the rest of `tok` after a marker call +-- (the marker call itself emits 0 words, but the source pattern may bundle the marker with the next instruction on the same line, +-- separated by no top-level comma). +-- Returns the word count contributed by that rest. -- @param tok string -- @param word_counts table -- @return integer @@ -418,8 +403,8 @@ end -- Offset computation + header generation -- ════════════════════════════════════════════════════════════════════════════ --- Compute branch offsets as `target_word - branch_word - 1` (the --- standard MIPS branch-immediate encoding). +-- Compute branch offsets as `target_word - branch_word - 1` +-- (the standard MIPS branch-immediate encoding). -- @param labels table -- @param branches table[] -- @return BranchOffset[] @@ -528,9 +513,9 @@ local function process_source(ctx, src) return out_path end ---- Run the offsets pass. For each source, emits a per-module ---- `.offsets.h` containing `#define _atom_offset_F_T = N` ---- constants for every `atom_offset(F, T)` reference in the source's atoms. +--- Run the offsets pass. +--- For each source, emits a per-module `.offsets.h` containing `#define _atom_offset_F_T = N` constants for every `atom_offset(F, T)` reference +--- in the source's atoms. --- @param ctx PassCtx --- @return PassResult function M.run(ctx) diff --git a/scripts/passes/report.lua b/scripts/passes/report.lua index 82576c1..31335c7 100644 --- a/scripts/passes/report.lua +++ b/scripts/passes/report.lua @@ -20,10 +20,8 @@ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ --- Resolve `arg[0]` to an absolute-ish script directory so that --- `require("duffle")` resolves against `scripts/` regardless of CWD. --- Note: this boilerplate is duplicated in 6 other entry scripts; a --- Phase-6 extraction target (`duffle.setup_package_path()`). +-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD. +-- Note: this boilerplate is duplicated in 6 other entry scripts; a Phase-6 extraction target (`duffle.setup_package_path()`). -- Bootstrap: see `ps1_meta.lua` for the rationale. -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Uses `debug.getinfo` to find this file's own directory, so it works @@ -37,9 +35,8 @@ local duffle = require("duffle") -- Constants -- ════════════════════════════════════════════════════════════════════════════ --- Section separators used in the rendered text reports. The thin rules --- are hand-tuned to align with the per-section content width; do not --- change without also checking the section renderers below. +-- Section separators used in the rendered text reports. +-- The thin rules are hand-tuned to align with the per-section content width; do not change without also checking the section renderers below. local RULE_THICK = "========================================================" local SECTION_HEADER_ATOMS = "── Atoms ────────────────────────────────────────────────" local SECTION_HEADER_ANNOTS = "── Annotations ──────────────────────────────────────────" @@ -147,8 +144,7 @@ local PASS_NAME = "report" -- Per-MODULE annotation report (aggregated across all sources in a dir) -- ════════════════════════════════════════════════════════════════════════════ --- Extract the basename (last path segment) of a forward- or back-slash --- separated path. Returns the input unchanged if no separator is found. +-- Extract the basename (last path segment) of a forward- or back-slash separated path. Returns the input unchanged if no separator is found. -- @param path string -- @return string local function source_basename(path) @@ -306,8 +302,7 @@ end -- ════════════════════════════════════════════════════════════════════════════ --- Render the per-project summary (`build/gen/annotation_validation.txt`). ---- Aggregates totals across all sources; lists per-source error counts ---- if any source has errors. +--- Aggregates totals across all sources; lists per-source error counts if any source has errors. --- @param all_results AnnotationResult[] --- @return string local function render_project_report(all_results) @@ -356,8 +351,7 @@ end -- Orchestration helpers -- ════════════════════════════════════════════════════════════════════════════ --- Group source files by their `dir` field. Used to mirror the --- per-DIRECTORY partitioning the annotation pass uses. +-- Group source files by their `dir` field. Used to mirror the per-DIRECTORY partitioning the annotation pass uses. -- @param sources SourceFile[] -- @return table -- map of dir -> sources in that dir local function group_sources_by_dir(sources) @@ -369,19 +363,17 @@ local function group_sources_by_dir(sources) return by_dir end --- (internal) Validate each source in `dir_sources` via the annotation pass, --- tagging each result with `result.source = src.path` for downstream rendering. --- Returns the list of module results + the flat list of all results (for the --- project-wide summary). +-- (internal) Validate each source in `dir_sources` via the annotation pass, tagging each result with `result.source = src.path` for downstream rendering. +-- Returns the list of module results + the flat list of all results (for the project-wide summary). -- @param ctx PassCtx -- @param dir_sources SourceFile[] -- @return AnnotationResult[], AnnotationResult[] local function validate_module_sources(ctx, dir_sources) - local annotation = require("passes.annotation") + local annotation = require("passes.annotation") local module_results = {} - local all_results = {} + local all_results = {} for _, src in ipairs(dir_sources) do - local result = annotation.validate(ctx, src) + local result = annotation.validate(ctx, src) result.source = src.path module_results[#module_results + 1] = result all_results[#all_results + 1] = result @@ -394,7 +386,7 @@ end -- @return boolean local function module_has_content(module_results) for _, r in ipairs(module_results) do - if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0 + if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0 or #r.macros > 0 or #r.errors > 0 or #r.warnings > 0 then return true end @@ -416,9 +408,8 @@ end local M = {} ---- Run the report pass. Renders one `.annotations.txt` ---- per source-directory that has content, plus the project-wide ---- `annotation_validation.txt` summary. +--- Run the report pass. +--- Renders one `.annotations.txt` per source-directory that has content, plus the project-wide `annotation_validation.txt` summary. --- @param ctx PassCtx --- @return PassResult function M.run(ctx) @@ -433,11 +424,10 @@ function M.run(ctx) local all_results_for_summary = {} for _, entry in ipairs(module_entries) do - debug_log("entry: dir=%s basename=%s atoms_count=%d dir_sources=%d\n", - entry.dir, entry.dir_basename, entry.atoms_count, #(by_dir[entry.dir] or {})) + debug_log("entry: dir=%s basename=%s atoms_count=%d dir_sources=%d\n", entry.dir, entry.dir_basename, entry.atoms_count, #(by_dir[entry.dir] or {})) if entry.atoms_count > 0 or #(by_dir[entry.dir] or {}) > 0 then - local dir_sources = by_dir[entry.dir] or {} + local dir_sources = by_dir[entry.dir] or {} local module_results, all_results = validate_module_sources(ctx, dir_sources) for _, r in ipairs(all_results) do all_results_for_summary[#all_results_for_summary + 1] = r diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 463e81a..621460d 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -1,57 +1,32 @@ --- passes/static_analysis.lua — Per-atom static-analysis checks. --- --- The 5 checks currently shipped: ---- 1. **GTE pipeline-fill** — every `gte_cmdw_*` invocation must be ---- preceded by the minimum number of `nop` words (per ---- `duffle.duffle.GTE_PIPELINE_LATENCY`) so the COP2 pipeline latency is ---- fully retired before the command issues. ---- 2. **mac_yield uniformity** — every atom body must contain exactly ---- one `mac_yield()` call (control transfer pattern). ---- 3. **ABI handoff** — every `atom_bind(Binds_X)` must reference a ---- `typedef Struct_(Binds_X) { ... }` declaration. ---- 4. **GPU port-store shape** — per-shape (`f3`/`f4`/`g4`/etc.) the ---- sum of `mac_format_X_color` + `mac_gte_store_X_*` + ---- `mac_insert_ot_tag_X` words must equal the GP0 cmd's expected ---- packet size. ---- 5. **per-atom cycle budget** — sum each atom body's instruction ---- latencies (per `duffle.duffle.INSTRUCTION_LATENCY`); report total. +--- 1. **GTE pipeline-fill** — every `gte_cmdw_*` invocation must be preceded by the minimum number of `nop` words +--- (per `duffle.GTE_PIPELINE_LATENCY`) so the COP2 pipeline latency is fully retired before the command issues. +--- 2. **mac_yield uniformity** — every atom body must contain exactly one `mac_yield()` call (control transfer pattern). +--- 3. **ABI handoff** — every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration. +--- 4. **GPU port-store shape** — per-shape (`f3`/`f4`/`g4`/etc.) the sum of `mac_format_X_color` + `mac_gte_store_X_*` + +--- `mac_insert_ot_tag_X` words must equal the GP0 cmd's expected packet size. +--- 5. **per-atom cycle budget** — sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total. --- --- The orchestrator (`ps1_meta.lua`) wires this module in via the --- PASSES table: ---- `["static-analysis"] = { module = "passes.static_analysis", ---- kind = "validation", ---- deps = {"word-counts", "components"}, ---- out = { { kind = "report", ---- path_template = "/.static_analysis.txt" } } }` +--- `["static-analysis"] = { module = "passes.static_analysis", kind = "validation", deps = {"word-counts", "components"}, +--- out = { { kind = "report", path_template = "/.static_analysis.txt" } } }` --- ---- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. See ---- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`. +--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. See `lua.md` in the ps1-ai styleguides. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ --- `duffle.setup_package_path()` resolves `arg[0]` and prepends `scripts/` --- (and `scripts/passes/`) to `package.path`, so `require("duffle")` --- resolves regardless of CWD. See `duffle.lua` for the implementation. - --- Bootstrap: see `ps1_meta.lua` for the rationale. -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). --- Uses `debug.getinfo` to find this file's own directory, so it works --- both standalone and when require'd from the orchestrator. +-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. local _src = debug.getinfo(1, "S").source:sub(2) local _dir = _src:match("(.*[/\\])") or "./" dofile(_dir .. "../duffle_paths.lua") local duffle = require("duffle") --- Domain tables (single source of truth in duffle.lua). - - - - - - -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ @@ -137,7 +112,7 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" --- @field atom AtomBody --- @field tokens Token[] -- the tokens in the atom body, annotated --- @field findings Finding[] -- findings for this atom ---- @field total_cycles integer -- sum of token cycle costs (Phase 3) +--- @field total_cycles integer -- sum of token cycle costs -- ════════════════════════════════════════════════════════════════════════════ -- Source walkers @@ -232,7 +207,7 @@ local function find_atom_bodies(source_text) elseif c == 91 then local _, a = duffle.read_brackets(inner, inner_pos); inner_pos = a elseif c == 34 or c == 39 then - inner_pos = duffle.duffle.skip_str_or_cmt(inner, inner_pos) + 1 + inner_pos = duffle.skip_str_or_cmt(inner, inner_pos) + 1 else inner_pos = inner_pos + 1 end @@ -381,7 +356,7 @@ local function tokenize_body(body) elseif c == 91 then -- '[' local _, a = duffle.read_brackets(body, scan); scan = a elseif c == 34 or c == 39 then -- '"' or '\'' - scan = duffle.duffle.skip_str_or_cmt(body, scan) + 1 + scan = duffle.skip_str_or_cmt(body, scan) + 1 else scan = scan + 1 end @@ -448,9 +423,9 @@ local function check_gte_pipeline_fill(atoms, findings, line_of) line = line, check = "gte_pipeline_fill", kind = "warning", - msg = string.format( - "%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry", - a.name, line, variant), + msg = string.format( + "%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry", + a.name, line, variant), } ti = ti + 1 elseif need > 0 then @@ -950,7 +925,9 @@ local function check_gpu_portstore_shape(atoms, findings) findings[#findings + 1] = { atom = a.name, line = a.line, check = "gpu_portstore_shape", kind = "warning", - msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...) but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated. Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.", + msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)" + .. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated." + .. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.", a.name, a.line), } end @@ -970,7 +947,7 @@ local function check_gpu_portstore_shape(atoms, findings) end -- ════════════════════════════════════════════════════════════════════════════ --- Check #5: per-atom cycle budget (Phase 3) +-- Check #5: per-atom cycle budget -- ════════════════════════════════════════════════════════════════════════════ --- Compute the cycle cost of one token. The token is a string like @@ -1179,8 +1156,8 @@ local function analyze_atom_paths(atom) end if n >= 1 then dfs(1, 0, {}) end - -- cycles_full: sum of every token's cost (the previous model; useful - -- for comparing against the path-aware min/max). + -- cycles_full: sum of every token's cost (the legacy sum-of-all-tokens + -- value; over-counts BD-slot nops relative to the path-aware min/max). local cycles_full = 0 for tok_idx = 1, n do cycles_full = cycles_full + costs[tok_idx] end @@ -1210,9 +1187,9 @@ local function analyze_atom_paths(atom) } end ---- Backward-compat wrapper: returns total cycle count (the previous ---- "best case" value, which over-counts BD-slot nops) + unknown macro ---- list. New code should call `analyze_atom_paths(atom)` instead. +--- Returns total cycle count (the sum-of-all-tokens value, which over-counts +--- BD-slot nops) + unknown macro list. New code should call +--- `analyze_atom_paths(atom)` instead. local function count_atom_cycles(atom) local tokens = tokenize_body(atom.body) local total = 0 @@ -1242,7 +1219,8 @@ local function check_per_atom_cycle_budget(atoms, findings) findings[#findings + 1] = { atom = a.name, line = a.line, check = "per_atom_cycle_budget", kind = "warning", - msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; cycle count will be +%d per call (best-case). Add an entry to duffle.M.duffle.INSTRUCTION_LATENCY.", + msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " + .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.", a.name, a.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES), } end @@ -1274,7 +1252,7 @@ local function validate(ctx, src) check_gpu_portstore_shape(atoms, findings) check_per_atom_cycle_budget(atoms, findings) - -- Phase 3 cycle-budget output: attach per-path cycle data to each + -- Path-aware cycle-budget output: attach per-path cycle data to each -- atom. Best-case (no-stall) cycle count with BD-slot absorbed; the -- `cycles_full` field is the legacy sum-of-all-tokens value (kept -- for backward compat; over-counts BD-slot nops). @@ -1322,7 +1300,7 @@ local function validate(ctx, src) } end - -- Phase 3: cycle-budget summary line. Per-path min/max totals. + -- Path-aware cycle-budget summary line. Per-path min/max totals. if #atoms > 0 then local total_min = 0 local total_max = 0 @@ -1432,10 +1410,9 @@ local function emit_static_analysis_txt(ctx, src, result) return out_path end --- (Old per-source emit function above kept for backward compat but no --- longer called from M.run; replaced by `emit_module_static_analysis_txt` --- which aggregates by directory. Kept because some test harnesses may --- still call it directly.) +-- (Old per-source emit function above; kept for backward compat but no +-- longer called from M.run. Replaced by `emit_module_static_analysis_txt` +-- which aggregates by directory.) --- Per-directory emit. Aggregates atoms + findings across every source --- in `dir_sources` and writes a single report to @@ -1518,15 +1495,13 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin add(string.format(" ! line %d %s", w.line, w.msg)) end - -- Per-atom cycle counts (Phase 3 path-aware). For each atom: + -- Per-atom cycle counts (path-aware). For each atom: -- min = shortest path through the body (earliest exit) -- max = longest path through the body (full fall-through) -- br = number of branch instructions -- paths = number of distinct paths reached -- Both min and max are best-case (no stalls); BD-slot nops are - -- absorbed into branch costs (MIPS semantics). The previous "best - -- case" model counted every token separately, which double-counted - -- BD-slot nops; the path-aware model is the MIPS-accurate value. + -- absorbed into branch costs (MIPS semantics). add("") add("── Per-atom cycle counts (path-aware, best case, no stalls) ─") if #atoms == 0 then @@ -1569,13 +1544,6 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin -- 0 atoms are skipped (they're just header files that declared -- no MipsAtom_ — they're already listed in the module's -- "Sources:" section above). - -- - -- TODO: per-source finding attribution. Currently we can't tell - -- which source a given error/warning came from (errors/warnings - -- only carry atom-name + line, not source-path). The per-atom - -- cycle section already shows which atoms are in which source - -- via the `(file_basename)` suffix. Adding source attribution to - -- error/warning would be a future enhancement. for _, src in ipairs(dir_sources) do local src_atoms = {} for _, a in ipairs(atoms) do @@ -1640,10 +1608,10 @@ function M.run(ctx) local errors = {} local warnings = {} - -- Phase 3.7+: aggregate per-DIRECTORY (per-module). One - -- static_analysis.txt per source-directory, emitted only if the - -- directory contains at least one atom. Empty-source directories - -- (e.g. duffle headers with no atoms) produce no report. + -- Aggregate per-DIRECTORY (per-module). One static_analysis.txt per + -- source-directory, emitted only if the directory contains at least + -- one atom. Empty-source directories (e.g. duffle headers with no + -- atoms) produce no report. -- -- Group sources by `src.dir`. The first component of `dir` is the -- module name (e.g. "code/duffle" -> "duffle", "code/gte_hello" -> @@ -1689,10 +1657,8 @@ function M.run(ctx) end end - -- Skip directories with zero atoms. The previous behavior emitted - -- a "" report per source; the new behavior emits nothing - -- at all (a directory with only headers / no MipsAtom_ is - -- "nothing to report"). + -- Skip directories with zero atoms — a directory with only + -- headers / no MipsAtom_ is "nothing to report". if #all_atoms == 0 then -- Still aggregate errors/warnings so orchestrator sees them, -- but don't write a file. diff --git a/scripts/passes/word_count_eval.lua b/scripts/passes/word_count_eval.lua index 573d769..baead0c 100644 --- a/scripts/passes/word_count_eval.lua +++ b/scripts/passes/word_count_eval.lua @@ -1,32 +1,25 @@ ---- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram ---- pipeline. +--- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram pipeline. --- --- Three responsibilities: ---- 1. **Public utilities** (used by `passes/components.lua`, ---- `passes/offsets.lua`, `passes/annotation.lua`): +--- 1. **Public utilities** (used by `passes/components.lua`, `passes/offsets.lua`, `passes/annotation.lua`): --- - `M.count_token_words(token, wc)` — words emitted by one token --- - `M.scan_dir(dir, suffix)` — glob walk for *.macs.h --- - `M.count_body_words(body, wc)` — words emitted by an atom body ---- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into ---- `ctx.shared.word_counts` for downstream passes. +--- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into `ctx.shared.word_counts` for downstream passes. --- 3. **Internal helpers** for the body scanner. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. See ---- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`. +--- Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ --- Resolve `arg[0]` to an absolute-ish script directory so that --- `require("duffle")` resolves against `scripts/` regardless of CWD. --- Note: this boilerplate is duplicated in 6 other entry scripts; a --- Phase-6 extraction target (`duffle.setup_package_path()`). +-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD. +-- Note: this boilerplate is duplicated in 6 other entry scripts; a Phase-6 extraction target (`duffle.setup_package_path()`). -- Bootstrap: see `ps1_meta.lua` for the rationale. -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). --- Uses `debug.getinfo` to find this file's own directory, so it works --- both standalone and when require'd from the orchestrator. +-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. local _src = debug.getinfo(1, "S").source:sub(2) local _dir = _src:match("(.*[/\\])") or "./" dofile(_dir .. "../duffle_paths.lua") @@ -36,14 +29,12 @@ local duffle = require("duffle") -- Constants -- ════════════════════════════════════════════════════════════════════════════ --- Windows separator chars — used to convert `dir /b /s` output (which uses --- `\`) into POSIX paths (which our scripts expect). +-- Windows separator chars — used to convert `dir /b /s` output (which uses `\`) into POSIX paths (which our scripts expect). local PATH_SEP_BACKSLASH = "\\" local PATH_SEP_FORWARD = "/" --- Glob command for Windows directory walk. `dir /b /s` lists all matching --- files recursively with bare paths (no headers); `2>nul` discards the --- "file not found" stderr when nothing matches. +-- Glob command for Windows directory walk. `dir /b /s` lists all matching files recursively with bare paths (no headers); +-- `2>nul` discards the "file not found" stderr when nothing matches. local DIR_GLOB_CMD = 'dir /b /s "%s\\%s" 2>nul' -- ════════════════════════════════════════════════════════════════════════════ @@ -88,8 +79,7 @@ local M = {} --- Count words emitted by a single comma-separated token inside an atom body. --- For most tokens (regular MIPS instructions) this returns 1. ---- For `mac_X(...)` calls, this returns the resolved word count from `wc` ---- (recursively if needed). For `nop2` etc., returns wc[name]. +--- For `mac_X(...)` calls, this returns the resolved word count from `wc` (recursively if needed). For `nop2` etc., returns wc[name]. --- For unknown macros, returns 1 and (optionally) warns. --- --- @param token string -- a single token from split_top_level_commas @@ -97,7 +87,7 @@ local M = {} --- @return integer function M.count_token_words(token, wc) local s = duffle.trim(token) - if s == "" then return 0 end + if s == "" then return 0 end local name, after = duffle.read_ident(s, 1) if not name then return 1 end if wc[name] then return wc[name] end @@ -113,30 +103,25 @@ end -- └────────────────────────────────────────────────────────────────────┘ --- Recursively scan a directory for files matching a glob suffix. ---- No regex per the no_regex constraint — uses plain byte matching ---- via `dir /b /s` on Windows. +--- No regex per the no_regex constraint — uses plain byte matching via `dir /b /s` on Windows. --- ---- The `.macs.h` files produced by the components pass always live at ---- `//gen/`. We can shortcut the `dir /b /s` walk by ---- listing modules first (one `dir /b /ad`), then walking each `/gen/` ---- (one `dir /b` per module, no recursion). For projects with 2 modules and ---- 0 .macs.h files, this drops the cost from ~52ms (full recursive walk of ---- the entire project tree) to ~5ms. +--- The `.macs.h` files produced by the components pass always live at `//gen/`. +--- We can shortcut the `dir /b /s` walk by listing modules first (one `dir /b /ad`), then walking each `/gen/` +--- (one `dir /b` per module, no recursion). +--- For projects with 2 modules and 0 .macs.h files, this drops the cost from ~52ms +--- (full recursive walk of the entire project tree) to ~5ms. --- --- @param dir string -- directory to scan (absolute or relative) --- @param suffix string -- file pattern, e.g. "*.macs.h" --- @return string[] --- Cache the scan_dir result per (dir, suffix) in package.loaded. Each --- `io.popen` call on Windows is ~50-100ms of subprocess overhead, so --- caching the result saves a fixed cost on every build. The cache --- persists for the lifetime of the Lua process (cleared when ps1_meta.lua --- exits). If a build removes/creates .macs.h files mid-process, the --- caller can invalidate by calling `M._invalidate_scan_cache()`. +-- Cache the scan_dir result per (dir, suffix) in package.loaded. +-- Each `io.popen` call on Windows is ~50-100ms of subprocess overhead, so caching the result saves a fixed cost on every build. +-- The cache persists for the lifetime of the Lua process (cleared when ps1_meta.lua exits). +-- If a build removes/creates .macs.h files mid-process, the caller can invalidate by calling `M._invalidate_scan_cache()`. local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__" --- Recursively scan a directory for files matching a glob suffix. ---- No regex per the no_regex constraint — uses plain byte matching ---- via `dir /b /s` on Windows. +--- No regex per the no_regex constraint — uses plain byte matching via `dir /b /s` on Windows. --- --- @param dir string -- directory to scan (absolute or relative) --- @param suffix string -- file pattern, e.g. "*.macs.h" @@ -144,22 +129,17 @@ local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__" function M.scan_dir(dir, suffix) local key = dir .. "\0" .. suffix - -- Check the in-process cache first. (Mostly helps when a build - -- triggers multiple `M.run` calls -- e.g. the audit_lua_nesting - -- script's stress tests -- but the cost is ~free either way.) + -- Check the in-process cache first. (Mostly helps when a build triggers multiple `M.run` calls -- e.g. + -- the audit_lua_nesting script's stress tests but the cost is ~free either way.) local cache = package.loaded[SCAN_CACHE_KEY] - if cache and cache[key] then - return cache[key] - end + if cache and cache[key] then return cache[key] end local results = {} local pipe = io.popen(DIR_GLOB_CMD:format(dir, suffix)) if not pipe then - -- Cache the empty result too (avoids re-scan if the dir is - -- genuinely empty -- e.g. a clean build before components - -- has run yet). - cache = cache or {} - cache[key] = results + -- Cache the empty result too (avoids re-scan if the dir is genuinely empty -- e.g. a clean build before components has run yet). + cache = cache or {} + cache[key] = results package.loaded[SCAN_CACHE_KEY] = cache return results end @@ -170,18 +150,15 @@ function M.scan_dir(dir, suffix) pipe:close() -- Cache the result. - cache = cache or {} - cache[key] = results + cache = cache or {} + cache[key] = results package.loaded[SCAN_CACHE_KEY] = cache return results end ---- Invalidate the scan cache (call after creating new .macs.h files ---- in the same Lua process — usually not needed). -function M._invalidate_scan_cache() - package.loaded[SCAN_CACHE_KEY] = nil -end +--- Invalidate the scan cache (call after creating new .macs.h files in the same Lua process — usually not needed). +function M._invalidate_scan_cache() package.loaded[SCAN_CACHE_KEY] = nil end -- ┌────────────────────────────────────────────────────────────────────┐ -- │ Shared utility: count_body_words │ @@ -189,9 +166,8 @@ end --- Count words emitted by an entire atom body (a brace-delimited block). --- Splits by top-level commas; for each token, delegates to count_token_words. ---- Handles `atom_label(name)` / `atom_offset(tag, name)` markers (record at ---- current pos, do NOT advance pos; if the marker call bundles an instruction ---- after it, count that instruction too). +--- Handles `atom_label(name)` / `atom_offset(tag, name)` markers +--- (record at current pos, do NOT advance pos; if the marker call bundles an instruction after it, count that instruction too). --- --- @param body string -- brace-delimited atom body (without braces) --- @param wc WordCounts -- the shared word-count table @@ -208,10 +184,8 @@ function M.count_body_words(body, wc) local is_marker = leading_ident == "atom_label" or leading_ident == "atom_offset" if is_marker then -- Marker call: record at current pos, do NOT advance pos. - -- But the source pattern may bundle the marker with the next - -- instruction on a new line (no top-level comma between them). - -- In that case, the rest of `tok` after the marker call is - -- a real instruction that must still be counted. + -- But the source pattern may bundle the marker with the next instruction on a new line (no top-level comma between them). + -- In that case, the rest of `tok` after the marker call is a real instruction that must still be counted. local marker_end = M.find_marker_call_end(tok) if marker_end > 0 and marker_end < #tok then local rest = duffle.trim(tok:sub(marker_end + 1)) @@ -226,8 +200,7 @@ function M.count_body_words(body, wc) return total end ---- Find the end position (just past the closing ')') of the first ---- atom_label/atom_offset call in `tok`. Returns 0 if no such call. +--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call. --- Internal helper for count_body_words. --- --- @param tok string @@ -244,10 +217,10 @@ function M.find_marker_call_end(tok) elseif ch == "/" then -- comment — skip past it (delegated to duffle.skip_str_or_cmt) local nx = duffle.skip_str_or_cmt(tok, pos) - pos = (nx > pos) and nx or (pos + 1) + pos = (nx > pos) and nx or (pos + 1) else local ident, after_ident = duffle.read_ident(tok, pos) - local marker_end = find_marker_end(tok, ident, after_ident) + local marker_end = find_marker_end(tok, ident, after_ident) if marker_end > 0 then return marker_end end pos = after_ident or (pos + 1) end @@ -255,8 +228,8 @@ function M.find_marker_call_end(tok) return 0 end --- (internal) If `ident` is `atom_label`/`atom_offset` followed by `(...)`, --- return the position just past the closing ')'. Otherwise 0. +-- (internal) If `ident` is `atom_label`/`atom_offset` followed by `(...)`, return the position just past the closing ')'. +-- Otherwise 0. -- @param tok string -- @param ident string|nil -- @param after_ident integer @@ -273,10 +246,8 @@ end -- │ Pass entry: M.run(ctx) — "word-counts" pass │ -- └────────────────────────────────────────────────────────────────────┘ ---- Load metadata.h + scan for existing *.macs.h files into ---- ctx.shared.word_counts. Loading the .macs.h files is idempotent: ---- entries from later (current-build) .macs.h files override ---- metadata.h entries of the same name. +--- Load metadata.h + scan for existing *.macs.h files into ctx.shared.word_counts. +--- Loading the .macs.h files is idempotent: entries from later (current-build) .macs.h files override metadata.h entries of the same name. --- --- @param ctx PassCtx --- @return PassResult @@ -301,7 +272,6 @@ function M.run(ctx) end ctx.shared.word_counts = wc - return { outputs = {}, errors = {}, warnings = {} } end diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index b60c193..bf5a4d4 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -79,9 +79,7 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__" --- @class PassOutputEntry --- @field [string] string -- dynamic shape; key is the output kind - -- (e.g. "macs_h", "offsets_h", "errors_h", - -- "annotations_txt", "static_analysis_txt", - -- "summary_txt"), value is the path + -- (e.g. "macs_h", "offsets_h", "errors_h", "annotations_txt", "static_analysis_txt", "summary_txt"), value is the path --- @class Finding --- @field line integer -- source line (or 0 for pass-level) @@ -177,8 +175,7 @@ local ALL_PASS_NAMES = { "offsets", "static-analysis", "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. +--- Append every pass name to args.requested_set. Used by --all and by the "default to --all if no pass flags were given" fallback. --- @param args ParsedArgs local function request_all_passes(args) for _, n in ipairs(ALL_PASS_NAMES) do @@ -186,11 +183,9 @@ local function request_all_passes(args) end 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. +-- 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. local FLAG_HANDLERS = {} -- ════════════════════════════════════════════════════════════════════════════ @@ -233,50 +228,25 @@ EXAMPLE: ]]) end --- Per-flag handlers. Each takes (args, argv, arg_idx) and returns --- the new arg_idx (so multi-arg flags like --source FILE advance --- it). Termination flags like --help call os.exit() instead. This --- replaces the 8-way `if/elseif/elseif...` chain that nested 4 --- levels deep and made the dispatch logic hard to scan. +-- Per-flag handlers. Each takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). +-- Termination flags like --help call os.exit() instead. +-- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan. -- --- Populated AFTER print_help so the --help handler can reference it --- as an upvalue (Lua resolves locals at closure-call time, but if the --- closure is defined before the local, it falls back to _G). +-- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time, +-- but if the closure is defined before the local, it falls back to _G). FLAG_HANDLERS["--help"] = function(args) print_help() os.exit(0) end -FLAG_HANDLERS["--dry-run"] = function(args) - args.dry_run = true -end +FLAG_HANDLERS["--dry-run"] = function(args) args.dry_run = true end +FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end +FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) args.sources[#args.sources + 1] = argv[arg_idx + 1]; return arg_idx + 1 end +FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) args.metadata = argv[arg_idx + 1]; return arg_idx + 1 end +FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) args.out_root = argv[arg_idx + 1]; return arg_idx + 1 end +FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_root = argv[arg_idx + 1]; return arg_idx + 1 end -FLAG_HANDLERS["--verbose"] = function(args) - args.verbose = true -end - -FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) - args.sources[#args.sources + 1] = argv[arg_idx + 1] - return arg_idx + 1 -end - -FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) - args.metadata = argv[arg_idx + 1] - return arg_idx + 1 -end - -FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) - args.out_root = argv[arg_idx + 1] - return arg_idx + 1 -end - -FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) - args.project_root = argv[arg_idx + 1] - return arg_idx + 1 -end - --- Pass-flag handler. Reads the closed-set table, expands --all, --- appends to requested_set. Single-statement, no nesting. +-- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set. Single-statement, no nesting. FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a) local name = PASS_FLAG_TO_NAME[a] if name == ALL_PASSES_SENTINEL then @@ -303,7 +273,7 @@ local function parse_args(argv) local pos = 1 while pos <= #argv do - local a = argv[pos] + local a = argv[pos] local handler = FLAG_HANDLERS[a] if handler then pos = handler(args, argv, pos) or pos @@ -318,14 +288,12 @@ local function parse_args(argv) end -- Default: --all if no explicit pass flags. - if #args.requested_set == 0 then - request_all_passes(args) - end + if #args.requested_set == 0 then request_all_passes(args) end -- Defaults: project_root = dirname(metadata). if args.metadata and not args.project_root then local d = duffle.dirname(args.metadata) - if #d > 0 and (d:sub(-1) == "/" or d:sub(-1) == "\\") then + if #d > 0 and (d:sub(-1) == "/" or d:sub(-1) == "\\") then d = d:sub(1, -2) end args.project_root = duffle.dirname(d) @@ -355,7 +323,7 @@ end local function build_ctx(args) local sources = {} for _, path in ipairs(args.sources) do - local f = io.open(path, "r") + local f = io.open(path, "r") if not f then io.stderr:write("ps1_meta: cannot open --source " .. path .. "\n") os.exit(EXIT_INTERNAL_ERROR) @@ -394,8 +362,7 @@ end -- Topological sort (Kahn's algorithm + cycle detection) -- ════════════════════════════════════════════════════════════════════════════ ---- Compute the dep-closure of `requested_set`: include every pass name ---- transitively required by the requested set. +--- Compute the dep-closure of `requested_set`: include every pass name transitively required by the requested set. --- --- @param passes table --- @param requested_set string[] @@ -431,8 +398,7 @@ local function count_entries(t) return n end ---- Compute in-degrees for the Kahn sort: for each pass in `needed`, ---- the number of its deps that are also in `needed`. +--- Compute in-degrees for the Kahn sort: for each pass in `needed`, the number of its deps that are also in `needed`. --- --- @param passes table --- @param needed table @@ -450,8 +416,7 @@ local function compute_in_degrees(passes, needed) return in_degree end ---- Seed the Kahn ready queue with passes whose in-degree is 0, sorted ---- alphabetically for deterministic execution order. +--- Seed the Kahn ready queue with passes whose in-degree is 0, sorted alphabetically for deterministic execution order. --- --- @param in_degree table --- @return string[] @@ -464,9 +429,8 @@ local function seed_ready_queue(in_degree) return ready end --- (internal) Pop the next ready pass, decrement the in-degree of every --- remaining pass that depended on it (inserting newly-zero-degree passes --- back into the ready queue), and append to `order`. Keeps `ready` sorted. +-- (internal) Pop the next ready pass, decrement the in-degree of every remaining pass that depended on it +-- (inserting newly-zero-degree passes back into the ready queue), and append to `order`. Keeps `ready` sorted. -- @param passes table -- @param needed table -- @param in_degree table @@ -506,11 +470,9 @@ local function topo_sort(passes, requested_set) process_next_ready(passes, needed, in_degree, ready, order) end - -- Cycle detection: if order doesn't include all needed passes, - -- some are stuck with in_degree > 0 (the cycle closed on itself - -- before Kahn could process them). Without this check, a fully- - -- closed cycle (e.g. A -> B -> A) would silently return an empty - -- order list, leaving the orchestrator to dispatch nothing. + -- Cycle detection: if order doesn't include all needed passes, some are stuck with in_degree > 0 (the cycle closed on itself + -- before Kahn could process them). Without this check, a fully-closed cycle (e.g. A -> B -> A) would silently return an emspty order list, + -- leaving the orchestrator to dispatch nothing. if #order ~= count_entries(needed) then for name, deg in pairs(in_degree) do if deg > 0 then @@ -527,8 +489,7 @@ end -- ════════════════════════════════════════════════════════════════════════════ --- Render the dep graph as ASCII art. Output width capped at 78 columns. ---- Falls back to the simpler "Resolved dependency order" list only if ---- graph width exceeds terminal width. +--- Falls back to the simpler "Resolved dependency order" list only if graph width exceeds terminal width. --- --- @param passes table --- @param requested string[] -- originally-requested passes (subset of closed) @@ -591,8 +552,7 @@ end -- Main orchestrator -- ════════════════════════════════════════════════════════════════════════════ --- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` --- for downstream passes to consume. +-- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` for downstream passes to consume. -- @param ctx PassCtx -- @param pass_name string -- @param result PassResult @@ -606,9 +566,8 @@ local function accumulate_pass_result(ctx, pass_name, result) end end --- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it --- reported errors, write each error to stderr. Returns true if any --- validation errors were reported. +-- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it reported errors, write each error to stderr. +-- Returns true if any validation errors were reported. -- @param pass_name string -- @param pass PassDescriptor -- @param result PassResult diff --git a/scripts/update_deps.ps1 b/scripts/update_deps.ps1 index 6f97b8a..036ffa1 100644 --- a/scripts/update_deps.ps1 +++ b/scripts/update_deps.ps1 @@ -11,11 +11,17 @@ $misc = join-path $PSScriptRoot 'helpers/misc.ps1' . $misc # TODO(Ed): Review usage of these deps -# I orgiinally cloned them when starting to get to the C runtime usage of the course +# I originally cloned them when starting to get to the C runtime usage of the course # However, based on the heavy reliance of the PSX.Dev extension I might fallback; also # The gdb server doesn't need the full repo and were only using the src/mips # which has a standalone repo (nuggets) # armips may not be used at all but I'm not sure... +# +# PCSX-Redux: built via MSBuild (VS2022) — automated in the build section below. +# Requires: VS2022 with C++ desktop workload + PlatformToolset=v143 retarget. +# The .vcxproj files request v145; we pass /p:PlatformToolset=v143 to MSBuild. +# NuGet packages are restored automatically on first build. +# Output: toolchain\pcsx-redux\vsprojects\x64\Debug\pcsx-redux.exe $url_armips = 'https://github.com/Kingcom/armips.git' $url_pcsx_redux = 'https://github.com/grumpycoders/pcsx-redux.git' @@ -44,6 +50,33 @@ pop-location # $psyq_obj_parser = join-path $path_pcsx_redux_binaries 'psyq-obj-parser.exe' +# ════════════════════════════════════════════════════════════════════════════ +# PCSX-Redux — built via MSBuild (VS2022) +# +# Requires: Visual Studio 2022 with the C++ desktop workload. +# The .vcxproj files target platform toolset v145, but VS2022 ships v143; +# we pass /p:PlatformToolset=v143 to retarget at build time (no file edits). +# NuGet packages (glfw, luajit.native, libFFmpeg-lite, x64sentry) are +# restored automatically by MSBuild on first build. +# +# Output: toolchain\pcsx-redux\vsprojects\x64\Debug\pcsx-redux.exe +# ════════════════════════════════════════════════════════════════════════════ + +# Locate MSBuild from the VS2022 install (no hardcoded path — uses vswhere). +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { + write-error "vswhere not found at '$vswhere'. Install Visual Studio 2022 with the C++ desktop workload." + exit 1 +} +$msbuild_exe = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" 2>$null | Select-Object -First 1 +if (-not $msbuild_exe) { + write-error "MSBuild not found via vswhere. Install Visual Studio 2022 with the C++ desktop workload." + exit 1 +} + +$path_pcsx_sln = join-path $path_pcsx_redux 'vsprojects\pcsx-redux.sln' +& $msbuild_exe $path_pcsx_sln /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /m /v:minimal + # Locate luajit via scoop. `luajit.exe` is on PATH via scoop's shim; # we use `scoop prefix` to find the install root for the include dir # (needed to compile lpeg against luajit's headers). @@ -80,3 +113,19 @@ $lpeg_compile_args = @( push-location $path_lpeg & gcc @lpeg_compile_args pop-location + +# ════════════════════════════════════════════════════════════════════════════ +# OpenBIOS — built from the PCSX-Redux source tree via make + mipsel-none-elf +# +# OpenBIOS is an open-source PS1 BIOS implementation (no retail BIOS dump needed). +# It builds with the MIPS cross-toolchain (`mipsel-none-elf-gcc`, on PATH via the `mips` toolchain installer) +# + `make` (on PATH via scoop). +# +# Output: toolchain\pcsx-redux\src\mips\openbios\openbios.bin +# ════════════════════════════════════════════════════════════════════════════ + +$path_openbios = join-path $path_pcsx_redux 'src\mips\openbios' +push-location $path_openbios +& make clean +& make +pop-location