From 7289e7c89c4321e69dd5d1be30160ef792f17041 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Tue, 4 Aug 2026 16:01:01 -0400 Subject: [PATCH] Added jump_rel (can't use abs jump with asm dsl). Fixes + improvements to ps1 asm meta passes. --- code/duffle/mips.h | 22 +- code/hello_joypad/hello_joypad.c | 16 -- code/hello_joypad/hello_joypad.tape.c | 29 +- scripts/duffle.lua | 164 ++++++++---- scripts/passes/components.lua | 139 +++++++++- scripts/passes/dwarf_injection.lua | 23 +- scripts/passes/offsets.lua | 54 +++- scripts/passes/static_analysis.lua | 371 ++++++++++++++++++++------ 8 files changed, 610 insertions(+), 208 deletions(-) diff --git a/code/duffle/mips.h b/code/duffle/mips.h index a603781..da633e2 100644 --- a/code/duffle/mips.h +++ b/code/duffle/mips.h @@ -362,10 +362,28 @@ enum { _BitOffsets = 0 /* call_reg rs — jump-and-link to register-held address; link in $ra. */ #define call_reg(rs) jump_link((rs), R_RA) -/* j target — absolute jump within the current 256MB region. */ +/* j target — absolute jump within the current 256MB region. + * WARNING: `jump(off)` CANNOT BE USED for within-atom jumps in the current pipeline. + * The MIPS j opcode encodes `(target_addr >> 2)` in its 26-bit immediate field; an ABSOLUTE byte address, not a relative word offset. + * The metaprogram computes `off` as a relative word offset (`target_word_idx - branch_word_idx - 1`), which the assembler/linker does NOT resolve. + * + * `jump(off)` is only safe when the BUILD PIPELINE owns the absolute position of the emitted code — i.e. when: s + * - the build emits a symbol-relative `.word` expression that the linker resolvess via `R_MIPS_26`, OR + * - the code is hand-assembled with explicit absolute targets, OR a custom post-build patcher resolves the 26-bit field. + */ #define jump(off) enc_i(op_j, R_0, R_0, (off)) -/* call_addr off — jump-and-link to immediate address. */ +/* jump_rel off — unconditional relative jump (the within-atom-safe `jump`). + * MIPS I R3000A has no "branch always" opcode. The idiom for an unconditional relative jump is `beq $0, $0, off`. + */ +#define jump_rel(off) branch_equal(R_0, R_0, (off)) + +/* call_addr off — jump-and-link to immediate address. + * + * Same WARNING as `jump(off)` above: the jal opcode also encodes an absolute 26-bit target. + * For within-atom calls, the current pipeline has no equivalent always-taken call-and-link idiom. + * Workaround: `branch_link` (always-taken branch + explicit `la $ra, next_word_addr; jr $ra`), or just use `call_reg($tmp)` after loading the target into a register. + */ #define call_addr(off) enc_i(op_jal, R_0, R_0, (off)) /* --- Store family (mirrors the load family) --- */ diff --git a/code/hello_joypad/hello_joypad.c b/code/hello_joypad/hello_joypad.c index 9c97b52..dd94764 100644 --- a/code/hello_joypad/hello_joypad.c +++ b/code/hello_joypad/hello_joypad.c @@ -478,22 +478,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf) // C-side state (pa->used) has already been updated by the tape! // smem.floor.rot.y += 5; } - // --- TAPE DIAGNOSTICS --- - if (0) - { - LP_ U4 mem_temp_tape[512]; FArena tape_arena; farena_init(& tape_arena, slice_ut_arr(mem_temp_tape)); - TapeBuilder tb = tb_make_old(& tape_arena); tb_scope(& tb) { - // Skip set_gte_world atom for diagnostics to isolate the triangle loop - for (U4 i = 0; i < Floor_num_faces; i++) { - // tb_emit(& tb, code_diag_yield); - // tb_emit(& tb, code_diag_color); - // tb_emit(& tb, code_diag_gte); - } - } - 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]; - } } GCC_OPTIMIZATION_ENABLE diff --git a/code/hello_joypad/hello_joypad.tape.c b/code/hello_joypad/hello_joypad.tape.c index 7cc7159..929a4db 100644 --- a/code/hello_joypad/hello_joypad.tape.c +++ b/code/hello_joypad/hello_joypad.tape.c @@ -459,9 +459,7 @@ atom_label(disconnected) /* === Disconnected body. */ load_upper_i(R_T4, 0x8080), or_i_self(R_T4, 0x8080), store_word( R_T4, R_PadState, O_(PadState,left_x)), store_byte( R_RawId, R_PadState, O_(PadState,id)), - branch_equal(R_0, R_0, atom_offset(disconnected, snap_end)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(disconnected, snap_end)), nop, + jump_rel(atom_offset(disconnected, snap_end)), nop, atom_label(skip_disconnected) /* === Case 2: Pending (status == 0 && id == 0) @@ -479,9 +477,7 @@ atom_label(pending) /* === Pending body */ load_upper_i(R_T4, 0x8080), or_i_self(R_T4, 0x8080), store_word( R_T4, R_PadState, O_(PadState,left_x)), store_byte( R_RawId, R_PadState, O_(PadState,id)), - branch_equal(R_0, R_0, atom_offset(pending, snap_end)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(pending, snap_end)), nop, + jump_rel(atom_offset(pending, snap_end)), nop, atom_label(id_dispatch) /* === Case 3-6: ID dispatch */ add_ui(R_T4, R_0, 0x41), branch_ne(R_RawId, R_T4, atom_offset(id_dispatch, try_analog_stick)), @@ -503,9 +499,7 @@ atom_label(id_dispatch) /* === Case 3-6: ID dispatch */ add_ui( R_T4, R_0, 0x41), store_byte( R_T4, R_PadState, O_(PadState,id)), - branch_equal(R_0, R_0, atom_offset(id_dispatch, snap_end)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(id_dispatch, snap_end)), nop, + jump_rel(atom_offset(id_dispatch, snap_end)), nop, atom_label(try_analog_stick) /* === Case 4: AnalogStick (id == 0x53)*/ add_ui(R_T4, R_0, 0x53), branch_ne(R_RawId, R_T4, atom_offset(try_analog_stick, try_analog_pad)), @@ -526,9 +520,7 @@ atom_label(analog_stick) /* === AnalogStick body store_half( R_T4, R_PadState, O_(PadState,right_x)), add_ui( R_T5, R_0, 0x53), /* R_T5 = id value (clobbers left_xy, already stored) */ store_byte( R_T5, R_PadState, O_(PadState,id)), - branch_equal(R_0, R_0, atom_offset(analog_stick, snap_end)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(analog_stick, snap_end)), nop, + jump_rel(atom_offset(analog_stick, snap_end)), nop, atom_label(try_analog_pad) /* === Case 5-6: AnalogPad (id & 0xF0 == 0x70) */ and_i( R_T4, R_RawId, 0xF0), @@ -550,9 +542,7 @@ atom_label(analog_pad) /* === AnalogPad body store_half( R_T4, R_PadState, O_(PadState,right_x)), store_byte( R_RawId, R_PadState, O_(PadState,id)), - branch_equal(R_0, R_0, atom_offset(analog_pad, snap_end)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(analog_pad, snap_end)), nop, + jump_rel(atom_offset(analog_pad, snap_end)), nop, atom_label(try_unsupported) /* === Case 7: Unsupported — fall through from the AnalogPad range-check miss. */ add_ui( R_T4, R_0, PadStatus_Unsupported), @@ -650,10 +640,7 @@ atom_label(dead_check_upper) /* R_T4 = (0x90 < left_x) ? 1 : 0 → (left_x > 0x90) ? 1 : 0 */ set_lt_u(R_T4, R_T4, R_T3), branch_ne(R_T4, R_0, atom_offset(dead_zone_high_check, dead_high_active)), add_ui( R_T4, R_0, 0x80), /* BD-slot: pre-load 0x80 for dead_high_active */ - branch_equal(R_0, R_0, atom_offset(dead_zone_skip, exit_stick)), nop, - /* Fall-through = left_x in [0x70, 0x90] (dead zone); skip analog entirely. */ - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(dead_zone_skip, exit_stick)), nop, + jump_rel(atom_offset(dead_zone_skip, exit_stick)), nop, atom_label(dead_low_active) /* R_T3 = left_x (from line 632 lbu; not clobbered between dead_zone_low_check branch + its BD-slot `add_ui R_T4, 0x80`). @@ -675,9 +662,7 @@ atom_label(dead_low_active) add_u( R_T0, R_T0, R_T4), store_half( R_T0, R_FloorRot, O_(V3_S2,y)), - branch_equal(R_0, R_0, atom_offset(end_low, exit_stick)), nop, - // TODO(Ed): Lua metaprogram: Support jump instruction here.. - // jump(atom_offset(end_low, exit_stick)), nop, + jump_rel(atom_offset(end_low, exit_stick)), nop, atom_label(dead_high_active) /* R_T3 = left_x (from line 641 lbu in dead_check_upper; not clobbered between dead_zone_high_check branch + its BD-slot `add_ui R_T4, 0x80`). diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 0d97ac5..b0b4857 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -10,7 +10,7 @@ --- * **Word-count loader** (`load_word_counts` for `WORD_COUNT(...)` metadata files). --- * **Line lookup** (`LineIndex` returns an O(log N) `line_of(pos)` closure for source-mapping). --- * **Domain tables** (`TAPE_ATOM_MACROS`, `GTE_PIPELINE_LATENCY`, `GP0_CMD_SIZE`, `GP0_CMD_BY_SHAPE`, ---- `GP0_MACRO_CONTRIB`, `INSTRUCTION_LATENCY`). +--- `INSTRUCTION_LATENCY`). --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex. @@ -1430,33 +1430,7 @@ M.GP0_CMD_BY_SHAPE = { ["g4"] = 0x38, ["gt4"] = 0x3C, } --- TODO(Ed): REMOVE THIS HARDCODE, THIS SHOULD BE RESOLVED AUTOMATICALLY --- Per-macro prim-buffer contribution: how many 32-bit words each macro writes to the primitive being built in main RAM. --- (This counts RAM-side prim-buffer words, not .text instruction words.) --- The sum across `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X` calls in an atom body must equal --- `GP0_CMD_SIZE[GP0_CMD_BY_SHAPE[shape]]`. -M.GP0_MACRO_CONTRIB = { - ["mac_format_f3_color"] = 1, - ["mac_format_g3_color"] = 3, - ["mac_format_g4_color"] = 4, - ["mac_gte_store_f3"] = 3, - ["mac_gte_store_g3"] = 3, - ["mac_gte_store_g4_p012"] = 3, - ["mac_gte_store_g4_p3"] = 1, - ["mac_insert_ot_tag_f3"] = 1, - ["mac_insert_ot_tag_g4"] = 1, -} - --- Per-macro cycle cost (best-case, no stalls). Used by the static-analysis pass to emit per-atom cycle budgets. --- The counts cover the expanded instruction sequence the macro emits (not just the surface token in source). --- Worked example — `mac_pack_color_word(off, cmd, r, g, b)` expands to: --- load_upper_i(R_AT, (cmd << 8) | b) -- 1 cycle --- or_i_self(R_AT, (g << 8) | r) -- 1 cycle --- store_word(R_AT, R_PrimCursor, off) -- 1 cycle --- = 3 cycles total --- --- `mac_yield` emits a control-transfer sequence (load_word, add_ui_self, jump_reg, nop). The atom body's cycle budget excludes --- the yield's cost (we model it as 0); the runtime cost lands in the next atom's prologue. +-- Per-instruction cycle cost (best-case, no stalls). Used by the static-analysis pass to emit per-atom cycle budgets. -- -- GTE command values are the GTE instruction's intrinsic cycles — the latency after any pre-cmd `nop2` has retired. -- When the source emits `nop2, gte_cmdw_X`, the nops' cycles are added separately (1+1) plus the gte_cmdw_X value here: @@ -1473,6 +1447,13 @@ M.GP0_MACRO_CONTRIB = { -- See `docs/psx-spx/docs/geometrytransformationenginegte.md` for per-command cycle counts and -- `docs/psx-spx/docs/gtepipelinetimings.md` for the hardware-verified input-latch boundaries (most inputs become -- safe to clobber after 0-4 cycles). +-- +-- Per-macro cycle costs (`mac_yield`, `mac_pack_color_word`, ...) and per-macro prim-buffer contributions +-- (`mac_format_*_color`, `mac_gte_store_*`, `mac_insert_ot_tag_*`) are NOT hardcoded here. +-- `passes/components.lua::compute_components_metadata` derives both from each `MipsAtomComp_(ac_X)` body in +-- `code/duffle/lottes_tape.h`, stores the values on `corpus.components[name].cycle_cost` and +-- `corpus.components[name].gp0_contrib`, and `passes/static_analysis.lua` reads those fields directly. +-- The `mac_yield` cost is 0 by convention (the runtime cost lands in the next atom's prologue). M.INSTRUCTION_LATENCY = { -- CPU ALU (single-cycle R3000A ops) ["nop"] = 1, @@ -1568,22 +1549,6 @@ M.INSTRUCTION_LATENCY = { ["gte_load_v2"] = 2, ["gte_load_v0v1v2"] = 6, - -- TODO(Ed): REMOVE THIS HARDCODE, THIS SHOULD BE RESOLVED AUTOMATICALLY - -- 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"] = 0, - ["mac_pack_color_word"] = 3, -- lui + ori + sw - ["mac_format_f3_color"] = 3, -- = mac_pack_color_word - ["mac_format_g4_color"] = 12, -- 4 x mac_pack_color_word - ["mac_load_tri_indices"] = 3, -- 3 x lhu - ["mac_gte_load_tri_verts"] = 18, -- 3 x {sll, addu, lw, lw, mtc2, mtc2} - ["mac_gte_store_f3"] = 3, - ["mac_gte_store_g3"] = 3, - ["mac_gte_store_g4_p012"] = 3, - ["mac_gte_store_g4_p3"] = 1, - ["mac_insert_ot_tag_f3"] = 11, -- 11 .word slots in the macro body - ["mac_insert_ot_tag_g4"] = 11, - -- Annotation markers (emit no code; pure metaprogram hints) ["atom_label"] = 0, ["atom_offset"] = 0, @@ -2212,10 +2177,15 @@ local function _project_emission_inner(root_body_entry, ctx_table) end local function emit_marker(kind, name, target, line, - immediate_call_text, root_call_text_w) + immediate_call_text, root_call_text_w, + consuming_encoder, consuming_arg_pos) local inv_ids = open_invocation_ids_snapshot() local outermost = inv_ids[1] or 0 -- Markers carry the open invocation stack snapshot. `call_text` / `root_call_text` belong to words, not markers — markers are zero-width and skip per-word call-site attribution. + -- `consuming_encoder` + `consuming_arg_pos` carry the surrounding control-transfer instruction context + -- (e.g. `branch_le_zero` consuming its 3rd argument, or `jump` / `call_addr` consuming their only argument). + -- `passes/offsets.lua` reads these to dispatch per-consuming-instruction offset encoding. + -- nil for top-level markers (where the marker is the entire token — no surrounding consuming instruction). local it = { kind = kind, name = name, @@ -2225,17 +2195,77 @@ local function _project_emission_inner(root_body_entry, ctx_table) outermost_invocation_id = outermost, } if target ~= nil then it.target = target end + if consuming_encoder then it.consuming_encoder = consuming_encoder end + if consuming_arg_pos then it.consuming_arg_pos = consuming_arg_pos end items[#items + 1] = it markers[#markers + 1] = { - kind = kind, - name = name, - line = line, - word_index = word_idx, - target = target, + kind = kind, + name = name, + line = line, + word_index = word_idx, + target = target, + consuming_encoder = consuming_encoder, + consuming_arg_pos = consuming_arg_pos, } end - local function emit_embedded_markers(tok, tok_line) + -- Count top-level commas in `tok` between position `from_pos` (inclusive) and `to_pos` (exclusive). + -- Tracks paren depth so commas inside nested () don't count. Skips string literals + comments. + -- Used by `emit_embedded_markers` to compute `consuming_arg_pos` for each embedded marker. + local function count_top_level_commas(tok, from_pos, to_pos) + local depth = 0 + local count = 0 + local i = from_pos + while i < to_pos do + local c = tok:sub(i, i) + if c == "'" or c == '"' then + local next_pos = M.skip_str_or_cmt(tok, i) + i = (next_pos > i) and next_pos or (i + 1) + elseif c == "/" and tok:sub(i + 1, i + 1) == "/" then + -- line comment: skip to end of line + local nl = tok:find("\n", i, true) + i = (nl and nl + 1) or (#tok + 1) + elseif c == "/" and tok:sub(i + 1, i + 1) == "*" then + -- block comment: skip to matching */ + local close = tok:find("*/", i + 2, true) + i = (close and close + 2) or (#tok + 1) + elseif c == "(" then + depth = depth + 1 + i = i + 1 + elseif c == ")" then + depth = depth - 1 + i = i + 1 + elseif c == "," and depth == 0 then + count = count + 1 + i = i + 1 + else + i = i + 1 + end + end + return count + end + + -- Find the position of the consuming instruction's open paren (the `(` that + -- starts the consuming instruction's argument list). Returns nil if the token's + -- leading text isn't an ident followed by `(` (e.g. the ident is at the start of a + -- non-instruction token). + local function find_consuming_paren(tok) + local i = 1 + while i <= #tok do + local c = tok:sub(i, i) + if c == "(" then return i end + if not c:match("[%w_]") and c ~= " " then return nil end + i = i + 1 + end + return nil + end + + local function emit_embedded_markers(tok, tok_line, consuming_encoder) + -- When called with a non-nil `consuming_encoder`, the marker is nested inside that + -- instruction's argument list. We compute each marker's arg position by counting + -- top-level commas between the consuming instruction's `(` and the marker's start. + local consuming_paren = nil + if consuming_encoder then consuming_paren = find_consuming_paren(tok) end local pos = 1 while pos <= #tok do -- trim leading whitespace and comments before each scan. @@ -2261,10 +2291,20 @@ local function _project_emission_inner(root_body_entry, ctx_table) pos = after goto continue_loop end - -- commit: label takes 1 arg, offset takes 2. + -- Commit: label takes 1 arg, offset takes 2. + -- For embedded markers, propagate the consuming_encoder + the marker's arg position + -- (1-based) so `passes/offsets.lua` can dispatch per-consuming-instruction offset encoding. + -- Top-level markers (no consuming_encoder) get nil for both — the offsets pass treats + -- them as branch-equivalent for backward compatibility. + local arg_pos = nil + if consuming_encoder and consuming_paren then + arg_pos = count_top_level_commas(tok, consuming_paren + 1, pos) + 1 + end local args = split_top_level_args(inner) - if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line) - else emit_marker("offset", args[1] or "", args[2] or "", tok_line) + if ident == "atom_label" then + emit_marker("label", args[1] or "", nil, tok_line, nil, nil, consuming_encoder, arg_pos) + else + emit_marker("offset", args[1] or "", args[2] or "", tok_line, nil, nil, consuming_encoder, arg_pos) end pos = after_paren ::continue_loop:: @@ -2391,9 +2431,21 @@ local function _project_emission_inner(root_body_entry, ctx_table) local _, args = token_ident_and_args(tok) local tok_line = line_of(body_off + bt.rel) or 0 -- embedded markers live only in non-marker tokens. - if ident ~= "atom_label" and ident ~= "atom_offset" then emit_embedded_markers(tok, tok_line) end + -- Pass `ident` as the consuming instruction so `emit_embedded_markers` can compute + -- each marker's arg position + record the consuming_encoder for the offsets pass. + -- Canonicalize `jump_rel` to `branch_equal` (its preprocessor-expanded form) so the + -- `consuming_encoder` metadata in marker records is canonical. `jump_rel` is the within-atom-safe + -- unconditional jump alias from `code/duffle/mips.h`; the C preprocessor expands it BEFORE + -- the metaprogram sees the source, but the raw token ident is still `jump_rel` here. + local consuming_encoder_for_markers = (ident == "jump_rel") and "branch_equal" or ident + if ident ~= "atom_label" and ident ~= "atom_offset" then + emit_embedded_markers(tok, tok_line, consuming_encoder_for_markers) + end -- atom_label / atom_offset: terminal markers, no further descent. - if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line); return + -- Top-level markers (the marker IS the entire token) have no consuming instruction; + -- nil for both `consuming_encoder` and `consuming_arg_pos`. The offsets pass treats + -- these as branch-equivalent for backward compatibility. + if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line); return elseif ident == "atom_offset" then emit_marker("offset", args[1] or "", args[2] or "", tok_line); return end if ident:sub(1, 4) == "mac_" then diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 711bcde..03097f5 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -339,6 +339,118 @@ local function count_all_components(components, wc) return counts end +-- ═══════════════════════════════════════════ +-- Per-component metadata derivation (replaces the hardcoded `M.GP0_MACRO_CONTRIB` + `M.INSTRUCTION_LATENCY[mac_*]` tables that previously lived in `duffle.lua`). +-- +-- Each `MipsAtomComp_(ac_X) { body }` definition in `code/duffle/lottes_tape.h` is the canonical source. +-- The `mac_X(...)` macros are GENERATED from these definitions by `emit_component_macros_h` for tape-side composition; +-- the metaprogram must NEVER walk the generated variants to derive metadata. +-- Always walk the original `MipsAtomComp_` body via `cc.body_tokens`. +-- ═══════════════════════════════════════════ + +--- (internal) Recursive cycle-cost derivation. Sum `latency[ident]` per emitted instruction in the component body, +--- recursing through nested `mac_*` calls (so `mac_format_g4_color`'s cost = 4 × `mac_pack_color_word`'s cost). +--- +--- Special rule: `mac_yield`'s cost = 0 (per `lottes_tape.h:125-130` "the runtime cost lands in the next atom's prologue"). +--- @param name string -- component bare name (e.g. "yield", "pack_color_word") +--- @param comp_by_name table +--- @param latency table +--- @param cache table -- shared memoization; `-1` sentinel detects cycles +--- @return integer +local function cycle_cost_rec(name, comp_by_name, latency, cache) + if cache[name] ~= nil then return cache[name] end + cache[name] = -1 + local cc = comp_by_name[name] + local n + if cc then + if name == "yield" then + -- mac_yield's cost is 0 by convention (the runtime cost lands in the next atom's prologue). + n = 0 + else + n = 0 + local tokens = cc.body_tokens + for _, t in ipairs(tokens) do + local trimmed = t.tok + if trimmed ~= "" then + local ident = duffle.read_ident(trimmed, 1) + if ident and ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then + -- Nested `mac_X(...)` call: recurse. + local nested = ident:sub(MAC_PREFIX_LEN + 1) + n = n + cycle_cost_rec(nested, comp_by_name, latency, cache) + else + -- Leaf instruction or pseudo-macro. Look up in INSTRUCTION_LATENCY; default 1. + n = n + (latency[ident] or 1) + end + end + end + end + else + n = 1 + end + cache[name] = n + return n +end + +--- (internal) Recursive GP0 prim-buffer contribution. Count `store_word` / `store_half` / `store_byte` +--- calls in the component body that target `R_PrimCursor` (these are the +--- RAM-side prim-buffer words the macro contributes), recursing through nested `mac_*` calls. +--- +--- Only `R_PrimCursor`-targeting stores count. Stores targeting other registers (e.g. `R_OtBase`, heap pointers) are not prim-buffer contributions. +--- @param name string +--- @param comp_by_name table +--- @param cache table +--- @return integer +local function gp0_contrib_rec(name, comp_by_name, cache) + if cache[name] ~= nil then return cache[name] end + cache[name] = -1 + local cc = comp_by_name[name] + local n + if cc then + n = 0 + local tokens = cc.body_tokens + for _, t in ipairs(tokens) do + local trimmed = t.tok + if trimmed ~= "" then + local ident = duffle.read_ident(trimmed, 1) + if ident and ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then + -- Nested `mac_X(...)` call: recurse. + local nested = ident:sub(MAC_PREFIX_LEN + 1) + n = n + gp0_contrib_rec(nested, comp_by_name, cache) + elseif ident == "store_word" or ident == "store_half" or ident == "store_byte" then + if trimmed:find("R_PrimCursor", 1, true) then + n = n + 1 + end + end + end + end + else + n = 0 + end + cache[name] = n + return n +end + +--- Compute `cycle_cost` + `gp0_contrib` for every component in `components` in a single pass. +--- Memoization cache is built ONCE (per source) and shared across both helpers so that +--- a nested `mac_Y` reference inside a `mac_X` body computes its values once. +--- @param components Component[] +--- @param latency table +--- @return table +local function compute_components_metadata(components, latency) + local comp_by_name = {} + for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end + local cc_cache = {} + local gc_cache = {} + local out = {} + for _, c in ipairs(components) do + out[c.name] = { + cycle_cost = cycle_cost_rec(c.name, comp_by_name, latency, cc_cache), + gp0_contrib = gp0_contrib_rec(c.name, comp_by_name, gc_cache), + } + end + return out +end + -- ════════════════════════════════════════════════════════════════════════════ -- Per-component emit logic -- ════════════════════════════════════════════════════════════════════════════ @@ -534,25 +646,29 @@ end --- (internal) Populate `corpus.components` with this source's components-by-name map. --- First declaration wins; later declarations of the same bare name are dropped and recorded as a collision via `corpus.collisions` (kind = "component"). ---- The pass does NOT write to `ctx.shared.components` (ownership follows the canonical contract). ---- The `debug_skip` field mirrors the scanner-owned declaration record (`c.debug_skip`). +--- The pass does NOT write to `ctx.shared.components`. --- No parallel skip map is built here; consumers that need the per-component skip state read `corpus.components[name].debug_skip` directly. ---- @param corpus table -- the corpus +--- The `cycle_cost` + `gp0_contrib` fields are populated from `metadata[c.name]` (computed by `compute_components_metadata` against the original `MipsAtomComp_` body). +--- @param corpus table -- the corpus --- @param src SourceFile --- @param components Component[] -local function update_canonical_components(corpus, src, components) +--- @param metadata table +local function update_canonical_components(corpus, src, components, metadata) local rel_path = src.path:gsub("\\", "/") for _, c in ipairs(components) do -- Keyed by bare name (e.g. `yield`, `load_tri_indices`). -- The atoms_source_map pass looks up components by bare name from the corpus; -- `mac_` prefix lives at the call-site identifier and is stripped before lookup. + local m = metadata and metadata[c.name] or nil if corpus.components[c.name] == nil then corpus.components[c.name] = { - name = c.name, - line = c.line, - path = rel_path, - kind = c.kind or "comp_bare", - debug_skip = c.debug_skip == true, + name = c.name, + line = c.line, + path = rel_path, + kind = c.kind or "comp_bare", + debug_skip = c.debug_skip == true, + cycle_cost = m and m.cycle_cost or nil, + gp0_contrib = m and m.gp0_contrib or nil, } else -- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it. @@ -632,12 +748,15 @@ function M.run(ctx) -- Use `corpus.word_counts` so the recursive lookup sees both authored-metadata entries -- (loaded by word_count_eval.run) AND same-source component entries (populated earlier in this loop by `update_canonical_word_counts`). local counts = count_all_components(components, corpus.word_counts) + -- Derive cycle_cost + gp0_contrib from the original `MipsAtomComp_` body tokens + -- (NOT from the generated `mac_*` variants — those are written to disk above). + local metadata = compute_components_metadata(components, duffle.INSTRUCTION_LATENCY) local macs_path = emit_component_macros_h(ctx, src, components, counts) if macs_path then outputs[#outputs + 1] = { macs_h = macs_path } -- Populate the projections AFTER disk emission (so the byte-identical `.macs.h` contract is preserved before any current-count mutation). update_canonical_word_counts(corpus, components, counts) - update_canonical_components(corpus, src, components) + update_canonical_components(corpus, src, components, metadata) update_canonical_component_body_index(corpus, src, components, src.scan) end end diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 0edebae..acac4b8 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -73,10 +73,6 @@ local DW_RLE_start_length = DWARF5_RNGLISTS.start_length -- File-index lookup for the existing main line unit (Unit 2). -- Populated at pass start by `init_file_index_lookup(elf_path)` from the runtime ELF (see `elf_dwarf.read_line_unit_file_table`). --- The hardcoded indices and the `PROVENANCE_BASENAME_TO_FILE_INDEX` table that previously lived here were retired in `conductor/tracks/dwarf_file_index_lookup_20260731/` --- (red of the --- `TODO(Ed): Remove this HARDCODE` from line 156); the runtime lookup reads the --- actual gcc-emitted `.debug_line` file table instead. local _file_index_by_basename = nil -- [basename] = 1-based line-table file index local _file_path_by_index = nil -- [1-based index] = full source path (diagnostics / future consumers) local _default_atom_source_index = nil -- any valid index used in opaque-row fallbacks @@ -840,12 +836,15 @@ end --- load_word(R_FaceCursor, R_TapePtr, O_(Binds_CubeTri,FaceCursor)), --- ... --- +--- Also matches `load_half` / `load_half_u` / `load_byte` / `load_byte_u` (any MIPS load instruction with `(R_, R_, O_(, FieldName))` shape). +--- Every field's `byte_size` + `offset` determine which load to emit; this function only records the (reg, field) pair. +--- --- The GPR for each `R_` is looked up in the merged register_alias_registry; aliases absent from the registry --- (no `atom_reg` opt-in) are silently skipped — the resulting rbind record will be incomplete and the atom will fail to bind a usable piece chain. --- This is intentional: silently falling back to a hardcoded GPR would mask the missing opt-in. --- --- 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). +--- statements (each entry is a single `load_*` call or other statement). --- @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 @@ -853,14 +852,18 @@ end local function parse_body_load_pairs(body_tokens, binds_name, registries) local pairs = {} local reg_index_by_name = (registries and registries.register_alias_registry) or {} + -- One regex that matches any of: load_word, load_half, load_half_u, load_byte, load_byte_u, gte_lw, gte_lwc2. + -- The captured ident is `kind`; `inner` holds the parens body for arg parsing. + local load_pattern = "^(load_word|load_half|load_half_u|load_byte|load_byte_u|gte_lw|gte_lwc2)%s*%((.*)%)$" for _, t in ipairs(body_tokens or {}) do local tok = duffle.trim(t.tok or "") - -- Match "load_word(...)" — the entire call is one body_tokens entry. - local inner = tok:match("^load_word%s*%((.*)%)$") - if inner then + local kind, inner = tok:match(load_pattern) + if kind then local args = duffle.split_top_level_commas(inner) - -- Expected shape: (R_, R_TapePtr, O_(Binds_, FieldName)) - if #args >= 3 then + -- Expected shape for an rbind piece-chain load: (R_, R_TapePtr, O_(Binds_, FieldName)) + -- The second arg MUST be R_TapePtr — loads from other bases (e.g. `load_byte_u(R_RawStatus, R_PadRaw, 0)`) + -- are field-derivative loads that read already-bound tape values; they're NOT a new piece-chain. + if #args >= 3 and duffle.trim(args[2]) == "R_TapePtr" then local reg_name = duffle.trim(args[1]) local third_arg = duffle.trim(args[3]) -- Match O_(Binds_, FieldName) diff --git a/scripts/passes/offsets.lua b/scripts/passes/offsets.lua index 003fb65..c9746b4 100644 --- a/scripts/passes/offsets.lua +++ b/scripts/passes/offsets.lua @@ -57,10 +57,12 @@ local OFFSET_MACRO_COL = 44 --- @field warnings table[] -- {line=, msg=} entries; build-succeeds --- @class BranchOffset ---- @field tag string -- the marker tag (e.g. "F" in `atom_offset(F, T)`) ---- @field target string -- the target label name (e.g. "T" in `atom_offset(F, T)`) ---- @field branch_word integer -- branch word position within the atom body ---- @field offset integer -- computed `target_word - branch_word - 1` +--- @field tag string -- the marker tag (e.g. "F" in `atom_offset(F, T)`) +--- @field target string -- the target label name (e.g. "T" in `atom_offset(F, T)`) +--- @field branch_word integer -- branch word position within the atom body +--- @field offset integer -- computed per consuming instruction (see `compute_offsets`) +--- @field consuming_encoder string|nil -- the instruction consuming the offset (e.g. "branch_le_zero", "jump", "call_addr") +--- @field consuming_arg_pos integer|nil -- 1-based arg position within the consuming instruction's arg list --- @class AtomData --- @field name string -- atom name @@ -72,7 +74,7 @@ local OFFSET_MACRO_COL = 44 -- ════════════════════════════════════════════════════════════════════════════ -- MARKER_PROJECTORS is the marker-kind data table. --- The emission-model pass already records marker word positions; +-- The emission-model pass already records marker word positions + consuming-instruction context; -- this pass only projects those records into the label/branch lookup shape needed by offset computation. local MARKER_PROJECTORS = { label = function(state, marker) @@ -80,9 +82,11 @@ local MARKER_PROJECTORS = { end, offset = function(state, marker) state.branches[#state.branches + 1] = { - tag = marker.name, - target = marker.target, - branch_word = marker.word_index, + tag = marker.name, + target = marker.target, + branch_word = marker.word_index, + consuming_encoder = marker.consuming_encoder, + consuming_arg_pos = marker.consuming_arg_pos, } end, } @@ -104,7 +108,19 @@ end -- Offset computation + header generation -- ════════════════════════════════════════════════════════════════════════════ ---- Compute branch offsets as `target_word - branch_word - 1` (the standard MIPS branch-immediate encoding). +--- Compute branch offsets per consuming instruction. +--- +--- Disposition table: +--- `branch_*` -> relative offset: `target_word - branch_word - 1` (MIPS branch-immediate encoding). +--- `jump` / `call_addr` -> same value as `branch_*` (a relative word offset). +--- The duffle headers' `enc_i` macro truncates the value to the immediate-field width (16 bits for branches, 26 bits for jumps). +--- For tape-atom bodies within a single module, this works for `j`/`jal` because the linker's symbol resolution produces the correct 26-bit absolute target via standard `j` relocations. +--- For cross-module `j`/`jal` (atom body in one module, target in another), the linker emits a `R_MIPS_26` relocation against the lower 26 bits; the upper 4 bits come from the PC of the delay slot following the `j`. +--- The metaprogram doesn't know either at compile time, so the emitted value is the relative word offset that the duffle `enc_i` macro places in the immediate field; the toolchain handles the rest. +--- `jump_reg` / `call_reg` / `jump_link` -> ERROR. Register-form jumps have no offset field; `atom_offset` is invalid. +--- +--- Top-level `atom_offset(F, T)` markers (where the marker is the entire token — `consuming_encoder` == nil) default to `branch_*` behavior (relative offset). +--- This preserves backward compatibility for any top-level marker that may exist outside a control-transfer instruction. --- @param labels table --- @param branches table[] --- @return BranchOffset[] @@ -115,11 +131,23 @@ local function compute_offsets(labels, branches) if not target then error("Branch target '" .. br.target .. "' has no atom_label (at word " .. br.branch_word .. ")") end + local consuming = br.consuming_encoder + local offset + if consuming == "jump_reg" or consuming == "call_reg" or consuming == "jump_link" then + -- Register-form jumps have no offset field. `atom_offset` cannot be used here. + error("atom_offset cannot be used with " .. consuming + .. " (register-form jumps have no offset field); at word " .. br.branch_word) + end + -- All other consuming instructions (including `branch_*`, `jump`, `call_addr`, and nil for top-level markers) use the same relative offset value. + -- The MIPS encoding differs per opcode but the duffle `enc_i` macro handles the truncation to the immediate-field width. + offset = target - br.branch_word - 1 results[#results + 1] = { - target = br.target, - tag = br.tag, - branch_word = br.branch_word, - offset = target - br.branch_word - 1, + target = br.target, + tag = br.tag, + branch_word = br.branch_word, + offset = offset, + consuming_encoder = br.consuming_encoder, + consuming_arg_pos = br.consuming_arg_pos, } end return results diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 710eb90..e91ed29 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -23,7 +23,9 @@ --- 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. +--- 6. Per-Atom Cycle Budget: Sum each atom body's instruction latencies — non-`mac_*` tokens look up `duffle.INSTRUCTION_LATENCY[ident]`; +--- `mac_*` tokens look up `pipe_ctx.components_by_name[bare_name].cycle_cost` (auto-derived from the original `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`). +--- Report total. --- --- Per-source rules (registry-driven): --- 8. enum_alias_membership: Every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`, `atom_type(...)`, `atom_reads`, or `atom_writes` @@ -191,15 +193,21 @@ end -- -- The classification is stored on `atom.paths.tok_class` as an array indexed by token index (1..#tokens). -- Each entry has: --- ident — the leading identifier (e.g. "load_word", "gte_cmdw_rtpt", "nop", "mac_yield") --- nop_words — 0 / 1 / 2 (for "nop" / "nop2" / anything else) --- nop_prefix — consecutive nop words ending just BEFORE this token (forward-pass pre-compute; --- makes preceding-nop lookup O(N)) --- is_yield — true if this token is `mac_yield` or `mac_yield(...)` --- is_atom_label — true if this token is `atom_label(name)`; label_name has the name --- is_branch — true if this token is `branch_*(...)`; branch_label has the label or false --- is_load_word — true if this token starts with `load_word(` --- is_store_word — true if this token starts with `store_word(` +-- ident — the leading identifier (e.g. "load_word", "gte_cmdw_rtpt", "nop", "mac_yield") +-- nop_words — 0 / 1 / 2 (for "nop" / "nop2" / anything else) +-- nop_prefix — consecutive nop words ending just BEFORE this token (forward-pass pre-compute; +-- makes preceding-nop lookup O(N)) +-- is_yield — true if this token is `mac_yield` or `mac_yield(...)` +-- is_atom_label — true if this token is `atom_label(name)`; label_name has the name +-- is_branch — true if this token is `branch_*(...)` OR an unconditional-jump-with-offset (`jump(off)` / `call_addr(off)`); branch_label has the target label or false +-- is_unconditional_jump — true if this token is `jump` or `call_addr` (BD slot + single successor — taken only; no fall-through). +-- Mutually exclusive with the conditional-branch semantics; combined with `is_branch` above. +-- is_terminal_jump — true if this token is `jump_reg` / `call_reg` / `jump_link` (transfers control OUT of the current atom; the `mac_yield()` handshake ends in `jump_reg(R_AtomJmp), nop`). +-- No offset field — `atom_offset` is invalid here. Terminates the current path in the CFG. +-- is_load — true if this token starts with any of: load_word, load_half, load_half_u, load_byte, +-- load_byte_u, gte_lw, gte_lwc2. These all have MIPS load-delay semantics (the +-- destination register is volatile for 1 word after the load). +-- is_store_word — true if this token starts with `store_word(` -- -- Checks that need the leading ident use `tok_class.ident` instead of re-matching the token string. -- Checks that need "how many nops before token i" use `tok_class.nop_prefix` instead of walking backwards. @@ -211,9 +219,11 @@ end --- @field is_yield boolean --- @field is_atom_label boolean --- @field label_name string|nil -- for atom_label(name) ---- @field is_branch boolean ---- @field branch_label string|false|nil -- for branch_*(..., atom_offset(F, label)) ---- @field is_load_word boolean +--- @field is_branch boolean -- conditional branch OR unconditional-jump-with-offset +--- @field is_unconditional_jump boolean -- `jump` / `call_addr` only +--- @field is_terminal_jump boolean -- `jump_reg` / `call_reg` / `jump_link` only +--- @field branch_label string|false|nil -- for branch_*(..., atom_offset(F, label)) OR jump/call_addr +--- @field is_load boolean -- load_word | load_half | load_half_u | load_byte | load_byte_u | gte_lw | gte_lwc2 --- @field is_store_word boolean --- @field mac_format_shape string|nil -- "f3" / "g4" etc. for mac_format_X_color; nil otherwise --- @field is_gte_store boolean -- ident matches `mac_gte_store_` @@ -224,12 +234,38 @@ end --- @field o_arg2 string|nil -- second arg of O_(, ) captures --- @field s_arg1 string|nil -- arg of S_() captures; nil for non-S_ tokens +-- The set of MIPS instruction idents that have a load-delay slot. +-- Per MIPS I R3000A: `lw`, `lh`, `lhu`, `lb`, `lbu`, `lwc2` (gte_lw). +-- Note: `lui` (load_upper_i) does NOT have a load delay on MIPS I — it's an ALU op, not a load. +-- The `load_imm_*` macros are lui + ori sequences with no per-component load delay either. +local LOAD_INSTRUCTION_IDENTS = { + load_word = true, + load_half = true, + load_half_u = true, + load_byte = true, + load_byte_u = true, + gte_lw = true, + gte_lwc2 = true, +} + -- Patterns for O_(, ) and S_() captures. -- UNANCHORED, the substring can appea anywhere in the token (e.g., `load_word(R_T0, R_TapePtr, O_(Binds_X, field))` matches at position ~24). -- The binds_name match is deferred to check_abi_handoff (which compares tc.o_arg1 == atom.info.binds). local O_PATTERN = "O_%(([%w_]+),%s*([%w_]+)%s*%)" local S_PATTERN = "S_%(([%w_]+)%s*%)" +-- Ident patterns for control-transfer instruction kinds: +-- * `branch_*` (conditional): `branch_equal`, `branch_ne`, `branch_lt_zero`, `branch_ge_zero`, `branch_le_zero`, `branch_gt_zero`. +-- * `jump` / `call_addr` (unconditional absolute): one immediate offset field; can carry `atom_offset(F, T)`. +-- * `jump_reg` / `call_reg` / `jump_link` (register-form): no offset field; `atom_offset` is invalid; transfers OUT of the current atom. +local BRANCH_PATTERN = "^branch_[%w_]+%s*%(" +-- `jump_rel(off)` is an ergonomic alias for `branch_equal(R_0, R_0, off)` (the within-atom-safe unconditional jump — see `code/duffle/mips.h`). +-- The C preprocessor expands it BEFORE the metaprogram sees the source, but for source-level metadata consistency we still match it here and classify it as a branch_equal. +-- This keeps `consuming_encoder` canonical for any downstream tooling that consults the metadata field. +local JUMP_REL_PATTERN = "^jump_rel%s*%(" +local UNCOND_JUMP_PATTERN = "^%f[%w](jump|call_addr)%f[%W]" +local TERMINAL_JUMP_PATTERN = "^%f[%w](jump_reg|call_reg|jump_link)%f[%W]" + local function classify_tokens(tokens) local n = #tokens local tc = {} @@ -245,8 +281,10 @@ local function classify_tokens(tokens) local is_atom_label = false local label_name = nil local is_branch = false + local is_unconditional_jump = false + local is_terminal_jump = false local branch_label = nil - local is_load_word = ident == "load_word" + local is_load = LOAD_INSTRUCTION_IDENTS[ident] == true local is_store_word = ident == "store_word" -- Per-check pre-computes (R3 lift). @@ -262,9 +300,21 @@ local function classify_tokens(tokens) if ident == "atom_label" then is_atom_label = true label_name = tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)") - elseif tok:match("^branch_[%w_]+%s*%(") then + elseif tok:match(BRANCH_PATTERN) or tok:match(JUMP_REL_PATTERN) then + -- Conditional branch OR `jump_rel` (the within-atom-safe unconditional jump alias). + -- Both encode a 16-bit signed relative word offset. is_branch = true branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false + elseif tok:match(UNCOND_JUMP_PATTERN) then + -- Unconditional absolute jump / call: `jump(off)` / `call_addr(off)`. + -- One immediate offset field; can carry an `atom_offset(F, T)` marker (the offsets pass dispatches on `consuming_encoder` — see `passes/offsets.lua::compute_offsets`). + is_branch = true + is_unconditional_jump = true + branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false + elseif tok:match(TERMINAL_JUMP_PATTERN) then + -- Register-form jump / call: no offset field; `atom_offset` is invalid here (the offsets pass will error if one is supplied). + -- Transfers control OUT of the current atom — the CFG treats this as a path terminator. + is_terminal_jump = true end -- mac_format_X_color / mac_gte_store_ / mac_insert_ot_tag_ (used by check_gpu_portstore_shape). @@ -283,24 +333,26 @@ local function classify_tokens(tokens) if is_store_word and tok:find("R_PrimCursor", 1, true) then writes_r_prim_cursor = true end tc[tok_idx] = { - ident = ident, - nop_words = nop_words, - nop_prefix = nop_run, - is_yield = is_yield, - is_atom_label = is_atom_label, - label_name = label_name, - is_branch = is_branch, - branch_label = branch_label, - is_load_word = is_load_word, - is_store_word = is_store_word, - mac_format_shape = mac_format_shape, - is_gte_store = is_gte_store, - is_ot_tag = is_ot_tag, - writes_r_prim_cursor = writes_r_prim_cursor, - reads_r_tape_ptr = reads_r_tape_ptr, - o_arg1 = o_arg1, - o_arg2 = o_arg2, - s_arg1 = s_arg1, + ident = ident, + nop_words = nop_words, + nop_prefix = nop_run, + is_yield = is_yield, + is_atom_label = is_atom_label, + label_name = label_name, + is_branch = is_branch, + is_unconditional_jump = is_unconditional_jump, + is_terminal_jump = is_terminal_jump, + branch_label = branch_label, + is_load = is_load, + is_store_word = is_store_word, + mac_format_shape = mac_format_shape, + is_gte_store = is_gte_store, + is_ot_tag = is_ot_tag, + writes_r_prim_cursor = writes_r_prim_cursor, + reads_r_tape_ptr = reads_r_tape_ptr, + o_arg1 = o_arg1, + o_arg2 = o_arg2, + s_arg1 = s_arg1, } -- Advance the nop run for the NEXT token. if nop_words > 0 then nop_run = nop_run + nop_words @@ -381,8 +433,7 @@ local function is_cop2_consumer_of(consumer_event, destination, producer_rel) end -- True iff `consumer_event` reads the GPR operand at any position the destination register occupies. --- The read-position lookup consults `duffle.OPERAND_READ_POSITIONS` --- for the consumer's encoder and walks each `args[pos]` to find an operand-equal match. +-- The read-position lookup consults `duffle.OPERAND_READ_POSITIONS` for the consumer's encoder and walks each `args[pos]` to find an operand-equal match. local function is_gpr_consumer_of(consumer_event, destination) local consumer_token = consumer_event.encoder or consumer_event.ident local read_pos = duffle.OPERAND_READ_POSITIONS or {} @@ -449,8 +500,8 @@ local function shift_left_u4(value, amount) return wrap_u4(value * (2 ^ amount)) end --- Resolve only a standalone integer literal. Compound C expressions remain --- unknown by design; the analyzer must not pretend to be a C evaluator. +-- Resolve only a standalone integer literal. +-- Compound C expressions remain unknown by design; the analyzer must not pretend to be a C evaluator. local function parse_integer_literal(raw) if type(raw) ~= "string" then return nil end raw = duffle.trim(raw) @@ -1024,7 +1075,9 @@ end -- A subsequent MFC2 (or any encoder that reads a C2 register) that picks the WRONG register for the active role emits a `result_role_mismatch` warning. -- For example, reading `C2_SXY0` after RTPS is wrong: the `latest_screen_xy` role is `C2_SXY2`. -- --- Note: the OLD `gte_result_position` check also emitted table-gap info findings for `_post_` components missing a row in `duffle.GTE_COMPONENT_RESULT_CONTRACTS`. That table-gap check was based on the `_post_` NAMING convention rather than hardware truth, and was removed (the user did not want naming to encode ordering semantics; a proper `atom_info` directive for ordering semantics is a future TODO). +-- Note: the OLD `gte_result_position` check also emitted table-gap info findings for `_post_` components missing a row in `duffle.GTE_COMPONENT_RESULT_CONTRACTS`. +-- That table-gap check was based on the `_post_` NAMING convention rather than hardware truth, and was removed +-- (the user did not want naming to encode ordering semantics; A proper `atom_info` directive for ordering semantics is a future TODO). -- -- The first `transfer_hazards` reader comment above records the projection contract. -- ───────────────────────────────────────────────────────────────────────── @@ -1315,6 +1368,107 @@ local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings) end end +-- ════════════════════════════════════════════════════════════════════════════ +-- Check #1d: load-delay slot violations (per-atom) +-- �═══════════════════════════════════════════════════════════════════════════ + +--- Walk every emitted word event of one atom. For each `is_load` event (lw / lh / lhu / lb / lbu / lwc2), +--- mark the destination register as "volatile through" the NEXT emitted slot — MIPS I R3000A load-delay +--- semantics. If any subsequent event in that 1-slot window reads the volatile register, emit a `load_delay_violation` +--- finding (severity: error — the load result is unavailable in the delay slot). +--- +--- The register becomes non-volatile again at word N+2 (the load has retired), OR sooner if a non-load instruction overwrites the register +--- (the overwriter's write is the fresh producer; the load's value is shadowed and never observed by any reader). +--- +--- Runtime-helper atoms / components (`debug_skip == true`) are exempt: their internal load-then-use sequences +--- are part of the fixed handshake (e.g. `ac_load_tri_indices` loads into R_T0..R_T2, but those are caller-supplied). +--- +--- The walker reads `duffle.OPERAND_READ_POSITIONS[event.encoder]` to determine which args are read-source +--- (the destination of a load is in `writes`, not `reads` — see `duffle.INSTRUCTION_GPR_EFFECTS`). +--- The check is purely structural; it does not consult the GPR-value lattice (no constant propagation needed for load-delay detection — the volatility window is unconditional). +local function check_load_delay_slots(atom, pipe_ctx, findings) + if atom.kind ~= "atom" then return end + local events = atom.paths.word_events or {} + if #events == 0 then return end + if is_runtime_helper(atom) then return end + + local gpr_effects = duffle.INSTRUCTION_GPR_EFFECTS or {} + local read_positions = duffle.OPERAND_READ_POSITIONS or {} + -- volatile_until[reg] = 1-based word_events index; the slot AFTER which the register is safe. + -- `nil` means "not currently volatile". + local volatile_until = {} + + -- Compute the "net reads" of an event: read-positions MINUS write-positions. + -- A position that is BOTH read and written (e.g. `add_ui rt, rs, imm` where the duffle table lists position 1 as both. + -- See `duffle.OPERAND_READ_POSITIONS["add_ui"] = {1, 2}` and `INSTRUCTION_GPR_EFFECTS["add_ui"].writes = {1}` — + -- and for genuine RMW ops like `add rt, rs, rt` where position 1 IS both read+written) is not a "read" for load-delay purposes: + -- The write shadows whatever value the register previously held. Only positions that are reads WITHOUT a co-occurring write to the same register count as net reads. + local function net_reads(event_ident, args) + local effect = gpr_effects[event_ident] + local positions = read_positions[event_ident] + if not positions then return {} end + local writes_set = {} + if effect and effect.writes then + for _, pos in ipairs(effect.writes) do writes_set[pos] = true end + end + local net = {} + for _, pos in ipairs(positions) do + if not writes_set[pos] then net[#net + 1] = pos end + end + return net + end + + for event_idx, event in ipairs(events) do + local event_ident = event.encoder or event.ident + local args = event.args or {} + local is_load = LOAD_INSTRUCTION_IDENTS[event_ident] == true + + -- (1) Is this event reading a register that's still volatile from a previous load? + -- Skip the load instruction itself (the load's own argument list may "read" its destination via `OPERAND_READ_POSITIONS`: + -- e.g. `addiu rt, rs, imm` lists position 1 (rt) as a "read", but rt is the destination; the within-load argument list is not a separate consumer). + -- Use `net_reads` to ignore RMW positions (write shadows read within the same instruction). + if not is_load then + for _, pos in ipairs(net_reads(event_ident, args)) do + local reg = args[pos] + if type(reg) == "string" and reg:sub(1, 2) == "R_" then + local until_idx = volatile_until[reg] + if until_idx and event_idx <= until_idx then + local ev_line = line_for_word_event(event) + findings[#findings + 1] = { + atom = atom.name, + line = ev_line, + check = "load_delay_violation", + kind = "error", + msg = string.format("%s at line %d reads %s at word %d, but a prior load's " + .. "delay slot is not over until word %d; insert a `nop` between the " + .. "load and this instruction.", + atom.name, ev_line, reg, event_idx, until_idx), + } + end + end + end + end + + -- (2) Update the volatile set based on what this event writes. + local effect = gpr_effects[event_ident] + if effect and effect.writes then + for _, pos in ipairs(effect.writes) do + local reg = args[pos] + if type(reg) == "string" and reg:sub(1, 2) == "R_" then + if is_load then + -- Load: destination volatile for exactly 1 slot (the delay slot). + volatile_until[reg] = event_idx + 1 + else + -- Non-load write to this register: overwrites shadow the load; the volatile state ends. + -- If another reader comes later, it sees the overwriter's value (or unknown), not the stale load value. + volatile_until[reg] = nil + end + end + end + end + end +end + -- ════════════════════════════════════════════════════════════════════════════ -- Check #2: mac_yield uniformity -- ════════════════════════════════════════════════════════════════════════════ @@ -1463,7 +1617,7 @@ local function check_abi_handoff(atom, pipe_ctx, findings) for tok_idx = 1, #tokens do local tc_entry = tc[tok_idx] -- scan: load_word(R_*, R_TapePtr, O_(, )) - if tc_entry.is_load_word and tc_entry.reads_r_tape_ptr and tc_entry.o_arg1 == binds_name then + if tc_entry.is_load and tc_entry.reads_r_tape_ptr and tc_entry.o_arg1 == binds_name then local field = tc_entry.o_arg2 if field then found_field_set[field] = true @@ -1508,14 +1662,15 @@ end -- Check #4: GPU port-store shape -- ════════════════════════════════════════════════════════════════════════════ ---- For every baked atom body, detect which GP0 primitive it's emitting ---- (first `mac_format__color` call). Sum contributions from `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`. +--- For every baked atom body, detect which GP0 primitive it's emitting +--- (first `mac_format__color` call). Sum contributions from `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`. --- Compare to duffle.GP0_CMD_SIZE[cmd_byte]. Mismatch = error. --- --- Soft behavior (warnings): ---- - Atoms emitting a primitive via raw `store_word(R_PrimCursor, ...)` (no `mac_format_X_color` call) emit a "manual packet assembly" advisory. +--- - Atoms emitting a primitive via raw `store_word(R_PrimCursor, ...)` (no `mac_format_X_color` call) emit a "manual packet assembly" advisory. --- Cannot auto-validate. ---- - Atoms containing a `mac_(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory. +--- - Atoms containing a `mac_(...)` call whose `name` is not registered in `pipe_ctx.components_by_name` emit a "new macro; +--- Not in corpus.components" advisory — the auto-derivation returned nil for that name. --- --- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives. local function check_gpu_portstore_shape(atom, pipe_ctx, findings) @@ -1540,15 +1695,23 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings) cmd_line = atom.line + line_in_body[tokens[tok_idx].rel] end saw_format = true - local n = duffle.GP0_MACRO_CONTRIB["mac_format_" .. shape .. "_color"] + -- gp0_contrib is auto-derived from the original `MipsAtomComp_(ac_format__color) { body }` body + -- in `passes/components.lua::compute_components_metadata` and stored on `corpus.components`. + local comp = pipe_ctx.components_by_name["format_" .. shape .. "_color"] + local n = comp and comp.gp0_contrib if n then contrib = contrib + n end end if tc_entry.is_gte_store then - local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident] + -- `tc_entry.ident` is the macro-variant form (`mac_gte_store_f3`); strip the `mac_` prefix for the bare-name corpus lookup. + local bare = tc_entry.ident:sub(#"mac_" + 1) + local comp = pipe_ctx.components_by_name[bare] + local n = comp and comp.gp0_contrib if n then contrib = contrib + n end end if tc_entry.is_ot_tag then - local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident] + local bare = tc_entry.ident:sub(#"mac_" + 1) + local comp = pipe_ctx.components_by_name[bare] + local n = comp and comp.gp0_contrib if n then contrib = contrib + n end end if tc_entry.writes_r_prim_cursor then @@ -1585,23 +1748,30 @@ end -- ════════════════════════════════════════════════════════════════════════════ --- Walk all paths through an atom body and return per-path cycle sums. ---- Builds a tiny CFG: each token has a "next" pointer; branches have two (fall-through + taken). ---- The BD-slot nop after a branch is absorbed into the branch's cost (MIPS-accurate: BD slot always runs), ---- and is SKIPPED when continuing down the fall-through path (otherwise we'd double-count it). +--- Builds a tiny CFG: each token has a "next" pointer. Three control-transfer kinds are recognized (set by `classify_tokens`): +--- * `branch_*` (conditional): 2 successors — fall-through (BD slot absorbed) + taken (if `atom_offset` target known). +--- * `jump` / `call_addr` (unconditional absolute): 1 successor — taken only (BD slot absorbed into the cost). +--- * `jump_reg` / `call_reg` / `jump_link` (register-form): terminator — transfers control OUT of the current atom (e.g. `mac_yield()` ends in `jump_reg(R_AtomJmp), nop`). +--- +--- The BD-slot nop after ANY of these (conditional branch, unconditional jump, terminal jump) is absorbed into the control-transfer's cost +--- (MIPS-accurate: the BD slot always runs) and is SKIPPED in the successor list (otherwise we'd double-count it). --- --- Returns: --- 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) +--- branches - number of branches in the body (conditional + unconditional-with-offset) +--- paths - number of distinct paths reached (terminated at mac_yield / terminal_jump / 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) +--- unknown_macros - list of unique ident names with no cost lookup: non-`mac_*` idents not in `duffle.INSTRUCTION_LATENCY`, +--- plus `mac_*` idents whose bare name is missing from `pipe_ctx.components_by_name` (i.e. no `MipsAtomComp_` for it). +local function analyze_atom_paths(atom, pipe_ctx) local tokens = atom.paths.tokens or duffle.tokenize_body(atom.body) local tc = atom.paths.tok_class or classify_tokens(tokens) local n = #tokens -- Build label + branch maps from the pre-computed classification (no re-scan). + -- `branches` keys both `branch_*` (conditional) and `jump`/`call_addr` (unconditional absolute); + -- The latter resolve via `tc[tok_idx].branch_label` the same way (the offsets pass produces a valid relative offset for both). local labels = {} local branches = {} for tok_idx = 1, n do @@ -1615,23 +1785,46 @@ local function analyze_atom_paths(atom) end -- Pre-compute per-token cycle costs from the pre-computed ident (no re-match). + -- For non-`mac_*` tokens: lookup `duffle.INSTRUCTION_LATENCY[c.ident]` directly. + -- For `mac_*` tokens: lookup `pipe_ctx.components_by_name[bare_name].cycle_cost`, which `passes/components.lua::compute_components_metadata` derived from the originals + -- `MipsAtomComp_(ac_X) { body }` definition (sum of `INSTRUCTION_LATENCY` per emitted instruction, + -- recursing through nested `mac_*` calls). `mac_yield` is special-cased to 0 by `compute_components_metadata` (the runtime cost lands in the next atom's prologue). local costs = {} local unknown_set = {} for tok_idx = 1, n do local c = tc[tok_idx] - local cost = duffle.INSTRUCTION_LATENCY[c.ident] - if cost == nil then - cost = duffle.UNKNOWN_INSTRUCTION_CYCLES - unknown_set[c.ident] = true + local ident = c.ident + local cost + if ident:sub(1, #"mac_") == "mac_" then + -- `mac_*` token: lookup corpus.components[bare_name].cycle_cost. + local bare = ident:sub(#"mac_" + 1) + local comp = pipe_ctx.components_by_name and pipe_ctx.components_by_name[bare] + if comp and comp.cycle_cost ~= nil then + cost = comp.cycle_cost + else + cost = duffle.UNKNOWN_INSTRUCTION_CYCLES + unknown_set[ident] = true + end + else + cost = duffle.INSTRUCTION_LATENCY[ident] + if cost == nil then + cost = duffle.UNKNOWN_INSTRUCTION_CYCLES + unknown_set[ident] = true + end end costs[tok_idx] = cost end - -- A token is a terminator if it's `mac_yield`. - local function is_terminator(tok_idx) return tc[tok_idx].is_yield end - - -- A token is a "branch" if the classification says so. + -- Three control-transfer predicates (set by `classify_tokens`): + -- is_terminator — path ends here (`mac_yield` or register-form jump); empty successors. + -- is_branch — has an immediate offset (`branch_*`, `jump`, `call_addr`); 1-2 successors depending on unconditional_jump. + -- is_unconditional_jump — when is_branch is also true: skip fall-through (target only). + local function is_terminator(tok_idx) + local c = tc[tok_idx] + return c.is_yield or c.is_terminal_jump + end local function is_branch(tok_idx) return tc[tok_idx].is_branch end + local function is_unconditional_jump(tok_idx) return tc[tok_idx].is_unconditional_jump end local function successors(tok_idx) local tok = tokens[tok_idx].tok if is_terminator(tok_idx) then @@ -1640,11 +1833,22 @@ local function analyze_atom_paths(atom) if is_branch(tok_idx) then local label = branches[tok_idx] -- may be false for literal-offset branches local succ = {} - -- Fall-through: skip the BD slot (tok_idx+1). Use tok_idx+2. + if is_unconditional_jump(tok_idx) then + -- Unconditional absolute jump / call: BD slot absorbed; single successor — the taken path. + -- The instruction word after the BD slot is unreachable in this atom's execution. + if label then + local label_pos = labels[label] + if label_pos and label_pos + 1 <= n then + succ[#succ + 1] = label_pos + 1 + end + end + -- For literal-offset jumps (label == false), the target is a non-tracked address; conservatively omit. + return succ, nil + end + -- Conditional branch: BD slot absorbed; two successors — fall-through (tok_idx+2) + taken (if known). if tok_idx + 2 <= n then succ[#succ + 1] = tok_idx + 2 end - -- Taken: only if the branch has a known atom_offset target. if label then local label_pos = labels[label] if label_pos and label_pos + 1 <= n then @@ -1680,11 +1884,11 @@ local function analyze_atom_paths(atom) return end - -- Add this token's cost. For a branch, ADD the BD-slot cost too - -- (and skip the BD slot in the successor list — already done in `successors` above for fall-through; - -- for taken path the BD slot was at tok_idx+1 which is now skipped entirely). + -- Add this token's cost. For ANY control-transfer (conditional branch, unconditional jump, terminal jump), + -- ADD the BD-slot cost too — MIPS-accurate: the BD slot always runs. Skip the BD slot in the successor list (already done in `successors` above; + -- for the taken path the BD slot was at tok_idx+1 which is now skipped entirely). local cost = costs[tok_idx] - if is_branch(tok_idx) and tok_idx + 1 <= n then + if (is_branch(tok_idx) or is_terminator(tok_idx)) and tok_idx + 1 <= n then cost = cost + costs[tok_idx + 1] end local new_acc = acc + cost @@ -1715,7 +1919,7 @@ local function analyze_atom_paths(atom) for macro_name in pairs(unknown_set) do unknown_list[#unknown_list + 1] = macro_name end table.sort(unknown_list) - -- branch_count: number of `branch_*(...)` tokens. + -- branch_count: number of control-transfer tokens with an immediate offset (`branch_*` + `jump` + `call_addr`). local branch_count = 0 for _ in pairs(branches) do branch_count = branch_count + 1 end @@ -1733,9 +1937,9 @@ local function analyze_atom_paths(atom) end --- Per-source check that emits one finding per unknown macro seen ---- (deduplicated across atoms so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). ---- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms ---- (so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). +--- (deduplicated across atoms so the warning section doesn't get spammed with N copies of the same diagnostic). +--- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms +--- (so the warning section doesn't get spammed with N copies of the same diagnostic). --- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery, which walks tokens and computes per-token cycle costs. local function check_per_atom_cycle_budget(atom, pipe_ctx, findings) local p = atom.paths or {} @@ -1745,8 +1949,11 @@ local function check_per_atom_cycle_budget(atom, pipe_ctx, findings) findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "per_atom_cycle_budget", kind = "warning", - msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " - .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY." + msg = string.format("%s at line %d uses macro `%s` with no cycle_cost lookup; " + .. "cycle count will be +%d per call (best-case). For `mac_*` idents, ensure the " + .. "corresponding `MipsAtomComp_(ac_X)` is in scope of the build so " + .. "`passes/components.lua::compute_components_metadata` can derive its cost; " + .. "for non-`mac_*` idents, add an entry to `duffle.INSTRUCTION_LATENCY`." , atom.name, atom.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES), } end @@ -1910,7 +2117,7 @@ local function check_binds_no_substruct_deref(_src, pipe_ctx, findings) local line_in_body = a.paths and a.paths.line_in_body or {} for ti = 1, #tokens do local tc_entry = tc[ti] - if (tc_entry.is_load_word or tc_entry.is_store_word) + if (tc_entry.is_load or tc_entry.is_store_word) and tc_entry.o_arg1 and tc_entry.o_arg2 then local type_name = tc_entry.o_arg1 local field_name = tc_entry.o_arg2 @@ -1969,6 +2176,7 @@ local CHECK_RULES = { { name = "gte_role_mismatch", per_atom = check_gte_role_mismatch }, { name = "hazard_nop_use", per_atom = check_hazard_nop_use }, { name = "control_transfer_delay_slot_use",per_atom = check_control_transfer_delay_slot_use}, + { name = "load_delay_violation", per_atom = check_load_delay_slots }, { 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 }, @@ -1998,7 +2206,7 @@ local function build_corpus_pipe_ctx(ctx) .. "no per-source fallback is supported)", 0) end -- The pipe_ctx views REFERENCE the corpus tables directly (no copies). --- Every consumer observes mutations through the corpus tables directly. + -- Every consumer observes mutations through the corpus tables directly. return { -- Cross-source lookup tables. register_alias_registry = corpus.register_alias_registry or {}, @@ -2008,6 +2216,10 @@ local function build_corpus_pipe_ctx(ctx) atom_phases = corpus.atom_phases or {}, binds_by_name = corpus.binds_by_name or {}, atoms_by_name = corpus.atoms_by_name or {}, + -- Per-component metadata (cycle_cost + gp0_contrib) auto-derived from the original + -- `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`. + -- Keyed by bare name (e.g. `format_f3_color`, `gte_store_f3`); the `mac_` prefix at call sites is stripped before lookup. + components_by_name = corpus.components or {}, -- Corpus-wide ordered list of atom_info records (source-order + duplicates). atom_infos_list = corpus.atom_infos or {}, -- Corpus-wide collisions (recorded by scan_source.merge_corpus_registries). @@ -2059,15 +2271,16 @@ local function validate(ctx, src, corpus_pipe_ctx) atom_infos_list = atom_infos or {}, register_alias_registry = corpus_pipe_ctx.register_alias_registry, type_name_registry = corpus_pipe_ctx.type_name_registry, + -- Per-component metadata (cycle_cost + gp0_contrib) auto-derived from the original `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`. + components_by_name = corpus_pipe_ctx.components_by_name, } - -- Shared cross-source component-body index is owned by the corpus - -- (`corpus.component_body_index`, populated by `passes/components.lua`). + -- Shared cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`). -- Per-atom checks consume the corpus-owned index directly. pipe_ctx.component_body_index = (corpus and corpus.component_body_index) or {} --- 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. --- Every piece of state derived from an atom body lives on `atom.paths` (per-atom mega-struct); - --- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list. + --- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`. --- Each `check_*` function accepts one atom and its shared context. --- Per-source rules run once after this loop completes (no parallel dispatch table). --- @@ -2093,7 +2306,7 @@ local function validate(ctx, src, corpus_pipe_ctx) a.paths.tok_class = classify_tokens(a.paths.tokens) -- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths. - analyze_atom_paths(a) + analyze_atom_paths(a, pipe_ctx) -- Run the single forward walker for transfer-hazard policy. -- Runs once per atom BEFORE the CHECK_RULES per-atom dispatch so the `transfer_hazards` reader (`check_transfer_hazards`) can