From 08af73d0d2103ec7d5089ab966e7347e2352bd93 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 23 Jul 2026 10:18:30 -0400 Subject: [PATCH] Lua Metaprogram: Improvements to static analysis + others. --- code/duffle/lottes_tape.h | 6 +- code/gte_hello/gen/gte_hello.offsets.h | 4 +- code/gte_hello/hello_gte.c | 19 +- code/gte_hello/hello_gte_tape.c | 17 +- scripts/duffle.lua | 563 +++++++++++++++++++++---- scripts/elf_dwarf.lua | 2 +- scripts/passes/annotation.lua | 3 +- scripts/passes/atoms_source_map.lua | 45 +- scripts/passes/components.lua | 1 - scripts/passes/dwarf_injection.lua | 12 +- scripts/passes/scan_source.lua | 17 +- scripts/passes/static_analysis.lua | 349 +++++++++++---- scripts/ps1_meta.lua | 354 ++++------------ 13 files changed, 871 insertions(+), 521 deletions(-) diff --git a/code/duffle/lottes_tape.h b/code/duffle/lottes_tape.h index e1f2808..7cf69e8 100644 --- a/code/duffle/lottes_tape.h +++ b/code/duffle/lottes_tape.h @@ -60,7 +60,7 @@ enum { MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }; /* Generalized Tape Engine Runner */ -FI_ void tape_run(Slice_U4 tape) { register U4* tp rgcc(R_TapePtr) = tape.ptr; asm volatile( +FI_ void tape_run(Slice_MipsCode tape) { register U4* tp rgcc(R_TapePtr) = u4_r(tape.ptr); asm volatile( asm_words( add_ui( R_SP, R_SP, -MipsStackAlignment) /* Allocate stack space */ , store_word( R_RA, R_SP, 0) /* Safely backup $ra to the stack */ @@ -91,8 +91,8 @@ FI_ TapeBuilder tb_make(Slice mem) { return (TapeBuilder){ mem.ptr, mem.len, 0 } FI_ void tb_emit(TapeBuilder* tb, MipsCode* atom) { u4_r(tb->ptr)[tb->used] = u4_(atom); ++ tb->used; } FI_ void tb_data(TapeBuilder* tb, U4 data) { u4_r(tb->ptr)[tb->used] = u4_(data); ++ tb->used; } -FI_ Slice_U4 tb_end (TapeBuilder* tb) { tb_emit(tb,tape_exit); return (Slice_U4){ C_(U4*,tb->ptr), tb->used }; } -FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Slice_U4){ C_(U4*,tb.ptr), tb.used }; } +FI_ Slice_MipsCode tb_end (TapeBuilder* tb) { tb_emit(tb,tape_exit); return (Slice_MipsCode){ C_(U4*,tb->ptr), tb->used }; } +FI_ Slice_MipsCode tb_slice(TapeBuilder tb) { return (Slice_MipsCode){ C_(U4*,tb.ptr), tb.used }; } #define tb_scope(tb) for(U4 tbs_once=0;tbs_once==0;++tbs_once,tb_emit(tb,tape_exit)) #pragma endregion Tape Drive diff --git a/code/gte_hello/gen/gte_hello.offsets.h b/code/gte_hello/gen/gte_hello.offsets.h index e75de50..d5370e6 100644 --- a/code/gte_hello/gen/gte_hello.offsets.h +++ b/code/gte_hello/gen/gte_hello.offsets.h @@ -15,9 +15,9 @@ enum { atom_offset_bounds_chk_cube_g4_face_exit = _atom_offset_bounds_chk_cube_g4_face_exit, }; -// --- atom: floor_f3_face (66 words) --- +// --- atom: floor_f3_face (58 words) --- -#define _atom_offset_culling_floor_f3_face_exit 29 +#define _atom_offset_culling_floor_f3_face_exit 25 #define _atom_offset_bounds_chk_floor_f3_face_exit 13 enum { diff --git a/code/gte_hello/hello_gte.c b/code/gte_hello/hello_gte.c index 7a38603..9302e9e 100644 --- a/code/gte_hello/hello_gte.c +++ b/code/gte_hello/hello_gte.c @@ -99,8 +99,13 @@ typedef Struct_(Ent_Floor) { A2_V3_S2 faces; }; -enum { scratchpad_size = 1024, }; +enum { + Scratchpad_Len = 1024, + MemTape_Len = 512, +}; typedef Struct_(SMemory) { + U4 MemTape[MemTape_Len]; + DoubleBuffer screen_buf; A2_OrderingTable_Buffer ordering_tbl; PrimitiveArena primitives; @@ -207,6 +212,8 @@ void update(PrimitiveArena* pa, U4* ordering_buf) A2_S2 p; //??? S4 flag; //???? + TapeBuilder tb = tb_make(slice_ut_arr(smem.MemTape)); + // Draw Cube if (0) { @@ -259,8 +266,7 @@ void update(PrimitiveArena* pa, U4* ordering_buf) U4 prim_base = u4_(pa->buf[smem.active_buf_id]); U4 prim_cursor = prim_base + pa->used; - LP_ U4 mem_temp_tape[512]; - TapeBuilder tb = tb_make(slice_ut_arr(mem_temp_tape)); tb_scope(& tb) { + tb.used = 0; tb_scope(& tb) { tb_emit(& tb, rbind_cube_g4_face); tb_data(& tb, prim_cursor); tb_data(& tb, u4_(smem.cube.faces)); @@ -344,12 +350,11 @@ void update(PrimitiveArena* pa, U4* ordering_buf) U4 prim_base = u4_(pa->buf[smem.active_buf_id]); U4 prim_cursor = prim_base + pa->used; - // TODO(Ed): We should do a bounds check beforehand to confirm pa can hold all tris. + // TODO(Ed): We should do a bounds check beforehand to confirm pa can hold all tris? // The tape atoms in-flight should not need to care. // Prepare the tape. (Push protocol to tape) - LP_ U4 mem_temp_tape[512]; - TapeBuilder tb = tb_make(slice_ut_arr(mem_temp_tape)); tb_scope(& tb) { + tb.used = 0; tb_scope(& tb) { tb_emit(& tb, set_gte_world); tb_data(& tb, u4_(& smem.tform_world)); @@ -367,7 +372,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf) tb_data(& tb, u4_(& pa->used)); tb_data(& tb, prim_base); } - tape_run(tb_slice(tb));// Fire off the tape. // C-side state (pa->used) has already been updated by the tape! @@ -394,7 +398,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf) B1* prim_cursor = (B1*)r_(pa->buf)[smem.active_buf_id] + pa->used; tape_run(tb_slice(tb)); pa->used = (U4)prim_cursor - (U4)r_(pa->buf)[smem.active_buf_id]; - smem.floor.rot.y += 5; } } diff --git a/code/gte_hello/hello_gte_tape.c b/code/gte_hello/hello_gte_tape.c index 6b4e9df..5d6fbbb 100644 --- a/code/gte_hello/hello_gte_tape.c +++ b/code/gte_hello/hello_gte_tape.c @@ -104,27 +104,26 @@ MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri), atom_phase(f }; internal -atom_dbg_skip_over() +// atom_dbg_skip_over() MipsAtom_(floor_f3_face) atom_info(atom_phase(floor_f3) , atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase) - , atom_writes(R_PrimCursor, R_FaceCursor) + , atom_writes(R_PrimCursor, R_FaceCursr) ) { mac_load_tri_indices( R_T0, R_T1, R_T2), mac_gte_load_tri_verts(R_T0, R_T1, R_T2), - nop2, gte_cmdw_rotate_translate_perspective_triple, - nop2, gte_cmdw_nclip, + nop2, gte_cmdw_rotate_translate_perspective_triple, // 2 nops retire the final cpu -> gte writes before RTPT + /*nop2,*/ gte_cmdw_nclip, /* Culling (Branch forward if Backface) */ - nop2, gte_mv_from_data_r(R_T0, C2_MAC0), - nop, - branch_le_zero(R_T0, atom_offset(culling, floor_f3_face_exit)), nop, + /*nop2,*/ gte_mv_from_data_r(R_T0, C2_MAC0), + nop, branch_le_zero(R_T0, atom_offset(culling, floor_f3_face_exit)), nop, // required gte -> cpu load-delay slot. /* Format Primitive */ mac_format_f3_color(0xFF, 0xFF, 0xFF), // RGB-form (R=FF, G=FF, B=FF = white) mac_gte_store_f3_post_rtpt(), /* Calculate Depth */ - nop2, gte_avg_sort_z3, - nop2, gte_mv_from_data_r(R_T1, C2_OTZ), + /*nop2,*/ gte_avg_sort_z3, + /*nop2,*/ gte_mv_from_data_r(R_T1, C2_OTZ), /* Bounds Check OTZ < 2048 (Branch forward to skip insertion) */ add_ui( R_AT, R_0, OrderingTbl_Len), set_lt_u( R_AT, R_T1, R_AT), diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 8b8afec..41dab09 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -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_, ...` 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_` 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 +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 diff --git a/scripts/elf_dwarf.lua b/scripts/elf_dwarf.lua index aaab7ed..4c7ebde 100644 --- a/scripts/elf_dwarf.lua +++ b/scripts/elf_dwarf.lua @@ -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) diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index 8753825..2b6ce92 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -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. --- diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index d1fcb15..375f153 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -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 -- {[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 `/.atoms.sourcemap.txt` per source file that contains at least one `MipsAtom_(name)` / `MipsCode code_` declaration. --- Also emits `/.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 diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 5aa16c8..ffc8d8b 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -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) diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 865d87c..cbec377 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -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_ 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] diff --git a/scripts/passes/scan_source.lua b/scripts/passes/scan_source.lua index fdeb4fa..f50d596 100644 --- a/scripts/passes/scan_source.lua +++ b/scripts/passes/scan_source.lua @@ -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 { }` 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, _, , _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 [] { }` populates `out.register_alias_registry`. + -- `enum [] { }` 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 `, and `typedef TSet_()` 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 ` - -- into the shared `_code_macros` registry before skipping the line. + -- Resolve `#define R_*_Code ` 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 diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 2b8205d..532d6ad 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -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 = "/.static_analysis.txt" } } }` +--- `["static-analysis"] = { +--- module = "passes.static_analysis", +--- kind = "diagnostic", +--- deps = {"word-counts", "components"}, +--- out = { { kind = "report", path_template = "/.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 diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index 21595b1..15ac911 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -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 = "/.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 /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 ---- @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 " " 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