mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-08-04 14:48:48 +00:00
Lua Metaprogram: Improvements to static analysis + others.
This commit is contained in:
+478
-85
@@ -14,8 +14,7 @@
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Required native extension: lfs (LuaFileSystem). Built by `update_deps.ps1` to
|
||||
-- `toolchain/lfs/lfs.dll` and wired into package.cpath by `scripts/duffle_paths.lua`.
|
||||
-- Required native extension: lfs (LuaFileSystem). Built by `update_deps.ps1` to `toolchain/lfs/lfs.dll` and wired into package.cpath by `scripts/duffle_paths.lua`.
|
||||
-- If lfs is missing, `require` throws — fail loud per the build-tool convention.
|
||||
local lfs = require("lfs")
|
||||
|
||||
@@ -228,7 +227,7 @@ end
|
||||
-- Section 3: I/O primitives
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- File contents intentionally use io.open below. LuaFileSystem handles path
|
||||
-- File contents intentionally use io.open below. LuaFileSystem handles path
|
||||
-- metadata, directory iteration, the current directory, and mkdir; it does not
|
||||
-- expose file-content read/write streams.
|
||||
function M.read_file(path)
|
||||
@@ -618,17 +617,13 @@ end
|
||||
--- Count words contributed by the non-marker portion of `tok` (after the marker's closing `)`).
|
||||
--- Returns 0 if `tok` isn't a marker call or has no trailing content.
|
||||
---
|
||||
--- `count_token_words_fn` is injected by the caller rather than imported here because the
|
||||
--- dependency arrow already points the other way: `passes/offsets.lua` and
|
||||
--- `passes/atoms_source_map.lua` both `require("word_count_eval")` and pass its
|
||||
--- `count_token_words` as the 3rd argument to this function, while `word_count_eval`
|
||||
--- itself loads `duffle` via `duffle_paths.lua` (see `passes/word_count_eval.lua` near
|
||||
--- the top of the file) and calls `duffle.trim` / `duffle.read_ident` /
|
||||
--- `duffle.skip_ws_and_cmt` from `M.count_token_words`. Importing `word_count_eval`
|
||||
--- `count_token_words_fn` is injected by the caller rather than imported here because the dependency arrow already points the other way:
|
||||
--- `passes/offsets.lua` and `passes/atoms_source_map.lua` both `require("word_count_eval")` and pass its `count_token_words` as the 3rd argument to this function,
|
||||
--- while `word_count_eval` itself loads `duffle` via `duffle_paths.lua` (see `passes/word_count_eval.lua` near the top of the file)
|
||||
--- and calls `duffle.trim` / `duffle.read_ident` / `duffle.skip_ws_and_cmt` from `M.count_token_words`. Importing `word_count_eval`
|
||||
--- from this module would reverse that direction and form a recursive require cycle.
|
||||
--- The callback keeps the marker-syntax helpers (`find_marker_call_end`,
|
||||
--- `is_marker_token`, this function) shared in `duffle` without making the foundational
|
||||
--- utility depend on a pass module.
|
||||
--- The callback keeps the marker-syntax helpers (`find_marker_call_end`, `is_marker_token`, this function)
|
||||
--- shared in `duffle` without making the foundational utility depend on a pass module.
|
||||
--- @param tok string
|
||||
--- @param word_counts table
|
||||
--- @param count_token_words_fn fun(tok: string, wc: table): integer
|
||||
@@ -699,79 +694,238 @@ end
|
||||
-- Section 7: domain tables
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- The annotation DSL has been reduced to a single annotation macro:
|
||||
-- atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...))
|
||||
-- The annotation DSL has been reduced to a single annotation macro: atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...))
|
||||
-- All phase / region / cadence / async / resource / group tokens have been dropped.
|
||||
-- They may be reintroduced later as optional sub-calls of atom_info;
|
||||
-- for now, the parser only recognizes atom_info + its three sub-calls (atom_bind, atom_reads, atom_writes).
|
||||
-- They may be reintroduced later as optional sub-calls of atom_info;
|
||||
-- For now, the parser only recognizes atom_info + its three sub-calls (atom_bind, atom_reads, atom_writes).
|
||||
M.TAPE_ATOM_MACROS = {
|
||||
["atom_info"] = { kind = "info", binds = false },
|
||||
}
|
||||
|
||||
-- GTE pipeline-fill latency table.
|
||||
-- GTE command-alias resolution table.
|
||||
--
|
||||
-- For each `gte_cmdw_*` macro in code/duffle/gte.h, the minimum number of consecutive COP2 "nop" words that MUST appear
|
||||
-- before the command issues so that any preceding `lwc2`/`swc2`/C2 state writes have retired before the GTE starts reading its input registers.
|
||||
-- Maps each GTE command macro that may appear in source to its CANONICAL short form.
|
||||
-- Both forms resolve to the same PSX-SPX-documented pipeline semantics; the canonical
|
||||
-- name is the only one that appears in `GTE_COMMAND_INPUTS` and the per-check producer / consumer reports.
|
||||
-- Aliases resolve exactly once; unknown idents (e.g. an MVMVA with a custom `(sf, mx, v, cv, lm)` payload that is not on this list)
|
||||
-- are reported as "command unknown" by the check, not silently treated as 0-cycle.
|
||||
--
|
||||
-- The check (`scripts/passes/static_analysis.lua :: check_gte_pipeline_fill`) walks each atom body,
|
||||
-- counts the consecutive nop words before every `gte_cmdw_*` invocation, and reports a finding if the count is below this minimum.
|
||||
--
|
||||
-- PRE-FILL vs POST-FILL: this table models PRE-cmdw nops (retiring preceding C2 writes).
|
||||
-- The PSX-SPX pipeline timings doc (`docs/psx-spx/docs/gtepipelinetimings.md`) measures a DIFFERENT number:
|
||||
-- the smallest N nops between `cop2` and `mtc2` to a specific input register at which the write no longer affects the output.
|
||||
-- For nearly all instructions, inputs latch in the first 0-4 cycles — the GTE snapshots its input register file early and works
|
||||
-- from internal pipeline storage afterward.
|
||||
-- The documented total cycle count is NOT the "do not touch inputs" window; the actual read window is much shorter.
|
||||
--
|
||||
-- The `gte_rtpt()` / `gte_nclip()` 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_<X>, ...` form instead — that `nop2,` is the pre-fill this check validates.
|
||||
-- So values here reflect the source-level convention, NOT the wrapper-internal pre-fill.
|
||||
--
|
||||
-- Cycle counts from PSX-SPX `docs/psx-spx/docs/geometrytransformationenginegte.md`:
|
||||
-- cmd PSX-SPX cycles min pre-nops rationale
|
||||
-- rtps 15 2 8c per perspective divide + 6c for IR1..4 + mac write
|
||||
-- rtpt 23 2 3x rtps worth of pipeline depth (per-vertex pipeline fill)
|
||||
-- nclip 8 2 MAC0 write + 5c for sign computation
|
||||
-- avsz3 5 2 5c to compute average + write OTZ (all inputs latch at N=0)
|
||||
-- avsz4 6 2 avsz3 + 1c extra for 4th vertex
|
||||
-- mvmva 8 2 IR1..4 write + matrix work (8c regardless of mx/v/cv selection)
|
||||
-- op 6 0 cross product; output to IR1..3 only (atomic 6c calc, no pre-fill needed)
|
||||
--
|
||||
-- The pre-nop values (2 for most commands) are conservative: PSX-SPX pipeline timings show most inputs latch at N=0-1
|
||||
-- relative to a preceding mtc2, but 2 nops is the gte.h convention for retiring preceding lwc2/swc2 + C2 state.
|
||||
-- OP is set to 0 because it's a short atomic op with no input that needs a long retire window.
|
||||
--
|
||||
-- Aliases are listed separately because source code may use either the alias or the canonical name.
|
||||
M.GTE_PIPELINE_LATENCY = {
|
||||
-- Minimum number of consecutive `nop` words that must appear IMMEDIATELY BEFORE a `gte_cmdw_<X>` 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 `docs/psx-spx/docs/geometrytransformationenginegte.md` (cycle counts) and
|
||||
-- `docs/psx-spx/docs/gtepipelinetimings.md` (input-latch boundaries).
|
||||
-- Source conventions (per `code/duffle/gte.h`): The C source ships both short canonical macros
|
||||
-- (`gte_cmdw_rtps`, `gte_cmdw_rtpt`, `gte_cmdw_nclip`, `gte_cmdw_avsz3`, `gte_cmdw_avsz4`, `gte_cmdw_mvmva`, `gte_cmdw_op`)
|
||||
-- and human-readable aliases (`gte_cmdw_rotate_translate_perspective_*`, `gte_cmdw_avg_sort_z3`, etc.).
|
||||
-- Every alias row maps source ident -> canonical short ident.
|
||||
M.GTE_COMMAND_ALIASES = {
|
||||
-- Canonical -> canonical (identity).
|
||||
["gte_cmdw_rtps"] = "gte_cmdw_rtps",
|
||||
["gte_cmdw_rtpt"] = "gte_cmdw_rtpt",
|
||||
["gte_cmdw_nclip"] = "gte_cmdw_nclip",
|
||||
["gte_cmdw_mvmva"] = "gte_cmdw_mvmva",
|
||||
["gte_cmdw_op"] = "gte_cmdw_op",
|
||||
["gte_cmdw_avsz3"] = "gte_cmdw_avsz3",
|
||||
["gte_cmdw_avsz4"] = "gte_cmdw_avsz4",
|
||||
-- Aliases -> canonical.
|
||||
["gte_cmdw_rotate_translate_perspective_single"] = "gte_cmdw_rtps",
|
||||
["gte_cmdw_rotate_translate_perspective_triple"] = "gte_cmdw_rtpt",
|
||||
["gte_cmdw_avg_sort_z3"] = "gte_cmdw_avsz3",
|
||||
["gte_cmdw_avg_sort_z4"] = "gte_cmdw_avsz4",
|
||||
["gte_cmdw_outer_product"] = "gte_cmdw_op",
|
||||
["gte_cmdw_wedge"] = "gte_cmdw_op",
|
||||
-- Bare-name aliases (no `gte_cmdw_` prefix; used in atom bodies directly):
|
||||
-- gte_avg_sort_z3 / gte_avg_sort_z4 are the duffle-side aliases for AVSZ3/4.
|
||||
-- alias-to-canonical resolution lives in `check_gte_write_retire` (via
|
||||
-- `M.GTE_COMMAND_ALIASES`); see static_analysis.lua :: check_gte_write_retire.
|
||||
["gte_avg_sort_z3"] = "gte_cmdw_avsz3",
|
||||
["gte_avg_sort_z4"] = "gte_cmdw_avsz4",
|
||||
}
|
||||
|
||||
-- Canonical macros (from code/duffle/gte.h)
|
||||
["gte_cmdw_rtps"] = 2, -- RTPS: 15 cycles (PSX-SPX)
|
||||
["gte_cmdw_rtpt"] = 2, -- RTPT: 23 cycles (PSX-SPX)
|
||||
["gte_cmdw_nclip"] = 2, -- NCLIP: 8 cycles (PSX-SPX)
|
||||
["gte_cmdw_op"] = 0, -- OP: 6 cycles, atomic (PSX-SPX)
|
||||
["gte_cmdw_mvmva"] = 2, -- MVMVA: 8 cycles (PSX-SPX)
|
||||
["gte_cmdw_avsz3"] = 2, -- AVSZ3: 5 cycles (PSX-SPX)
|
||||
["gte_cmdw_avsz4"] = 2, -- AVSZ4: 6 cycles (PSX-SPX)
|
||||
-- GTE command input-set table.
|
||||
--
|
||||
-- For each canonical command, the set of C2 registers whose recent CPU-to-COP2 write
|
||||
-- must retire before the command can issue. Per PSX-SPX `docs/psx-spx/docs/cpuspecifications.md:407-419`:
|
||||
-- * A store to COP2 registers (mtc2/ctc2) has a delay of 2..3 clock cycles.
|
||||
-- * In most cases the delay is 2 cycles; special cases like writes to IRGB
|
||||
-- (which additionally affect IR1/IR2/IR3) take 3 cycles.
|
||||
-- * "Store delays are counted in numbers of clock cycles (not in numbers of opcodes).
|
||||
-- For 3 cycle delay, one must usually insert 3 cached opcodes (or one uncached opcode)."
|
||||
--
|
||||
-- Per PSX-SPX `docs/psx-spx/docs/gtepipelinetimings.md`
|
||||
-- (the per-instruction input-latch measurement, which is the SAME phenomenon modeled from the command side), the values are:
|
||||
-- rtps: every data register, every control register (RT/TR/OFX/OFY/H/DQA/DQB)
|
||||
-- rtpt: same superset (rtpt reads V0..V2, the RT matrix, the TR vector, OFX/OFY, H, DQA, DQB)
|
||||
-- nclip: SXY0, SXY1, SXY2 (no RT/TR/OFX inputs)
|
||||
-- mvmva: variable (depends on the chosen mx / v / cv selector); treated conservatively as the union of all RT + TR + BK + IR columns (the data inputs the command can read).
|
||||
-- op: IR1, IR2, IR3 (cross-product output, atomic; consumers treat as fan-out only)
|
||||
-- avsz3/avsz4: SZ0..SZ3 + ZSF3/ZSF4
|
||||
--
|
||||
-- We model the data-register + control-register superset.
|
||||
-- Per PSX-SPX `gtepipelinetimings.md`, every relevant input is in this set;
|
||||
-- the per-input latching values listed there are the SAME number's command-side view
|
||||
-- (a recent mtc2/ctc2 to that register must retire the same number of cycles before the command issues).
|
||||
-- Anything not in the set is safe to clobber immediately after a prior command.
|
||||
M.GTE_COMMAND_INPUTS = {
|
||||
-- RTPS / RTPT: every data + every rotation/translation control + screen offset + projection.
|
||||
["gte_cmdw_rtps"] = {
|
||||
-- Data register file (entire)
|
||||
"C2_VXY0", "C2_VZ0", "C2_VXY1", "C2_VZ1", "C2_VXY2", "C2_VZ2",
|
||||
"C2_RGB", "C2_OTZ",
|
||||
"C2_IR0", "C2_IR1", "C2_IR2", "C2_IR3",
|
||||
"C2_SZ0", "C2_SZ1", "C2_SZ2", "C2_SZ3",
|
||||
-- Rotation matrix (RT) + translation (TR).
|
||||
"gte_cr_RT11", "gte_cr_RT12", "gte_cr_RT13",
|
||||
"gte_cr_RT21", "gte_cr_RT22", "gte_cr_RT23",
|
||||
"gte_cr_RT31", "gte_cr_RT32", "gte_cr_RT33",
|
||||
"gte_cr_TRX", "gte_cr_TRY", "gte_cr_TRZ",
|
||||
-- Screen offset + projection plane distance.
|
||||
"gte_cr_OFX", "gte_cr_OFY", "gte_cr_H",
|
||||
-- Depth queuing parameters (consumed by the depth-cue path inside the perspective op).
|
||||
"gte_cr_DQA", "gte_cr_DQB",
|
||||
},
|
||||
["gte_cmdw_rtpt"] = {
|
||||
-- Same superset as rtps; rtpt repeats rtps three times, so every rtps input also applies here.
|
||||
"C2_VXY0", "C2_VZ0", "C2_VXY1", "C2_VZ1", "C2_VXY2", "C2_VZ2",
|
||||
"C2_RGB", "C2_OTZ",
|
||||
"C2_IR0", "C2_IR1", "C2_IR2", "C2_IR3",
|
||||
"C2_SZ0", "C2_SZ1", "C2_SZ2", "C2_SZ3",
|
||||
"gte_cr_RT11", "gte_cr_RT12", "gte_cr_RT13",
|
||||
"gte_cr_RT21", "gte_cr_RT22", "gte_cr_RT23",
|
||||
"gte_cr_RT31", "gte_cr_RT32", "gte_cr_RT33",
|
||||
"gte_cr_TRX", "gte_cr_TRY", "gte_cr_TRZ",
|
||||
"gte_cr_OFX", "gte_cr_OFY", "gte_cr_H",
|
||||
"gte_cr_DQA", "gte_cr_DQB",
|
||||
},
|
||||
-- NCLIP: reads SXY0/SXY1/SXY2 only (per PSX-SPX gtepipelinetimings.md §12.6).
|
||||
["gte_cmdw_nclip"] = {
|
||||
"C2_SXY0", "C2_SXY1", "C2_SXY2",
|
||||
},
|
||||
-- MVMVA: variable (depends on the chosen mx / v / cv selector).
|
||||
-- We Conservatively treats the command's input set as the union of every potential matrix + translation + background-color input.
|
||||
-- Any recent write to one of these registers must retire.
|
||||
["gte_cmdw_mvmva"] = {
|
||||
"C2_VXY0", "C2_VZ0", "C2_VXY1", "C2_VZ1", "C2_VXY2", "C2_VZ2",
|
||||
"C2_IR1", "C2_IR2", "C2_IR3",
|
||||
"gte_cr_RT11", "gte_cr_RT12", "gte_cr_RT13",
|
||||
"gte_cr_RT21", "gte_cr_RT22", "gte_cr_RT23",
|
||||
"gte_cr_RT31", "gte_cr_RT32", "gte_cr_RT33",
|
||||
"gte_cr_TRX", "gte_cr_TRY", "gte_cr_TRZ",
|
||||
},
|
||||
-- OP (outer product): atomic, no inputs that need retiring (the command reads IR1..IR3 but they are local accumulators not driven by the CPU).
|
||||
-- The dependency window is the IRGB fan-out (3 cycles) on the OUTPUT side, not the input side.
|
||||
["gte_cmdw_op"] = {},
|
||||
-- AVSZ3 / AVSZ4: read SZ0..SZ3 + ZSF3/ZSF4.
|
||||
["gte_cmdw_avsz3"] = {
|
||||
"C2_SZ0", "C2_SZ1", "C2_SZ2", "C2_SZ3",
|
||||
"gte_cr_ZSF3",
|
||||
},
|
||||
["gte_cmdw_avsz4"] = {
|
||||
"C2_SZ0", "C2_SZ1", "C2_SZ2", "C2_SZ3",
|
||||
"gte_cr_ZSF4",
|
||||
},
|
||||
}
|
||||
|
||||
-- Aliases (must have the same value as their canonical target)
|
||||
["gte_cmdw_rotate_translate_perspective_single"] = 2,
|
||||
["gte_cmdw_rotate_translate_perspective_triple"] = 2,
|
||||
["gte_cmdw_avg_sort_z4"] = 2,
|
||||
-- COP2 write-retire slot table.
|
||||
--
|
||||
-- Number of cached instruction slots required for a recent CPU-to-COP2 write to retire before the next dependent command can issue.
|
||||
-- Per PSX-SPX `cpuspecifications.md:407-419` and `gtepipelinetimings.md`
|
||||
-- (every per-input N for a recent mtc2/ctc2, with two cached instructions being the common case and three for IRGB / ORGB writes).
|
||||
--
|
||||
-- The model is intentionally small:
|
||||
-- cpu_to_cop2 -- default for any mtc2 / ctc2 / lwc2 / swc2 to a COP2 register
|
||||
-- cpu_to_irgb -- override for writes to the IRGB / ORGB control registers (the documented 3-cycle fan-out)
|
||||
--
|
||||
-- The downstream check (`check_gte_write_retire`) resolves the per-event class from the C2 destination
|
||||
-- and the write kind (gte_mv_to_data_r vs. gte_mv_to_ctrl_r + control-register index).
|
||||
-- A future pass can introduce command-specific per-input-N tables; the structural place to add them is here.
|
||||
M.COP2_WRITE_RETIRE_SLOTS = {
|
||||
cpu_to_cop2 = 2,
|
||||
cpu_to_irgb = 3,
|
||||
}
|
||||
|
||||
-- Outer product aliases (same canonical op, 0 pre-fill nops).
|
||||
-- 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,
|
||||
["gte_cmdw_wedge"] = 0,
|
||||
-- Operand-class table for the COP2->GPR load-delay check.
|
||||
--
|
||||
-- Maps each emitting-token ident to the SET of GPR operand positions it READS (not writes).
|
||||
-- Covers the current encoder vocabulary (`code/duffle/mips.h` + `code/duffle/gte.h`);
|
||||
-- expand by adding rows here as new encoders land.
|
||||
--
|
||||
-- Semantics:
|
||||
-- * A "GPR operand position" is the textual slot in the macro's argument list,
|
||||
-- 1-based; e.g. `load_word(rt, base, off)` has positional operands 1 (rt), 2 (base), 3 (off);
|
||||
-- The table reads operands 1 + 2 + 3 to find what GPRs the macro touches.
|
||||
-- * The check tracks one entry per destination GPR per MFC2/CFC2 event.
|
||||
-- A subsequent event is considered a "use" iff any of its READ operand positions reference that destination GPR's ident (e.g. `R_T0`).
|
||||
-- * Branch delay slots are out of scope (MIPS control-flow; tracked separately).
|
||||
M.OPERAND_READ_POSITIONS = {
|
||||
-- CPU ALU with one or two GPR operands. Reads every GPR operand.
|
||||
["add_ui"] = {1, 2},
|
||||
["add_ui_self"] = {1},
|
||||
["add_si"] = {1, 2},
|
||||
["add_u"] = {1, 2, 3},
|
||||
["add_u_self"] = {1, 2},
|
||||
["sub_s"] = {1, 2, 3},
|
||||
["sub_u"] = {1, 2, 3},
|
||||
["and_i"] = {1, 2},
|
||||
["and_u"] = {1, 2, 3},
|
||||
["or_i"] = {1, 2},
|
||||
["or_i_self"] = {1},
|
||||
["or_u"] = {1, 2, 3},
|
||||
["or_u_self"] = {1, 2},
|
||||
["xor_i"] = {1, 2},
|
||||
["xor_u"] = {1, 2, 3},
|
||||
["slt_s"] = {1, 2, 3},
|
||||
["slt_u"] = {1, 2, 3},
|
||||
["slt_si"] = {1, 2},
|
||||
["slt_ui"] = {1, 2},
|
||||
["mult_s"] = {1, 2},
|
||||
["mult_u"] = {1, 2},
|
||||
["div_s"] = {1, 2},
|
||||
["div_u"] = {1, 2},
|
||||
-- Shifts: shift_lleft(rd, rt, shamt); the rt operand is the value, rd is dest.
|
||||
["shift_lleft"] = {1, 2},
|
||||
["shift_lright"] = {1, 2},
|
||||
["shift_aright"] = {1, 2},
|
||||
["shift_lleft_self"] = {1},
|
||||
-- Loads: load_word(rt, base, off); the rt operand is the destination (so it's WRITTEN, not read) and base + off are non-GPR operands.
|
||||
-- Treat load_* as NOT reading any GPR operand position (the rt WRITE is not a read for our purposes).
|
||||
-- The single operand in the table for `load_*` is `rt`, but the check treats it as a write, so we leave the read-positions table empty.
|
||||
["load_word"] = {},
|
||||
["load_half_u"] = {},
|
||||
["load_byte_u"] = {},
|
||||
["load_half"] = {},
|
||||
["load_byte"] = {},
|
||||
["load_upper_i"] = {},
|
||||
["load_ui"] = {},
|
||||
-- Stores write to memory; base + rt operands are non-read for load-delay purposes.
|
||||
["store_word"] = {},
|
||||
["store_half"] = {},
|
||||
["store_byte"] = {},
|
||||
-- Branches read rs (+ rt for beq/bne). The branch delay slot is out of scope.
|
||||
["branch_equal"] = {1, 2},
|
||||
["branch_ne"] = {1, 2},
|
||||
["branch_le_zero"] = {1},
|
||||
["branch_lt_zero"] = {1},
|
||||
["branch_ge_zero"] = {1},
|
||||
["branch_gt_zero"] = {1},
|
||||
-- Jumps / link: jr / jalr read rs only (the target). RD is the destination link.
|
||||
["jump_reg"] = {1},
|
||||
["jump_link"] = {1},
|
||||
["call_reg"] = {1},
|
||||
["call_addr"] = {},
|
||||
["jump"] = {},
|
||||
-- mask_upper is a 2-word macro: shift_lleft then shift_lright. The first reads rt.
|
||||
["mask_upper"] = {1, 2},
|
||||
-- move from/to HI/LO.
|
||||
["mov_from_high"] = {},
|
||||
["mov_from_low"] = {},
|
||||
["mov_to_high"] = {1},
|
||||
["mov_to_low"] = {1},
|
||||
-- GTE transfers / loads / stores / commands: the relevant table values live in the check itself
|
||||
-- (gte_mv_to_* writes its rt operand, gte_mv_from_* writes its rt operand, and `gte_*` commands are atomic-from-the-CPU-POV once they issue.
|
||||
-- They don't trigger load-delay violations because the CPU holds until the command completes).
|
||||
["gte_mv_from_data_r"] = {},
|
||||
["gte_mv_from_ctrl_r"] = {},
|
||||
["gte_mv_to_data_r"] = {},
|
||||
["gte_mv_to_ctrl_r"] = {},
|
||||
["gte_lw"] = {},
|
||||
["gte_sw"] = {},
|
||||
}
|
||||
|
||||
-- GP0 packet sizes (total words including the 1-word tag) per GP0 cmd byte.
|
||||
@@ -885,13 +1039,13 @@ M.INSTRUCTION_LATENCY = {
|
||||
["set_lt_u"] = 1, ["set_lt_ui"] = 1,
|
||||
["set_lt_s"] = 1, ["set_lt_si"] = 1,
|
||||
-- Multiply / divide (no hardware multiplier; software via inline asm)
|
||||
["mult_u"] = 12, ["mult_s"] = 12,
|
||||
["div_u"] = 35, ["div_s"] = 35,
|
||||
["mult_u"] = 12, ["mult_s"] = 12,
|
||||
["div_u"] = 35, ["div_s"] = 35,
|
||||
-- Loads (1 cycle + load-delay slot; the delay is typically absorbed by
|
||||
-- the next instruction in a well-pipelined sequence, so we count 1)
|
||||
["load_word"] = 1,
|
||||
["load_half_u"] = 1, ["load_half"] = 1,
|
||||
["load_byte_u"] = 1, ["load_byte"] = 1,
|
||||
["load_half_u"] = 1, ["load_half"] = 1,
|
||||
["load_byte_u"] = 1, ["load_byte"] = 1,
|
||||
["load_upper_i"] = 1,
|
||||
-- 2-word loads (lui + ori) used for >16-bit immediates
|
||||
["load_imm"] = 2,
|
||||
@@ -922,9 +1076,9 @@ M.INSTRUCTION_LATENCY = {
|
||||
["gte_mv_from_ctrl_r"] = 1,
|
||||
["gte_lw"] = 1, ["gte_lwc2"] = 1,
|
||||
["gte_sw"] = 1, ["gte_swc2"] = 1,
|
||||
-- COP2 commands (intrinsic cycles per PSX-SPX, 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)
|
||||
-- COP2 commands (intrinsic cycles per PSX-SPX,
|
||||
-- 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"] = 23, -- RTPT: 23 cycles (PSX-SPX)
|
||||
["gte_cmdw_rtps"] = 15, -- RTPS: 15 cycles (PSX-SPX)
|
||||
["gte_cmdw_nclip"] = 8, -- NCLIP: 8 cycles (PSX-SPX)
|
||||
@@ -955,8 +1109,7 @@ M.INSTRUCTION_LATENCY = {
|
||||
["gte_load_v2"] = 2,
|
||||
["gte_load_v0v1v2"] = 6,
|
||||
-- mac_* helpers (cycle cost = sum of the expanded instructions)
|
||||
-- mac_yield transfers control; cycle budget is 0 (the next atom
|
||||
-- absorbs the cost).
|
||||
-- mac_yield transfers control; cycle budget is 0 (the next atom absorbs the cost).
|
||||
["mac_yield"] = 0,
|
||||
["mac_pack_color_word"] = 3, -- lui + ori + sw
|
||||
["mac_format_f3_color"] = 3, -- = mac_pack_color_word
|
||||
@@ -983,4 +1136,244 @@ M.INSTRUCTION_LATENCY = {
|
||||
-- advisory so the cycle budget stays accurate as the codebase grows.
|
||||
M.UNKNOWN_INSTRUCTION_CYCLES = 1
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Section 8: Cross-source component-body index + word-event expansion
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Two pure helpers that supersede the per-pass local component-body builders (`atoms_source_map.build_cross_source_component_body_index`)
|
||||
-- and provide the shared, memoized "semantic emitted-word event stream" every downstream pass can read from without re-walking the pre-tokenized bodies.
|
||||
|
||||
--- @class ComponentBodyEntry
|
||||
--- @field body_tokens table -- pre-tokenized {{tok=string, rel=integer}, ...}
|
||||
--- @field body_off integer -- byte offset of body[1] in `source`
|
||||
--- @field line_of fun(pos:integer):integer -- byte-offset → 1-based line number in `source`
|
||||
--- @field source string -- absolute path of the source containing the declaration
|
||||
--- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration
|
||||
--- @field kind string -- "comp_bare" | "comp_proc"
|
||||
|
||||
--- @class WordEvent
|
||||
--- @field word integer -- 0-based word index across the entire expansion (root atom + recursed bodies)
|
||||
--- @field ident string -- leading identifier of the emitting token (for nop2 → "nop")
|
||||
--- @field args string[] -- top-level comma-split args of the emitting token (trimmed)
|
||||
--- @field source string -- where the token is defined (component source for recursed; atom source for root)
|
||||
--- @field line integer -- source line of the token (within `source`)
|
||||
--- @field call_source string -- always the ROOT atom's source path (preserved across recursion)
|
||||
--- @field call_line integer -- root atom's call-site line (preserved across recursion)
|
||||
|
||||
--- @class WordEventError
|
||||
--- @field kind string -- "cycle" (currently the only error kind)
|
||||
--- @field msg string -- deterministic human-readable description
|
||||
--- @field source string -- path of the source containing the offending token
|
||||
--- @field line integer -- 1-based line of the offending token within `source`
|
||||
|
||||
--- Build (and memoize) the cross-source component-body index keyed by the BARE component name (`gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`).
|
||||
---
|
||||
--- Components are declared in one source (the header holding `MipsAtomComp_(ac_X)` or `MipsAtomComp_Proc_(ac_X, { ... })`) but invoked from any source that calls `mac_X(...)`.
|
||||
--- Body-offsets + body_tokens + line_of live with the declaration, so a per-source index misses invocations from other sources
|
||||
--- (this is the bug class that motivated moving the index to duffle).
|
||||
---
|
||||
--- Only `comp_bare` / `comp_proc` declarations contribute (a `mac_X(...)` invocation can only resolve to one of those).
|
||||
--- First declaration wins; subsequent redeclarations would collide, but today's sources declare each component exactly once.
|
||||
---
|
||||
--- The memoized table is stored at `ctx.shared.component_body_index` so callers can detect "already built" without re-scanning every source. Idempotent:
|
||||
--- safe to call from multiple passes within the same build.
|
||||
--- @param ctx table -- the PassCtx; reads `ctx.sources` + writes `ctx.shared.component_body_index`
|
||||
--- @return table<string, ComponentBodyEntry>
|
||||
function M.get_component_body_index(ctx)
|
||||
local shared = ctx.shared or {}
|
||||
if shared.component_body_index ~= nil then return shared.component_body_index end
|
||||
local index = {}
|
||||
for _, src in ipairs(ctx.sources or {}) do
|
||||
if src.scan and src.scan.atoms then
|
||||
local line_of = src.scan.line_of
|
||||
for _, atom in ipairs(src.scan.atoms) do
|
||||
if atom.kind == "comp_bare" or atom.kind == "comp_proc" then
|
||||
-- Prefer `atom.name` (the bare identifier, stripped of `ac_`); fall back to `raw_name`
|
||||
-- only if the stripped name is absent (defensive — current scan-source always sets both).
|
||||
local name = atom.name or atom.raw_name
|
||||
if name and not index[name] then
|
||||
index[name] = {
|
||||
body_tokens = atom.body_tokens,
|
||||
body_off = atom.body_off,
|
||||
line_of = line_of,
|
||||
source = src.path,
|
||||
declaration = atom.line,
|
||||
kind = atom.kind,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
shared.component_body_index = index
|
||||
ctx.shared = shared
|
||||
return index
|
||||
end
|
||||
|
||||
-- ASCII byte constants used by split_top_level_args (kept local to keep Section 8 self-contained).
|
||||
local E_BYTE_OPEN_PAREN = 0x28
|
||||
local E_BYTE_OPEN_BRACE = 0x7B
|
||||
local E_BYTE_OPEN_BRACK = 0x5B
|
||||
local E_BYTE_DQUOTE = 0x22
|
||||
local E_BYTE_SQUOTE = 0x27
|
||||
local E_BYTE_COMMA = 0x2C
|
||||
|
||||
-- Map an open-delimiter byte to its matching close string for read_balanced.
|
||||
local E_OPEN_CLOSE = {
|
||||
[E_BYTE_OPEN_PAREN] = ")",
|
||||
[E_BYTE_OPEN_BRACE] = "}",
|
||||
[E_BYTE_OPEN_BRACK] = "]",
|
||||
}
|
||||
|
||||
-- Split the INSIDE of a `f(...)` call on top-level commas.
|
||||
-- Honors nested parens / braces / brackets and skips strings / comments.
|
||||
-- Returns a list of trimmed argument strings in source order.
|
||||
-- (Mirrors split_top_level_commas but for paren-body args; intentionally distinct so a caller's brace-body split isn't confused with an arg list.)
|
||||
-- @param inner string
|
||||
-- @return string[]
|
||||
local function split_top_level_args(inner)
|
||||
local args = {}
|
||||
if not inner or inner == "" then return args end
|
||||
local pos = 1
|
||||
local len = #inner
|
||||
local start = 1
|
||||
while pos <= len do
|
||||
local c = inner:byte(pos)
|
||||
local close = E_OPEN_CLOSE[c]
|
||||
if close then
|
||||
local _, after = M.read_balanced(inner, string.char(c), close, pos)
|
||||
pos = after
|
||||
elseif c == E_BYTE_DQUOTE or c == E_BYTE_SQUOTE then
|
||||
pos = M.skip_str_or_cmt(inner, pos)
|
||||
elseif c == E_BYTE_COMMA then
|
||||
args[#args + 1] = M.trim(inner:sub(start, pos - 1))
|
||||
start = pos + 1
|
||||
pos = pos + 1
|
||||
else
|
||||
pos = pos + 1
|
||||
end
|
||||
end
|
||||
if start <= len then args[#args + 1] = M.trim(inner:sub(start, len)) end
|
||||
return args
|
||||
end
|
||||
|
||||
-- Extract the leading identifier + top-level args list from a token string.
|
||||
-- Returns (ident, args). For tokens without a `(...)` call, args is `{}`.
|
||||
-- @param tok string
|
||||
-- @return string, string[]
|
||||
local function token_ident_and_args(tok)
|
||||
local ident, after = M.read_ident(tok, 1)
|
||||
if not ident then return "?", {} end
|
||||
local paren_pos = M.skip_ws_and_cmt(tok, after)
|
||||
if tok:sub(paren_pos, paren_pos) ~= "(" then return ident, {} end
|
||||
local inner = M.read_parens(tok, paren_pos)
|
||||
if not inner then return ident, {} end
|
||||
return ident, split_top_level_args(inner)
|
||||
end
|
||||
|
||||
-- The macro-name prefix that marks a `mac_X(...)` component invocation.
|
||||
local E_MAC_PREFIX = "mac_"
|
||||
local E_MAC_PREFIX_LEN = 4
|
||||
|
||||
--- Expand a body entry into the flat sequence of emitted machine-word events.
|
||||
---
|
||||
--- Semantics (one event per emitted machine word):
|
||||
--- * **Direct one-word encoders** (`load_word`, `add_ui`, `nop`, `gte_lw`, ...): one event with `ident` = leading ident, `args` = parsed top-level args.
|
||||
--- * **`nop2`** (2-word pseudo-instruction): two events, BOTH with `ident = "nop"` so the canonical "this slot is a no-op" semantic is visible to downstream analyses.
|
||||
--- * **Any other N-word token** in `word_counts` (e.g. `mask_upper` = 2, `load_imm_2w` = 2): N events sharing the same `ident` + `args` so useful CPU words retire slots in the cycle budget.
|
||||
--- * **Known `mac_X(...)` calls**: recursively expand the indexed component body, including nested components. Every event from the expansion carries:
|
||||
--- - `source` / `line` = the COMPONENT'S source path + the line of the token within the component body (i.e. "definition site").
|
||||
--- - `call_source` / `call_line` = the ROOT atom's source path + call-site line, PRESERVED across recursion (nested-nested events still point at the original root, not at an intermediate component).
|
||||
--- * **Unknown `mac_X`** (not in `component_index`): fall back to `word_counts[ident]` if present; otherwise emit exactly one opaque event so the cycle budget still accounts for the word.
|
||||
--- * **Marker tokens** (`atom_label(...)` / `atom_offset(...)`): zero events (they are pure metaprogram hints, not emitted machine words).
|
||||
---
|
||||
--- Cycle protection: a per-expansion `visiting` set tracks components currently on the expansion stack; a re-entry produces a deterministic `{kind = "cycle", ...}` error and aborts that branch (does NOT hang, does NOT recurse).
|
||||
---
|
||||
--- Pure: does NOT mutate `body_entry`, `component_index`, or `word_counts`. Memoization is the caller's responsibility (callers that want it precomputed for many atoms should memoize `word_events` / `word_event_errors` per atom).
|
||||
--- @param body_entry table -- `{body_tokens, body_off, line_of, source, declaration}` (declaration = root atom's atom.line)
|
||||
--- @param component_index table -- the bare-name → ComponentBodyEntry map from M.get_component_body_index
|
||||
--- @param word_counts table -- macro name → emitted-word count (from `ctx.shared.word_counts`)
|
||||
--- @return WordEvent[], WordEventError[]
|
||||
function M.expand_word_events(body_entry, component_index, word_counts)
|
||||
local events = {}
|
||||
local errors = {}
|
||||
|
||||
-- `word_idx` is 0-based across the entire expansion (root atom body + every recursed component body).
|
||||
-- Each emitted machine word consumes one slot.
|
||||
local word_idx = 0
|
||||
|
||||
local root_call_source = body_entry.source
|
||||
local root_call_line = body_entry.declaration or 0
|
||||
|
||||
local function expand(tokens, body_off, line_of, def_source, call_source, call_line, visiting)
|
||||
for _, bt in ipairs(tokens) do
|
||||
local tok = M.trim(bt.tok or "")
|
||||
if tok ~= "" then
|
||||
local ident, args = token_ident_and_args(tok)
|
||||
local tok_line = (line_of and line_of(body_off + bt.rel)) or 0
|
||||
|
||||
if ident == "atom_label" or ident == "atom_offset" then
|
||||
-- Marker: zero events.
|
||||
else
|
||||
-- Strip the `mac_` prefix to look up the component by its BARE name.
|
||||
local bare = nil
|
||||
if ident:sub(1, E_MAC_PREFIX_LEN) == E_MAC_PREFIX then
|
||||
bare = ident:sub(E_MAC_PREFIX_LEN + 1)
|
||||
end
|
||||
|
||||
if bare and component_index and component_index[bare] then
|
||||
if visiting[bare] then
|
||||
-- Cycle: this component is already on the expansion stack.
|
||||
errors[#errors + 1] = {
|
||||
kind = "cycle",
|
||||
msg = string.format("component cycle detected involving %q", bare),
|
||||
source = def_source,
|
||||
line = tok_line,
|
||||
}
|
||||
else
|
||||
visiting[bare] = true
|
||||
local inner = component_index[bare]
|
||||
-- `call_source` / `call_line` (the ROOT atom site) are PRESERVED — we do NOT
|
||||
-- update them when recursing. Nested events keep pointing at the original root atom.
|
||||
expand(inner.body_tokens, inner.body_off, inner.line_of,
|
||||
inner.source, call_source, call_line, visiting)
|
||||
visiting[bare] = nil
|
||||
end
|
||||
else
|
||||
-- Direct token (or unknown `mac_X` falling back). Emit `n` events.
|
||||
local n = 1
|
||||
if word_counts and word_counts[ident] then n = word_counts[ident] end
|
||||
local out_ident = (ident == "nop2") and "nop" or ident
|
||||
for _ = 1, n do
|
||||
word_idx = word_idx + 1
|
||||
events[#events + 1] = {
|
||||
word = word_idx - 1,
|
||||
ident = out_ident,
|
||||
args = args,
|
||||
source = def_source,
|
||||
line = tok_line,
|
||||
call_source = call_source,
|
||||
call_line = call_line,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Initial call: the root atom body. `def_source` and `call_source` both start at the atom's source;
|
||||
-- `call_line` starts at the atom's declaration line (every event from the root body inherits this).
|
||||
expand(
|
||||
body_entry.body_tokens,
|
||||
body_entry.body_off or 0,
|
||||
body_entry.line_of,
|
||||
body_entry.source,
|
||||
root_call_source,
|
||||
root_call_line,
|
||||
{})
|
||||
|
||||
return events, errors
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -240,7 +240,7 @@ end
|
||||
|
||||
-- Pure-Lua 5.3 LEB128 readers (no `bit` library). `2^shift` arithmetic matches the existing parser.
|
||||
-- Offsets are 0-based; returns (value, next_pos).
|
||||
-- Track A Task 10: promoted from `local function` to M.* exports so passes/dwarf_injection.lua
|
||||
-- Promoted from `local function` to M.* exports so passes/dwarf_injection.lua
|
||||
-- can import them as file-scope locals per the 2nd-caller lift precedent
|
||||
-- (the uleb128 + sleb128 encoders were promoted the same way).
|
||||
function M.read_uleb128_at(buf, pos)
|
||||
|
||||
@@ -387,8 +387,7 @@ end
|
||||
|
||||
--- Migration warning emitted alongside the new registry-membership check.
|
||||
---
|
||||
--- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase
|
||||
--- are the wave-context aliases opted in via `#define atom_reg` in lottes_tape.h (Task 21).
|
||||
--- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase are the context aliases opted in via `#define atom_reg` in lottes_tape.h.
|
||||
--- Any source referencing an R_X that's NOT in the registry will trip the new check; a single pass-level info entry
|
||||
--- (emitted only when at least one such rejection lands in this source) tells users where to look.
|
||||
---
|
||||
|
||||
@@ -641,42 +641,6 @@ end
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Build the cross-source component body index used by `render_provenance` to attribute each emitted `.word` to its actual line within the macro body.
|
||||
---
|
||||
--- Components are declared in one source (the header that contains `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
|
||||
--- but invoked from many source files (every atom body that calls `mac_X(...)`).
|
||||
--- The body_offset + body_tokens + line_of live with the declaration source, so a per-source index would miss invocations from other sources.
|
||||
---
|
||||
--- The cross-source index is keyed by the bare component name (`gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`)
|
||||
--- `strip_mac_prefix_from_token` strips the `mac_` prefix from call-site identifiers and yields that exact bare name;
|
||||
--- matching it here keeps the lookup aligned with the `ctx.shared.components` map's keying convention.
|
||||
--- First declaration wins (subsequent redeclarations would collide; today's sources declare each component exactly once).
|
||||
--- @param ctx PassCtx
|
||||
--- @return table<string, table> -- {[comp_name] = {body_off, body_tokens, line_of}}
|
||||
local function build_cross_source_component_body_index(ctx)
|
||||
local index = {}
|
||||
for _, src in ipairs(ctx.sources or {}) do
|
||||
if src.scan and src.scan.atoms then
|
||||
local line_of = src.scan.line_of
|
||||
for _, atom in ipairs(src.scan.atoms) do
|
||||
if atom.kind == "comp_bare" or atom.kind == "comp_proc" then
|
||||
-- Prefer `atom.name` (stripped of `ac_` prefix); fall back to `raw_name`
|
||||
-- only if the stripped name is absent (defensive — current scan-source always sets both).
|
||||
local name = atom.name or atom.raw_name
|
||||
if name and not index[name] then
|
||||
index[name] = {
|
||||
body_off = atom.body_off,
|
||||
body_tokens = atom.body_tokens,
|
||||
line_of = line_of,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
--- Pass entry: emit one `<out_root>/<basename>.atoms.sourcemap.txt` per source file that contains at least one `MipsAtom_(name)` / `MipsCode code_<name>` declaration.
|
||||
--- Also emits `<out_root>/<basename>.atoms.provenance.txt`:
|
||||
--- per-.word provenance with `mac_X(...)` component resolution back to the component's definition file:line + the per-word body line.
|
||||
@@ -704,10 +668,13 @@ function M.run(ctx)
|
||||
local comp = (ctx.shared and ctx.shared.components) or {}
|
||||
|
||||
-- Cross-source component body index.
|
||||
-- Built ONCE so every source's provenance writer can resolve `mac_X(...)` invocations back to the macro's body tokens (regardless of which source declared the component).
|
||||
-- Per-source copies were insufficient — the atom file (`hello_gte_tape.c`) does not contain the `MipsAtomComp_(...)` declarations,
|
||||
-- Built ONCE (and memoized at `ctx.shared.component_body_index`) so every source's provenance writer can resolve `mac_X(...)`
|
||||
-- invocations back to the macro's body tokens (regardless of which source declared the component).
|
||||
-- The atom file (`hello_gte_tape.c`) does not contain the `MipsAtomComp_(...)` declarations,
|
||||
-- so the body data would be missing for every component invocation the atom file emitted.
|
||||
local comp_body_index = build_cross_source_component_body_index(ctx)
|
||||
-- Superseded by `duffle.get_component_body_index` so the same index is shared with `static_analysis.lua`
|
||||
-- and any future dependency-check pass (sourcemap/provenance byte-identical because the new entry only ADDS fields).
|
||||
local comp_body_index = duffle.get_component_body_index(ctx)
|
||||
|
||||
-- Always emit the canonical text form (per-source).
|
||||
for _, src in ipairs(ctx.sources) do
|
||||
|
||||
@@ -235,7 +235,6 @@ local function extract_arg_names(args_str)
|
||||
if trimmed ~= "" then
|
||||
-- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`),
|
||||
-- then walk back over the identifier chars (alnum + `_`).
|
||||
-- Plex: inlined the 2 single-caller helpers (no 2-caller rule met).
|
||||
local ident_end = #trimmed
|
||||
while ident_end > 0 do
|
||||
local ch = trimmed:sub(ident_end, ident_end)
|
||||
|
||||
@@ -904,7 +904,6 @@ end
|
||||
---
|
||||
--- Pre-tokenized: `body_tokens` is the scan-source pass's pre-split list of top-level
|
||||
--- statements (each entry is a single `load_word(...)` call or other statement).
|
||||
--- Plex: prepass move — use the pre-computed data instead of re-walking the body text.
|
||||
--- @param body_tokens table[] -- the atom's pre-tokenized body statements (from atom.body_tokens)
|
||||
--- @param binds_name string -- expected Binds_X name (skip pairs with mismatching binds)
|
||||
--- @param registries table -- merged registries from collect_per_source_registries
|
||||
@@ -1540,8 +1539,7 @@ local function build_new_strings(atom_table, registries)
|
||||
-- Register names (one per unique debug-visible R_Name alias from the merged registry,
|
||||
-- filtered to MIPS GPR 0..31 — the same filter that build_inserted_children applies
|
||||
-- for the RR_<name> locals, so .debug_str entries stay in sync with .debug_info).
|
||||
-- Plex: Lua's pairs() is non-deterministic; sort the alias names first so the emitted
|
||||
-- .debug_str bytes are byte-identical across runs.
|
||||
-- Lua's pairs() is non-deterministic; sort the alias names first so the emitted$ .debug_str bytes are byte-identical across runs.
|
||||
local sorted_alias_names = {}
|
||||
for r_name, alias in pairs(registries.register_alias_registry or {}) do
|
||||
if alias.code and alias.code >= 0 and alias.code <= 31 then
|
||||
@@ -1648,7 +1646,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
|
||||
local insertion_start = main_cu_end_excl - 1
|
||||
|
||||
-- Closure state: bytes + next_offset + the typed-view/structure/abstract offset caches.
|
||||
-- All step-emitters mutate this state in place. Plex: Fleury expose structure over interface.
|
||||
-- All step-emitters mutate this state in place.
|
||||
local S = {
|
||||
bytes = {},
|
||||
next_offset = insertion_start, -- 0-based section offset of the NEXT byte to emit
|
||||
@@ -1658,7 +1656,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
|
||||
member_base_type_offsets = {}, -- {[tn.."|"..byte_size.."|"..encoding] = section_offset}
|
||||
base_type_section_offset = nil, -- set by emit_unsigned_int_base_type
|
||||
}
|
||||
-- Plex helpers.
|
||||
|
||||
local function emit(s)
|
||||
S.bytes[#S.bytes + 1] = s
|
||||
S.next_offset = S.next_offset + #s
|
||||
@@ -2023,7 +2021,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
|
||||
end
|
||||
end
|
||||
|
||||
-- 5-step precedence chain. Plex: data table; the dispatch loop runs the first step that yields a non-nil offset.
|
||||
-- 5-step precedence chain. The dispatch loop runs the first step that yields a non-nil offset.
|
||||
-- Each step returns the type's section offset or nil if it missed.
|
||||
-- Adding a step = 1 row in the table + 1 function. The 5-level nested if/else is gone.
|
||||
-- Per-atom precomputed state is captured in upvalues: atom_view, reg_to_field_ctx, atom_view_ctx_fields,
|
||||
@@ -2068,7 +2066,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
|
||||
end,
|
||||
}
|
||||
|
||||
-- Plex: iterate `by_alias` in sorted order (Lua's pairs() is non-deterministic;
|
||||
-- Iterate `by_alias` in sorted order (Lua's pairs() is non-deterministic;
|
||||
-- sorting ensures byte-identical DWARF output across builds).
|
||||
for _, r_name in ipairs(by_alias_order) do
|
||||
local alias = by_alias[r_name]
|
||||
|
||||
@@ -706,7 +706,7 @@ local function scan_skip_qualifiers(source, pos)
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Track A helpers: enum / atom_reg / R_*_Code parsing
|
||||
-- enum / atom_reg / R_*_Code parsing
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- The parser walks `enum { <body> }` declarations and emits one AliasEntry per R_* entry whose value is followed by a bare `atom_reg` token.
|
||||
@@ -804,7 +804,7 @@ local function parse_enum_int_literal(text, start)
|
||||
end
|
||||
|
||||
-- Returns true iff `name` matches the `R_*_Code` ident pattern (R, _, <anything>, _Code).
|
||||
-- Uses byte checks for the fixed bytes per the Track A spec.
|
||||
-- Uses byte checks for the fixed bytes.
|
||||
local function is_r_code_macro(name)
|
||||
if not name or #name < 6 then return false end
|
||||
if name:byte(1) ~= BYTE_R then return false end
|
||||
@@ -1581,7 +1581,7 @@ local DECL_PARSERS = {
|
||||
typedef = parse_typedef_binds,
|
||||
_Pragma = parse_pragma_macro,
|
||||
pragma = parse_pragma_dummy,
|
||||
-- Track A: `enum [<tag>] { <body> }` populates `out.register_alias_registry`.
|
||||
-- `enum [<tag>] { <body> }` populates `out.register_alias_registry`.
|
||||
enum = parse_enum,
|
||||
}
|
||||
|
||||
@@ -1612,20 +1612,20 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
|
||||
types = {},
|
||||
atom_views = {},
|
||||
line_of = line_of,
|
||||
-- Track A: source-derived register-alias registry (atom_reg opt-in entries).
|
||||
-- Source-derived register-alias registry (atom_reg opt-in entries).
|
||||
-- Keys are full R_* idents (never stripped); see parse_enum / parse_enum_body.
|
||||
register_alias_registry = {},
|
||||
-- Track A: source-derived type-name registry.
|
||||
-- Source-derived type-name registry.
|
||||
-- Populated from `typedef Struct_(...)`, `typedef Enum_(...)`, `typedef <type> <alias>`, and `typedef <type> TSet_(<name>)` declarations.
|
||||
-- The propagation pass at the end of `scan_source()` resolves byte_size via the builtin map,
|
||||
-- typedef chain walking (cycle-guarded, depth <= 8), and struct field sums.
|
||||
-- See `propagate_type_sizes()` below.
|
||||
type_name_registry = {},
|
||||
-- Track A: shared `R_*_Code -> integer code` registry
|
||||
-- Shared `R_*_Code -> integer code` registry
|
||||
-- (passed in from M.run pass 1; same reference so preprocessor intercept writes are visible to the enum-value resolver).
|
||||
-- Stripped from `src.scan` before return.
|
||||
_code_macros = code_macros or {},
|
||||
-- Track A: shared raw RHS body table (passed in from M.run pass 1a;
|
||||
-- Shared raw RHS body table (passed in from M.run pass 1a;
|
||||
-- same reference so preprocessor intercept writes are visible to the cross-source chain walker in resolve_code_macro_value).
|
||||
-- Stripped from `src.scan` before return.
|
||||
_code_macro_bodies = code_macro_bodies or {},
|
||||
@@ -1642,8 +1642,7 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
|
||||
-- _Pragma is an operator (not a directive) — it doesn't start with #.
|
||||
local pp_pos = duffle.skip_preprocessor_line(source, pos)
|
||||
if pp_pos then
|
||||
-- Track A intercept: resolve `#define R_*_Code <int-or-symbol>`
|
||||
-- into the shared `_code_macros` registry before skipping the line.
|
||||
-- Resolve `#define R_*_Code <int-or-symbol>` into the shared `_code_macros` registry before skipping the line.
|
||||
try_extract_code_macro(source, pos, out._code_macros, out._code_macro_bodies)
|
||||
pos = pp_pos
|
||||
else
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
--- passes/static_analysis.lua — Per-atom static-analysis checks.
|
||||
---
|
||||
--- The 9 checks currently shipped:
|
||||
--- Per-atom rules:
|
||||
--- 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.
|
||||
--- Per-source rules (registry-driven, added 2026-07-16):
|
||||
--- 6. **enum_alias_membership** — every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`,
|
||||
--- `atom_type(...)`, `atom_reads`, or `atom_writes` must be in `scan.register_alias_registry`. Missing -> warning.
|
||||
--- 7. **atom_type_consistency** — every `reg_type_overrides[R_X].type_name` must resolve in `scan.type_name_registry`. Missing -> error.
|
||||
--- 8. **binds_no_substruct_deref** — every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body
|
||||
--- must reference a leaf scalar (pointer-to-struct counts as leaf; nested struct members do NOT). Missing -> warning (build continues).
|
||||
--- 9. **reads_writes_alias_membership** — distinct check name duplicating #6's reads/writes coverage so the report can
|
||||
--- attribute failures to a precedence class. Missing -> warning (build continues).
|
||||
--- Per-atom rules:
|
||||
--- 1. gte_write_retire: Every `gte_mv_to_data_r` / `gte_mv_to_ctrl_r` (CPU-to-COP2 write) must retire the documented slot count
|
||||
--- (`duffle.COP2_WRITE_RETIRE_SLOTS`: 2 slots default, 3 slots for writes to `gte_cr_IRGB` / `gte_cr_ORGB`)
|
||||
--- before any subsequent `gte_cmdw_*` consumes one of the command's input registers (`duffle.GTE_COMMAND_INPUTS`).
|
||||
--- The check walks `atom.paths.word_events` (the semantic emitted-word stream). Every emitted machine word
|
||||
--- (including CPU ALU, branch, GTE transfer, `nop`/`nop2` half, and `mac_X(...)`-expanded words) counts as one retired slot.
|
||||
--- 2. cop2_gpr_load_delay: Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` (COP2-to-GPR read) requires 1 retired slot
|
||||
--- before the destination GPR can be used as an operand of the next instruction.
|
||||
--- The check walks `atom.paths.word_events` and consults `duffle.OPERAND_READ_POSITIONS` to classify which emitted tokens read each GPR operand position.
|
||||
--- Branch delay slots are out of scope (separate MIPS control-flow concern).
|
||||
--- 3. mac_yield uniformity: Every atom body must contain exactly one `mac_yield()` call (control transfer pattern).
|
||||
--- 4. Binding handoff: Every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration.
|
||||
--- 5. 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.
|
||||
--- 6. Per-Atom Cycle Budget: Sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total.
|
||||
---
|
||||
--- Per-source rules (registry-driven):
|
||||
--- 7. enum_alias_membership: Every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`, `atom_type(...)`, `atom_reads`, or `atom_writes`
|
||||
--- must be in `scan.register_alias_registry`.
|
||||
--- 8. atom_type_consistency: Every `reg_type_overrides[R_X].type_name` must resolve in `scan.type_name_registry`.
|
||||
--- 9. binds_no_substruct_deref: Every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body must reference a leaf scalar (pointer-to-struct counts as leaf;
|
||||
--- nested struct members do NOT).
|
||||
--- 10. reads_writes_alias_membership: Distinct check name duplicating #7's reads/writes coverage so the report can attribute failures to a precedence class.
|
||||
---
|
||||
--- 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 = "<out_root>/<basename>.static_analysis.txt" } } }`
|
||||
--- `["static-analysis"] = {
|
||||
--- module = "passes.static_analysis",
|
||||
--- kind = "diagnostic",
|
||||
--- deps = {"word-counts", "components"},
|
||||
--- out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } }
|
||||
--- }
|
||||
--- `kind = "diagnostic"` keeps every finding visible in the report; the orchestrator does not exit non-zero
|
||||
--- on static-analysis errors. Annotation and header-output validation remain build-stopping.
|
||||
---
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible.
|
||||
|
||||
@@ -122,7 +134,7 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
|
||||
--- @field total_cycles integer -- sum of token cycle costs
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- classify_tokens — per-token classification (the plex's pre-computed data layer)
|
||||
-- classify_tokens — per-token classification
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ONE forward pass over the token list produces a flat table of per-token classifications.
|
||||
@@ -250,54 +262,208 @@ local function classify_tokens(tokens)
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Check #1: GTE pipeline-fill
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Check #1: GTE CPU→C2 write retirement (producer/consumer scoreboard).
|
||||
--
|
||||
-- Reads `atom.paths.word_events` (the semantic emitted-word stream from `duffle.expand_word_events`).
|
||||
-- Every `gte_mv_to_data_r` / `gte_mv_to_ctrl_r` event stages a write into a pending-writes queue;
|
||||
-- Every `gte_cmdw_*` event intersects its command input set against the still-pending queue.
|
||||
-- A write that has not retired its documented slot count (`M.COP2_WRITE_RETIRE_SLOTS.cpu_to_cop2`, override for writes to gte_cr_IRGB / gte_cr_ORGB) is reported as an `error`.
|
||||
--
|
||||
-- Convention: every emitted instruction slot — CPU ALU, branch, GTE transfer, GTE command, `nop`, `mask_upper` half, and every word expanded from a `mac_X(...)` component;
|
||||
-- Counts as one retired slot.
|
||||
-- This replaces the prior literal-NOP-prefix heuristic; the canonical `nop2` is the conservative fallback for callers that haven't modeled their producer/consumer pairs.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Check a single gte_cmdw_* token for pipeline-fill compliance.
|
||||
-- Uses the pre-computed `tok_class` entry (nop_prefix replaces the backward walk; ident replaces the per-token match).
|
||||
local function check_one_gte_cmdw(atom, tc_entry, ti, line_in_body, findings)
|
||||
local ident = tc_entry.ident
|
||||
if not ident:match("^gte_cmdw_") then return end
|
||||
-- Return true iff `reg_ident` is an IRGB/ORGB write that requires the 3-cycle fan-out.
|
||||
local function is_irgb_destination(reg_ident)
|
||||
return reg_ident == "gte_cr_IRGB" or reg_ident == "gte_cr_ORGB"
|
||||
end
|
||||
|
||||
local variant = ident:match("^gte_cmdw_(.+)$")
|
||||
local need = duffle.GTE_PIPELINE_LATENCY[ident]
|
||||
local line = atom.line + line_in_body[atom.paths.tokens[ti].rel]
|
||||
-- Look up the alias → canonical mapping.
|
||||
-- Defaults to the input ident so MVMVA variants + unknown idents surface as `command_unknown` warnings rather than being silently treated as 0-cycle.
|
||||
local function canonical_command(ident)
|
||||
local aliases = duffle.GTE_COMMAND_ALIASES or {}
|
||||
return aliases[ident] or ident
|
||||
end
|
||||
|
||||
if need == nil then
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name,
|
||||
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.GTE_PIPELINE_LATENCY -- add a min_nops entry",
|
||||
atom.name, line, variant),
|
||||
}
|
||||
elseif need > 0 then
|
||||
local have = tc_entry.nop_prefix
|
||||
if have < need then
|
||||
local function push_write(pending, reg_ident, slots, source_path, line)
|
||||
pending[#pending + 1] = {
|
||||
reg = reg_ident,
|
||||
slots = slots,
|
||||
source = source_path,
|
||||
line = line,
|
||||
}
|
||||
end
|
||||
|
||||
local function retire_writes(pending, max_elapsed)
|
||||
-- Walk pending in FIFO order; advance their slot counters; remove any whose counter has met or exceeded the required retire window.
|
||||
-- The walk index advances monotonically; once a pending entry's `elapsed >= required` the slot is freed and the next pending entry can retire on the same cycle.
|
||||
local out = {}
|
||||
for i = 1, #pending do
|
||||
pending[i].elapsed = (pending[i].elapsed or 0) + max_elapsed
|
||||
if pending[i].elapsed < pending[i].slots then
|
||||
out[#out + 1] = pending[i]
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function check_gte_write_retire(atom, pipe_ctx, findings)
|
||||
local events = atom.paths.word_events
|
||||
if not events or #events == 0 then return end
|
||||
local pending = {}
|
||||
local inputs = duffle.GTE_COMMAND_INPUTS or {}
|
||||
local slots_def = duffle.COP2_WRITE_RETIRE_SLOTS or { cpu_to_cop2 = 2, cpu_to_irgb = 3 }
|
||||
|
||||
for _, ev in ipairs(events) do
|
||||
-- Every non-write slot retires pending writes by 1.
|
||||
if ev.ident == "gte_mv_to_data_r" or ev.ident == "gte_mv_to_ctrl_r" then
|
||||
local dest_reg = ev.args and ev.args[2] or nil
|
||||
if dest_reg then
|
||||
local required = is_irgb_destination(dest_reg) and slots_def.cpu_to_irgb or slots_def.cpu_to_cop2
|
||||
push_write(pending, dest_reg, required, ev.source, ev.line)
|
||||
end
|
||||
-- Advance pre-existing writes by 1 (this very slot counts).
|
||||
pending = retire_writes(pending, 1)
|
||||
elseif ev.ident == "gte_cmdw_rtps" or ev.ident == "gte_cmdw_rtpt" or ev.ident == "gte_cmdw_nclip"
|
||||
or ev.ident == "gte_cmdw_mvmva" or ev.ident == "gte_cmdw_op"
|
||||
or ev.ident == "gte_cmdw_avsz3" or ev.ident == "gte_cmdw_avsz4"
|
||||
or duffle.GTE_COMMAND_ALIASES[ev.ident] ~= nil
|
||||
then
|
||||
-- A dependent GTE command stalls until in-flight commands complete (hardware interlock).
|
||||
-- We still retire all pending writes by 1 for the slot this command occupies, then check the still-pending writes against the command's input set.
|
||||
-- If the command reads a register whose write is still pending, that is a true hazard.
|
||||
pending = retire_writes(pending, 1)
|
||||
local canonical = canonical_command(ev.ident)
|
||||
local cmd_inputs = inputs[canonical]
|
||||
if cmd_inputs == nil then
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name,
|
||||
line = ev.line,
|
||||
check = "gte_write_retire",
|
||||
kind = "warning",
|
||||
msg = string.format("%s at line %d uses `%s` but the canonical command is not in GTE_COMMAND_INPUTS -- add an entry (or confirm the alias)"
|
||||
, atom.name, ev.line, ev.ident
|
||||
),
|
||||
}
|
||||
else
|
||||
for _, pw in ipairs(pending) do
|
||||
-- Check intersection with the command's input set.
|
||||
for _, in_reg in ipairs(cmd_inputs) do
|
||||
if pw.reg == in_reg then
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name,
|
||||
line = ev.line,
|
||||
check = "gte_write_retire",
|
||||
kind = "error",
|
||||
msg = string.format("%s at line %d: recent %s at %s:%d has not retired (need %d slot(s); %s reads %s at slot %d)"
|
||||
, atom.name, ev.line, pw.reg, pw.source, pw.line, pw.slots, ev.ident, in_reg, pw.slapsed or 0
|
||||
),
|
||||
}
|
||||
break -- one finding per pending write per command
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- A command does NOT introduce a new pending CPU→C2 write
|
||||
-- (the command writes its RESULT registers, which are not subject to the CPU-side retire window).
|
||||
-- We leave `pending` as-is after the input-set check so the next event sees the post-retire state.
|
||||
else
|
||||
-- Plain CPU word / branch / GTE transfer read / nop2 half / etc.
|
||||
pending = retire_writes(pending, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- (Surface expansion-cycle diagnostics as informational findings, not errors.)
|
||||
local errors = atom.paths.word_event_errors or {}
|
||||
for _, e in ipairs(errors) do
|
||||
if e.kind == "cycle" then
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name,
|
||||
line = line,
|
||||
check = "gte_pipeline_fill",
|
||||
kind = "error",
|
||||
msg = string.format(
|
||||
"%s at line %d needs %d nop word%s immediately BEFORE `gte_cmdw_%s`; only %d found",
|
||||
atom.name, line, need, need == 1 and "" or "s", variant, have),
|
||||
line = e.line,
|
||||
check = "gte_write_retire",
|
||||
kind = "info",
|
||||
msg = string.format("%s at line %d: %s", atom.name, e.line, e.msg),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Per-atom: check every `gte_cmdw_*` for pipeline-fill compliance.
|
||||
--- Uses the pre-computed `atom.paths.tok_class` — nop_prefix (forward-pass pre-compute)
|
||||
--- replaces the old backward walk; ident replaces the per-token `tok:match` classification.
|
||||
local function check_gte_pipeline_fill(atom, pipe_ctx, findings)
|
||||
local tc = atom.paths.tok_class
|
||||
local line_in_body = atom.paths.line_in_body
|
||||
local tn = #atom.paths.tokens
|
||||
for ti = 1, tn do
|
||||
check_one_gte_cmdw(atom, tc[ti], ti, line_in_body, findings)
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Check #1b: COP2→GPR load delay (mfc2 / cfc2 → first GPR consumer).
|
||||
--
|
||||
-- Reads `atom.paths.word_events`. Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` stages a destination GPR + 1 remaining slot.
|
||||
-- A subsequent event that READS from that GPR (per the OPERAND_READ_POSITIONS table + operand-position rules)
|
||||
-- within the same instruction slot emits a finding with `kind = "error"`.
|
||||
--
|
||||
-- Branch delay slots + BD-slot absorption are out of scope (separate MIPS control-flow concern).
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Return the textual ident of the GPR read at `pos` in the macro's argument list, or nil if the operand is not a GPR.
|
||||
-- Operands that are numeric literals (e.g. `0`, `4`, `0xFFFF`) or type keywords (`U4`, `C2_VZ2`) are not GPR reads.
|
||||
local function operand_gpr_at(ev, pos)
|
||||
local op = ev.args and ev.args[pos] or nil
|
||||
if not op then return nil end
|
||||
if op:sub(1, 2) == "0x" or op:sub(1, 2) == "0X" then return nil end
|
||||
if op:match("^%d") then return nil end -- numeric literal
|
||||
if op == "true" or op == "false" then return nil end
|
||||
-- Strip any trailing whitespace; the operator scanner already trims, but be defensive against raw args.
|
||||
return op:match("^%s*(%S+)%s*$")
|
||||
end
|
||||
|
||||
local function check_cop2_gpr_load_delay(atom, pipe_ctx, findings)
|
||||
local events = atom.paths.word_events
|
||||
if not events or #events == 0 then return end
|
||||
local pending = {} -- { gpr = "R_T0", line = N, source = path, slots = 1 }
|
||||
|
||||
for _, ev in ipairs(events) do
|
||||
if ev.ident == "gte_mv_from_data_r" or ev.ident == "gte_mv_from_ctrl_r" then
|
||||
local dst = ev.args and ev.args[1] or nil
|
||||
if dst then
|
||||
pending[#pending + 1] = {
|
||||
gpr = dst,
|
||||
slots = 1,
|
||||
source = ev.source,
|
||||
line = ev.line,
|
||||
}
|
||||
end
|
||||
-- The transfer itself counts as one slot (the destination GPR is updated after the next instruction), so the existing pending list advances.
|
||||
for i = 1, #pending do pending[i].slots = pending[i].slots - 1 end
|
||||
local next_pending = {}
|
||||
for _, p in ipairs(pending) do
|
||||
if p.slots > 0 then next_pending[#next_pending + 1] = p end
|
||||
end
|
||||
pending = next_pending
|
||||
else
|
||||
-- Resolve read positions for this emitting token.
|
||||
local read_pos = duffle.OPERAND_READ_POSITIONS and duffle.OPERAND_READ_POSITIONS[ev.ident]
|
||||
if read_pos then
|
||||
for _, pw in ipairs(pending) do
|
||||
for _, pos in ipairs(read_pos) do
|
||||
local op = operand_gpr_at(ev, pos)
|
||||
if op and op == pw.gpr then
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name,
|
||||
line = ev.line,
|
||||
check = "cop2_gpr_load_delay",
|
||||
kind = "error",
|
||||
msg = string.format("%s at line %d: %s reads %s but the load from %s at %s:%d has not retired (need 1 slot)"
|
||||
, atom.name, ev.line, ev.ident, pw.gpr, ev.ident, pw.source, pw.line
|
||||
),
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Advance all pending writes by 1 for the slot this instruction occupies.
|
||||
for i = 1, #pending do pending[i].slots = pending[i].slots - 1 end
|
||||
local next_pending = {}
|
||||
for _, p in ipairs(pending) do
|
||||
if p.slots > 0 then next_pending[#next_pending + 1] = p end
|
||||
end
|
||||
pending = next_pending
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -308,8 +474,9 @@ end
|
||||
--- Every atom body must contain exactly one `mac_yield()` call and it must be the LAST top-level token in the body
|
||||
--- (so the tape runtime can pick up cleanly at the next atom's bound registers).
|
||||
---
|
||||
--- Empty bodies are not currently flagged — runtime infrastructure atoms like `MipsAtom_(yield) { mac_yield() }`
|
||||
--- and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end is the contract.
|
||||
--- Empty bodies are not currently flagged — runtime infrastructure atoms like
|
||||
--- `MipsAtom_(yield) { mac_yield() }` and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }`
|
||||
--- are valid as-is; mac_yield at the end is the contract.
|
||||
--- Stage 2: signature uniformized to `(atom, pipe_ctx, findings)` — pipe_ctx is ignored here.
|
||||
local function check_mac_yield_uniformity(atom, pipe_ctx, findings)
|
||||
-- Per-kind semantics:
|
||||
@@ -402,22 +569,22 @@ local function check_mac_yield_uniformity(atom, pipe_ctx, findings)
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Check #3: ABI handoff discipline
|
||||
-- Check #3: Binding handoff discipline
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- For every atom with `atom_bind(Binds_X)`, verify the atom body reads every field of `Binds_X` from R_TapePtr (in any order)
|
||||
--- and advances R_TapePtr by S_(Binds_X) at the end. Mismatches are errors.
|
||||
---
|
||||
--- This is the "job boundary sanity check": Binds_X is the atom's input payload (like a C function's argument struct).
|
||||
--- The body must read each input field and advance the input cursor past the payload.
|
||||
--- The order of reads doesn't matter — each field is at a different offset in the struct, and the advance at the end is what keeps the tape pointer in sync.
|
||||
--- Binds_X is the atom phase's input payload (like a C function's argument struct).
|
||||
--- The body must read each input field and advance the input cursor past the payload. The order of reads doesn't matter.
|
||||
--- Each field is at a different offset in the struct, and the advance at the end is what keeps the tape pointer in sync.
|
||||
---
|
||||
--- Rules:
|
||||
--- 1. Body MUST contain one `load_word(R_*, R_TapePtr, O_(Binds_X, field))` per field of Binds_X. Missing field = error.
|
||||
--- 2. Body MUST contain an `add_ui_self(R_TapePtr, S_(Binds_X))` (or equivalent advance by the struct's byte count). Missing = error.
|
||||
--- 3. atom_bind(Binds_X) where Binds_X doesn't exist = error.
|
||||
--- Per-atom: verify the atom body reads every field of its `Binds_X` from R_TapePtr and advances R_TapePtr by S_(Binds_X).
|
||||
--- Signature changed in Stage 1B: takes `(atom, pipe_ctx, findings)` where `pipe_ctx` carries the cross-atom
|
||||
--- Per-atom: Verify the atom body reads every field of its `Binds_X` from R_TapePtr and advances R_TapePtr by S_(Binds_X).
|
||||
--- Signature changed in Stage 1B: Takes `(atom, pipe_ctx, findings)` where `pipe_ctx` carries the cross-atom
|
||||
--- `info_by_atom` + `binds_index` tables (built once by validate() before the per-atom loop).
|
||||
--- Per-atom iteration now lives in validate(); this is a per-atom predicate.
|
||||
local function check_abi_handoff(atom, pipe_ctx, findings)
|
||||
@@ -575,10 +742,8 @@ end
|
||||
--- cycles_min - shortest path through the body (sum of token costs)
|
||||
--- cycles_max - longest path through the body
|
||||
--- branches - number of branches in the body
|
||||
--- paths - number of distinct paths reached (terminated at
|
||||
--- mac_yield or end-of-body)
|
||||
--- has_loops - true iff a path re-entered a token it had visited
|
||||
--- (warning; loop bodies aren't supported)
|
||||
--- paths - number of distinct paths reached (terminated at mac_yield or end-of-body)
|
||||
--- has_loops - true iff a path re-entered a token it had visited (warning; loop bodies aren't supported)
|
||||
--- unknown_macros - list of unique macro names not in duffle.INSTRUCTION_LATENCY
|
||||
local function analyze_atom_paths(atom)
|
||||
local tokens = atom.paths.tokens or duffle.tokenize_body(atom.body)
|
||||
@@ -753,7 +918,7 @@ end
|
||||
-- Severity: WARNING (build continues).
|
||||
-- The rule is intentionally permissive because the production `code/duffle/` and `code/gte_hello/`
|
||||
-- sources use R_* aliases in atom_reads / atom_writes that may not yet be opted in via the
|
||||
-- bare `atom_reg` marker. R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase ARE opted in (lottes_tape.h Task 21).
|
||||
-- bare `atom_reg` marker. R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase ARE opted in.
|
||||
-- Raw C-ABI aliases like R_T0..R_T3 are intentionally NOT auto-included (per the prototype principle:
|
||||
-- no auto-include of wave-context; explicit opt-in only). Warnings keep the build green
|
||||
-- and surface the migration gap so users see which atoms still need opt-in registration.
|
||||
@@ -761,7 +926,7 @@ local function check_enum_alias_membership(_src, pipe_ctx, findings)
|
||||
local reg_registry = pipe_ctx.register_alias_registry or {}
|
||||
|
||||
-- (a) atom_dbg_reg_default(R_X, T) -- pipe_ctx.types.
|
||||
-- source_line is on every entry; emit the diagnostic against the default declaration's own line so the report's
|
||||
-- source_line is on every entry; emit the diagnostic against the default declaration's own line so the report's
|
||||
-- "Findings by atom" section can attribute the failure to the marker location.
|
||||
for reg, def in pairs(pipe_ctx.types or {}) do
|
||||
if not reg_registry[reg] then
|
||||
@@ -996,14 +1161,15 @@ end
|
||||
-- This is the plex pattern: the iteration is in ONE place (validate), the variation is in DATA (this table).
|
||||
|
||||
local CHECK_RULES = {
|
||||
{ name = "gte_pipeline_fill", per_atom = check_gte_pipeline_fill },
|
||||
{ name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity },
|
||||
{ name = "abi_handoff", per_atom = check_abi_handoff },
|
||||
{ name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape },
|
||||
{ name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget },
|
||||
{ name = "enum_alias_membership", per_source = check_enum_alias_membership },
|
||||
{ name = "atom_type_consistency", per_source = check_atom_type_consistency },
|
||||
{ name = "binds_no_substruct_deref", per_source = check_binds_no_substruct_deref },
|
||||
{ name = "gte_write_retire", per_atom = check_gte_write_retire },
|
||||
{ name = "cop2_gpr_load_delay", per_atom = check_cop2_gpr_load_delay },
|
||||
{ name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity },
|
||||
{ name = "abi_handoff", per_atom = check_abi_handoff },
|
||||
{ name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape },
|
||||
{ name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget },
|
||||
{ name = "enum_alias_membership", per_source = check_enum_alias_membership },
|
||||
{ name = "atom_type_consistency", per_source = check_atom_type_consistency },
|
||||
{ name = "binds_no_substruct_deref", per_source = check_binds_no_substruct_deref },
|
||||
{ name = "reads_writes_alias_membership",per_source = check_reads_writes_alias_membership},
|
||||
}
|
||||
|
||||
@@ -1048,13 +1214,17 @@ local function validate(ctx, src)
|
||||
atoms = atoms,
|
||||
types = scan.types or {},
|
||||
atom_infos_list = atom_infos or {},
|
||||
register_alias_registry = scan.register_alias_registry or {},
|
||||
register_alias_registry = scan.register_alias_registry or {},
|
||||
type_name_registry = scan.type_name_registry or {},
|
||||
}
|
||||
-- Shared cross-source component-body index (built once per pass via duffle's memoizing helper).
|
||||
-- `atom.paths.word_events` + `atom.paths.word_event_errors` are populated below and consumed by
|
||||
-- the per-atom checks (`check_gte_write_retire`, `check_cop2_gpr_load_delay`).
|
||||
pipe_ctx.component_body_index = duffle.get_component_body_index(ctx)
|
||||
|
||||
-- THE per-atom pipeline. ONE iteration of atoms; the 5 check_* functions + analyze_atom_paths
|
||||
-- all run here, sharing a single tokenize_body + build_body_line_index per body.
|
||||
-- Plex move: every piece of state derived from an atom body lives on `atom.paths` (the per-atom mega-struct);
|
||||
-- Every piece of state derived from an atom body lives on `atom.paths` (the per-atom mega-struct);
|
||||
-- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list.
|
||||
-- Stage 1B: each check_* now takes `(atom, ...)` instead of `(atoms, findings)` — no more single-atom `{a}` shim.
|
||||
-- Per-source rules run once after this loop completes (no parallel dispatch table).
|
||||
@@ -1065,11 +1235,24 @@ local function validate(ctx, src)
|
||||
a.paths.line_in_body = duffle.build_body_line_index(a.body)
|
||||
a.paths.tok_class = classify_tokens(a.paths.tokens)
|
||||
|
||||
-- Precompute the semantic emitted-word event stream for this atom. The per-atom checks
|
||||
-- (`check_gte_write_retire`, `check_cop2_gpr_load_delay`) read these to retire slots on the
|
||||
-- actual emitted machine words (including `mac_X(...)`-expanded words from nested components).
|
||||
local body_entry = {
|
||||
body_tokens = a.body_tokens,
|
||||
body_off = a.body_off,
|
||||
line_of = src.scan.line_of,
|
||||
source = src.path,
|
||||
declaration = a.line,
|
||||
}
|
||||
a.paths.word_events, a.paths.word_event_errors =
|
||||
duffle.expand_word_events(body_entry, pipe_ctx.component_body_index, ctx.shared.word_counts or {})
|
||||
|
||||
-- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths.
|
||||
analyze_atom_paths(a)
|
||||
|
||||
-- Run all per-atom checks on this one atom via the CHECK_RULES data table (Muratori: data over control flow).
|
||||
-- Adding a new check = 1 row in CHECK_RULES; this loop never needs editing.
|
||||
-- Adding a new check = 1 row in CHECK_RULES; this loop never needs editing.
|
||||
for _, rule in ipairs(CHECK_RULES) do
|
||||
if rule.per_atom then rule.per_atom(a, pipe_ctx, findings) end
|
||||
end
|
||||
|
||||
+82
-272
@@ -62,10 +62,11 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
|
||||
|
||||
--- @class PassDescriptor
|
||||
--- @field module string -- module name passed to require()
|
||||
--- @field kind string -- "shared" | "header-output" | "validation" | "report"
|
||||
--- @field kind string -- "shared" | "header-output" | "validation" | "diagnostic" | "report"
|
||||
--- -- Report severity is independent from process exit policy (see PASS_KIND_STOP_ON_ERROR).
|
||||
--- @field deps string[] -- names of upstream passes
|
||||
--- @field groups string[]? -- OPTIONAL build-phase groups this pass is a root of
|
||||
--- -- (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only
|
||||
--- -- (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only
|
||||
--- @field desc string -- human description (used by --help + ASCII graph)
|
||||
--- @field out PassOutput[] -- output paths (used by --dry-run + report)
|
||||
|
||||
@@ -114,18 +115,15 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
|
||||
--- @field verbose boolean -- if true, log diagnostic info
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- PASSES table (data, not code) — the orchestrator's dep graph
|
||||
-- PASSES Table
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Build-phase groups: each PASSES row may declare membership in one or more
|
||||
-- named groups via `groups = { ... }`. The CLI flags --pre-link and
|
||||
-- --post-link request the *roots* of their group; topo_sort then closes
|
||||
-- transitive dependencies from those roots, and dispatch_passes runs every
|
||||
-- pass in the resulting closure without phase-filtering.
|
||||
-- Build-phase groups: Each PASSES row may declare membership in one or more named groups via `groups = { ... }`.
|
||||
-- The CLI flags --pre-link and --post-link request the *roots* of their group; topo_sort then closes transitive dependencies from those roots,
|
||||
-- and dispatch_passes runs every pass in the resulting closure without phase-filtering.
|
||||
--
|
||||
-- A row without a `groups` entry is dependency-only: it runs only when a
|
||||
-- transitive dep requests it, but it remains directly requestable through
|
||||
-- its explicit CLI flag (e.g. --atoms-source-map, --scan-source).
|
||||
-- A row without a `groups` entry is dependency-only: it runs only when a transitive dep requests it,
|
||||
-- but it remains directly requestable through its explicit CLI flag (e.g. --atoms-source-map, --scan-source).
|
||||
|
||||
local PASSES = {
|
||||
["scan-source"] = {
|
||||
@@ -167,7 +165,10 @@ local PASSES = {
|
||||
},
|
||||
["static-analysis"] = {
|
||||
module = "passes.static_analysis",
|
||||
kind = "validation",
|
||||
-- "diagnostic" — every `error`/`warning` finding is written to the report file;
|
||||
-- the orchestrator does NOT exit non-zero on these findings (see PASS_KIND_STOP_ON_ERROR).
|
||||
-- Report severity is independent from process exit policy.
|
||||
kind = "diagnostic",
|
||||
deps = {"scan-source", "word-counts", "components"},
|
||||
desc = "Static analysis: GTE pipeline-fill, mac_yield uniformity, ABI handoff, GPU port-store shape, per-atom cycle budget, type consistency",
|
||||
out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } },
|
||||
@@ -210,10 +211,8 @@ local PASSES = {
|
||||
}
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- Phase-root selection: derive the sorted set of roots belonging to a named
|
||||
-- build-phase group, then append them to `args.requested_set`. topo_sort
|
||||
-- closes the transitive deps from there; dispatch_passes runs every resolved
|
||||
-- pass without phase-filtering.
|
||||
-- Phase-root selection: derive the sorted set of roots belonging to a named build-phase group, then append them to `args.requested_set`.
|
||||
-- topo_sort closes the transitive deps from there; dispatch_passes runs every resolved pass without phase-filtering.
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
--- @param group_name string -- the build-phase group ("pre-link" | "post-link")
|
||||
@@ -235,9 +234,8 @@ local function roots_for_group(group_name)
|
||||
end
|
||||
|
||||
--- Append every root belonging to `group_name` to `args.requested_set`.
|
||||
--- Errors loudly if no PASSES row declares the group, so a typo'd or
|
||||
--- future-removed group name cannot silently fall through to pre-link
|
||||
--- (or any other default) and dispatch nothing.
|
||||
--- Errors loudly if no PASSES row declares the group, so a typo'd or future-removed group name
|
||||
--- cannot silently fall through to pre-link (or any other default) and dispatch nothing.
|
||||
--- @param args ParsedArgs
|
||||
--- @param group_name string
|
||||
local function request_roots_for_group(args, group_name)
|
||||
@@ -253,22 +251,26 @@ local function request_roots_for_group(args, group_name)
|
||||
end
|
||||
end
|
||||
|
||||
-- Pass-kind taxonomy: which kinds stop the build on errors?
|
||||
-- Pass-kind taxonomy: Which kinds stop the build on errors?
|
||||
--
|
||||
-- Report severity is independent from process exit policy. A "diagnostic" pass still writes every `error`/`warning` finding into its report file,
|
||||
-- but `report_validation_errors` returns early for non-stopping kinds, so nothing is printed to stderr and the orchestrator does not exit non-zero.
|
||||
--
|
||||
-- Closed-set discipline: adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true".
|
||||
local PASS_KIND_STOP_ON_ERROR = {
|
||||
["shared"] = false,
|
||||
["header-output"] = true,
|
||||
["validation"] = true,
|
||||
["diagnostic"] = false,
|
||||
["report"] = false,
|
||||
}
|
||||
|
||||
-- Closed set of CLI flags -> pass names.
|
||||
-- Per-pass flags (e.g. --word-counts) live here; phase flags (--pre-link,
|
||||
-- --post-link, --all) live in FLAG_HANDLERS because they own side effects
|
||||
-- or invoke group-derivation logic. --dwarf-injection is *also* a per-pass
|
||||
-- opt-in flag, but its selection + opt-in state are both owned by the
|
||||
-- explicit FLAG_HANDLERS entry below (it sets args.flags.dwarf_injection
|
||||
-- and appends "dwarf-injection" to requested_set), so it is intentionally
|
||||
-- absent from this table.
|
||||
-- Per-pass flags (e.g. --word-counts) live here; phase flags (--pre-link, --post-link, --all)
|
||||
-- live in FLAG_HANDLERS because they own side effects or invoke group-derivation logic.
|
||||
-- dwarf-injection is *also* a per-pass opt-in flag, but its selection + opt-in state are both owned by the
|
||||
-- explicit FLAG_HANDLERS entry below (it sets args.flags.dwarf_injection and appends "dwarf-injection" to requested_set),
|
||||
-- so it is intentionally absent from this table.
|
||||
local PASS_FLAG_TO_NAME = {
|
||||
["--word-counts"] = "word-counts",
|
||||
["--components"] = "components",
|
||||
@@ -281,9 +283,8 @@ local PASS_FLAG_TO_NAME = {
|
||||
["--all"] = ALL_PASSES_SENTINEL,
|
||||
}
|
||||
|
||||
--- Append every pass name to args.requested_set. Names are derived from
|
||||
--- PASSES (no parallel name list); used by --all and by any caller that
|
||||
--- wants the full closure.
|
||||
--- Append every pass name to args.requested_set.
|
||||
--- Names are derived from PASSES (no parallel name list); used by --all and by any caller that wants the full closure.
|
||||
--- @param args ParsedArgs
|
||||
local function request_all_passes(args)
|
||||
local names = {}
|
||||
@@ -341,7 +342,7 @@ COMMON_FLAGS:
|
||||
--project-root DIR Project root for .macs.h scan (default: dirname(metadata))
|
||||
--gdb-runtime Also emit <out_root>/gdb_tape_atoms_runtime.gdb (post-link, requires --elf)
|
||||
--elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection)
|
||||
--dry-run Print dep order + ASCII graph; exit 0 without running
|
||||
--dry-run Print dep order (alphabetical); exit 0 without running
|
||||
--verbose Print per-pass debug output
|
||||
--help Show this help and exit
|
||||
|
||||
@@ -379,26 +380,21 @@ FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_roo
|
||||
-- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`).
|
||||
FLAG_HANDLERS["--gdb-runtime"] = function(args) args.flags = args.flags or {}; args.flags.gdb_runtime = true end
|
||||
FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx) args.flags = args.flags or {}; args.flags.elf_path = argv[arg_idx + 1]; return arg_idx + 1 end
|
||||
-- Enable DWARF injection (default OFF). Opts in to the post-link pass and
|
||||
-- sets the flag in one shot — the explicit handler below owns both
|
||||
-- selection and opt-in state, so --dwarf-injection is intentionally absent
|
||||
-- from PASS_FLAG_TO_NAME.
|
||||
-- Enable DWARF injection (default OFF). Opts in to the post-link pass and sets the flag in one shot.
|
||||
-- The explicit handler below owns both selection and opt-in state, so --dwarf-injection is intentionally absent from PASS_FLAG_TO_NAME.
|
||||
FLAG_HANDLERS["--dwarf-injection"] = function(args)
|
||||
args.flags = args.flags or {}
|
||||
args.flags.dwarf_injection = true
|
||||
args.requested_set[#args.requested_set + 1] = "dwarf-injection"
|
||||
end
|
||||
-- Build-phase flags: --pre-link and --post-link request the roots of their
|
||||
-- declared groups (see roots_for_group). topo_sort closes transitive deps
|
||||
-- from those roots; dispatch_passes runs every pass in the resolved
|
||||
-- closure without phase-filtering.
|
||||
-- Build-phase flags: --pre-link and --post-link request the roots of their declared groups (see roots_for_group).
|
||||
-- topo_sort closes transitive deps from those roots; dispatch_passes runs every pass in the resolved closure without phase-filtering.
|
||||
FLAG_HANDLERS["--pre-link"] = function(args)
|
||||
request_roots_for_group(args, "pre-link")
|
||||
end
|
||||
-- Batch post-link phase: gdb-runtime + dwarf-injection in one luajit cold
|
||||
-- start. Sets the same opt-in flags as --gdb-runtime + --dwarf-injection
|
||||
-- and selects the post-link build-phase group.
|
||||
-- --elf is required; parse_args enforces it after all flags are parsed.
|
||||
-- Batch post-link phase: gdb-runtime + dwarf-injection in one luajit cold start.
|
||||
-- Sets the same opt-in flags as --gdb-runtime + --dwarf-injection and selects the post-link build-phase group.
|
||||
-- elf is required; parse_args enforces it after all flags are parsed.
|
||||
FLAG_HANDLERS["--post-link"] = function(args)
|
||||
args.flags = args.flags or {}
|
||||
args.flags.gdb_runtime = true
|
||||
@@ -406,7 +402,7 @@ FLAG_HANDLERS["--post-link"] = function(args)
|
||||
request_roots_for_group(args, "post-link")
|
||||
end
|
||||
|
||||
-- G' (atom locals) is now consolidated into --dwarf-injection; no separate flag.
|
||||
-- (atom locals) is now consolidated into --dwarf-injection; no separate flag.
|
||||
|
||||
-- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set.
|
||||
FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a)
|
||||
@@ -448,9 +444,8 @@ local function parse_args(argv)
|
||||
pos = pos + 1
|
||||
end
|
||||
|
||||
-- Default: --pre-link if no explicit pass flags were given. The first
|
||||
-- invocation of a build is always pre-link, so this avoids silently
|
||||
-- also invoking post-link work in builds without an ELF artifact.
|
||||
-- Default: --pre-link if no explicit pass flags were given.
|
||||
-- The first invocation of a build is always pre-link, so this avoids silently also invoking post-link work in builds without an ELF artifact.
|
||||
if #args.requested_set == 0 then request_roots_for_group(args, "pre-link") end
|
||||
|
||||
-- Defaults: project_root = dirname(metadata).
|
||||
@@ -471,11 +466,9 @@ local function parse_args(argv)
|
||||
os.exit(EXIT_INTERNAL_ERROR)
|
||||
end
|
||||
|
||||
-- Post-link opt-ins (--gdb-runtime, --dwarf-injection) write output that
|
||||
-- depends on the linked ELF. Without --elf the metaprogram can't satisfy
|
||||
-- those requests, so refuse loud and early. This covers the explicit
|
||||
-- --post-link batch, --dwarf-injection by itself, and --gdb-runtime by
|
||||
-- itself.
|
||||
-- Post-link opt-ins (--gdb-runtime, --dwarf-injection) write output that depends on the linked ELF.
|
||||
-- Without --elf the metaprogram can't satisfy those requests, so refuse loud and early.
|
||||
-- This covers the explicit --post-link batch, --dwarf-injection by itself, and --gdb-runtime by itself.
|
||||
local flags = args.flags or {}
|
||||
local elf_path = flags.elf_path
|
||||
local has_elf = type(elf_path) == "string" and #elf_path > 0
|
||||
@@ -556,7 +549,6 @@ end
|
||||
---
|
||||
--- Implementation note: the 4 algorithm phases (dep-closure, in-degree, ready-queue, sort) are inlined as 3 small blocks within this function.
|
||||
--- Each was a 1-caller helper; the 2-caller rule doesn't apply, so inlining produces a single readable function
|
||||
--- (plex: small patterns → shared, but only when shared; here they're not).
|
||||
local function topo_sort(passes, requested_set)
|
||||
-- Phase 1: dep-closure. Include every pass name transitively required by `requested_set`.
|
||||
local needed = {}
|
||||
@@ -637,224 +629,42 @@ end
|
||||
-- ASCII dep graph renderer (Decision 6 in the spec)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- 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.
|
||||
--- @param passes table<string, PassDescriptor>
|
||||
--- @param closed string[] -- dep-closed execution order
|
||||
--- @return string
|
||||
local function render_dep_graph(passes, closed)
|
||||
--- Topological dep-order printer (used by --dry-run).
|
||||
---
|
||||
--- ASCII art graph rendering was removed from this file.
|
||||
--- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual;
|
||||
--- the canonical ASCII view there is regenerated by hand whenever PASSES rows change.
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
local function render_dep_order(passes, closed)
|
||||
local lines = {}
|
||||
local function add(s) lines[#lines + 1] = s end
|
||||
|
||||
add("[ps1_meta] Resolved dependency order (closed under deps):")
|
||||
lines[#lines + 1] = "[ps1_meta] Resolved dependency order (closed under deps):"
|
||||
for pass_idx, name in ipairs(closed) do
|
||||
local p = passes[name]
|
||||
local deps_str = (#p.deps == 0) and "(no deps)" or
|
||||
"(deps: " .. table.concat(p.deps, ", ") .. ")"
|
||||
add(string.format(" %d. %-22s %-45s [%s]",
|
||||
pass_idx, name, deps_str, p.kind))
|
||||
lines[#lines + 1] = string.format(" %d. %-22s %-45s [%s]",
|
||||
pass_idx, name, deps_str, p.kind)
|
||||
end
|
||||
add("")
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
|
||||
-- Data-driven ASCII graph built from the actual PASSES table.
|
||||
-- Kahn layers determine the row of each box; each pass becomes a 4-row
|
||||
-- box (top border, name, kind+output-count, bottom border). Boxes in
|
||||
-- the same layer are rendered side-by-side; layers are connected by a
|
||||
-- 'v' marker row whose 'v' chars are centered under each box, indicating
|
||||
-- the downward 'feeds into' direction.
|
||||
--
|
||||
-- Layout invariants enforced here:
|
||||
-- * MAX_GRAPH_WIDTH = 78 cols: no emitted line exceeds this. The
|
||||
-- "simplest" way to stay under the budget is to limit each sub-row
|
||||
-- to MAX_BOXES_PER_ROW = 3 boxes; for the canonical 9-row PASSES
|
||||
-- table the largest layer has 3 boxes, so no wrap engages today.
|
||||
-- If a future layer grows past 3 boxes, the layer is split into
|
||||
-- adjacent sub-rows (each ending in its own 'v'-marker row).
|
||||
-- * No silent truncation: per-layer box width is computed from the
|
||||
-- layer's widest content (max(name length, kind-suffix length))
|
||||
-- plus a 1-char leading + 1-char trailing padding + 2 wall chars.
|
||||
-- Long names widen the box; they are NEVER truncated.
|
||||
-- * Collision-safety: every PASSES row has a unique name, kind, and
|
||||
-- out-list, so two distinct passes cannot produce visually identical
|
||||
-- boxes.
|
||||
add("[ps1_meta] Pass graph (read top-to-bottom; edges = 'feeds into'):")
|
||||
add("")
|
||||
|
||||
-- Compute Kahn layer per pass: layer L = max(deps' layer) + 1, layer 0
|
||||
-- for deps-less passes. Repeated sweeps until every pass in `closed` is
|
||||
-- assigned (handles forward refs that resolve on the second pass).
|
||||
local pass_layer, max_layer = {}, 0
|
||||
local sorted_closed = {}
|
||||
for _, name in ipairs(closed) do sorted_closed[#sorted_closed + 1] = name end
|
||||
table.sort(sorted_closed)
|
||||
local function assign_layers()
|
||||
local assigned_count = 0
|
||||
for _, name in ipairs(sorted_closed) do
|
||||
if pass_layer[name] == nil then
|
||||
local p = passes[name]
|
||||
local max_dep, ready = -1, true
|
||||
for _, dep in ipairs(p.deps) do
|
||||
if pass_layer[dep] == nil then ready = false; break end
|
||||
if pass_layer[dep] > max_dep then max_dep = pass_layer[dep] end
|
||||
end
|
||||
if ready then
|
||||
pass_layer[name] = max_dep + 1
|
||||
if pass_layer[name] > max_layer then max_layer = pass_layer[name] end
|
||||
assigned_count = assigned_count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return assigned_count
|
||||
end
|
||||
while assign_layers() > 0 do end
|
||||
-- Defensive invariant: any unresolved pass is a bug. topo_sort already
|
||||
-- errors on cycles before this point, so reaching here means a logic
|
||||
-- error in the renderer (or a synthetic call that bypassed topo_sort).
|
||||
-- Surface the failure loudly with the offending names; never silently
|
||||
-- place unresolved passes at layer 0 (which would corrupt the graph).
|
||||
local unresolved = {}
|
||||
for _, name in ipairs(sorted_closed) do
|
||||
if pass_layer[name] == nil then unresolved[#unresolved + 1] = name end
|
||||
end
|
||||
if #unresolved > 0 then
|
||||
error("render_dep_graph: unresolved Kahn layer for pass(es): "
|
||||
.. table.concat(unresolved, ", ")
|
||||
.. "; topo_sort should have caught this earlier")
|
||||
end
|
||||
|
||||
-- Bucket passes by layer; sort each bucket alphabetically for stability.
|
||||
local layers = {}
|
||||
for i = 0, max_layer do layers[i] = {} end
|
||||
for _, name in ipairs(sorted_closed) do
|
||||
layers[pass_layer[name]][#layers[pass_layer[name]] + 1] = name
|
||||
end
|
||||
for i = 0, max_layer do table.sort(layers[i]) end
|
||||
|
||||
-- Layout constants. Boxes in the same layer share the same width
|
||||
-- (computed as the layer's widest content + padding + walls).
|
||||
local MAX_GRAPH_WIDTH = 78
|
||||
local MAX_BOXES_PER_ROW = 3
|
||||
local GAP = 3
|
||||
|
||||
local function pad_right(s, width)
|
||||
if #s >= width then return s:sub(1, width) end
|
||||
return s .. string.rep(" ", width - #s)
|
||||
end
|
||||
|
||||
-- Compute the box width (in chars, including both walls) for a layer.
|
||||
-- The interior is the wider of (a) the longest pass name + 1 leading
|
||||
-- space and (b) the longest "<kind> <N>" suffix + 1 leading space.
|
||||
-- Then add 2 for the wall chars.
|
||||
--
|
||||
-- Why +1 (not +2): the +1 formula means the layer's max-content row
|
||||
-- has 0 padding before the right wall (the `|` is immediately after
|
||||
-- the content). This is the collision-safe widening policy the task
|
||||
-- requires — long names widen the box and never get trailing padding.
|
||||
-- Shorter passes in the same layer get trailing padding to fill the
|
||||
-- interior to the layer's uniform width; they never get truncated.
|
||||
local function box_w_for_layer(bucket)
|
||||
local max_content = 0
|
||||
for _, name in ipairs(bucket) do
|
||||
local p = passes[name]
|
||||
local out_n = #(p.out or {})
|
||||
local kind_suf = string.format("%s %d>", p.kind, out_n)
|
||||
if #name > max_content then max_content = #name end
|
||||
if #kind_suf > max_content then max_content = #kind_suf end
|
||||
end
|
||||
-- Interior = 1 leading space + max_content + 0 trailing (the
|
||||
-- trailing `|` IS the right boundary); walls = 2.
|
||||
return max_content + 3
|
||||
end
|
||||
|
||||
-- Render one pass as a 4-row box at the given box_w. The interior is
|
||||
-- always padded to fit exactly; no string is ever truncated.
|
||||
local function render_box(name, box_w)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Topological dep-order printer (used by --dry-run).
|
||||
--
|
||||
-- ASCII art graph rendering was removed from this file.
|
||||
-- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual;
|
||||
-- The canonical ASCII view there is regenerated by hand whenever PASSES rows change.
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
local function render_dep_order(passes, closed)
|
||||
local lines = {}
|
||||
lines[#lines + 1] = "[ps1_meta] Resolved dependency order (closed under deps):"
|
||||
for pass_idx, name in ipairs(closed) do
|
||||
local p = passes[name]
|
||||
local out_n = #(p.out or {})
|
||||
local kind_suf = string.format("%s %d>", p.kind, out_n)
|
||||
local interior = box_w - 2
|
||||
local border = "+" .. string.rep("-", interior) .. "+"
|
||||
return {
|
||||
border,
|
||||
"|" .. pad_right(" " .. name, interior) .. "|",
|
||||
"|" .. pad_right(" " .. kind_suf, interior) .. "|",
|
||||
border,
|
||||
}
|
||||
local deps_str = (#p.deps == 0) and "(no deps)" or
|
||||
"(deps: " .. table.concat(p.deps, ", ") .. ")"
|
||||
lines[#lines + 1] = string.format(" %d. %-22s %-45s [%s]",
|
||||
pass_idx, name, deps_str, p.kind)
|
||||
end
|
||||
|
||||
-- Join a single row (1..4) across all boxes in a sub-row, with GAP
|
||||
-- spaces between adjacent boxes.
|
||||
local function join_row(box_rows, row_idx)
|
||||
local parts = {}
|
||||
for i, b in ipairs(box_rows) do
|
||||
parts[#parts + 1] = b[row_idx]
|
||||
if i < #box_rows then parts[#parts + 1] = string.rep(" ", GAP) end
|
||||
end
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
-- 'v' marker row beneath a sub-row: one 'v' centered under each box.
|
||||
local function v_marker_row(box_rows)
|
||||
local total = 0
|
||||
local centers = {}
|
||||
for i, b in ipairs(box_rows) do
|
||||
local w = #b[1] -- box width = length of the top border row
|
||||
local center = total + math.floor(w / 2)
|
||||
centers[#centers + 1] = center
|
||||
total = total + w + GAP
|
||||
end
|
||||
-- total now includes a trailing GAP we don't want; trim it.
|
||||
total = total - GAP
|
||||
local s = string.rep(" ", total)
|
||||
for _, c in ipairs(centers) do
|
||||
s = s:sub(1, c) .. "v" .. s:sub(c + 2)
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Render a single sub-row (a contiguous chunk of a layer's bucket).
|
||||
-- Emits the 4 box rows + an empty line + the 'v' marker row + an
|
||||
-- empty line, EXCEPT the very last sub-row of the very last layer
|
||||
-- omits the trailing 'v' marker (nothing flows below it).
|
||||
local function render_subrow(bucket_chunk, is_last_subrow, is_last_layer)
|
||||
local box_w = box_w_for_layer(bucket_chunk)
|
||||
local boxes = {}
|
||||
for _, name in ipairs(bucket_chunk) do boxes[#boxes + 1] = render_box(name, box_w) end
|
||||
add(join_row(boxes, 1)) -- top borders
|
||||
add(join_row(boxes, 2)) -- names
|
||||
add(join_row(boxes, 3)) -- kind + output-count
|
||||
add(join_row(boxes, 4)) -- bottom borders
|
||||
-- 'v' marker row beneath this sub-row connects downward to the
|
||||
-- next sub-row of the same layer (if any) OR to the next layer.
|
||||
-- Skip the trailing 'v' only on the very last sub-row of the
|
||||
-- final layer, where nothing flows below it.
|
||||
if not is_last_subrow or not is_last_layer then
|
||||
add("")
|
||||
add(v_marker_row(boxes))
|
||||
add("")
|
||||
end
|
||||
end
|
||||
|
||||
for layer_idx = 0, max_layer do
|
||||
local bucket = layers[layer_idx]
|
||||
local is_last = (layer_idx == max_layer)
|
||||
-- Split the layer into sub-rows of at most MAX_BOXES_PER_ROW boxes.
|
||||
-- With a 20-char box width (the canonical case: name "static-analysis"
|
||||
-- is 15 chars, suffix "header-output 2>" is 16 chars) and GAP=3,
|
||||
-- 3 boxes per sub-row = 3*20 + 2*3 = 66 cols + a 'v' row of 66 cols;
|
||||
-- well within MAX_GRAPH_WIDTH. A 4th box would push to 4*20 + 3*3 = 89,
|
||||
-- which is why MAX_BOXES_PER_ROW = 3 (wrap when >3).
|
||||
local chunk_size = math.min(MAX_BOXES_PER_ROW, #bucket)
|
||||
if chunk_size < 1 then chunk_size = 1 end
|
||||
for chunk_start = 1, #bucket, chunk_size do
|
||||
local chunk_end = math.min(chunk_start + chunk_size - 1, #bucket)
|
||||
local chunk = {}
|
||||
for i = chunk_start, chunk_end do chunk[#chunk + 1] = bucket[i] end
|
||||
local is_last_subrow = (chunk_end == #bucket)
|
||||
render_subrow(chunk, is_last_subrow, is_last)
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
|
||||
@@ -925,9 +735,10 @@ local function main(argv)
|
||||
local requested = args.requested_set
|
||||
local closed = topo_sort(PASSES, requested)
|
||||
|
||||
-- --dry-run: print dep order + ASCII graph, exit OK.
|
||||
-- --dry-run: print the closed dep order and exit OK.
|
||||
-- (The hand-rendered PASSES graph lives in docs/guide_metaprogram_ssdl.md; see Decision 6.)
|
||||
if args.dry_run then
|
||||
io.write(render_dep_graph(PASSES, closed))
|
||||
io.write(render_dep_order(PASSES, closed))
|
||||
os.exit(EXIT_OK)
|
||||
end
|
||||
|
||||
@@ -944,15 +755,14 @@ local function main(argv)
|
||||
end
|
||||
|
||||
-- Module export for in-process consumers (tests that dofile this script).
|
||||
-- The closure above, `render_dep_graph`, and the canonical `PASSES` table
|
||||
-- are exposed so a test can render the graph for synthetic PASSES tables
|
||||
-- without spawning a subprocess. The conditional `main(...)` call below
|
||||
-- only fires when this file is invoked as the entry script (arg[0] ends
|
||||
-- in "ps1_meta.lua"); in dofile() mode (test's arg[0] does not match),
|
||||
-- main() is skipped and the chunk returns `_M` to the caller.
|
||||
-- The closed dep-order printer + the canonical `PASSES` table are exposed so a test
|
||||
-- can observe the resolved dep order for synthetic PASSES tables without spawning a subprocess.
|
||||
-- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua");
|
||||
-- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller.
|
||||
local _M = {
|
||||
render_dep_graph = render_dep_graph,
|
||||
PASSES = PASSES,
|
||||
render_dep_order = render_dep_order,
|
||||
PASSES = PASSES,
|
||||
PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR,
|
||||
}
|
||||
|
||||
if arg and arg[0] and arg[0]:match("ps1_meta%.lua$") then
|
||||
|
||||
Reference in New Issue
Block a user