diff --git a/code/duffle/atom_dsl.h b/code/duffle/atom_dsl.h index ea4f1e8..248f85a 100644 --- a/code/duffle/atom_dsl.h +++ b/code/duffle/atom_dsl.h @@ -11,7 +11,7 @@ * Pure macro anntation. * --------------- * Don't want to constraint the macro usage to some attribute placment constraint, etc, don't want ot dela with the compiler. - * atom_info, atom_bind, atom_reads, atom_writes, atom_label, atom_dbg_skip_over each expand to a C comment or to nothing + * atom_info, atom_bind, atom_reads, atom_writes, atom_label, atom_dbg_skip each expand to a C comment or to nothing * (C preprocessor strips them to whitespace). * * ============================================================================ @@ -90,13 +90,18 @@ #define atom_info(...) /* atom_info(__VA_ARGS__) */ /* ---------------------------------------------------------------------------- - * DEBUG SOURCE-STEP MARKERS + * DEBUG SOURCE-STEP MARKER * - * Place atom_dbg_skip_over() before a MipsAtom_, MipsAtomComp_, or MipsAtomComp_Proc_. + * Place `atom_dbg_skip` (BARE) before a MipsAtom_, MipsAtomComp_, or MipsAtomComp_Proc_. * The following declaration kind determines whether the marker selects a whole atom or a component inline view. * The source scanner associates the marker with that declaration; placement diagnostics are handled by the annotation pass. + * + * Example: + * atom_dbg_skip MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }; + * atom_dbg_skip MipsAtomComp_(ac_yield) { ... }; + * atom_dbg_skip MipsAtomComp_Proc_(ac_format_f3_color, { ... }); * ----------------------------------------------------------------------------*/ -#define atom_dbg_skip_over() /* atom_dbg_skip_over: skip the following atom or component source view */ +#define atom_dbg_skip /* atom_dbg_skip: skip the following atom or component source view */ /* ---------------------------------------------------------------------------- * Typed-view annotations (Registry for DWARF RR_ chain resolution) @@ -117,7 +122,7 @@ * The preferred correlation mechanism; atom_ctx is the escape hatch for non-natural cases. * * All three expand to C comments - * (the bare-token convention matching `atom_reg` and `atom_dbg_skip_over`). + * (the bare-token convention matching `atom_reg` and `atom_dbg_skip`). * The Lua scanner reads the bare tokens in source-as-written; the C preprocessor strips them. * ----------------------------------------------------------------------------*/ #define atom_type(T) /* atom_type: associate with the preceding enum entry (enum site) or this register (atom-info site) */ diff --git a/code/duffle/gen/duffle.macs.h b/code/duffle/gen/duffle.macs.h index 3eafae5..a9691b5 100644 --- a/code/duffle/gen/duffle.macs.h +++ b/code/duffle/gen/duffle.macs.h @@ -9,6 +9,12 @@ #define WORD_COUNT(name, count) enum { words_##name = (count) }; #endif +/* atom_dbg_skip */ +/* --------------------------------------------------------------------------- + * MACRO ATOM Components (Reusable Assembly Components) + * These do NOT yield. They are expanded inline inside Tape Atoms. + * ---------------------------------------------------------------------------*/ +// The 'Yield' sequence for Tape Atoms (mac_yield). #define mac_yield(...) \ load_word(R_AtomJmp, R_TapePtr, 0) \ , add_ui_self( R_TapePtr, S_(MipsCode)) \ @@ -16,6 +22,7 @@ , nop WORD_COUNT(mac_yield, 4) +/* atom_dbg_skip */ /* Words: 3; Loads 3 S2 indices from the face array */ #define mac_load_tri_indices(...) \ load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)) \ @@ -23,6 +30,8 @@ WORD_COUNT(mac_yield, 4) , load_half_u(R_T2, R_FaceCursor, 2 * S_(S2)) WORD_COUNT(mac_load_tri_indices, 3) +/* atom_dbg_skip */ +/* Words: 18; Translates indices to vertex addresses and pushes them to GTE */ #define mac_gte_load_tri_verts(...) \ shift_lleft(R_AT, R_T0, v3s2_byteoff) \ , add_u_self(R_AT, R_VertBase) \ @@ -80,10 +89,12 @@ WORD_COUNT(mac_insert_ot_tag_g4, 11) , store_word( R_AT, R_PrimCursor, (off)) WORD_COUNT(mac_pack_color_word, 3) +/* atom_dbg_skip */ #define mac_format_f3_color(r, g, b) \ mac_pack_color_word(O_(Poly_F3,color), gp0_cmd_poly_f3, r, g, b) WORD_COUNT(mac_format_f3_color, 3) +/* atom_dbg_skip */ /* Words: 3; Stores the 3 transformed (V2_S2 screen) vertices to the F3. * PIPELINE: post-RTPT (SXY0=v0.screen, SXY1=v1.screen, SXY2=v2.screen). */ #define mac_gte_store_f3_post_rtpt(...) \ @@ -99,6 +110,7 @@ WORD_COUNT(mac_gte_store_f3_post_rtpt, 3) , mac_pack_color_word(O_(Poly_G4,c3), 0, r3,g3,b3) WORD_COUNT(mac_format_g4_color, 12) +/* atom_dbg_skip */ /* Words: 3; Stores the 3 transformed (V2_S2 screen) vertices of the * G4 triangle portion to p0/p1/p2. * PIPELINE: post-RTPT, pre-RTPS (SXY0=v0.screen, SXY1=v1.screen, SXY2=v2.screen). @@ -113,6 +125,7 @@ WORD_COUNT(mac_format_g4_color, 12) , gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p2)) WORD_COUNT(mac_gte_store_g4_p012_post_rtpt_pre_rtps, 3) +/* atom_dbg_skip */ /* Words: 1; Stores the V3 screen coord to the G4's p3 slot. * PIPELINE: post-RTPS (SXY2 holds v3.screen because RTPS writes its * single-vertex result to SXY2; SXY0 still holds v0.screen from the diff --git a/code/duffle/gp_vendor_sym.h b/code/duffle/gp_vendor_sym.h index ea22d3e..fa9027d 100644 --- a/code/duffle/gp_vendor_sym.h +++ b/code/duffle/gp_vendor_sym.h @@ -2,7 +2,7 @@ * duffle DSL — GPU Vendor Mnemonics (opt-in) * ============================================================================ * - * Provides the PSYQ-style CamelCase aliases for the canonical duffle GPU primitive setters and OT operations. + * Provides the PSYQ-style CamelCase aliases for the duffle GPU primitive setters and OT operations. * The duffle snake_case names are primary; this header is for users who prefer the PSYQ SDK function names from the legacy C API. * * USAGE: #include "duffle/gp_vendor_sym.h" // after gp.h @@ -24,7 +24,7 @@ * The gp0_cmd_* / gp1_cmd_* byte constants are already short and descriptive; no vendor alias is provided for them. * * The vendor mnemonics are NOT registered with the duffle word-count metadata (word_counts.metadata.h). - * They expand to the duffle canonical macros which DO have word-count entries + * They expand to the duffle macros which DO have word-count entries * (the ones emitted by mac_format_f3_color / mac_gte_store_f3 / etc.). Verification: V13 (objdump byte-identical) holds. * ============================================================================ */ diff --git a/code/duffle/gte.h b/code/duffle/gte.h index 595a646..25bc669 100644 --- a/code/duffle/gte.h +++ b/code/duffle/gte.h @@ -353,41 +353,34 @@ enum { _C2_TX_SUBS_ = 0 /* GTE command words for the common cases. * - * These are pure compile-time integer constants — the C compiler - * constant-folds them into `.word` directives in .rodata. Use them - * inside `asm_inline(...)` blocks (see `gte_rtpt` below for the - * canonical idiom). + * These are pure compile-time integer constants — the C compiler constant-folds them into `.word` directives in .rodata. + * Use them inside `asm_inline(...)` blocks (see `gte_rtpt` below for the idiom). * * Decomposition (per the `enc_gte_` definitions above): * gte_cmdw_ = gte_cmd_base | enc_gte_cmd() - * The SF/MX/V/CV/LM fields are all zero in the common cases (standard - * rotation-matrix, no scaling factor, V0 vector, translation vector, - * no clamp), so the only varying bits are the `cmd` field. + * The SF/MX/V/CV/LM fields are all zero in the common cases + * (standard rotation-matrix, no scaling factor, V0 vector, translation vector, no clamp), + * so the only varying bits are the `cmd` field. * - * Naming follows the file's convention: `gte_cmd_*` is the raw - * 6-bit `cmd` field id, `gte_cmdw_*` is the fully-encoded 32-bit - * instruction word ready to drop into a `.word` directive. + * Naming follows the file's convention: `gte_cmd_*` is the raw 6-bit `cmd` field id, `gte_cmdw_*` + * is the fully-encoded 32-bit instruction word ready to drop into a `.word` directive. * * -------------------------------------------------------------------------- * PsyQ-compatibility note (RTPS/RTPT): - * The original Sony PsyQ `inline_n.h` ships RTPT as `cop2 0x0280030` and - * RTPS as `cop2 0x0180001`. Both have `0x20` set in the upper-reserved - * region (bit 21) AND `sf=1` (bit 19) — i.e. the "no division" flag. - * Per psx-spec these bits are reserved/must-be-zero, but the real GTE - * hardware and PCSX-Redux's GTE model both IGNORE them on these two - * commands (the perspective divide happens regardless of `sf`). + * The original Sony PsyQ `inline_n.h` ships RTPT as `cop2 0x0280030` and RTPS as `cop2 0x0180001`. + * Both have `0x20` set in the upper-reserved region (bit 21) AND `sf=1` (bit 19) — i.e. the "no division" flag. + * Per psx-spec these bits are reserved/must-be-zero, + * but the real GTE hardware and PCSX-Redux's GTE model both IGNORE them on these two commands + * (the perspective divide happens regardless of `sf`). * - * If we emit a strictly-spec-compliant word (`sf=0`, reserved bits - * clear), PCSX-Redux's GTE checks those bits more strictly than the - * silicon does and RTPT silently no-ops — the floor's screen - * coordinates come out as raw projection-of-rotation (Z never - * divided), `nclip` ends up wrong, and the triangle is culled. + * If we emit a strictly-spec-compliant word (`sf=0`, reserved bits clear), + * PCSX-Redux's GTE checks those bits more strictly than the silicon does and RTPT silently no-ops — + * the floor's screen coordinates come out as raw projection-of-rotation (Z never divided), + * `nclip` ends up wrong, and the triangle is culled. * - * So for RTPS and RTPT we OR-in the `0x28` "PsyQ compat" pattern to - * match the working bit pattern everyone has shipped for 25 years. - * NCLIP/OP/MVMVA stay spec-clean — their reserved bits really are - * zero in the original PsyQ source. + * So for RTPS and RTPT we OR-in the `0x28` "PsyQ compat" pattern to match the working bit pattern everyone has shipped for 25 years. + * NCLIP/OP/MVMVA stay spec-clean — their reserved bits really are zero in the original PsyQ source. * -------------------------------------------------------------------------- */ #define gte_cmdw_psyq_compat (1u << 21 | enc_gte_sf(gte_sf_integer)) @@ -424,9 +417,9 @@ enum { _C2_TX_SUBS_ = 0 * @details Loads values from an SVECTOR struct to GTE data registers C2_VXY0 * (XY at offset 0) and C2_VZ0 (Z at offset 4) using `lwc2`. * - * Uses string-style GCC inline asm with `%0` substitution because the - * base register `r0` is a runtime GPR chosen by the compiler — it cannot - * be encoded into a static `.word` constant. + * Uses string-style GCC inline asm with `%0` substitution because the + * base register `r0` is a runtime GPR chosen by the compiler. + * It cannot be encoded into a static `.word` constant. * * Usage: * asm_gte_load_v0(svector_ptr); @@ -458,26 +451,21 @@ enum { /* gte_load_vN(r_ptr, base) — placeholder-punned lwc2 loaders * - * Emits `.word` constants encoding `lwc2 $N, off()` for the chosen - * GTE vector register, where `` is the GPR number you pass in + * Emits `.word` constants encoding `lwc2 $N, off()` for the chosen GTE vector register, where `` is the GPR number you pass in * (typically one of R_T4..R_T9 for the standard "3-pointer" pattern). * * The caller MUST bind `r_ptr` to that same GPR via a register variable: * register V3_S2* p_in_12 __asm__("$12") = my_ptr; * gte_load_v0(p_in_12, R_T4); // R_T4 = 12, base is $12 * - * Then `"r"(r_ptr)` inside the asm binds to $12 (the only register - * `p_in_12` can live in), which is exactly the register the .word - * constants expect. A `"$12"` clobber would conflict with the - * register-variable binding ("asm specifier for variable conflicts - * with asm clobber list"), so we omit it. The other ABI-clobbers - * ($2/$8/$9/$31) stay because the GTE instructions don't touch - * caller-saved GPRs but the kernel does treat them as volatile. + * Then `"r"(r_ptr)` inside the asm binds to $12 (the only register `p_in_12` can live in), + * which is exactly the register the .word constants expect. A `"$12"` clobber would conflict with the register-variable binding + * ("asm specifier for variable conflicts with asm clobber list"), so we omit it. + * The other ABI-clobbers ($2/$8/$9/$31) stay because the GTE instructions don't touch caller-saved GPRs but the kernel does treat them as volatile. * * WHICH REGISTER TO PICK * ---------------------- - * Any caller-saved GPR is safe. Recommended default for an RTPT-style - * 3-pointer pipeline: + * Any caller-saved GPR is safe. Recommended default for an RTPT-style 3-pointer pipeline: * gte_load_v0(p0, R_T4); // $12 * gte_load_v1(p1, R_T5); // $13 * gte_load_v2(p2, R_T6); // $14 @@ -490,8 +478,7 @@ enum { * clobbers section : "$2", "$8", ..., "memory" (from asm_clobber) * 3 colons total, GCC-legal. No string-syntax mnemonics in the .word body. * - * The `asm_clobber(...)` helper from gcc_asm.h prepends the colon that - * starts the clobbers section. */ + * The `asm_clobber(...)` helper from gcc_asm.h prepends the colon that starts the clobbers section. */ #define gte_load_v0(r_ptr, base) asm volatile( \ asm_words( gte_lw_v0_xy(base), gte_lw_v0_z(base) ) \ asm_rpins, r_use(r_ptr) \ @@ -510,11 +497,11 @@ enum { asm_clobber: rlit(R_V0), rlit(R_T0), rlit(R_T1), rlit(R_RA), clb_mem_drain \ ) -/* gte_load_v0v1v2(p0, p1, p2, b0, b1, b2) — the canonical prelude to gte_cmd_rtpt. +/* gte_load_v0v1v2(p0, p1, p2, b0, b1, b2) — prelude to gte_cmd_rtpt. * * Loads all three GTE input vectors (6 words) from three separate pointers, - * one per GTE vector register, each loaded from its own base GPR. Caller - * must bind each `pN` to `bN` via a register variable. + * one per GTE vector register, each loaded from its own base GPR. + * Caller must bind each `pN` to `bN` via a register variable. * * register V3_S2* p0 rgcc(R_T4) = verts[0].ptr; // → __asm__("$12") * register V3_S2* p1 rgcc(R_T5) = verts[1].ptr; // → __asm__("$13") diff --git a/code/duffle/gte_vendor_sym.h b/code/duffle/gte_vendor_sym.h index 8c19d74..3a362bd 100644 --- a/code/duffle/gte_vendor_sym.h +++ b/code/duffle/gte_vendor_sym.h @@ -2,7 +2,7 @@ * duffle DSL — GTE Vendor Mnemonics (opt-in) * ============================================================================ * - * Provides the textbook MIPS assembly mnemonics for the GTE/COP2 instructions as thin aliases to the canonical duffle macros in gte.h. + * Provides the textbook MIPS assembly mnemonics for the GTE/COP2 instructions as thin aliases to the duffle macros in gte.h. * The duffle names are primary; this header is for users who prefer the textbook mnemonics. * * USAGE: #include "duffle/gte_vendor_sym.h" // after gte.h diff --git a/code/duffle/lottes_tape.h b/code/duffle/lottes_tape.h index 6fe6d50..8966f3a 100644 --- a/code/duffle/lottes_tape.h +++ b/code/duffle/lottes_tape.h @@ -57,7 +57,7 @@ enum { * ---------------------------------------------------------------------------*/ /* The 'Exit' Atom */ -atom_dbg_skip_over() MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }; +atom_dbg_skip MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }; /* Generalized Tape Engine Runner */ NI_ void tape_run(Slice_MipsCode tape) { register U4* tp rgcc(R_TapePtr) = u4_r(tape.ptr); asm volatile( @@ -104,21 +104,21 @@ FI_ Slice_MipsCode tb_slice(TapeBuilder tb) { return (Sl * ---------------------------------------------------------------------------*/ // The 'Yield' sequence for Tape Atoms (mac_yield). -atom_dbg_skip_over() MipsAtomComp_(ac_yield) { +atom_dbg_skip MipsAtomComp_(ac_yield) { load_word(R_AtomJmp, R_TapePtr, 0), add_ui_self( R_TapePtr, S_(MipsCode)), jump_reg( R_AtomJmp), nop, }; /* Words: 3; Loads 3 S2 indices from the face array */ -atom_dbg_skip_over() MipsAtomComp_(ac_load_tri_indices) { +atom_dbg_skip MipsAtomComp_(ac_load_tri_indices) { load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)), load_half_u(R_T1, R_FaceCursor, 1 * S_(S2)), load_half_u(R_T2, R_FaceCursor, 2 * S_(S2)), }; /* Words: 18; Translates indices to vertex addresses and pushes them to GTE */ -atom_dbg_skip_over() MipsAtomComp_(ac_gte_load_tri_verts) { +atom_dbg_skip MipsAtomComp_(ac_gte_load_tri_verts) { shift_lleft(R_AT, R_T0, v3s2_byteoff), add_u_self(R_AT, R_VertBase), load_word(R_V0, R_AT, O_(V3_S2,x)), load_word(R_V1, R_AT, O_(V3_S2,z)), gte_mv_to_data_r(R_V0, C2_VXY0), gte_mv_to_data_r(R_V1, C2_VZ0), shift_lleft(R_AT, R_T1, v3s2_byteoff), add_u_self(R_AT, R_VertBase), load_word(R_V0, R_AT, O_(V3_S2,x)), load_word(R_V1, R_AT, O_(V3_S2,z)), gte_mv_to_data_r(R_V0, C2_VXY1), gte_mv_to_data_r(R_V1, C2_VZ1), shift_lleft(R_AT, R_T2, v3s2_byteoff), add_u_self(R_AT, R_VertBase), load_word(R_V0, R_AT, O_(V3_S2,x)), load_word(R_V1, R_AT, O_(V3_S2,z)), gte_mv_to_data_r(R_V0, C2_VXY2), gte_mv_to_data_r(R_V1, C2_VZ2), @@ -166,11 +166,11 @@ MipsAtomComp_Proc_(ac_pack_color_word, { /* Words: 3; Emits the F3 command+color word (cmd byte | BLUE | GREEN | RED) * Args: _r, _g, _b are 8-bit RGB byte values (not raw 16-bit fields). */ FI_ MipsAtom ac_format_f3_color(U1 r, U1 g, U1 b) -atom_dbg_skip_over() MipsAtomComp_Proc_(ac_format_f3_color, { mac_pack_color_word(O_(Poly_F3,color), gp0_cmd_poly_f3, r, g, b) }) +atom_dbg_skip MipsAtomComp_Proc_(ac_format_f3_color, { mac_pack_color_word(O_(Poly_F3,color), gp0_cmd_poly_f3, r, g, b) }) /* Words: 3; Stores the 3 transformed (V2_S2 screen) vertices to the F3. * PIPELINE: post-RTPT (SXY0=v0.screen, SXY1=v1.screen, SXY2=v2.screen). */ -atom_dbg_skip_over() MipsAtomComp_(ac_gte_store_f3_post_rtpt) { +atom_dbg_skip MipsAtomComp_(ac_gte_store_f3_post_rtpt) { gte_sw(C2_SXY0, R_PrimCursor, O_(Poly_F3,p0)), gte_sw(C2_SXY1, R_PrimCursor, O_(Poly_F3,p1)), gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_F3,p2)), @@ -198,7 +198,7 @@ MipsAtomComp_Proc_(ac_format_g4_color, { * three registers aligned with v0/v1/v2 you must store before RTPS). * The macro name declares the pipeline position; check #6 (GTE state- * machine validation) verifies the call site matches the declaration. */ -atom_dbg_skip_over() MipsAtomComp_(ac_gte_store_g4_p012_post_rtpt_pre_rtps) { +atom_dbg_skip MipsAtomComp_(ac_gte_store_g4_p012_post_rtpt_pre_rtps) { gte_sw(C2_SXY0, R_PrimCursor, O_(Poly_G4,p0)), gte_sw(C2_SXY1, R_PrimCursor, O_(Poly_G4,p1)), gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p2)), @@ -210,7 +210,7 @@ atom_dbg_skip_over() MipsAtomComp_(ac_gte_store_g4_p012_post_rtpt_pre_rtps) { * earlier RTPT — DO NOT read SXY0 here, that's the bug this name * prevents). */ -atom_dbg_skip_over() MipsAtomComp_(ac_gte_store_g4_p3_post_rtps) { gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p3)) }; +atom_dbg_skip MipsAtomComp_(ac_gte_store_g4_p3_post_rtps) { gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p3)) }; #pragma endregion Macro Atom Components diff --git a/code/duffle/mips.h b/code/duffle/mips.h index e22f53d..c38f770 100644 --- a/code/duffle/mips.h +++ b/code/duffle/mips.h @@ -436,7 +436,7 @@ enum { _BitOffsets = 0 /* --- Shift-amount alias (matches the gas convention `\p3 = shamt`) --- */ #define shift_amount(rd, rt, n) shift_lleft(rd, rt, n) -/* nop — canonical sll $0, $0, 0 */ +/* nop — sll $0, $0, 0 */ #define nop shift_lleft(rdiscard, rdiscard, 0) #define nop2 nop, nop diff --git a/code/duffle/mips_vendor_sym.h b/code/duffle/mips_vendor_sym.h index deccdb2..f932b99 100644 --- a/code/duffle/mips_vendor_sym.h +++ b/code/duffle/mips_vendor_sym.h @@ -2,7 +2,7 @@ * duffle DSL — MIPS Vendor Mnemonics (opt-in) * ============================================================================ * - * Provides the textbook MIPS assembly mnemonics as thin aliases to the canonical duffle macros in mips.h. + * Provides the textbook MIPS assembly mnemonics as thin aliases to the duffle macros in mips.h. * The duffle names are primary; this header is for users who prefer the textbook mnemonics. * * USAGE: #include "duffle/mips_vendor_sym.h" // after mips.h diff --git a/code/gte_hello/hello_gte_tape.c b/code/gte_hello/hello_gte_tape.c index 0814de3..0b3cf75 100644 --- a/code/gte_hello/hello_gte_tape.c +++ b/code/gte_hello/hello_gte_tape.c @@ -103,7 +103,7 @@ MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri), atom_phase(f mac_yield() }; -// atom_dbg_skip_over() +atom_dbg_skip internal MipsAtom_(floor_f3_face) atom_info(atom_phase(floor_f3) , atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase) diff --git a/scripts/duffle.lua b/scripts/duffle.lua index d610201..8119c8c 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -1,15 +1,16 @@ ---- duffle.lua — Shared primitives + domain tables for the tape-atom ---- metaprograms. +--- duffle.lua — shared primitives + domain tables for the tape-atom metaprograms. --- ---- This module is the source for: ---- - **Character classification** (`is_space`, `is_alpha`, `is_alnum`, `is_digit`, plus the byte-fast `_byte` variants). ---- - **String/path primitives** (`trim`, `dirname`, `basename_no_ext`, `normalize_path`, `canonical_path_key`, `find_byte`). ---- - **I/O primitives** (`read_file`, `write_file`, `ensure_dir`). ---- - **Canonical corpus resolution** (`parse_direct_quoted_includes`, `resolve_source_corpus`). ---- - **C-language scanner** (`skip_ws_and_cmt`, `skip_str_or_cmt`, `read_ident`, `read_parens`, `read_braces`, `read_brackets`, `read_balanced`, `scan_to_char`, `split_top_level_commas`). ---- - **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`). +--- One ownership statement, then the rest is signal: +--- * **Character classification** (`is_space`, `is_alpha`, `is_alnum`, `is_digit`, plus the byte-fast `_byte` variants). +--- * **String / path primitives** (`trim`, `dirname`, `basename_no_ext`, `normalize_path`, `canonical_path_key`, `find_byte`). +--- * **I/O primitives** (`read_file`, `write_file`, `ensure_dir`). +--- * **Corpus resolution** (`parse_direct_quoted_includes`, `resolve_source_corpus`). +--- * **C-language scanner** (`skip_ws_and_cmt`, `skip_str_or_cmt`, `read_ident`, `read_parens`, `read_braces`, `read_brackets`, +--- `read_balanced`, `scan_to_char`, `split_top_level_commas`). +--- * **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`). --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex. @@ -73,27 +74,22 @@ local BYTE_DIGIT_9 = 0x39 -- '9' -- Section -1: Bootstrap (path-setup at module load) -- ════════════════════════════════════════════════════════════════════════════ -- --- Path setup is done by `scripts/duffle_paths.lua`, which derives the repo root from `debug.getinfo(1, "S").source` (NO subprocess, ~0ms) and then calls `require("duffle")`. --- Repository paths come from `scripts/duffle_paths.lua` because: --- 1. Entry and pass scripts load `duffle_paths.lua`. --- The `find_repo_root` / `setup_package_path` defined here was dead code in practice. --- 2. `git rev-parse` costs ~100-180ms per subprocess spawn on Windows. --- `debug.getinfo` is <1ms. There's no reason to keep the slow path even as a "fallback". +-- Path setup runs through `scripts/duffle_paths.lua`, which derives the repo root from `debug.getinfo(1, "S").source` +-- (no subprocess, ~0ms) and then calls `require("duffle")`. +-- Entry and pass scripts load `duffle_paths.lua` first; a `find_repo_root` / `setup_package_path` defined here was dead code in practice. +-- `git rev-parse` costs ~100-180ms per subprocess spawn on Windows; `debug.getinfo` is <1ms, so we keep only the fast path. -- --- If a future use case ever needs to load `duffle.lua` WITHOUT going through `duffle_paths.lua`, set `package.path` manually before `require`. +-- To load `duffle.lua` outside `duffle_paths.lua`, set `package.path` manually before `require`. -- See `docs/guide_metaprogram_ssdl.md` §"I/O primitives" for the pattern. -- ════════════════════════════════════════════════════════════════════════════ -- Section 0: LPeg patterns (compiled once at module load) -- ════════════════════════════════════════════════════════════════════════════ -- --- LPeg is a required dependency (PEG library, no regex). --- It's loaded via `package.cpath` (configured by `duffle_paths.lua` to find `toolchain/lpeg/lpeg.dll`). --- LPeg handles the high-level scanner, while Section 1 handles byte classification. --- only relevant at the high-level scanner stage; the byte-by-byte helpers in Section 1 are sufficient for the classification primitives. +-- LPeg is a required dependency (PEG library, no regex). It's loaded via `package.cpath` — `duffle_paths.lua` wires the path to `toolchain/lpeg/lpeg.dll`. +-- LPeg handles the high-level scanner; the byte-by-byte helpers in Section 1 handle classification primitives that LPeg's CPython-level cost would dominate. -- --- If the require fails, fail loud with an actionable message. The build script (`update_deps.ps1`) builds lpeg.dll into `toolchain/lpeg/`; --- if it's missing, run `update_deps.ps1`. +-- If the require fails, fail loud with an actionable message. The build script (`update_deps.ps1`) builds lpeg.dll into `toolchain/lpeg/`; run it when the dll is missing. local lpeg_ok, lpeg = pcall(require, "lpeg") if not lpeg_ok then io.stderr:write("[duffle] require('lpeg') failed: ", lpeg, "\n") @@ -395,7 +391,7 @@ end --- Group a list of `SourceFile`-shaped records by their `dir` field. --- Used by the annotation / static-analysis / report passes to partition sources into per-DIRECTORY (per-module) buckets before emitting per-module reports. ---- Insertion order preserved within each bucket (matches source order in `ctx.sources`). +--- Insertion order is preserved within each bucket (matches source order in `corpus.source_order`). --- @param sources table[] -- list of source records (each having a `dir` string field) --- @return table -- map of `dir` -> sources in that dir function M.group_sources_by_dir(sources) @@ -562,7 +558,7 @@ local function splice_c_lines(source) end --- Parse direct quoted preprocessor includes from one source buffer. ---- Translation Line splicing occurs ahead of comment, string, and directive processing. +--- Line splicing occurs ahead of comment, string, and directive processing. --- Interpreted records retain original physical include text and line numbers. --- Angle includes and include-like text inside comments/strings are ignored. --- @param source_text string @@ -850,13 +846,10 @@ function M.split_top_level_commas(body) if has_real_content(chunk) then tokens[#tokens + 1] = chunk elseif #tokens > 0 then - -- Pure comment/string chunk at top level (no preceding instruction content within this chunk). - -- APPEND it to the LAST token so emit-context callers (components.lua build_component_lines) - -- can convert `// trailing comment` to `/* */` and emit it with the macro body. - -- For word counting, count_token_words only inspects the leading ident, so a trailing comment doesn't affect the count. - -- - -- This is the second-half fix to commit 98e27c2: the first fix correctly broke top-level comments off from the NEXT statement (fixing macro-call word counts); - -- This fix preserves them on the PREVIOUS statement (restoring the comments in the emitted .macs.h output). + -- Pure comment/string chunk at top level. + -- Append it to the LAST token so emit-context callers (components.lua build_component_lines) can convert + -- `// trailing comment` to `/* */` and emit it with the macro body. + -- count_token_words only inspects the leading ident, so a trailing comment does not affect the count. tokens[#tokens] = tokens[#tokens] .. chunk end end @@ -1048,18 +1041,16 @@ M.TAPE_ATOM_MACROS = { -- GTE command-alias resolution table. -- --- 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. +-- Maps every source-side GTE command macro to its canonical short ident. +-- Both forms run the same PSX-SPX-documented pipeline semantics. +-- Aliases resolve exactly once; an unknown ident (an MVMVA with a custom `(sf, mx, v, cv, lm)` payload that is not on this list) lands as +-- "command unknown" from the check rather than being silently treated as 0-cycle. -- --- 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. +-- Source conventions (per `code/duffle/gte.h`): the C source ships short idents (`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.). Each alias row maps the source ident to its short form. M.GTE_COMMAND_ALIASES = { - -- Canonical -> canonical (identity). + -- Identity rows: short form resolves to itself. ["gte_cmdw_rtps"] = "gte_cmdw_rtps", ["gte_cmdw_rtpt"] = "gte_cmdw_rtpt", ["gte_cmdw_nclip"] = "gte_cmdw_nclip", @@ -1067,7 +1058,7 @@ M.GTE_COMMAND_ALIASES = { ["gte_cmdw_op"] = "gte_cmdw_op", ["gte_cmdw_avsz3"] = "gte_cmdw_avsz3", ["gte_cmdw_avsz4"] = "gte_cmdw_avsz4", - -- Aliases -> canonical. + -- Long-form aliases resolve to the short form. ["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", @@ -1082,28 +1073,27 @@ M.GTE_COMMAND_ALIASES = { -- 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`: +-- For each 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. +-- * 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: +-- 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). +-- 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 +-- We model the data-register + control-register superset. Every relevant input is in this set per PSX-SPX `gtepipelinetimings.md`; +-- the per-input latching values there describe 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. +-- Anything outside this 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"] = { @@ -1166,15 +1156,15 @@ M.GTE_COMMAND_INPUTS = { -- GTE command output-set + semantic role table. -- --- For each canonical command, the SET of C2 data registers the command writes as outputs, paired with the SEMANTIC ROLE of each output. --- The semantic role is the basis for the `_post_` contract validation: --- The contract says "after , the latest screen-XY is C2_SXY2" (NOT C2_SXY0. The FIFO side effects do NOT make SXY0 the newest result). +-- For each command, the set of C2 data registers the command writes as outputs, paired with the SEMANTIC ROLE of each output. +-- The semantic role is the basis for the `_post_` contract validation. +-- The contract says "after , the latest screen-XY is C2_SXY2" (C2_SXY0 is wrong; the FIFO side effects leave SXY0 as an older FIFO entry, never the newest). -- -- Per PSX-SPX `docs/psx-spx/docs/geometrytransformationenginegte.md`: --- * RTPS: writes VXY/VZ -> MAC results; the SINGLE projected screen coordinate is written to C2_SXY2 (the IRGB -> SXY2 path via the perspective divide). --- C2_SXY0 and C2_SXY1 are NOT written. +-- * RTPS: writes VXY/VZ -> MAC results; the single projected screen coordinate is written to C2_SXY2 (the IRGB -> SXY2 path via the perspective divide). +-- C2_SXY0 and C2_SXY1 are untouched. -- * RTPT: writes three projected screen coordinates into SXY0, SXY1, SXY2 in pipeline order. --- The LAST projection is in C2_SXY2; a reader that wants "the last RTPT result" must read C2_SXY2. +-- The last projection lives in C2_SXY2; a reader that wants "the last RTPT result" reads C2_SXY2. -- * NCLIP: writes a single MAC result into C2_SZ3 (the inner-product sum); no screen XY output. -- * AVSZ3 / AVSZ4: write average Z into C2_OTZ (single output). -- * OP: writes C2_IR1, C2_IR2, C2_IR3 (cross-product result; no projection). @@ -1190,22 +1180,21 @@ M.GTE_COMMAND_INPUTS = { -- * "mac_result" : generic MAC output (nclip, op, mvmva) -- -- Consumers: --- * passes/static_analysis.lua::analyze_hardware_relations (the walker consults this table after a GTE command to update `forward_state.post_command_roles` for `gte_result_position`). +-- * passes/static_analysis.lua::analyze_hardware_relations (the walker reads this after a GTE command to update +-- `forward_state.post_command_roles` for `gte_result_position`). -- * passes/static_analysis.lua::check_gte_result_position (per-atom CHECK_RULES reader; renders role mismatches). -- This table is consumed by the hardware-relation analyzer and result-position check. M.GTE_COMMAND_OUTPUTS = { - -- RTPS: writes ONE screen coordinate (the perspective-divide result) - -- into C2_SXY2; the FIFO side effects do NOT make SXY0 / SXY1 newest. - -- `latest_screen_xy` is C2_SXY2. + -- RTPS: writes one screen coordinate (the perspective-divide result) into C2_SXY2. + -- The FIFO side effects leave SXY0 / SXY1 untouched, so `latest_screen_xy` is C2_SXY2. ["gte_cmdw_rtps"] = { { register = "C2_SXY2", role = "latest_screen_xy" }, { register = "C2_SZ2", role = "latest_screen_z" }, { register = "C2_OTZ", role = "otz" }, { register = "C2_IR0", role = "latest_color" }, }, - -- RTPT: writes THREE screen coordinates; the LAST projection lands in - -- C2_SXY2. `latest_screen_xy` is C2_SXY2; C2_SXY0 / C2_SXY1 are the - -- earlier projections of the batched triple. + -- RTPT: writes three screen coordinates; the last projection lands in C2_SXY2 (`latest_screen_xy`). + -- C2_SXY0 / C2_SXY1 carry the earlier projections of the batched triple. ["gte_cmdw_rtpt"] = { { register = "C2_SXY0", role = "screen_xy[0]" }, { register = "C2_SXY1", role = "screen_xy[1]" }, @@ -1213,8 +1202,7 @@ M.GTE_COMMAND_OUTPUTS = { { register = "C2_SZ3", role = "latest_screen_z" }, { register = "C2_OTZ", role = "otz" }, }, - -- NCLIP: single MAC result; written to C2_SZ3 (the inner-product sum). - -- No screen XY output. + -- NCLIP: single MAC result; written to C2_SZ3 (the inner-product sum). No screen XY output. ["gte_cmdw_nclip"] = { { register = "C2_SZ3", role = "mac_result" }, }, @@ -1243,24 +1231,23 @@ M.GTE_COMMAND_OUTPUTS = { -- GTE command/post-command latch-window table. -- -- Per PSX-SPX `docs/psx-spx/docs/gtepipelinetimings.md`, a GTE command emits outputs that latch into the pipeline for a measured number of emitted words. --- A subsequent MTC2/CTC2 OVERWRITE of one of those outputs BEFORE the latch window expires is a hazard --- (the latched value in the pipeline is overwritten by the CPU before the pipeline consumes it). +-- A subsequent MTC2/CTC2 overwrite of one of those outputs before the latch window expires is a hazard: +-- the latched value in the pipeline gets overwritten by the CPU before the pipeline consumes it. -- --- This relation is the COMMAND -> REGISTER direction (the command is the producer, the MTC2/CTC2 is the consumer). --- It is NOT the same relation as the preceding MTC2 -> command input propagation --- (which is the REGISTER -> COMMAND direction and is staged by the producer step of `analyze_hardware_relations`). +-- This relation is the command -> register direction (the command is the producer; MTC2/CTC2 is the consumer). +-- It is the inverse of the MTC2 -> command input propagation (register -> command direction), which is staged by the +-- producer step of `analyze_hardware_relations`. -- --- The schema mirrors the producer-side relations (`direction`, `evidence`, `violation_kind`); --- `required` is the number of emitted words strictly between the command's last output word and the overwrite. --- `N=0` permits the immediately following overwrite instruction; `N=4` permits an overwrite that occurs after 4 intervening words. +-- The schema mirrors the producer-side relations (`direction`, `evidence`, `violation_kind`); `required` counts the +-- emitted words strictly between the command's last output word and the overwrite. +-- `required = 0` permits the immediately following overwrite; `required = 4` requires four intervening words. -- --- Per PSX-SPX `gtepipelinetimings.md` --- The per-command input latching measurements are the SAME number, just inverted: --- They describe when a recent MTC2/CTC2 must retire before the command issues; --- here we describe when a recent command's outputs latch into the pipeline before a later MTC2/CTC2 may overwrite them. +-- Per PSX-SPX `gtepipelinetimings.md` the per-command input latching measurements are the same numbers inverted. +-- They describe when a recent MTC2/CTC2 must retire before the command issues; this table describes when a recent +-- command's outputs latch into the pipeline before a later MTC2/CTC2 overwrites them. -- -- Consumers: --- * passes/static_analysis.lua::analyze_hardware_relations (the walker consults this table after a GTE command to stage post-command latch relations in `pending`). +-- * passes/static_analysis.lua::analyze_hardware_relations (stages post-command latch relations in `pending` after a GTE command). -- * passes/static_analysis.lua::check_gte_input_latch (per-atom CHECK_RULES reader; renders the over-the-boundary findings). -- This table is consumed by the hardware-relation analyzer and input-latch check. M.GTE_COMMAND_LATCH_WINDOWS = { @@ -1308,23 +1295,24 @@ M.GTE_COMMAND_LATCH_WINDOWS = { -- GTE component result contracts (immutable; keyed by bare component name). -- --- Register-role claims that cannot be inferred from the `_post_` suffix alone live here. --- The bare name (without the `_post_` suffix) is the key; the row carries the expected command, the expected role, and the expected C2 register. +-- Register-role claims that the `_post_` suffix alone cannot infer live here. +-- The bare name (the component name stripped of the `_post_` suffix) is the key; the row carries the expected +-- command, the expected role, and the expected C2 register. -- -- Known rows: --- * `gte_store_g4_p3_post_rtps`: post-RTPS polygon-emit slot reads the newest projected screen coordinate from C2_SXY2 --- (NOT C2_SXY0; the FIFO side effects do not make SXY0 the newest result). +-- * `gte_store_g4_p3_post_rtps`: post-RTPS polygon-emit slot reads the newest projected screen coordinate from C2_SXY2. +-- C2_SXY0 is wrong (C2_SXY0 is an older FIFO entry, never the newest post-RTPS result). -- --- Unknown `_post_` components (a `_post_` suffixed component name whose bare `` is not a row key) --- emit ONE `table_gap` info finding so downstream consumers can detect when the canonical contract table is incomplete for an authored atom body. +-- Unknown `_post_` components (a `_post_`-suffixed component whose bare `` is not a row key) emit one +-- `table_gap` info finding so downstream consumers can detect when the contract table is incomplete for an authored atom body. -- -- Consumers: --- * passes/static_analysis.lua::check_gte_result_position (per-atom CHECK_RULES reader; renders result-position findings). +-- * passes/static_analysis.lua::check_gte_result_position (renders result-position findings). -- * passes/static_analysis.lua::emit_table_gap_warning (called once per atom body; surfaces the missing-row diagnostic). -- This table is consumed by the result-position check. M.GTE_COMPONENT_RESULT_CONTRACTS = { -- Post-RTPS g4 p3 store contract: writes the latest screen XY (C2_SXY2) into the primitive's p3 slot. - -- Reads from C2_SXY0 would be a semantic mismatch (C2_SXY0 is the OLDEST post-RTPS SXY, not the newest one). + -- Reading from C2_SXY0 is a semantic mismatch — C2_SXY0 is the oldest post-RTPS SXY, not the newest one. ["gte_store_g4_p3_post_rtps"] = { command = "gte_cmdw_rtps", role = "latest_screen_xy", @@ -1334,15 +1322,14 @@ M.GTE_COMPONENT_RESULT_CONTRACTS = { -- 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. +-- Maps each emitting-token ident to the set of GPR operand positions it reads. +-- Covers the current encoder vocabulary (`code/duffle/mips.h` + `code/duffle/gte.h`); add 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. +-- * 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`). +-- A subsequent event counts as 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. @@ -1374,9 +1361,8 @@ M.OPERAND_READ_POSITIONS = { ["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. + -- Loads: load_word(rt, base, off); the rt operand is the destination (it's written, not read) and base + off are non-GPR operands. + -- The check treats the rt operand as a write, so the read-positions table for `load_*` is empty. ["load_word"] = {}, ["load_half_u"] = {}, ["load_byte_u"] = {}, @@ -1408,9 +1394,9 @@ M.OPERAND_READ_POSITIONS = { ["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 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; `gte_*` commands are atomic-from-the-CPU-POV + -- once they issue (the CPU holds until the command completes, so load-delay violations don't surface here). ["gte_mv_from_data_r"] = {}, ["gte_mv_from_ctrl_r"] = {}, ["gte_mv_to_data_r"] = {}, @@ -1461,9 +1447,10 @@ M.GP0_CMD_BY_SHAPE = { ["g4"] = 0x38, ["gt4"] = 0x3C, } --- Per-macro prim-buffer contribution --- (NOT .text instruction count this is "how many 32-bit words does this macro write to the primitive being built in main RAM"). --- Sum across `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X` calls in an atom body must equal GP0_CMD_SIZE[GP0_CMD_BY_SHAPE[shape]]. +-- Per-macro prim-buffer contribution: 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, @@ -1477,20 +1464,18 @@ M.GP0_MACRO_CONTRIB = { } -- 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 token it appears as in source). --- For example: --- mac_pack_color_word(off, cmd, r, g, b) emits: +-- 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 +-- = 3 cycles total -- --- mac_yield emits a control-transfer sequence (load_word, add_ui_self, jump_reg, nop) --- which "yields control" the atom body's cycle budget doesn't include the yield's cost (we model it as 0; --- runtime cost becomes part of the NEXT atom's prologue). +-- `mac_yield` emits a control-transfer sequence (load_word, add_ui_self, jump_reg, nop). 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. -- --- 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: +-- GTE command values are the GTE instruction's intrinsic cycles — the latency after any pre-cmd `nop2` has retired. +-- When the source emits `nop2, gte_cmdw_X`, the nops' cycles are added separately (1+1) plus the gte_cmdw_X value here: -- rtpt = 23 + 2 nops = 25 total cycles (PSX-SPX says 23 cycles for the cmd itself; the nops are pre-fill) -- rtps = 15 + 2 nops = 17 total -- nclip = 8 + 2 nops = 10 total @@ -1499,11 +1484,11 @@ M.GP0_MACRO_CONTRIB = { -- mvmva = 8 + 2 nops = 10 total -- op = 6 (no pre-cmd nops required; atomic) -- --- Note: the "total" above is the pre-fill nops + the GTE intrinsic cycles. --- PSX-SPX documents the GTE intrinsic cycles as the total execution time of the command itself (rtpt=23, rtps=15, nclip=8, etc.). --- The pre-fill nops are a codebase convention for retiring preceding C2 writes, not part of the GTE's own execution time. --- See `docs/psx-spx/docs/geometrytransformationenginegte.md` for the canonical per-command cycle counts and `docs/psx-spx/docs/gtepipelinetimings.md` --- for the hardware-verified input-latch boundaries (which show most inputs are safe to clobber after just 0-4 cycles). +-- PSX-SPX reports the GTE intrinsic cycles as the total execution time of the command itself (rtpt=23, rtps=15, nclip=8, etc.). +-- The pre-fill nops are a codebase convention for retiring preceding C2 writes. +-- 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). M.INSTRUCTION_LATENCY = { -- CPU ALU (single-cycle R3000A ops) ["nop"] = 1, @@ -1578,7 +1563,7 @@ M.INSTRUCTION_LATENCY = { ["gte_cmdw_op"] = 6, -- OP: 6 cycles (PSX-SPX) ["gte_cmdw_outer_product"] = 6, -- alias for OP ["gte_cmdw_wedge"] = 6, -- alias for OP - -- Long-form aliases (same cost as canonical) + -- Long-form aliases (same cycle cost as their short form) ["gte_cmdw_rotate_translate_perspective_single"] = 15, -- alias for rtps ["gte_cmdw_rotate_translate_perspective_triple"] = 23, -- alias for rtpt ["gte_cmdw_avg_sort_z4"] = 6, -- alias for avsz4 @@ -1628,29 +1613,30 @@ M.UNKNOWN_INSTRUCTION_CYCLES = 1 -- Hardware-relation policy table. -- --- The single forward-analyzer in `passes/static_analysis.lua::analyze_hardware_relations` --- reads every emitted word_event, matches its `encoder` against `row.token`, and: +-- The forward walker in `passes/static_analysis.lua::analyze_hardware_relations` reads every emitted word_event, +-- matches its `encoder` against `row.token`, and: -- * stages the event as a producer in `atom.paths.forward_state`; or -- * matches it as a consumer against pending producers and records a hazard on `atom.paths.hazards` when the gap is below `visibility.required`. -- --- Each row is the contract for one CPU-to-coprocessor transfer semantic --- (the coprocessor-to-CPU path mirrors the same shape). The `reads` / `writes` sub-tables carry the argument positions the analyzer inspects: +-- Each row is the contract for one CPU-to-coprocessor transfer semantic (the coprocessor-to-CPU path mirrors the same shape). +-- The `reads` / `writes` sub-tables carry the argument positions the analyzer inspects: -- * `writes.arg` is the destination operand (the producer's effect); the analyzer stages this register as a pending producer. --- * `reads` (when present) lists the operand positions the SAME token reads back from hardware; for MTC2 / CTC2 the producer reads the GPR source it is loading from. +-- * `reads` (when present) lists the operand positions the same token reads back from hardware; for MTC2 / CTC2 the producer reads the GPR source it is loading from. -- The `fanout_to` field (MTC2-IRGB row only) tells the consumer-match logic which downstream COP2 registers are transitively updated by the write. -- -- Visibility semantics: --- * `kind = "post_producer_words"` means the consumer must observe the producer's effect after `required` independent emitted words that are strictly between the producer and the consumer. --- The producer's own emitted slot does NOT retire the relation (per the canonical PSX-SPX rule: "Store delays are counted in numbers of clock cycles (not in numbers of opcodes). --- For 3 cycle delay, one must usuallys insert 3 cached opcodes (or one uncached opcode)."). +-- * `kind = "post_producer_words"` means the consumer observes the producer's effect after `required` independent emitted words that are +-- strictly between the producer and the consumer. The producer's own emitted slot is implicit (it counts as the slot of issue, not toward +-- `required`) — per the PSX-SPX rule: "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)." -- * `required` is the minimum count of intervening emitted words between producer and consumer. --- `required = 0` is permitted (the consumer may sit on the very next slot); --- `required < 0` would mean the consumer may sit on the same slot as the producer and is reserved for future "self-retires" relations. +-- `required = 0` permits the consumer on the very next slot; `required < 0` would place the consumer on the same slot as the producer +-- and is reserved for future "self-retires" relations. -- -- Evidence: --- * `evidence.confidence` is one of `"exact"`, `"conservative"`, `"unknown"`. The severity comes from `violation_kind`; --- a hardware measurement that the vendor caveats may still be `"conservative"` even when the underlying timing is numerically known. --- * `evidence.source` is the canonical upstream reference (file + line range) the row is sourced from. Doc-edits that add new rows must add the source citation here. +-- * `evidence.confidence` is one of `"exact"`, `"conservative"`, `"unknown"`. The severity comes from `violation_kind`; a hardware +-- measurement that the vendor caveats may still classify as `"conservative"` even when the underlying timing is numerically known. +-- * `evidence.source` is the upstream reference (file + line range) the row is sourced from. New rows must carry this citation. -- -- Consumers: -- * passes/static_analysis.lua::analyze_hardware_relations (forward walker). @@ -1715,7 +1701,7 @@ M.HARDWARE_RELATIONS = { semantic = "MFC2", token = "gte_mv_from_data_r", direction = "cop2_data_to_gpr", - reads = { domain = "cop2.data", arg = 2 }, + reads = { domain = "cop2.data", arg = 2 }, writes = { domain = "gpr", arg = 1 }, visibility = { kind = "post_producer_words", required = 1 }, evidence = { @@ -1730,7 +1716,7 @@ M.HARDWARE_RELATIONS = { semantic = "CFC2", token = "gte_mv_from_ctrl_r", direction = "cop2_control_to_gpr", - reads = { domain = "cop2.ctrl", arg = 2 }, + reads = { domain = "cop2.ctrl", arg = 2 }, writes = { domain = "gpr", arg = 1 }, visibility = { kind = "post_producer_words", required = 1 }, evidence = { @@ -1748,7 +1734,7 @@ M.HARDWARE_RELATIONS = { semantic = "MFC0", token = "sys_mov_from_cop0", direction = "cop0_control_to_gpr", - reads = { domain = "cop0.ctrl", arg = 2 }, + reads = { domain = "cop0.ctrl", arg = 2 }, writes = { domain = "gpr", arg = 1 }, visibility = { kind = "post_producer_words", required = 1 }, evidence = { @@ -1775,8 +1761,8 @@ M.HARDWARE_RELATIONS = { violation_kind = "info", clear_on_consumer = true, }, - -- COP2 data register -> memory (SWC2). This is a read of C2 state, not a CPU-to-COP2 write. - -- Keep the policy row for direction/provenance, but do not stage it as a later command-input producer. + -- COP2 data register -> memory (SWC2). A read of C2 state, not a CPU-to-COP2 write. + -- The policy row stays in for direction/provenance; staging it as a later command-input producer is suppressed. { id = "swc2_memory_write", semantic = "SWC2", @@ -1793,13 +1779,13 @@ M.HARDWARE_RELATIONS = { stage = false, }, -- MTC0 Status/SR.CU2. The ordinary COP0 store has no general store-delay relation; - -- This row is consumed by the dedicated CU2 transition logic in the same forward walk and is therefore not staged in `pending`. + -- this row feeds the dedicated CU2 transition logic in the same forward walk and is therefore not staged in `pending`. { id = "mtc0_cu2_visibility", semantic = "MTC0", token = "sys_mov_to_cop0", direction = "gpr_to_cop0_status", - reads = { domain = "gpr", arg = 1 }, + reads = { domain = "gpr", arg = 1 }, writes = { domain = "cop0.status", arg = 2 }, status_register = 12, visibility = { kind = "post_producer_words", required = 2 }, @@ -1832,18 +1818,18 @@ M.CU2_TRANSITION_POLICY = { -- Maps every CPU/GTE encoder used in production atoms and the focused transfer-hazard tests to its actual GPR operand effects. -- The analyzer applies this table to `atom.paths.forward_state.gpr_values`: -- * a write to a GPR invalidates its constant; --- * a constant-producing transform re-establishes a constant when its inputs are constant (the lattice for `gpr_values` is closed: --- `{kind="unknown"}` and `{kind="constant", value=}`). +-- * a constant-producing transform re-establishes a constant when its inputs are constant +-- (the `gpr_values` lattice is closed: `{kind="unknown"}` and `{kind="constant", value=}`). -- --- The schema is: --- reads = {pos1, pos2, ...} -- 1-based argument positions that are GPR reads. +-- Schema: +-- reads = {pos1, pos2, ...} -- 1-based argument positions that are GPR reads. -- writes = {pos1, pos2, ...} -- 1-based argument positions that are GPR writes. -- The argument positions refer to `word_event.args` (the top-level comma-split args of the emitting token, parsed by `tokenize_body`). --- Operands that are numeric literals, `0x` hex literals, or `U4`/`S4` type keywords are not GPR operand positions and are not listed. +-- Numeric literals, `0x` hex literals, and `U4`/`S4` type keywords are not GPR operand positions. -- --- Encoders not listed here are treated as "unknown writers" for any GPR they touch; --- Wknown writers invalidate `forward_state.gpr_values` for every GPR operand they touch (the analyzer cannot assume the result is a constant). --- This is deliberately conservative: a row missing for a writer means "we do not know what value the GPR now holds" rather than "the GPR keeps its previous constant". +-- Encoders absent from this table are treated as "unknown writers" for every GPR they touch. Unknown writers invalidate +-- `forward_state.gpr_values` for those operands — the analyzer cannot assume the result is a constant. +-- The shape is deliberately conservative: a row missing for a writer means "we do not know what value the GPR now holds". -- -- Consumers: -- * passes/static_analysis.lua::analyze_hardware_relations (forward walker). @@ -1994,10 +1980,9 @@ M.GPR_VALUE_RULES = { -- Control-transfer (branch/jump/call) delay-slot policy table. -- -- Used by the emitted-word delay-slot check to identify which emitted machine-word idents are control transfers whose next emitted word is the hardware delay slot. --- One table row per emitted encoder; the `family` field is informational (informational only; --- The check matches by `event.ident` against the row keys). --- `suppress_arg1` (when present) lists first-arg values that should NOT emit a finding even when the next emitted word is --- `nop` or absent — e.g. the fixed `mac_yield()` handshake uses `jump_reg(R_AtomJmp), nop` and is intentionally suppressed. +-- One table row per emitted encoder; the `family` field is informational. The check matches by `event.ident` against the row keys. +-- `suppress_arg1` (when present) lists first-arg values that suppress the finding even when the next emitted word is `nop` or absent +-- — for example, the fixed `mac_yield()` handshake uses `jump_reg(R_AtomJmp), nop` and is suppressed so the check stays signal-only. -- -- Consumers: -- * passes/static_analysis.lua::check_control_transfer_delay_slot_use @@ -2025,8 +2010,8 @@ M.CONTROL_TRANSFER_DELAY_SLOT_POLICIES = { -- 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. +-- Shared, memoized helpers: a single emitted-word event stream that every downstream pass reads from, +-- built once from the pre-tokenized bodies. --- @class ComponentBodyEntry --- @field body_tokens table -- pre-tokenized {{tok=string, rel=integer}, ...} @@ -2036,10 +2021,8 @@ M.CONTROL_TRANSFER_DELAY_SLOT_POLICIES = { --- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration --- @field kind string -- "comp_bare" | "comp_proc" --- The cross-source component-body index is owned by the canonical corpus --- (`corpus.component_body_index`, populated by `passes/components.lua`). --- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly; --- No per-pass memoization helper is needed. +-- The cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`). +-- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly; per-pass memoization helpers stay out of scope. -- ASCII byte constants used by split_top_level_args (kept local to keep Section 8 self-contained). local E_BYTE_OPEN_PAREN = 0x28 @@ -2110,45 +2093,66 @@ local E_MAC_PREFIX_LEN = 4 --- --- 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. +--- * **`nop2`** (2-word pseudo-instruction): two events, both with `ident = "nop"` so the recognized "this slot is a no-op" semantic is visible to downstream analyses. +--- * **Any other N-word token** in `word_counts`: 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. +--- - `call_source` / `call_line` = the ROOT atom's source path + call-site line, PRESERVED across recursion so nested events still point at the original root. +--- * **Unknown `mac_X`** (not in `component_index`): fall back to `word_counts[ident]` if present; otherwise emit one opaque event so the cycle budget 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). +--- Pure: reads `body_entry` / `component_index` / `word_counts`. Memoization is the caller's responsibility. +--- Callers wanting `word_events` / `word_event_errors` precomputed for many atoms should memoize them 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[] -- ════════════════════════════════════════════════════════════════════════════ --- Section 11: project_emission (canonical per-atom emission projection) +-- Section 11: project_emission (per-atom emission projection) -- ════════════════════════════════════════════════════════════════════════════ -- --- Canonical per-atom emission projection is owned by `passes/emission_model.lua`. --- The projection is built from the root atom body only (no nested component expansion at this stage); --- Invocation ancestry recursively expands nested components. --- The items stream is the single ordered source of truth; `word_events` and `markers` are dense views over it (never a separate walk). +-- Per-atom emission projection is owned by `passes/emission_model.lua`. +-- The projection is built from the root atom body only; invocation ancestry recursively expands nested components. +-- The items stream is the single ordered source of truth; `word_events` and `markers` are dense views over it. -- --- The helper below operates on a body string (not a body_entry) so the canonical pass can call it without depending on the older SourceScan / body_off conventions. +-- The helper below operates on a body string (not a body_entry) so the pass can call it without depending on the older SourceScan / body_off conventions. -- component_index argument is reserved for recursive component expansion. --- word_counts table is the canonical authored-metadata + current-component count table. +-- word_counts table is authored-metadata + current-component count table. --- @class EmissionProjection --- @field items table[] -- ordered stream of word|label|offset|invoke_begin|invoke_end --- @field word_events table[] -- dense view of items where kind == "word" --- @field markers table[] -- dense view of items where kind == "label"|"offset" ---- @field invocations table[] -- dense view of items where kind == "invoke_begin"|"invoke_end" +--- @field invocations InvocationRecord[] -- dense view of items where kind == "invoke_begin"|"invoke_end" --- @field errors table[] -- token-resolution failures surfaced without fail-loud --- @field warnings table[] -- opaque warnings (e.g. unknown uncounted macro) --- Internal recursive walker. Single source of truth for the items stream; --- `word_events`, `markers`, `invocations`, `errors`, `warnings` are dense views / side outputs derived while appending `items`. +--- @class InvocationRecord +--- Lives at `atom.paths.invocations[*]`. Constructed once at the single invocation-construction site +--- (`emit_invoke_begin` inside `_project_emission_inner`); `invoke_begin` / `invoke_end` markers in the items stream share the same `id`. +--- @field id integer -- 1-based, monotonic per-atom invocation id (0 is reserved for "no open invocation") +--- @field parent_id integer -- 0 for the outermost (root) call; otherwise the id of the immediately enclosing invocation +--- @field kind string -- "comp_bare" | "comp_proc" (component form that triggered the expansion) +--- @field component_name string -- the bare component name without the `mac_` prefix +--- @field call_text string -- the immediate `mac_X(...)` token text (or root call text for the outermost entry) +--- @field root_call_text string -- the IMMUTABLE outermost `mac_X(...)` token text for every word emitted in this call's expansion +--- @field call_path string -- source path of the call site (root atom source for direct calls, component source for nested expansions) +--- @field call_line integer -- source line of the call site +--- @field def_path string -- source path of the component definition +--- @field def_line integer -- source line of the component declaration +--- @field start_pos integer -- 0-based emitted-word position of the FIRST word inside this invocation (the value of `word_idx` AT `emit_invoke_begin` time, BEFORE the first word is emitted). Words emitted inside this invocation occupy `start_pos..start_pos+#body_lines-1` (inclusive, 0-based). Downstream DWARF/provenance consumers MUST read this; do NOT reconstruct it from `start_word` (which is the 1-based items index including `invoke_begin`/`invoke_end` markers). +--- @field end_pos integer -- 0-based position of the LAST word inside this invocation (set by `emit_invoke_end` to `word_idx - 1` AFTER all body words are emitted). +--- @field start_word integer -- 1-based items index of the `invoke_begin` item +--- @field end_word integer -- 1-based items index of the `invoke_end` item (set by `emit_invoke_end`) +--- @field word_count integer -- number of `word` items emitted between `start_word` and `end_word` (inclusive) +--- @field debug_skip boolean -- `debug_skip` stamp; true iff `corpus.components[name].debug_skip` is true at construction. Always boolean (never `nil`). +--- @field errors table[] -- per-invocation construction errors (cycle / count_mismatch); does not include pass-level errors + +-- Internal recursive walker. The items stream holds every emitted event in order; `word_events`, `markers`, +-- `invocations`, `errors`, `warnings` are dense views / side outputs appended alongside. -- -- Output rules: -- * `word` items record: `invocation_ids` (innermost last) and `outermost_invocation_id` (0 if no invocation is open). @@ -2161,7 +2165,7 @@ local E_MAC_PREFIX_LEN = 4 -- * Unknown uncounted macros emit one opaque word + one warning. Unknown metadata-backed macros (entry in `word_counts`) emit the declared word count, no warning. -- * Cycle detection uses an active DFS stack (`visiting`); a cycle appends a construction error to BOTH the projection errors and the cycle invocation's own errors, -- then breaks out without recursing (the cycle entry still receives an invocation ID + paired `invoke_begin` / `invoke_end` items, so the boundary invariant is preserved). --- * Component declared-count mismatch (declared vs. measured) is a construction error (kind = "count_mismatch"); it is recorded on the invocation record and the pass-level errors list. +-- * Component declared-count mismatch (declared vs. measured) is a construction error (kind = "count_mismatch"); recorded on the invocation record and pass-level errors list. -- * Final boundary check: if any invocation is still open at end of walk, surface a "unbalanced" construction error. local function _project_emission_inner(root_body_entry, ctx_table) local items = {} @@ -2224,8 +2228,7 @@ local function _project_emission_inner(root_body_entry, ctx_table) immediate_call_text, root_call_text_w) local inv_ids = open_invocation_ids_snapshot() local outermost = inv_ids[1] or 0 - -- Markers carry the open invocation stack snapshot but do NOT record `call_text` / `root_call_text` — - -- markers are zero-width and never participate in the per-word call-site attribution. + -- 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. local it = { kind = kind, name = name, @@ -2284,6 +2287,25 @@ local function _project_emission_inner(root_body_entry, ctx_table) local function emit_invoke_begin(inv_kind, component_name, call_text, root_call_text, call_path, call_line) next_inv_id = next_inv_id + 1 + -- Invocation-level debug_skip stamp: Emission pass owns `atom.paths.invocations[*].debug_skip`. + -- The stamp is resolved from the `corpus.components[name]` registry (passed in via `ctx_table.components` by `emission_model.run`), + -- NOT from a parallel skip map, source-text re-parse, or second pass over `invocations`. + -- Unmarked components stamp `false` (not `nil`) so consumers can dispatch on the boolean without nil checks. + -- + -- The walker has already found the component body in `ctx_table.component_index[component_name]`, so the matching entry MUST exist in `ctx_table.components[component_name]` + -- (both registries are populated from the same source by the components pass). + -- A missing entry is a corpus-plumbing bug; we fail loudly here rather than silently stamp `false` and mask the regression. + local components = ctx_table.components + local component_def = components and components[component_name] or nil + if not component_def then + error("duffle.emit_invoke_begin: component " .. string.format("%q", component_name) + .. " is present in `component_index` (the walker matched a `mac_" .. component_name .. "()` call) but absent from `components` (the canonical corpus.components registry). " + .. "This is a corpus-plumbing bug — the components pass must populate corpus.components[name] for every component it puts in corpus.component_body_index[name]. " + .. "The emission pass refuses to silently stamp `debug_skip = false` for a missing registry entry." + , 0 + ) + end + local debug_skip_stamp = component_def.debug_skip == true local inv = { id = next_inv_id, parent_id = 0, -- patched below by caller @@ -2295,29 +2317,39 @@ local function _project_emission_inner(root_body_entry, ctx_table) call_line = call_line, def_path = nil, -- patched below after component lookup def_line = nil, + -- 0-based emitted-word position. `word_idx` is the monotonic 0-based counter of `word` items emitted so far in this walk — + -- BEFORE this invocation's first word is emitted, it equals the position of the first word inside the invocation. + -- `start_word` (1-based items index of `invoke_begin`) is kept for items-walking consumers (Annotation pass bounds checks), + -- but DWARF / provenance rows MUST read `start_pos` because those rows are 1-based over the dense `word_events` stream (which has no `invoke_begin` items). + start_pos = word_idx, start_word = #items + 1, -- 1-based items index of invoke_begin - end_word = nil, -- patched by emit_invoke_end + end_pos = nil, -- patched by emit_invoke_end + end_word = nil, -- patched by emit_invoke_end word_count = 0, + debug_skip = debug_skip_stamp, errors = {}, } invocations[#invocations + 1] = inv - items[#items + 1] = { - kind = "invoke_begin", - invocation_id = inv.id, - word_index = word_idx, - invocation_ids = open_invocation_ids_snapshot(), + items [#items + 1] = { + kind = "invoke_begin", + invocation_id = inv.id, + word_index = word_idx, + invocation_ids = open_invocation_ids_snapshot(), } invocation_stack[#invocation_stack + 1] = inv return inv end local function emit_invoke_end(inv) - inv.end_word = #items + 1 -- 1-based items index of invoke_end + -- 0-based emitted-word position of the LAST word inside this invocation. + -- After the last body word was emitted, `word_idx` was incremented past it, so `word_idx - 1` is the 0-based position of the last word. + inv.end_pos = word_idx - 1 + inv.end_word = #items + 1 -- 1-based items index of invoke_end items[#items + 1] = { - kind = "invoke_end", - invocation_id = inv.id, - word_index = word_idx, - invocation_ids = open_invocation_ids_snapshot(), + kind = "invoke_end", + invocation_id = inv.id, + word_index = word_idx, + invocation_ids = open_invocation_ids_snapshot(), } for i = #invocation_stack, 1, -1 do if invocation_stack[i] == inv then @@ -2430,7 +2462,7 @@ local function _project_emission_inner(root_body_entry, ctx_table) end inv.word_count = wc_inside -- count_mismatch is a construction error: word_counts["mac_X"] is the declared count populated by the components pass; - -- we compare against the measured word count. + -- We compare against the measured word count. local declared = ctx_table.word_counts["mac_" .. bare] if declared and wc_inside ~= declared then local err = { @@ -2490,7 +2522,7 @@ local function _project_emission_inner(root_body_entry, ctx_table) } end ---- Project a body string into the canonical per-atom emission projection. +--- Project a body string into the per-atom emission projection. --- --- Semantics: --- * Direct one-word tokens (`nop`, `add_ui`, ...): one `word` item, encoder = ident, word_count = 1. @@ -2507,17 +2539,33 @@ end --- Every emitted `word` carries: `i` (0-based word index), `encoder`, `args` (top-level args), `def_path`, `def_line`, --- `call_text` (the immediate token spelling), `root_call_text` (outermost `mac_X(...)` text), `word_count` (always 1), --- `invocation_ids` (innermost last), `outermost_invocation_id`. ---- Markers carry: `kind`, `name`, `line`, `word_index`, `target` (only for offset kind), plus `invocation_ids` / `outermost_invocation_id` for the open invocation stack at that word. +--- Markers carry: `kind`, `name`, `line`, `word_index`, `target` (only for offset kind), plus `invocation_ids` / `outermost_invocation_id` +--- for the open invocation stack at that word. --- --- @param body_text string -- the raw atom body string --- @param component_index table -- bare-name → component record (corpus.component_body_index) --- @param word_counts table -- macro name → emitted word count +--- @param components table -- bare-name → component definition (corpus.components); REQUIRED — consumed at the invocation-construction site to stamp +--- `invocation.debug_skip`. A missing or non-table `components` raises a fail-loud error rather than silently falling back. --- @return EmissionProjection -function M.project_emission(body_text, component_index, word_counts) - -- Project nested invocation ancestry and construction failures. - -- The public surface remains `M.project_emission(body_text, ...)`; - -- the recursive walk is delegated to `_project_emission_inner` so that component bodies (which arrive as `{body_tokens, body_off, - -- line_of, source, declaration}` records from `corpus.component_body_index`) re-enter the same walker with the same shared output state. +function M.project_emission(body_text, component_index, word_counts, components) + -- The recursive walk delegates to `_project_emission_inner` so component bodies (which arrive as + -- `{body_tokens, body_off, line_of, source, declaration}` records from `corpus.component_body_index`) + -- re-enter the same walker with the same shared output state. + -- + -- The walker is body-relative: it builds `line_of` from `body_text` and stamps body-relative line numbers (1..N) + -- into `item.line` and `invocation.call_line`. `passes/emission_model.lua::stamp_root_provenance` performs the single + -- conversion from body-relative to physical source line at the close site, using the source's `line_of` closure that + -- the pass forwarded. One owner of the line state. + if type(components) ~= "table" then + error("duffle.project_emission: `components` is required " + .. "(bare-name -> component definition, e.g. corpus.components); " + .. "got " .. type(components) .. ". " + .. "The emission pass MUST forward the corpus registry " + .. "so the invocation-construction site can stamp `debug_skip` " + .. "without a second pass, source parse, or parallel lookup.", + 0) + end if type(body_text) ~= "string" or body_text == "" then -- Empty body: still return a valid (empty) projection. @@ -2531,17 +2579,18 @@ function M.project_emission(body_text, component_index, word_counts) } end - local tokens = M.tokenize_body(body_text) - local line_of = M.LineIndex(body_text) + local tokens = M.tokenize_body(body_text) return _project_emission_inner({ body_tokens = tokens, body_off = 0, - line_of = line_of, + line_of = M.LineIndex(body_text), source = "", declaration = 0, - }, { + }, + { component_index = component_index or {}, word_counts = word_counts or {}, + components = components, }) end diff --git a/scripts/launch_pcsx_debug.ps1 b/scripts/launch_pcsx_debug.ps1 index 17a5721..10952d8 100644 --- a/scripts/launch_pcsx_debug.ps1 +++ b/scripts/launch_pcsx_debug.ps1 @@ -28,11 +28,6 @@ param( $ErrorActionPreference = 'Stop' -$gdbInitPath = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\build\gen\hello_gte.gdbinit')) -if (-not (Test-Path -LiteralPath $gdbInitPath -PathType Leaf)) { - Write-Warning "Generated GDB skip sidecar missing (non-fatal): $gdbInitPath. Run the GTE build to regenerate it; debugger launch will continue without generated skip-over commands." -} - # ── Pre-checks ── foreach ($p in @($PcsxPath, $ExePath, $HelperZip)) { if (-not (Test-Path $p)) { diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index 42937e0..3ebbe7a 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -1,21 +1,20 @@ --- passes/annotation.lua — Atom-annotation DSL validator. --- --- Validates `MipsAtom_(name) atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...)) { ... }` declarations in source files. ---- Also reads: `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`) +--- Also reads `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`). --- ---- Source scanning: done ONCE upstream by `duffle.scan_source()` (ps1_meta.lua pre-scans each source and stashes the result in `src.scan`). +--- `duffle.scan_source()` scans each source once upstream; `ps1_meta.lua` stores that result in `src.scan`. --- ---- Writes: ---- - `/.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error) ---- - The annotations.txt report is rendered by `passes/report.lua` from the canonical `corpus.sources_by_dir` projection (re-validating each source via `M.validate()`). +--- Ownership: the canonical `ctx.shared.corpus` supplies cross-source registries, while each `src.scan` supplies its source's declarations and bodies. +--- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message. --- ---- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible +--- Writes `/.errors.h` once per module, with `#error` directives for findings that the C compile surfaces. +--- `passes/report.lua` renders annotations.txt from `corpus.sources_by_dir`, re-validating each source through `M.validate()`. +--- +--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. --- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale. --- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). --- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. --- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd). --- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. +-- Bootstrap follows the entry scripts; `scripts/duffle_paths.lua` sets package.path and package.cpath. See `ps1_meta.lua` for the rationale. +-- `debug.getinfo(1, "S").source` locates this file for standalone and orchestrated runs, then `duffle_paths.lua` returns the loaded `duffle` module. local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local write_file = duffle.write_file @@ -62,15 +61,15 @@ local ensure_dir = duffle.ensure_dir --- @field writes string[] -- R_* names (write targets) --- @field errors string[]|nil -- parse-time errors from scan_source (atom_info body malformed) ---- @class SkipOverMarker -- sub-shape of scan_source.lua's @class SkipOverMarker ---- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over") +--- @class DebugSkipMarker -- sub-shape of scan_source.lua's @class DebugSkipMarker +--- @field marker_kind string -- exact marker ident read from source. Only "atom_dbg_skip" (bare) is positive. --- @field marker_line integer --- @field args string|nil -- trimmed text inside the parens (nil when has_parens is false) --- @field has_parens boolean +--- @field is_bare boolean -- true iff marker_kind == "atom_dbg_skip" AND has_parens == false (the only positive form) --- @field pending boolean -- true while awaiting the following declaration --- @field superseded_by_marker_line integer|nil -- set on a marker that was bumped out of the pending slot --- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed ---- @field declaration_line integer|nil --- @class Finding --- @field line integer -- source line (or 0 for pass-level) @@ -104,10 +103,8 @@ local ensure_dir = duffle.ensure_dir -- Per-check functions (the CHECK_RULES table's payload) -- ════════════════════════════════════════════════════════════════════════════ -- --- Each check has a uniform `append_to_findings` shape (errors[] / warnings[] / info[]). --- The dispatcher in `validate()` decides which findings list each check writes to — by convention, --- "existence" checks (declaration must exist, struct must exist) write errors[]; "shape" checks (writes/reads must be wave-context) write warnings[]. --- The `macro_word_drift` check writes both errors[] (missing/mismatch) and info[] (match). +--- The dispatcher in `validate()` routes each result by convention: existence checks write errors[] and shape checks write warnings[]. +--- `macro_word_drift` writes errors[] for missing or mismatched metadata and info[] for a match. --- Check: every annotated atom must have a matching MipsAtom_(name) declaration. --- @param a AtomAnnotation @@ -138,9 +135,7 @@ local function check_unique_annotation(pipe_ctx, findings) end --- Check: BIND atoms must reference a real Binds_* struct. ---- Emitting a warning here keeps the annotation pass from being stop-on-error for the common test-fixture case, ---- while still surfacing the issue in the report. ---- The static-analysis report remains the source of truth for build-stopping errors. +--- I keep this as a warning so the annotation pass can report the common test-fixture case; `check_abi_handoff` in static analysis supplies the build-stopping error. --- @param a AtomAnnotation --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -182,9 +177,8 @@ local function check_macro_word_drift(m, wc, findings) } end ---- Check: atom_dbg_reg_default(R_X, ) must target a register declared as a debug-visible alias in `pipe_ctx.register_alias_registry`, ---- with a type name found in `pipe_ctx.type_name_registry`. ---- Pointer depth is still bounded to 0 or 1. Duplicate defaults are still detected. +--- Check: atom_dbg_reg_default(R_X, ) targets an alias in `pipe_ctx.register_alias_registry` and a type in `pipe_ctx.type_name_registry`. +--- Pointer depth remains bounded to 0 or 1, and duplicate defaults remain errors. --- @param _src SourceFile -- unused (kept for the per_source shape) --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -233,10 +227,8 @@ local function check_semantic_reg_defaults(_src, pipe_ctx, findings) end end ---- Check: atom_reg_types(R_X, ) entries must point to a register declared in `pipe_ctx.register_alias_registry`, with a type name found in `pipe_ctx.type_name_registry`. ---- The alias ident `R_` now encodes the GPR identity only for entries that are explicitly opted in via the bare `atom_reg` marker. ---- R_T0..R_T3 are intentionally NOT auto-included (per the prototype principle: no auto-include of wave-context; explicit opt-in only). ---- The check fires for any R_T0..R_T3 reference that hasn't been opted in via `#define atom_reg`. +--- Check: atom_reg_types(R_X, ) entries target an alias in `pipe_ctx.register_alias_registry` and a type in `pipe_ctx.type_name_registry`. +--- A bare `atom_reg` marker opts the `R_` alias into GPR identity; references to R_T0..R_T3 require the same explicit marker. --- @param _src SourceFile --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -267,7 +259,7 @@ local function check_atom_reg_types(_src, pipe_ctx, findings) end end ---- Check: atom_view(Binds_X) entries must reference a real Binds_* struct and that struct must declare at least one field. +--- Check: atom_view(Binds_X) entries reference a Binds_* struct with at least one field. --- @param _src SourceFile --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -296,8 +288,7 @@ local function check_atom_view_layout(_src, pipe_ctx, findings) end end ---- Check: Binds_* structs may not have duplicate field names ---- (they would defeat the typed-field name lookup that atom_view exposes in gdb). +--- Check: Binds_* structs require unique field names because atom_view uses those names for typed-field lookup in gdb. --- @param _src SourceFile --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -320,26 +311,30 @@ local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings) end end --- Check: skip-over markers must satisfy shape + placement constraints. ---- Walks the priority list once; at most one error is appended per marker so that a single source-level defect does not cascade into multiple findings. +-- Check: debug-skip markers must satisfy shape + placement constraints. +--- Walks the priority list once; each marker produces at most one error, so one source defect yields one finding. --- Priority order (first defect wins): ---- 1. has_parens == false -> requires parentheses: marker() ---- 2. args ~= "" -> takes no arguments ---- 3. superseded_by_marker_line -> duplicate marker (cite superseding line) ---- 4. pending + no target_kind -> dangling (no following declaration) ---- 5. unsupported target_kind -> marker precedes an unrelated declaration ---- Valid markers before whole-atom / bare-component / proc-component declarations emit no error and remain in src.scan.skip_over.atoms / .components. ---- @param marker SkipOverMarker +--- 1. marker_kind ~= "atom_dbg_skip" -> legacy/renamed spelling (use `atom_dbg_skip`) +--- 2. marker_kind == "atom_dbg_skip" AND has_parens -> parenthesized form (the marker is bare-only) +--- 3. args ~= "" -> takes no arguments +--- 4. superseded_by_marker_line -> duplicate marker (cite superseding line) +--- 5. pending + no target_kind -> dangling (no following declaration) +--- 6. unsupported target_kind -> marker precedes an unrelated declaration +--- Valid markers stamp `debug_skip` on whole-atom, bare-component, and proc-component declaration records in scan_source.lua. +--- @param marker DebugSkipMarker --- @param _pipe_ctx PipeCtx -- unused today; kept for plex-shape consistency with per_annot --- @param findings Findings local function check_skip_marker(marker, _pipe_ctx, findings) local kind = marker.marker_kind local line = marker.marker_line - if not marker.has_parens then + -- Tasks 6+7 left `scan.debug_skip_markers` with production records for `atom_dbg_skip` only; other identifiers take the walker's unrelated branch. + + if marker.has_parens then findings.errors[#findings.errors + 1] = { line = line, - msg = string.format("%s marker at line %d requires parentheses: marker()", kind, line), + msg = string.format("%s marker at line %d must be bare; the parenthesized form is no longer accepted (use `atom_dbg_skip MipsAtom_(name) { ... }`)", + kind, line), } return end @@ -384,11 +379,8 @@ end --- Warn when a source references an unregistered alias. --- ---- 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. ---- A source referencing an unregistered R_X emits one pass-level info entry ---- (emitted only when at least one such rejection lands in this source) tells users where to look. ---- ---- This check directs raw C-ABI register names to explicit alias registration. +--- R_TapePtr, R_AtomJmp, R_PrimCursor, R_FaceCursor, R_VertBase, and R_OtBase opt in through `#define atom_reg` in lottes_tape.h. +--- When a source uses an unregistered R_X, this check emits one pass-level info entry for that source and directs C-ABI register names to explicit alias registration. --- @param _src SourceFile --- @param pipe_ctx PipeCtx --- @param findings Findings @@ -421,7 +413,7 @@ end -- per_annot(annot, pipe_ctx, findings) -- runs once per AtomAnnotation -- post(pipe_ctx, findings) -- runs once after all per_annot calls complete (full-corpus aggregation) -- per_macro(macro, wc, findings) -- runs once per TAPE_WORDS / _Pragma macro declaration --- per_skip_marker(marker, pipe_ctx, findings) -- runs once per src.scan.skip_over.markers entry +-- per_skip_marker(marker, pipe_ctx, findings) -- runs once per src.scan.debug_skip_markers entry -- -- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing. @@ -444,14 +436,8 @@ local CHECK_RULES = { -- -- Pure check: read from src.scan, run validations, emit findings. The scan was done once upstream. ---- Build the corpus-wide pipe_ctx ONCE per pass run. ---- Reads the merged `corpus.*` registries (canonical cross-source lookups), ---- and the corpus-wide `atom_infos` list (preserving source order + duplicates). ---- The corpus is the source of truth; per-source scans retain body / declaration ---- ownership via `src.scan` and the per-source `atoms` / `atom_infos` projections. ---- ---- Canonical ownership: a context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message. ---- No per-source fallback synthesis is performed; callers MUST construct a canonical ctx through `build_ctx`. +--- Builds one pass-wide pipe_ctx from the merged `corpus.*` registries and source-ordered `corpus.atom_infos`; per-source declarations and bodies remain in `src.scan`. +--- The module ownership contract above requires callers to construct `ctx.shared.corpus` through `build_ctx`; the error message below enforces that gate. --- @param ctx PassCtx --- @return PipeCtx local function build_corpus_pipe_ctx(ctx) @@ -462,9 +448,7 @@ local function build_corpus_pipe_ctx(ctx) .. "no per-source fallback is supported)", 0) end - -- Corpus atom_infos preserves source-order + duplicates; - -- the per-check `check_unique_annotation` post-rule still flags duplicate annotation - -- names within this list. We pre-compute the annot_counts map here so the per_source checks can iterate it without re-walking. + -- `corpus.atom_infos` preserves source order and duplicates; I precompute counts here for `check_unique_annotation` and the per-source checks. local annot_counts = {} for _, info in ipairs(corpus.atom_infos or {}) do if info and info.atom_name then @@ -472,10 +456,9 @@ local function build_corpus_pipe_ctx(ctx) end end - -- The pipe_ctx views REFERENCE the corpus tables directly (no copies). -- Every consumer of these fields observes mutations via the canonical corpus without independently mutable registry construction. return { - -- Cross-source lookup tables (canonical corpus projections). + -- Cross-source lookup tables from corpus. register_alias_registry = corpus.register_alias_registry or {}, type_name_registry = corpus.type_name_registry or {}, atom_views = corpus.atom_views or {}, @@ -489,8 +472,7 @@ local function build_corpus_pipe_ctx(ctx) annot_counts = annot_counts, -- Corpus-wide collisions (recorded by scan_source.merge_corpus_registries). collisions = corpus.collisions or {}, - -- wc still consumed by check_macro_word_drift; reads from the canonical - -- `corpus.word_counts` table (built by word_count_eval.run). + -- `check_macro_word_drift` reads `corpus.word_counts`, populated by word_count_eval.run. word_counts = corpus.word_counts or {}, } end @@ -498,7 +480,7 @@ end --- Validate one source against its pre-scanned SourceScan payload + the corpus-wide pipe_ctx. --- @param ctx PassCtx --- @param src SourceFile ---- @param corpus_pipe_ctx PipeCtx|nil -- built once per pass from corpus registries; nil = self-build (canonical projection). +--- @param corpus_pipe_ctx PipeCtx|nil -- built once per pass from corpus registries; nil builds the same projection here. --- @return AnnotatedResult local function validate(ctx, src, corpus_pipe_ctx) corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx) @@ -527,11 +509,7 @@ local function validate(ctx, src, corpus_pipe_ctx) } end - -- Build the per-source pipe_ctx (Fleury: expose structure). - -- Cross-source visibility comes from `corpus_pipe_ctx`; - -- per-source declaration / body ownership comes from `src.scan`. - -- pipe_ctx.types / pipe_ctx.atom_views / pipe_ctx.seen_defaults / pipe_ctx.type_occurrences - -- are projected from the per-source scan so the per_source check rules can iterate the source-local occurrences. + -- Build a per-source pipe_ctx: shared lookups come from `corpus_pipe_ctx`, while declarations, bodies, types, views, defaults, and occurrences come from `src.scan`. local seen_defaults = {} for reg, _ in pairs(scan.types or {}) do seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 @@ -551,8 +529,7 @@ local function validate(ctx, src, corpus_pipe_ctx) seen_defaults = seen_defaults, atom_infos_list = atom_infos_list, binds_list = scan.binds or {}, - -- Source-derived registries: still populated from the scan payload as a convenience for callers that want source-local visibility. - -- The canonical cross-source lookup tables live in corpus_pipe_ctx. + -- See the module ownership contract; these shared lookup tables come from corpus_pipe_ctx. register_alias_registry = corpus_pipe_ctx.register_alias_registry, type_name_registry = corpus_pipe_ctx.type_name_registry, } @@ -563,9 +540,7 @@ local function validate(ctx, src, corpus_pipe_ctx) -- Each check writes to the list appropriate for its severity. local findings = { errors = {}, warnings = {}, info = {} } - -- Propagate parse-time errors from scan_source's atom_info parsing. - -- These are errors found in the atom_info(...) body itself (e.g., malformed args). - -- They are pre-existing in the scan payload — we just lift them into our findings list. + -- Lift parse-time errors already recorded in scan_source's atom_info payload into this pass's findings list. for _, a in ipairs(annots) do if a.errors then for _, msg in ipairs(a.errors) do @@ -589,11 +564,9 @@ local function validate(ctx, src, corpus_pipe_ctx) if rule.post then rule.post(pipe_ctx, findings) end end - -- Per-skip-marker rules. - -- Each raw marker recorded by scan_source (in scan.skip_over.markers) is validated independently; - -- the check emits at most one error per marker. - -- Valid markers stay attached to scan.skip_over.atoms /.components for dwarf_injection.lua consumer. - local skip_markers = scan.skip_over and scan.skip_over.markers or {} + -- scan_source records each marker in scan.debug_skip_markers; this loop validates each record independently and emits at most one error per marker. + -- Valid markers stamp `debug_skip = true` on the following atom or component declaration, which downstream consumers read directly. + local skip_markers = scan.debug_skip_markers or {} for _, marker in ipairs(skip_markers) do for _, rule in ipairs(CHECK_RULES) do if rule.per_skip_marker then rule.per_skip_marker(marker, pipe_ctx, findings) end @@ -684,15 +657,12 @@ function M.run(ctx) local errors = {} local warnings = {} - -- Build the corpus-wide pipe_ctx ONCE per pass run. + -- Build the shared pipe_ctx once for this run; every validate() call sees the same cross-source registries. -- The corpus owns the canonical cross-source registries; per-source scans retain body / declaration ownership. - -- The pipe_ctx is shared across every validate() invocation in this M.run so cross-source visibility is constant. local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) local corpus = ctx.shared.corpus - -- Per-DIRECTORY (per-module) aggregation. - -- Group sources by `src.dir`, validate every source in the dir, then emit ONE errors.h per dir. - -- The corpus owns `sources_by_dir`; this pass reads the corpus bucket directly. + -- Group `corpus.sources_by_dir` by module, validate every source in each bucket, and emit one errors.h per directory. local by_dir = (corpus and corpus.sources_by_dir) or {} for dir, dir_sources in pairs(by_dir) do diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index cbd8edc..c332856 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -1,23 +1,23 @@ --- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms. --- ---- Reads the canonical `atom.paths` projection produced by the upstream `emission_model` pass. ---- The ordered `items` stream, dense `word_events`, and `invocations` views are the only semantic inputs to this pass; ---- it emits one `WORD N LINE L TEXT T` line per emitted `.word`. +--- Writer: this pass, given `atom.paths` (the per-atom mutable surface owned by `emission_model`). Readers: +--- `passes/dwarf_injection.lua` (synthesizes DW_TAG_inlined_subroutine + per-word line program rows) and the gdb-runtime +--- wrapper at `scripts/gdb/gdb_tape_atoms.gdb` (loads the source map via `source `). --- ---- **Two output forms** (per the workspace's per-emission-form pattern from ---- `guide_metaprogram_ssdl.md`): ---- 1. **Canonical text form** — `/.atoms.sourcemap.txt`. ---- Format-version-tagged for forward-compat. ---- Lives in `/` (build/gen). ---- Matches the convention used by `annotation.lua` (`/.errors.h`) + `static_analysis.lua` (`/.static_analysis.txt`). +--- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs: one +--- `WORD N LINE L TEXT T` line per emitted `.word`, plus the per-word provenance form that DWARF synthesis consumes. +--- +--- **Two output forms** (per the workspace's per-emission-form pattern from `guide_metaprogram_ssdl.md`): +--- 1. **Sourcemap.txt form** — `/.atoms.sourcemap.txt`. Format-version-tagged for forward-compat. +--- Lives in `/` (build/gen). Mirrors the convention used by `annotation.lua` +--- (`/.errors.h`) and `static_analysis.lua` (`/.static_analysis.txt`). --- Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `/gen/`. ---- 2. **gdb-runtime form** — `/gdb_tape_atoms_runtime.gdb` ---- (pure gdb command script; addresses pre-computed via `nm`; the 9 user commands defined as `define ... end` blocks). ---- Emitted ONLY when `ctx.flags.gdb_runtime` is true AND `ctx.flags.elf_path` points to an existing ELF. ---- The gdb runtime form lets `gdb-multiarch --without-python` users (the common case on Windows MinGW builds) ---- load the source-map data via `source ` — no Python/Tcl/Guile required. +--- 2. **gdb-runtime form** — `/gdb_tape_atoms_runtime.gdb`. A pure gdb command script — addresses come +--- from `nm`, the 9 user commands are static `define ... end` blocks. Emitted when `ctx.flags.gdb_runtime` is true +--- AND `ctx.flags.elf_path` points to an existing ELF. Useful for `gdb-multiarch --without-python` users +--- (the common case on Windows MinGW builds) — `source ` loads it with no Python / Tcl / Guile required. --- ---- **Output format** (canonical text form): +--- **Output format** (sourcemap.txt form): --- ``` --- # FORMAT_VERSION 1 --- # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT @@ -31,11 +31,9 @@ --- ENDATOM --- ``` --- ---- Marker records are zero-width in `atom.paths.items`; they do not appear in ---- the dense word view and therefore emit no WORD rows. +--- Marker records are zero-width in `atom.paths.items`, so they emit no WORD rows in the dense word view. --- ---- **Conventions:** tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. +--- **Conventions:** tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup @@ -62,17 +60,16 @@ local FORMAT_VERSION = 1 --- @class AtomSourceMapCtx --- @field shared table -- `ctx.shared` ---- @field shared.corpus table -- canonical source-order corpus +--- @field shared.corpus table -- source-order registry; single writer is build_ctx --- @field shared.word_counts table --- @field out_root string -- output root (e.g. "build/gen") --- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path` -- ════════════════════════════════════════════════════════════════════════════ --- Canonical atom-path renderers +-- Atom-path renderers -- ════════════════════════════════════════════════════════════════════════════ ---- Join canonical words to canonical word items. `items` supplies the ordered ---- word boundaries, while `word_events` supplies call text and source lines. +--- Join word boundaries (from `items`) to per-word call text + source lines (from `word_events`). --- @param atom table --- @return table[], integer local function canonical_word_entries(atom) @@ -99,11 +96,11 @@ local function canonical_word_entries(atom) return entries, #events end ---- Render one atom's provenance stanza. Format 1 remains: ---- `WORD N CALL : MACRO ":" BODY ` ---- `WORD N CALL : RAW` ---- Component identity comes from the canonical outermost invocation record; ---- the count-table lookup is the canonical component declaration witness. +--- Render one atom's provenance stanza. Format 1 line shapes: +--- `WORD N CALL : MACRO ":" BODY ` (component invocation) +--- `WORD N CALL : RAW` (raw `.word` outside any mac_* component) +--- Component identity comes from the outermost invocation record; the count-table lookup confirms the component was +--- declared in `corpus.word_counts` (populated by word_count_eval + components passes). --- @param src table --- @param atom table --- @param wc table -- identity alias of corpus.word_counts @@ -162,7 +159,7 @@ local function render_provenance(src, wc) return table.concat(lines, "\n") .. "\n" end ---- Render one atom's stanza for the canonical text form (ATOM header line, N WORD lines, ENDATOM marker). +--- Render one atom's stanza for the sourcemap.txt form (ATOM header line, N WORD lines, ENDATOM marker). --- Returns (lines, total_words). --- @param src table --- @param atom table @@ -183,8 +180,8 @@ local function emit_atom_stanza(src, atom) return lines, total end ---- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). ---- Mirrors offsets.lua's `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter. +--- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). Mirrors offsets.lua's +--- `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter. --- @param src table --- @param wc table --- @return string @@ -219,8 +216,7 @@ local function gdb_escape(s) return (s:gsub("\\", "\\\\"):gsub('"', '\\"')) end ---- Build the list of atoms with addresses + word entries. ---- Shared helper for the gdb-runtime file emission. +--- Build the list of atoms with addresses + word entries. Shared helper for the gdb-runtime file emission. --- @param ctx PassCtx --- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries} local function build_atom_table(ctx) @@ -256,12 +252,12 @@ local function build_atom_table(ctx) return matched end ---- Append the 9 gdb command definitions to `lines`. Pure gdb scripting no Python, no Tcl, no Guile required. ---- **Fully hardcoded per-atom** because gdb doesn't do nested `$` substitution in var names ---- `$__atom_name_$__i` inside a `while` loop is treated as one literal identifier, not a concat. +--- Append the 9 gdb command definitions to `lines`. Pure gdb scripting — addresses come from `nm`, the convenience +--- vars set in `emit_gdb_runtime` provide printf args, and each command is a static sequence of `printf` / `tbreak` / +--- `if ... end` blocks. The Lua pass emits N atoms' worth of lines; runtime iteration is gdb's job. --- ---- Each command is a static sequence of `printf` / `tbreak` / `if ... end` blocks. ---- The Lua pass emits N atoms' worth of lines — no runtime iteration. +--- Why hardcoded per-atom: gdb's `$` substitution doesn't concat inside var names — `$__atom_name_$__i` in a `while` +--- loop resolves to one literal identifier, not `name_i`. Compile-time emission is the only path. --- @param lines table -- output line buffer (mutated in place) --- @param matched table -- list of atom records from `build_atom_table` local function append_gdb_commands(lines, matched) @@ -402,9 +398,9 @@ local function append_gdb_commands(lines, matched) lines[#lines + 1] = "end" end ---- Emit the gdb-runtime file (post-link). Pure gdb scripting — no Python. ---- Reads ELF addresses via `mipsel-none-elf-nm -S`, embeds them in `/gdb_tape_atoms_runtime.gdb` ---- so gdb loads the data via `set $var = ...` + `define ... end` blocks at source-time. +--- Emit the gdb-runtime file (post-link). Pure gdb scripting — addresses come from `mipsel-none-elf-nm -S`, get embedded +--- in `/gdb_tape_atoms_runtime.gdb`, and load via `set $var = ...` + `define ... end` blocks at gdb +--- source-time. --- @param ctx PassCtx local function emit_gdb_runtime(ctx) if not (ctx.flags and ctx.flags.gdb_runtime) then return end @@ -465,8 +461,7 @@ local function emit_gdb_runtime(ctx) local out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb" duffle.ensure_dir(duffle.dirname(out_path)) duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n") - io.stderr:write(string.format( - "[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched)) + -- io.stderr:write(string.format("[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched)) end -- ════════════════════════════════════════════════════════════════════════════ @@ -475,10 +470,10 @@ end local M = {} ---- 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. ---- Optionally also emit `/gdb_tape_atoms_runtime.gdb` when `ctx.flags.gdb_runtime` is true. +--- Pass entry. For each source that declares at least one `MipsAtom_(name)` / `MipsCode code_`, emit two files +--- in `/`: `.atoms.sourcemap.txt` (per-word call-site map) and `.atoms.provenance.txt` +--- (per-word definition + body line, resolved via the outermost `mac_X(...)` invocation). When `ctx.flags.gdb_runtime` +--- is true and `ctx.flags.elf_path` exists, also emit the post-link gdb script `/gdb_tape_atoms_runtime.gdb`. --- @param ctx PassCtx --- @return PassResult function M.run(ctx) @@ -491,8 +486,7 @@ function M.run(ctx) error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0) end - -- Word counts are owned by `corpus.word_counts`. - -- The canonical owner is `corpus.word_counts` (populated by `passes/word_count_eval.lua` + `passes/components.lua`). + -- Word counts come from `corpus.word_counts` (populated by word_count_eval + components passes). local wc = corpus.word_counts or {} if not next(wc) then warnings[#warnings + 1] = { @@ -501,7 +495,7 @@ function M.run(ctx) } end - -- Always emit the canonical text form (per-source). + -- Always emit the text form (per-source). for _, src in ipairs(corpus.source_order) do local has_projection = false for _, atom in ipairs((src.scan or {}).atoms or {}) do diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 6026d80..5009cbd 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -1,12 +1,12 @@ --- passes/components.lua — Component-macro header generator. --- ---- Reads the pre-scanned SourceScan payload (produced once upstream by `duffle.scan_source`) ---- for `MipsAtomComp_(ac_X)` and `MipsAtomComp_Proc_(ac_X, { body })` declarations, then does per-source backward lookups ---- for the function-args string (from the preceding `FI_ MipsAtom ac_X(...)` function declaration) ---- and the preceding comment block (for LSP/IntelliSense signature docs). +--- Ownership: `corpus.word_counts`, `corpus.components`, and `corpus.component_body_index`. +--- Scanner owns `declaration_comment` and `debug_skip` on each declaration record; this pass projects both forward. --- ---- Emits a per-directory `.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` ---- entries for downstream offset computation. +--- Reads the pre-scanned SourceScan payload from `duffle.scan_source` for `MipsAtomComp_(ac_X)` and `MipsAtomComp_Proc_(ac_X, { body })` declarations, +--- then resolves the function-args string from the preceding `FI_ MipsAtom ac_X(...)` declaration via a backward walk. +--- +--- Emits one `.macs.h` per source with `#define mac_X(sig) \` macros plus `WORD_COUNT(mac_X, N)` entries for downstream offset computation. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, --- Lua 5.3 compatible. @@ -59,7 +59,6 @@ local GEN_SUBDIR = "gen" --- @field sources SourceFile[] -- all source files in the build --- @field metadata_path string -- path to word_count.metadata.h --- @field shared table -- cross-pass shared state ---- @field shared.word_counts table -- populated by word-counts + components --- @field out_root string -- output root (e.g. "build/gen") --- @field project_root string -- project root (e.g. "code/") --- @field upstream table -- per-pass upstream outputs @@ -72,11 +71,13 @@ local GEN_SUBDIR = "gen" --- @field warnings table[] -- {line=, msg=} entries; build-succeeds --- @class Component ---- @field name string -- atom name (without `ac_` prefix) ---- @field body string -- brace-delimited body (without the braces) ---- @field args string|nil -- function-args string (function form only) ---- @field line integer -- source line of the declaration ---- @field comment string|nil -- preceding `/* */` or `//` comment block (signature doc) +--- @field name string -- atom name (without `ac_` prefix) +--- @field body string -- brace-delimited body (without the braces) +--- @field args string|nil -- function-args string (function form only) +--- @field line integer -- source line of the declaration +--- @field comment string|nil -- scanner-owned `declaration_comment`; the components pass reads it from the scanner record +--- @field kind string -- "comp_bare" | "comp_proc" +--- @field debug_skip boolean -- mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration -- ════════════════════════════════════════════════════════════════════════════ -- Local helpers (file I/O + path normalization) @@ -85,7 +86,11 @@ local GEN_SUBDIR = "gen" local M = {} -- ════════════════════════════════════════════════════════════════════════════ --- Back-walk helpers (composed into the 2 entry points below: find_function_args_for + preceding_comment_block) +-- Back-walk helpers (composed into the entry point below: find_function_args_for) +-- +-- Only the function-args lookup for proc components occurs here. +-- The preceding-comment walk occur in `scan_source.lua` — `a.declaration_comment` carries the resolved comment, +-- so this file reads it forward rather than re-walking the source. -- ════════════════════════════════════════════════════════════════════════════ --- Find the args of the function declaration that immediately precedes a `MipsAtomComp_Proc_` invocation of the given name. @@ -132,69 +137,6 @@ local function find_function_args_for(source, name, before_pos) return inner end ---- Find the contiguous comment block immediately preceding `pos` in `source`. ---- Returns the comment text (with the `/* */` or `//` markers preserved) or an empty string if no comment is adjacent. ---- ---- Used to copy signature comments from the source declaration (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) ---- over to the generated `mac_X` macro, so LSP/IntelliSense displays the args doc. ---- @param source string ---- @param pos integer ---- @return string -local function preceding_comment_block(source, pos) - local scan_pos = pos - local pieces = {} - while true do - -- skip whitespace backward; land on the next non-ws character. - local non_ws = scan_pos - 1 - while non_ws > 0 do - local ch = source:sub(non_ws, non_ws) - if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" then - non_ws = non_ws - 1 - else - break - end - end - if non_ws == 0 then break end - if non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/" then - -- block comment close: find the opening /* by walking back over /* candidates - -- in source[1..non_ws-1]. - local prefix = source:sub(1, non_ws - 1) - local open_at = nil - for scan = #prefix - 1, 1, -1 do - if prefix:sub(scan, scan + 1) == "/*" then - open_at = scan - break - end - end - if not open_at then break end - -- include the indentation before the /* by walking back over leading spaces + tabs. - local block_start = open_at - while block_start > 1 do - local ch = source:sub(block_start - 1, block_start - 1) - if ch ~= " " and ch ~= "\t" then break end - block_start = block_start - 1 - end - table.insert(pieces, 1, source:sub(block_start, non_ws)) - scan_pos = block_start - else - -- line comment path: must end in newline, must start with //. - local ch = source:sub(non_ws, non_ws) - if ch ~= "\n" and ch ~= "\r" then break end - -- walk back from non_ws to the start of the source line (most recent \n or position 1). - local line_start = non_ws - while line_start > 1 and source:sub(line_start - 1, line_start - 1) ~= "\n" do - line_start = line_start - 1 - end - local line = source:sub(line_start, non_ws) - if line:sub(1, 2) ~= "//" then break end - table.insert(pieces, 1, line) - scan_pos = line_start - 1 - end - end - if #pieces == 0 then return "" end - return table.concat(pieces, "\n") -end - -- ════════════════════════════════════════════════════════════════════════════ -- Argument-name extraction -- ════════════════════════════════════════════════════════════════════════════ @@ -246,8 +188,12 @@ end -- ════════════════════════════════════════════════════════════════════════════ --- Project pre-scanned MipsAtomComp_ / MipsAtomComp_Proc_ entries into Component shape. ---- Does per-source backward lookups for args (preceding function decl) and comment (preceding comment block). +--- Reads the scanner-owned `declaration_comment` (resolved by scan_source.lua, skipping backward across an associated bare `atom_dbg_skip` marker when present). +--- Per-source backward lookups remain in place only for the function `args` of proc components. +--- That lookup is unique to components.lua and stays separate from the declaration-comment walk. --- Carries `body_tokens` forward from scan-source so word_count_rec reads from the precomputed table instead of calling duffle.tokenize_body again. +--- Carries the scanner-owned `debug_skip` flag forward so the generated projection can emit `/* atom_dbg_skip */` +--- before the authored comment and so `update_canonical_components` can mirror the same field onto `corpus.components[name]`. --- @param source string -- the full source text (needed for backward lookups) --- @param scan table -- SourceScan from duffle.scan_source --- @return Component[] @@ -255,8 +201,10 @@ local function project_components(source, scan) local out = {} for _, a in ipairs(scan.atoms) do if a.kind == "comp_bare" or a.kind == "comp_proc" then - local args = find_function_args_for(source, a.raw_name, a.ident_pos) - local comment = preceding_comment_block(source, a.ident_pos) + local args = find_function_args_for(source, a.raw_name, a.ident_pos) + -- Comment ownership: scan_source.lua stamps `declaration_comment` on the record by walking backward past any associated bare marker. + -- The pass reads `declaration_comment` directly. + local comment = a.declaration_comment or "" out[#out + 1] = { line = a.line, name = a.name, @@ -266,6 +214,7 @@ local function project_components(source, scan) args = args, comment = comment, kind = a.kind, -- "comp_bare" | "comp_proc"; provenance emitter reads this. + debug_skip = a.debug_skip == true, } end end @@ -450,6 +399,9 @@ end --- Build the list of lines for one component --- (signature comment, `#define mac_X(...)` line with backslash-continued tokens, then `WORD_COUNT(mac_X, N)` entry). +--- For skipped components, a `/* atom_dbg_skip */` marker comment is emitted immediately before the authored comment block. +--- The marker is a single line, the comment comes next, and the `#define` line follows. The `debug_skip` stamp is scanner-owned +--- (`a.debug_skip == true` on the declaration record); the components pass projects it directly. --- @param c Component --- @param components Component[] --- @param wc table @@ -457,6 +409,13 @@ end local function build_component_lines(c, counts) local lines = {} + -- Marker comment: emitted once for every skipped component. + -- The marker is scanner-owned (declared by `atom_dbg_skip` immediately before the declaration in the source); + -- the components pass projects `c.debug_skip` and emits the marker as a generated comment. + if c.debug_skip then + lines[#lines + 1] = "/* atom_dbg_skip */" + end + if c.comment and c.comment ~= "" then for _, line in ipairs(split_comment_lines(c.comment)) do lines[#lines + 1] = line @@ -551,9 +510,9 @@ end -- Pass entry -- ════════════════════════════════════════════════════════════════════════════ ---- (internal) Extend the canonical `corpus.word_counts` with this source's component macros so offsets sees them without re-reading the file. +--- (internal) Extend `corpus.word_counts` with this source's component macros so offsets sees them without re-reading the file. --- First declaration wins: a later caller's count is dropped (the existing entry from the first source is preserved). ---- @param corpus table -- the canonical corpus +--- @param corpus table -- the corpus --- @param components Component[] --- @param counts table -- precomputed word counts (from count_all_components) local function update_canonical_word_counts(corpus, components, counts) @@ -567,33 +526,37 @@ local function update_canonical_word_counts(corpus, components, counts) end --- @class ComponentDef ---- @field name string -- bare name (without ac_/mac_ prefix) ---- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`) ---- @field path string -- absolute source path of the definition ---- @field kind string -- "comp_bare" | "comp_proc" +--- @field name string -- bare name (without ac_/mac_ prefix) +--- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`) +--- @field path string -- absolute source path of the definition +--- @field kind string -- "comp_bare" | "comp_proc" +--- @field debug_skip boolean -- mirror of the scanner-owned `a.debug_skip`; consumers read this directly ---- (internal) Populate the canonical `corpus.components` projection with this source's components-by-name map. +--- (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). ---- @param corpus table -- the canonical corpus +--- The `debug_skip` field mirrors the scanner-owned declaration record (`c.debug_skip`). +--- 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 --- @param src SourceFile --- @param components Component[] local function update_canonical_components(corpus, src, components) 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 canonical corpus; + -- 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. 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", + name = c.name, + line = c.line, + path = rel_path, + kind = c.kind or "comp_bare", + debug_skip = c.debug_skip == true, } else -- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it. - -- Identical-shape declarations (same path + line) do NOT record a collision (the first-wins entry already covers the case). + -- Identical-shape declarations (same path + line) reuse the first-wins entry without a collision record. local existing = corpus.components[c.name] if existing.path ~= rel_path or existing.line ~= c.line then local kind = c.kind or "comp_bare" @@ -611,10 +574,10 @@ local function update_canonical_components(corpus, src, components) end end ---- (internal) Populate the canonical `corpus.component_body_index` projection with this source's body index entries. +--- (internal) Populate `corpus.component_body_index` with this source's body index entries. --- First declaration wins; later declarations are dropped (no separate collision record: the components collision is already surfaced by `update_canonical_components`). ---- The pass does NOT write to `ctx.shared.component_body_index` (the corpus owns this projection). ---- @param corpus table -- the canonical corpus +--- The pass writes to `corpus.component_body_index` only (the corpus owns this projection). +--- @param corpus table -- the corpus --- @param src SourceFile --- @param components Component[] --- @param scan table -- the SourceScan payload (for line_of) @@ -641,13 +604,13 @@ function M.run(ctx) local errors = {} local warnings = {} - -- Canonical-corpus ownership gate. + -- Corpus ownership gate. local corpus = ctx.shared and ctx.shared.corpus if type(corpus) ~= "table" then - error("components.run requires ctx.shared.corpus (canonical corpus).", 0) + error("components.run requires ctx.shared.corpus.", 0) end if type(corpus.source_order) ~= "table" then - error("components.run requires ctx.shared.corpus.source_order (canonical corpus).", 0) + error("components.run requires ctx.shared.corpus.source_order.", 0) end if type(corpus.word_counts) ~= "table" then error("components.run requires ctx.shared.corpus.word_counts; " @@ -655,25 +618,24 @@ function M.run(ctx) .. "(see PASSES deps).", 0) end - -- Canonical projection ownership: + -- Projection ownership: -- * `corpus.word_counts["mac_"..name]` — current component count -- * `corpus.components[name]` — bare-name component definition -- * `corpus.component_body_index[name]` — body / line_of / source index - -- The pass does NOT mutate `ctx.shared.components` or `ctx.shared.component_body_index` - -- (ownership follows the canonical corpus; consumers read from the corpus directly). + -- The pass writes to the corpus only; consumers read from the corpus directly. for _, src in ipairs(corpus.source_order) do -- project_components reads from src.scan + does backward lookups on src.text local components = project_components(src.text, src.scan) if #components > 0 then -- Compute all component word counts once per source. - -- Use `corpus.word_counts` (the canonical count table) so the recursive lookup sees both authored-metadata entries + -- 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) local macs_path = emit_component_macros_h(ctx, src, components, counts) if macs_path then outputs[#outputs + 1] = { macs_h = macs_path } - -- Populate the canonical projections AFTER disk emission (so the byte-identical `.macs.h` contract is preserved before any current-count mutation). + -- 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_component_body_index(corpus, src, components, src.scan) diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 73da3fd..b59ecbb 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -1,12 +1,13 @@ --- passes/dwarf_injection.lua — Per-atom DWARF injection for tape-atom step-debug. --- ---- Reads the post-link ELF directly (lfs + io.open; walks the ELF32 section header table to find +--- Reads the post-link ELF directly (io.open; walks the ELF32 section header table to find --- `.debug_info` + `.debug_abbrev` + `.debug_str` + `.debug_line` + `.debug_aranges` + `.debug_rnglists`), --- APPENDS synthetic DWARF line-program sequences for every `code_` atom, EXTENDS the `.debug_aranges` ---- and main-CU range tables with the atom ranges, and APPENDS a new compilation unit to `.debug_info` with ---- per-atom `DW_TAG_subprogram` + per-wave-context-reg `DW_TAG_variable` entries ---- (so `R_PrimCursor` etc. appear as atom-scoped locals in VSCode's Variables pane). ---- Writes the new section data to `/.dwarf_*.bin` plus deterministic `/.gdbinit` skip commands. +--- and main-CU range tables with the atom ranges, and INSERTS synthetic atom/component DIE children into the +--- existing main compilation unit in `.debug_info` (no second compilation unit). +--- Per-atom `DW_TAG_subprogram` + per-register `DW_TAG_variable` entries make +--- `RR_PrimCursor` and the other opted-in aliases appear as atom-scoped locals in VSCode's Variables pane. +--- Writes the new section data to `/.dwarf_*.bin`. --- --- The `build_psyq.ps1` post-link hook then splices those `.bin` files into a copy of the ELF via: --- mipsel-none-elf-objcopy --update-section .debug_info= @@ -40,12 +41,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -- (read_elf_sections, nm, source-map parser, LE byte r/w). `list_dir` is the general directory primitive in duffle.lua. local elf_dwarf = require("elf_dwarf") --- Per-word body lines come from the canonical `atom.paths` projection. - -local lfs = require("lfs") - -- File-scope aliases to elf_dwarf helpers; the canonical implementations live in scripts/elf_dwarf.lua. --- ELF decoding helpers come from `elf_dwarf.lua`. local find_abbrev_table_end = elf_dwarf.find_abbrev_table_end -- Local DWARF opcode constants + length-prefixed integers (uleb128 + sleb128 encoders are in elf_dwarf.lua). local uleb128 = elf_dwarf.uleb128 @@ -85,8 +81,7 @@ local ATOM_SOURCE_FILE_INDEX = 11 -- without the prefix, gdb's `print R_PrimCursor` resolves to the enum constant, not to this DWARF variable. -- With `set print enum on`, gdb displays the enum identifier, so the Watch panel would show `R_PrimCursor = R_PrimCursor` instead of the register value. -- Each RR_'s DW_OP_regN uses the alias's `code` as N. --- DW_OP_bregN would describe a memory location addressed from a register; --- the breg form would make gdb dereference the atom register value rather than display it. +-- DW_OP_bregN would describe a memory location addressed from a register; the breg form would make gdb dereference the atom register value rather than display it. -- New abbreviation codes (100+ to avoid collision with gcc's existing 1-60+ codes). local ABBREV_CU = 0x64 -- 100: DW_TAG_compile_unit @@ -160,12 +155,16 @@ local DW_AT_decl_line = 0x3B -- DWARF5 §7.7.1: DW_AT_decl_line -- File index lookup table for the existing main line unit (Unit 2). -- Provenance paths come back with mixed slashes; we normalize to basename and look up against the line unit's actual file table. --- Current scope has two provenance basenames: hello_gte_tape.c (the atom's call site) and lottes_tape.h (the component definition). --- Both live in the existing gcc-generated line unit; --- their 1-based indices are stable across rebuilds because the include order in code/gte_hello/hello_gte.c determines the unit's file table. +-- Current scope has two provenance basenames: hello_gte_tape.c (the atom's call site) and lottes_tape.h (the component definition). +-- Both live in the existing gcc-generated line unit; their 1-based indices are stable across rebuilds because the include order +-- in code/gte_hello/hello_gte.c determines the unit's file table. +-- gcc only adds a file to the line table when it has actual line-number entries; +-- headers that are pure macros/typedefs (dsl.h, memory.h, math.h, mips.h, gp.h, gte.h, etc.) +-- never appear. lottes_tape.h is the FIRST include that emits line entries +-- (MipsAtomComp_ declarations), so it is the FIRST entry after the primary file. local PROVENANCE_BASENAME_TO_FILE_INDEX = { ["hello_gte_tape.c"] = ATOM_SOURCE_FILE_INDEX, -- = 11 - ["lottes_tape.h"] = 4, + ["lottes_tape.h"] = 2, } --- Resolve an absolute provenance path to the line-unit file index used by the emitting line program. @@ -205,7 +204,7 @@ local DW_FORM_udata = 0x0F -- ULEB128 (DW_AT_byte_size for struct_typ local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker) local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists) --- DW_OP_reg0 + DW_OP_piece are declared canonically above (lines 114-116) alongside the other DWARF5 §7.7.3 loclist opcodes. +-- DW_OP_reg0 + DW_OP_piece are declared above (lines 114-116) alongside the other DWARF5 §7.7.3 loclist opcodes. local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used for U4 base type) @@ -214,8 +213,8 @@ local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used f -- No DW_AT_language attribute is emitted (see build_debug_info_section's abbrev 100). -- R_ → MIPS GPR lookups go through the merged register_alias_registry --- (collected by collect_per_source_registries from ctx.sources[*].scan.register_alias_registry). --- Aliases without an `atom_reg` opt-in are absent from the registry, which means they are NOT debug-visible (no fallback GPR). +-- (collected by collect_per_source_registries from `corpus.register_alias_registry`). +-- Aliases without an `atom_reg` opt-in are absent from the registry; absent aliases are not debug-visible. -- See the precedence chain in build_inserted_children for the per-alias type resolution. -- Build the .debug_loclists section for every rbind atom. @@ -226,17 +225,15 @@ local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used f -- This is a conservative approximation that always reads the correct GPR value once the first load has retired (load_pc + 8). -- -- `tape_alias` (default: "R_TapePtr") names the wave-runtime pointer register whose value is the tape address. --- The GPR integer comes from the merged registry; if the alias is absent, this function fails loud --- (the build was misconfigured; rbind atoms depend on R_TapePtr being in the registry for their piece-chain DW_OP_breg location). +-- The GPR integer comes from the merged registry; absent alias fails loud — rbind atoms depend on R_TapePtr being in the registry for their piece-chain DW_OP_breg location. -- @param atom_table table[] -- list of atoms with .rbind set -- @param registries table -- merged registries from collect_per_source_registries -- @return string -- section bytes local function build_debug_loclists_section(atom_table, registries) registries = registries or {} -- R_TapePtr comes from the merged register_alias_registry (only present if the user opted it in via `#define atom_reg` in lottes_tape.h). - -- When absent we emit just the section terminator (a single DW_LLE_end_of_list byte); - -- the .debug_loclists section MUST not be empty for the linker, and `bind_args` will be emitted with no loclist PC range - -- (readelf will display it as having no .debug_loclists entries). + -- When absent we emit just the section terminator (a single DW_LLE_end_of_list byte); the .debug_loclists section stays non-empty so the linker accepts it, + -- and `bind_args` will be emitted with no loclist PC range (readelf will display it as having no .debug_loclists entries). local tape_alias_entry = registries.register_alias_registry and registries.register_alias_registry["R_TapePtr"] local tape_reg = tape_alias_entry and tape_alias_entry.code local parts = {} @@ -311,8 +308,7 @@ local function compute_loclists_offsets(atom_table) if atom.rbind then offsets[atom.name] = cursor local n_fields = #atom.rbind.fields - -- Sum the actual size of each tape piece based on the field's offset, not an assumed constant. - -- The expression below mirrors what build_debug_loclists_section produces: + -- Sum the actual size of each tape piece based on the field's offset (not an assumed constant); this expression mirrors what build_debug_loclists_section produces: -- 1 (DW_LLE_start_length) + 4 (PC) + 1 (uleb length prefix) + sum(tape_piece_size(field.offset)) -- + 1 (DW_LLE_start_length) + 4 (transition_pc) + 1 (uleb length prefix) + n_fields * 3 (gpr pieces) -- + 1 (DW_LLE_end_of_list) @@ -345,52 +341,11 @@ local DEFAULT_BASENAME = "hello_gte" --- @class DwarfInjectionCtx --- @field flags table -- ctx.flags; reads flags.elf_path + flags.dwarf_injection ---- @field sources table[] -- source files with scan.skip_over associations +--- @field sources table[] -- source files (reserved for future per-source state) --- @field out_root string -- output root (e.g. "build/gen") --- @field basename string -- input ELF basename (default "hello_gte") ---- Normalize a source path for GDB linespecs and component-association keys. ---- GDB accepts forward slashes on Windows; absolute paths avoid cwd-dependent sidecars and match provenance contract. ---- @param path string ---- @return string -local function normalize_debug_path(path) - return duffle.to_absolute_path(path):gsub("\\", "/") -end - ---- Consume the per-source scanner associations without naming any atom or component in production. ---- Whole atoms remain symbol-keyed; components are file-qualified internally so a source marker associates ---- with its exact component definition even though GDB 12 requires function-only skip entries for the resulting synthetic inline frame. ---- Iterates `corpus.source_order` (the canonical corpus projection). ---- @param corpus table -- the canonical corpus from `ctx.shared.corpus` ---- @return table -- {atoms = {[symbol] = association}, components = {[file|name] = association}} -local function collect_skip_over(corpus) - local skip_over = { atoms = {}, components = {} } - for _, src in ipairs((corpus and corpus.source_order) or {}) do - local scan_skip = src.scan and src.scan.skip_over - if scan_skip then - for atom_name, association in pairs(scan_skip.atoms or {}) do - skip_over.atoms[atom_name] = { - name = atom_name, - source_path = normalize_debug_path(src.path), - association = association, - } - end - for component_name, association in pairs(scan_skip.components or {}) do - local source_path = normalize_debug_path(src.path) - -- component_skip_key (the lower() .. "\0" .. component_name part) inlined at this single call site - -- (no other callers remain after pass 17). - skip_over.components[source_path:lower() .. "\0" .. component_name] = { - name = component_name, - source_path = source_path, - association = association, - } - end - end - end - return skip_over -end - ---- Project the canonical corpus registries into the shape the section builders expect. +--- Project the corpus registries into the shape the section builders expect. --- The corpus already owns the merged `register_alias_registry`, `type_name_registry`, `atom_views`, `atom_ctxs`, `atom_phases`, and `atom_infos` projections (populated by `passes.scan_source.lua`). --- This helper just references them so the rest of `dwarf_injection.lua` keeps the same `registries.` access shape it has always used. --- @@ -399,7 +354,7 @@ end --- --- When two sources register the same key, the last-writer wins (later sources override earlier). --- Today only one source declares wave-context enums, so collisions are absent. ---- @param corpus table -- the canonical corpus from `ctx.shared.corpus` +--- @param corpus table -- the corpus from `ctx.shared.corpus` --- @return table -- { --- register_alias_registry = {[R_Name] = AliasEntry}, --- type_name_registry = {[T] = TypeEntry}, @@ -407,7 +362,7 @@ end --- } local function collect_per_source_registries(corpus) -- The corpus already holds the merged registries; reference them directly. - -- No per-source iteration is needed because `passes.scan_source.lua` has already folded every per-source scan into the canonical tables. + -- `passes.scan_source.lua` has already folded every per-source scan into the corpus tables, so no per-source iteration is needed here. -- `atom_infos` is preserved byte-for-byte with no filtering; consumers consult `corpus.atoms_by_name` -- themselves when they need to know whether a particular atom_info corresponds to an actual atom record. local atom_infos_list = {} @@ -429,37 +384,8 @@ local function collect_per_source_registries(corpus) } end ---- Render deterministic debugger skip commands. ---- Ordering is stable by category: exact atom symbols first (lexicographic), then exact component function names (lexicographic full command). ---- Atom commands come from the matched nm/source-map table so the emitted name is the actual ELF symbol. ---- The scanner tables and command set both deduplicate repeated source observations. ---- @param skip_over table ---- @param atom_table table[] -- nm/source-map cross-reference; names are actual ELF symbols ---- @return string -local function build_gdbinit(skip_over, atom_table) - local commands = {} - local atom_names = {} - for _, atom in ipairs(atom_table) do - if atom.skip_over then atom_names[#atom_names + 1] = atom.name end - end - table.sort(atom_names) - for _, atom_name in ipairs(atom_names) do - commands[#commands + 1] = "skip function " .. atom_name - end - - local component_commands = {} - for _, component in pairs(skip_over.components) do - component_commands[#component_commands + 1] = "skip function mac_" .. component.name - end - table.sort(component_commands) - local prior = nil - for _, command in ipairs(component_commands) do - if command ~= prior then commands[#commands + 1] = command end - prior = command - end - - return table.concat(commands, "\n") .. "\n" -end +-- Skip semantics live in two DWARF projections: `atom.debug_skip` makes a whole atom opaque, and `invocation.debug_skip` +-- suppresses statement rows and inline DIEs for a selected invocation. No debugger command file is emitted by this pass. -- ════════════════════════════════════════════════════════════════════════════ -- LEB128 encoders @@ -472,7 +398,6 @@ end -- ════════════════════════════════════════════════════════════════════════════ -- DWARF line-program encoder -- ════════════════════════════════════════════════════════════════════════════ - --- Build the byte sequence for ONE atom's line program: --- DW_LNE_set_address(addr) --- [entry 1 emission] @@ -482,7 +407,7 @@ end --- DW_LNE_end_sequence --- --- Each "entry emission" emits one or more rows at the same PC, tracking the source state {file_idx, line}. ---- State transitions emit DW_LNS_set_file + DW_LNS_advance_line as needed; +--- State transitions emit DW_LNS_set_file + DW_LNS_advance_line as needed; --- same-state transitions emit only the row (copy). --- --- Entry rules: @@ -498,11 +423,16 @@ end --- - Extended opcode marker byte = 0. --- - Extended opcodes: marker byte + ULEB128 size + sub_opcode + payload. --- ---- Statement-state rules: +--- Statement-state rules (atom_dbg_step_ux_20260725 — per-row policy): --- * A marked whole atom emits one opaque is_stmt=false range row and no nested component rows; its subprogram symbol/range remains available. ---- * A marked component keeps its first-word call-site row true, toggles only the definition row/range false, and restores before the next unmarked row. +--- * Per-row policy at every other PC: +--- - Call-site row of any invocation's first word: is_stmt = true (unconditional; `want_call = true`). +--- - Body row of any invocation (first or subsequent): is_stmt = not inv.debug_skip (`want_body = not inv.debug_skip`). +--- - RAW word (no containing invocation): is_stmt = true (unconditional). +--- * The previous per-word `marked_idx` ancestor walk (Tasks 6+7) and the GDB 12 zero-instruction-prologue duplicate row at atom entry are DELETED; the new +--- first-word emission IS the entry statement. --- * Whole-atom suppression wins over component markers; no nested inversion. ---- @param atom table -- {name, addr, size_bytes, words, entries, invocations?, skip_over?} +--- @param atom table -- {name, addr, size_bytes, words, entries, invocations, debug_skip, word_events} --- @return string local function build_atom_sequence(atom) local function set_address(addr) @@ -526,12 +456,10 @@ local function build_atom_sequence(atom) if not atom.entries or #atom.entries == 0 then return set_address(atom.addr) .. end_sequence() end - -- A jump-threaded atom is reached by `jr`, not a C call. - -- GDB therefore cannot apply the parent `skip function` entry before descending into a visible child inline frame. - -- Make an explicitly marked atom DWARF-opaque: - -- one non-statement row covers its complete address range, with no per-word or component source transitions. + -- Whole-atom skip (the `atom.debug_skip` field, set by scan_source.run). + -- Make the atom DWARF-opaque: one non-statement row covers the complete address range; per-word and component source transitions are skipped. -- The atom's subprogram DIE remains, so symbolic lookup, explicit address breakpoints, and stepi are unaffected. - if atom.skip_over then + if atom.debug_skip then return table.concat({ set_address(atom.addr), set_file(ATOM_SOURCE_FILE_INDEX), @@ -544,17 +472,40 @@ local function build_atom_sequence(atom) }) end - -- Find which invocation each entry belongs to (if any). - -- Returns the invocation record or nil. Pre-computed once so we don't rescan per emitted row. - local function inv_for_idx(idx) - if not atom.invocations then return nil end - for _, inv in ipairs(atom.invocations) do - -- inv.start_pos / inv.end_pos are 0-based .word positions; entries are 1-based. + -- Per-word invocation ancestry (atom_dbg_step_ux_20260725): + -- * `innermost_idx[idx]` = deepest active invocation at idx (nil for RAW words). Used to pick body_lines[k] for body rows. + -- * `ancestry_idx[idx]` = full active ancestry at idx, ordered outermost-first (widest range first; innermost = last entry). + -- Consumed at the first word of every invocation (atom entry + every `idx == inv.start_pos + 1`). + -- Drives the nested-display rule: the outer invocation's call-site + body_lines[1] rows are + -- re-emitted at the inner's first word PC so the debugger displays the outer body line (not + -- the inner body line) when stepping into the inner. PROBLEM B fix. + -- + -- `start_pos` / `end_pos` are 0-based emitted-word positions stamped at construction/close time by + -- `duffle.emit_invoke_begin` / `duffle.emit_invoke_end`; missing values are a corpus-plumbing bug, so we let the index + -- expression fail loud with arithmetic-on-nil rather than silently producing `0+1=1` for a missing start_pos. + -- + -- The previous per-word `marked_idx` array (Tasks 6+7) is DELETED: a marked invocation's body rows are now driven + -- by the per-invocation `inv.debug_skip` predicate at the emit site, not by a precomputed ancestry flag. + local invs = atom.invocations or {} + local innermost_idx = {} + local ancestry_idx = {} + for idx = 1, #atom.entries do + innermost_idx[idx] = nil + ancestry_idx[idx] = {} + local active = {} + for _, inv in ipairs(invs) do if idx >= inv.start_pos + 1 and idx <= inv.end_pos + 1 then - return inv + active[#active + 1] = inv end end - return nil + -- Sort outermost-first (widest range first); innermost is the LAST entry (narrowest range). + table.sort(active, function(a, b) + return (a.end_pos - a.start_pos) > (b.end_pos - b.start_pos) + end) + ancestry_idx[idx] = active + if #active > 0 then + innermost_idx[idx] = active[#active] + end end local parts = { @@ -562,7 +513,7 @@ local function build_atom_sequence(atom) } -- Source state tracker: emits set_file + advance_line only on transitions, keeps bytes minimal. - -- Both fields stay in sync with what we emit so we never duplicate a set_file or skip a state-change emit. + -- Both fields stay in sync with what we emit, so we duplicate no set_file and skip no state-change emit. local cur_file = nil -- line-state.file_idx (nil = uninitialized) local cur_line = 1 -- line-state.line starts at 1 (per DWARF spec) local is_stmt = true -- main line unit default_is_stmt; every sequence ends restored @@ -589,87 +540,103 @@ local function build_atom_sequence(atom) parts[#parts + 1] = copy_op() end - -- Whole-atom suppression wraps the independent sequence itself. - -- File/line state setup follows while is_stmt is already false; the matching restore is emitted after the final row below. - if atom.skip_over then set_statement_state(false) end - - -- --- Atom entry (idx 1) ------------------------------------------------- - -- Determine entry-1's primary row (always the call-site file/line; - -- we may emit a second row immediately after if entry 1 is also a component invocation's first word). - local inv1 = inv_for_idx(1) - local entry_1 = atom.entries[1] - -- The atom body's own source file (the call site for any mac_X(...) inside it). local call_file_idx = ATOM_SOURCE_FILE_INDEX - -- Primary row: call site at atom start. - -- Capture its (file, line) so we can re-emit it as the GDB 12 zero-instruction-prologue marker below. - local entry_1_call_file = call_file_idx - local entry_1_call_line = (inv1 and inv1.call_line) or entry_1.line - local atom_is_stmt = not atom.skip_over + -- --- Atom entry (idx 1) ------------------------------------------------- + local entry_1 = atom.entries[1] + local entry_1_ancestry = ancestry_idx[1] - -- Primary row: call site at atom start. - emit_row(entry_1_call_file, entry_1_call_line, atom_is_stmt) - - -- If entry 1 is the first word of a component invocation, emit the component-definition row at the same PC immediately after. - -- The def line is always a statement target unless the inv is explicitly skip-over (atom_dbg_skip_over); - -- the whole-atom skip path takes the early-return at line ~497 above so atom_is_stmt is irrelevant here. - -- For the body row, prefer `body_lines[1]` (the actual source line of the first body word in the macro's expansion) - -- over `comp_line` (the macro signature line). - -- Canonical contract: the emission-model pass populates `body_lines` for every invocation; - -- absence now is an error (older emitters / external macros are no longer supported). - if inv1 and 1 == inv1.start_pos + 1 then - local comp_file_idx_1 = resolve_provenance_file_index(inv1.comp_file) - assert(inv1.body_lines, "missing body_lines: emitter did not run emission-model") - local body_line_1 = inv1.body_lines[1] - emit_row(comp_file_idx_1, body_line_1, not inv1.skip_over) + -- atom_dbg_step_ux_20260725: the call-site row at atom entry is ALWAYS is_stmt=true + -- (PROBLEM A fix). The previous `not marked_idx[1]` predicate suppressed this row when any + -- ancestor was marked; that behavior made source-level stepping skip past the call line entirely. + -- The new contract is `want_call = true` unconditionally. + -- + -- If atom entry 1 starts inside an invocation, walk the ancestry and emit a call-site row + (when + -- applicable) a body_lines[1] row for every active ancestor. For a non-nested invocation this is + -- just the one pair; for nested invocations this emits the outer call-site + body_lines[1] rows + -- BEFORE the inner pair so the debugger displays the outer body line at the inner's first word + -- (PROBLEM B fix). + -- + -- A marked OUTERMOST ancestor's body_lines[1] row is suppressed at this PC (the existing full-skip + -- contract is preserved for the marked outer range); its call-site row IS still emitted as a + -- statement. Marked INNER ancestors always emit their body_lines[1] row with is_stmt=false + -- (the per-invocation `want_body = not inv.debug_skip` predicate). + if #entry_1_ancestry == 0 then + -- RAW word at atom entry: single call-site row, always a statement target. + emit_row(call_file_idx, entry_1.line, true) + else + -- Atom starts in an invocation. Walk the ancestry outermost-first. + -- Each ancestor emits one call-site row (statement) and one body_lines[1] row + -- (statement iff unmarked; suppressed for marked outermost). + for ai, anc in ipairs(entry_1_ancestry) do + assert(anc.body_lines, "missing body_lines: emitter did not run emission-model") + assert(anc.body_lines[1] ~= nil + , "dwarf_injection: body_lines[1] missing on first-word entry for inv=" .. tostring(anc.component_name)) + assert(anc.call_path and anc.call_path ~= "" + , "dwarf_injection: inv.call_path is missing on invocation " .. tostring(anc.component_name) .. "; emitter did not run emission-model.") + emit_row(resolve_provenance_file_index(anc.call_path), anc.call_line, true) + local is_outermost = (ai == 1) + if not (is_outermost and anc.debug_skip) then + emit_row(resolve_provenance_file_index(anc.def_path), anc.body_lines[1], not anc.debug_skip) + end + end end - -- GDB 12 zero-instruction-prologue marker: duplicate of the PRIMARY entry-1 emission (the call-site row). - -- We must explicitly restore the source state to (call_file, call_line) before emitting the duplicate, - -- otherwise the duplicate lands at whatever state we ended entry-1 in (the component row, if entry 1 is an invocation start), - ---which would attach the marker to the wrong source statement. - --- - -- Only emitted at atom entry, NOT at every component-invocation start. - emit_row(entry_1_call_file, entry_1_call_line, atom_is_stmt) - -- --- Subsequent entries (idx 2..N) -------------------------------------- for idx = 2, #atom.entries do local entry = atom.entries[idx] - local inv = inv_for_idx(idx) + local inv = innermost_idx[idx] -- Advance PC by 1 .word (4 bytes on MIPS). parts[#parts + 1] = advance_pc(MIPS_BYTES_PER_WORD) if inv and idx == inv.start_pos + 1 then - -- First word of a component invocation: emit TWO rows at this PC. - -- 1) call-site row remains a statement target unless the whole atom is marked. - emit_row(call_file_idx, inv.call_line, atom_is_stmt) - -- 2) component body row at this PC. - -- Prefer `body_lines[1]` over `comp_line` so gdb's `step` lands on the macro's actual body line (not the signature line above `{ ... }`). - -- Canonical contract: `body_lines` is always populated by the emission-model pass. - local body_file_idx = resolve_provenance_file_index(inv.comp_file) - assert(inv.body_lines, "missing body_lines: emitter did not run emission-model") - local body_line_1 = inv.body_lines[1] - emit_row(body_file_idx, body_line_1, not inv.skip_over) + -- First word of the innermost active invocation (PROBLEM B fix — nested-display rule). + -- Walk the active ancestry outermost-first; for each ancestor emit a call-site row + -- (statement) + a body_lines[1] row. The inner-most invocation's call-site + body pair + -- become the LAST two rows in the sequence. Marked outermost ancestors suppress their + -- body_lines[1] row at this PC (the existing full-skip contract is preserved for the + -- marked outer range); all OTHER ancestors emit body_lines[1] with is_stmt = not debug_skip. + -- + -- This re-emits the outer ancestor's call-site + body rows at the inner's first word PC + -- for debugger context: source-level stepping now shows the outer body line (not the + -- inner body line) when stepping into the inner. PROBLEM B fix. + local ancestry = ancestry_idx[idx] + for ai, anc in ipairs(ancestry) do + assert(anc.body_lines, "missing body_lines: emitter did not run emission-model") + assert(anc.body_lines[1] ~= nil + , string.format("missing body_lines[1] for inv=%s start_pos=%d len=%d", + anc.component_name, anc.start_pos, #(anc.body_lines or {}))) + assert(anc.call_path and anc.call_path ~= "" + , "dwarf_injection: inv.call_path is missing on invocation " .. tostring(anc.component_name) .. "; emitter did not run emission-model.") + emit_row(resolve_provenance_file_index(anc.call_path), anc.call_line, true) + local is_outermost = (ai == 1) + if not (is_outermost and anc.debug_skip) then + emit_row(resolve_provenance_file_index(anc.def_path), anc.body_lines[1], not anc.debug_skip) + end + end elseif inv then - -- Subsequent word of an invocation: 1-based offset into body_lines: - -- word 1 of the invocation corresponds to body_lines[1], word 2 -> body_lines[2], etc. - -- 1-based offset = idx - inv.start_pos (since idx = inv.start_pos + i for the i-th body word). - -- Canonical contract: `body_lines` is always populated by the emission-model pass. - local body_file_idx = resolve_provenance_file_index(inv.comp_file) - local words_into = idx - inv.start_pos -- 1-based word position in invocation + -- Subsequent body word of the innermost active invocation: `body_lines[k]` is indexed by the 1-based offset of this word inside the invocation. + -- Both `idx` (1-based DWARF entry index) and `inv.start_pos` (0-based emitted-word position stamped at `emit_invoke_begin`) come from the same + -- monotonic counter, so `idx - inv.start_pos` is exactly the 1-based k (the first word of the invocation has `idx == inv.start_pos + 1`, hence `k == 1`). + -- atom_dbg_step_ux_20260725: `want_body = not inv.debug_skip`. The previous `want = not marked_idx[idx]` + -- (which suppressed ALL body rows when any ancestor was marked) is replaced by the per-invocation + -- predicate. Marked invocations emit non-statement body rows at every body word; unmarked + -- invocations emit statement body rows. assert(inv.body_lines, "missing body_lines: emitter did not run emission-model") - local body_line_i = inv.body_lines[words_into] - emit_row(body_file_idx, body_line_i, not inv.skip_over) + local words_into = idx - inv.start_pos + assert(inv.body_lines[words_into] ~= nil + , string.format("missing body_lines[%d] for inv=%s start_pos=%d len=%d idx=%d", + words_into, inv.component_name, inv.start_pos, #(inv.body_lines or {}), idx)) + emit_row(resolve_provenance_file_index(inv.def_path), inv.body_lines[words_into], not inv.debug_skip) else - -- RAW word: single call-site row. emit_row restores is_stmt after a selected component range before exposing this adjacent row. - emit_row(call_file_idx, entry.line, atom_is_stmt) + -- RAW word: single call-site row, always a statement target (the word itself is unmarked). + emit_row(call_file_idx, entry.line, true) end end -- Every sequence starts from default_is_stmt=true. - -- Restore that state before DW_LNE_end_sequence so a marked whole atom has explicit bounded toggles - -- and no state can leak to a following independent atom sequence. + -- Restore that state before DW_LNE_end_sequence: a marked whole atom keeps explicit bounded toggles and no state leaks to the following independent atom sequence. set_statement_state(true) parts[#parts + 1] = end_sequence() return table.concat(parts) @@ -682,30 +649,33 @@ end --- Build the atom table the section builders consume. --- Cross-references nm symbols with `corpus.atoms_by_name` and derives word rows + format-1 outermost invocation rows from `atom.paths`. --- ---- The atom table is built entirely from in-memory state — disk source-map and provenance text artifacts are NOT consulted. ---- Those artifacts are diagnostic outputs, not semantic inputs; the DWARF injection pass must remain correct regardless of their on-disk content. +--- The atom table is built entirely from in-memory state. Disk source-map and provenance text artifacts are diagnostic outputs, not semantic inputs; +--- the DWARF injection pass must remain correct regardless of their on-disk content. +--- +--- Skip-state ownership (Tasks 6+7): +--- * Whole-atom skip is read from the `atom.debug_skip` field (stamped by `scan_source.run` when the bare `atom_dbg_skip` marker +--- immediately precedes the declaration; `corpus.atoms_by_name` exposes it directly). +--- * Invocation skip is read from `atom.paths.invocations[*].debug_skip` (stamped by `duffle.emit_invoke_begin` +--- from `corpus.components[name].debug_skip`, no second-pass, no source parse, no parallel lookup). --- --- The result shape (one entry per ELF symbol matched against the corpus): ---- `{name, addr, size_bytes, words, entries, invocations, skip_over?}` +--- `{name, addr, size_bytes, words, entries, invocations, debug_skip?}` --- where: --- * `entries[i].pos` — 0-based `.word` position (matches the source-map format-1 row layout; downstream DWARF builders compare against this). --- * `entries[i].line` — call-site line for that word. --- * `entries[i].text` — trimmed encoder token text from `atom.paths.word_events`. ---- * `invocations[j]` — one entry per format-1 outermost `mac_X(...)` invocation with ---- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos, body_lines, skip_over}`. `body_lines[k]` +--- * `invocations[j]` — one entry per format-1 outermost `mac_X(...)` invocation with +--- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos, body_lines, debug_skip}`. `body_lines[k]` --- is the k-th word's source line within the component body. --- ---- @param corpus table -- the canonical corpus from `ctx.shared.corpus` +--- @param corpus table -- the corpus from `ctx.shared.corpus` --- @param addrs table -- ELF symbols keyed by atom name from `elf_dwarf.read_nm` ---- @param skip_over table -- {atoms = {[symbol] = association}, components = {[file|name] = association}} ---- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, skip_over?} -local function build_atom_table(corpus, addrs, skip_over) - -- Cross-ref: keep only atoms present in BOTH the nm symbol table AND - -- the canonical corpus projection. Output is sorted by ascending addr. +--- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, debug_skip?} +local function build_atom_table(corpus, addrs) + -- Cross-ref: keep only atoms present in BOTH the nm symbol table AND `corpus.atoms_by_name`. Output is sorted by ascending addr. local atoms_by_name = corpus.atoms_by_name or {} - -- Per-atom ingest. Returns nil if the atom is absent from the corpus - -- (caller skips it via the `if atom then ...` guard). + -- Per-atom ingest. Returns nil if the atom is absent from the corpus; the caller skips it via the `if atom then ...` guard. local function ingest_atom(name, info) local atom_record = atoms_by_name[name] if not atom_record then return nil end @@ -715,8 +685,7 @@ local function build_atom_table(corpus, addrs, skip_over) local invocations_proj = paths.invocations or {} -- Build the dense entries list from `word_events`. -- `word_events[i].i` = the 0-based `.word` position - -- `call_line` = the root atom's physical source line for that word - -- (stamped by emission_model) + -- `call_line` = the root atom's physical source line for that word (stamped by emission_model) local entries = {} for idx, ev in ipairs(word_events) do entries[#entries + 1] = { @@ -725,64 +694,32 @@ local function build_atom_table(corpus, addrs, skip_over) text = ev.call_text or "", } end + -- Whole-atom skip is read from the atom declaration record; the scanner owns it, no parallel lookup table. local atom = { name = name, addr = info[1], size_bytes = info[2], words = #word_events, entries = entries, - skip_over = skip_over.atoms[name] ~= nil, + debug_skip = atom_record.debug_skip == true, } - -- Group consecutive `word_events` rows whose outermost invocation is the SAME - -- format-1 invocation into a single `atom.invocations` entry. Rows sharing the - -- same comp_name / call_file / call_line / comp_file / comp_line are part of - -- the same group. Rows outside any invocation flush cur_inv. - if #invocations_proj > 0 then - local invocations = {} - -- Process one word_event row against the current group state. - -- Returns the (possibly updated) cur_inv. - local function row(ev, cur_inv) - local outer_id = ev.outermost_invocation_id - local outer_inv = outer_id and invocations_proj[outer_id] or nil - if not (outer_inv and outer_inv.component_name) then - -- raw row: flush any pending cur_inv; no new group starts. - if cur_inv then invocations[#invocations + 1] = cur_inv end - return nil - end - local inv_key = outer_inv.component_name - .. "|" .. (outer_inv.call_path or "") - .. "|" .. tostring(outer_inv.call_line or 0) - .. "|" .. (outer_inv.def_path or "") - .. "|" .. tostring(outer_inv.def_line or 0) - local ev_pos = ev.i or 0 - if cur_inv and cur_inv.key == inv_key then - -- same group: extend range + append body line. - cur_inv.end_pos = ev_pos - cur_inv.body_lines[#cur_inv.body_lines + 1] = ev.body_line or 0 - return cur_inv - end - -- key changed (or no current group): flush + start new. - if cur_inv then invocations[#invocations + 1] = cur_inv end - return { - key = inv_key, - comp_name = outer_inv.component_name, - call_file = outer_inv.call_path or "", - call_line = outer_inv.call_line or 0, - comp_file = outer_inv.def_path or "", - comp_line = outer_inv.def_line or 0, - start_pos = ev_pos, - end_pos = ev_pos, - skip_over = skip_over.components[normalize_debug_path(outer_inv.def_path or ""):lower() .. "\0" .. outer_inv.component_name] ~= nil, - body_lines = { ev.body_line or 0 }, - } - end - local cur_inv = nil - for _, ev in ipairs(word_events) do - cur_inv = row(ev, cur_inv) - end - if cur_inv then invocations[#invocations + 1] = cur_inv end - atom.invocations = invocations + -- Consume invocation records from `atom.paths.invocations`. It is the single producer of per-invocation body_lines, per-invocation debug_skip, + -- and per-invocation start_pos/end_pos; the walker stamped every field at the construction/close site (`emit_invoke_begin` / `emit_invoke_end` in duffle.lua). + -- DWARF reconstructs invocation ranges by reading `start_pos` / `end_pos` directly (not by grouping `word_events`, not by deriving + -- `start_pos`/`end_pos` from `start_word`/`end_word`). + -- `start_pos` is the 0-based emitted-word position stamped AT `emit_invoke_begin` time, while `start_word` is the 1-based items index + -- (which differs by the count of `invoke_begin`/`invoke_end`/marker items between this call and the previous one). + -- Using `start_word` would shift every `words_into` lookup by the marker count and break the call-site + body row pairing at the first word of every invocation. + atom.invocations = invocations_proj + for _, inv in ipairs(atom.invocations) do + -- Tasks 6+7: `debug_skip` flag is already stamped by `duffle.emit_invoke_begin` from `corpus.components[name].debug_skip`. + -- Normalize to boolean for downstream dispatch. A missing value is a corpus-plumbing bug; the fail-loud error was raised at the construction site. + inv.debug_skip = inv.debug_skip == true + assert(type(inv.start_pos) == "number" + , "dwarf_injection: inv.start_pos (0-based emitted-word position) is missing on invocation " .. tostring(inv.component_name) .. "; the construction site failed to stamp it.") + assert(type(inv.end_pos) == "number" + , "dwarf_injection: inv.end_pos (0-based emitted-word position) is missing on invocation " .. tostring(inv.component_name) .. "; the close site failed to stamp it.") end return atom end @@ -804,11 +741,11 @@ local function collect_component_defs(atom_table) local out = {} for _, atom in ipairs(atom_table) do for _, inv in ipairs(atom.invocations or {}) do - if not out[inv.comp_name] then - out[inv.comp_name] = { - name = inv.comp_name, - def_file = inv.comp_file, - def_line = inv.comp_line, + if not out[inv.component_name] then + out[inv.component_name] = { + name = inv.component_name, + def_file = inv.def_path, + def_line = inv.def_line, } end end @@ -884,9 +821,8 @@ end --- The `regs` list per atom is ordered: each entry is the MIPS reg index that holds the matching field in the source-order pop sequence. --- The piece chain uses (DW_OP_regN, DW_OP_piece, ULEB128(field_size)). --- ---- Binds fields come from `scan.binds`; no body-text source walk is needed. ---- per-source `scan.binds[i].fields` already carries the typed-field record after the scan-source generalization. ---- @param corpus table -- the canonical corpus from `ctx.shared.corpus` +--- Binds fields come from `scan.binds`; the per-source `scan.binds[i].fields` already carries the typed-field record after the scan-source generalization. +--- @param corpus table -- the corpus from `ctx.shared.corpus` --- @param atom_table table[] -- the cross-ref'd atom table from build_atom_table --- @param registries table -- merged registries from collect_per_source_registries --- @return table, table -- (rbind_atoms, rbind_structs) @@ -895,9 +831,9 @@ local function parse_rbind_atoms(corpus, atom_table, registries) local rbind_atoms = {} local rbind_structs = {} - -- Index binds by struct name; consume `scan.binds[i].fields` directly (no body-text re-walk). - -- The scan-source pass emits each Binds_X's fields as {[type_name, pointer_depth, offset, byte_size, ...]} - -- so this pass can build the rbind_structs entry without re-parsing. + -- Index binds by struct name; consume `scan.binds[i].fields` directly. + -- The scan-source pass emits each Binds_X's fields as {[type_name, pointer_depth, offset, byte_size, ...]}, + -- so this pass builds the rbind_structs entry without re-parsing. local binds_by_name = {} for _, src in ipairs((corpus and corpus.source_order) or {}) do local scan = src.scan @@ -973,12 +909,11 @@ end -- Section builders (single dispatch table — see guide_metaprogram_ssdl.md §11) -- ════════════════════════════════════════════════════════════════════════════ ---- Append per-atom line-program sequences to the existing main .debug_line unit +--- Append per-atom line-program sequences to the existing main .debug_line unit --- (the final unit, referenced by the main CU's DW_AT_stmt_list). --- --- This builder extends the main compilation unit. ---- No compilation unit pointed at it through DW_AT_stmt_list, so gdb ignored it. ---- It also encoded byte 13 as the extended-opcode marker; byte 13 is actually the first special opcode. +--- A detached synthetic line unit has no DW_AT_stmt_list referencing it, so gdb ignored it (a previous experiment); byte 13 is the first special opcode, not the extended-opcode marker. --- The existing final unit already contains hello_gte_tape.c as file index 11 and ends with a valid end_sequence. --- We preserve its bytes, append independent atom sequences, and increase only that unit's DWARF32 unit_length. --- @param existing string -- existing section bytes, byte-for-byte @@ -1097,8 +1032,8 @@ local function build_dwarf_aranges_section(existing, atom_table) if not is_last_unit then -- Malformed: walked past the end of the section without finding a unit whose end aligns with #existing. - -- This means a unit's length field overruns the section, or there is no terminator on the last unit. - -- For now, return existing unchanged to avoid making it worse; warn so the user can investigate. + -- A unit's length field overruns the section, or the last unit has no terminator. + -- Return existing unchanged to avoid making it worse; warn so the user can investigate. io.stderr:write("[dwarf_injection] WARN: .debug_aranges layout is malformed (no unit's end aligned with section end at " .. #existing .. " bytes); passing through unchanged\n") return existing end @@ -1154,10 +1089,8 @@ end -- Helpers: per-atom .debug_info synthesis (DW_TAG_subprogram + DW_TAG_variable) -- ════════════════════════════════════════════════════════════════════════════ ---- Build the location bytes (DW_FORM_exprloc) for a register-resident value. ---- DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f. DW_OP_reg15 is 0x5f; ---- 0x90 is DW_OP_regx, not the base of the compact register opcode range. --- Build the piece-chain location bytes (DW_FORM_exprloc) for an rbind atom's bind_args. +--- DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f; DW_OP_reg15 is 0x5f; 0x90 is DW_OP_regx, not the base of the compact register opcode range. --- --- The location expression is a sequence of (DW_OP_regN, DW_OP_piece, ULEB128(size)) tuples, one per field. --- GDB composites the pieces into a struct-shaped value matching the Binds_X layout. @@ -1190,7 +1123,7 @@ local function piece_chain_exprloc(rbind) local next_off = field_offset_by_name[rbind.regs[i + 1].field] if not next_off then -- Defensive: a load_word references a field not in the Binds_X struct. - -- Use struct.bytes as a fallback so the piece chain is still well-formed. + -- Fall back to struct.bytes so the piece chain stays well-formed. next_off = rbind.bytes end size = next_off - off @@ -1320,7 +1253,7 @@ end --- DW_AT_encoding (DW_FORM_data1) -- DW_ATE_unsigned --- Abbrev 107 (DW_TAG_subprogram, NO children) — abstract origin. --- DW_AT_name (DW_FORM_string) -- "mac_yield" etc. (component ident) ---- DW_AT_inline (DW_FORM_data1) -- DW_INL_declared_inlined (= 3) +--- DW_AT_inline (DW_FORM_data1) -- DW_INL_inlined (= 1) --- DW_AT_external (DW_FORM_data1) -- 1 (visible across the CU) --- Abbrev 108 (DW_TAG_inlined_subroutine, with children): --- DW_AT_abstract_origin (DW_FORM_ref4) -- → ABBREV_ABSTRACT_SUBPROGRAM @@ -1364,7 +1297,7 @@ local function build_new_abbrev() -- rbind composite. -- DW_FORM_udata (0x0F, ULEB128) is declared at module scope. For small values (struct byte_size, member offsets) 1 byte is enough; - -- we emit ULEB128 anyway for spec compliance. + -- we emit ULEB128 anyway for spec compliance with the DWARF abbrev encoding rules. local abbrev_struct_type = abbrev(ABBREV_STRUCT_TYPE, DW_TAG_structure_type, true, -- DW_CHILDREN_yes attr( DW_AT_name, DW_FORM_string) .. attr(DW_AT_byte_size, DW_FORM_udata)) @@ -1384,9 +1317,7 @@ local function build_new_abbrev() .. attr(DW_AT_byte_size, DW_FORM_data1) .. attr(DW_AT_encoding, DW_FORM_data1)) - -- Component step-into abstract + inline DIE abbreviations. - local DW_INL_declared_inlined = 0x03 -- DWARF5 §3.33.3: "this subroutine was declared inline" - -- Abstract subprograms carry DW_AT_decl_file and DW_AT_decl_line for definition-site resolution. + -- Abstract subprograms carry DW_AT_decl_file and DW_AT_decl_line for definition-site resolution, -- even when no inlined_subroutine instance currently maps to it. -- DW_FORM_udata is consistent with the call_file/call_line forms on abbrev 108. local abbrev_abstract_subprogram = abbrev(ABBREV_ABSTRACT_SUBPROGRAM, DW_TAG_subprogram, false, -- DW_CHILDREN_no @@ -1427,8 +1358,8 @@ end --- Strip a leading "R_" prefix from an enum alias name. ---- The .debug_str/.debug_info consumers display the local without the C-enum prefix (RR_PrimCursor, not RR_R_PrimCursor) ---- because the C-level identifier `R_PrimCursor` would collide with the enum constant value 15 when the user runs `print R_PrimCursor` in gdb. +--- The .debug_str/.debug_info consumers display the local without the C-enum prefix (RR_PrimCursor, not RR_R_PrimCursor). +--- Without the strip, the C-level identifier `R_PrimCursor` would collide with the enum constant value 15 when the user runs `print R_PrimCursor` in gdb. --- @param r_name string -- e.g. "R_PrimCursor" --- @return string -- "PrimCursor" (or the input unchanged if it does not start with "R_") local function strip_r_prefix(r_name) @@ -1474,7 +1405,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). - -- 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 @@ -1496,10 +1427,9 @@ end --- Build the DWARF DIE bytes to insert into the MAIN CU as children, immediately --- before the main CU's root children-terminator (the final 0 byte of the CU). --- ---- Insert the DIEs as children of the main compilation unit. ---- This keeps `RR_PrimCursor` and `bind_args` in scope for atom PCs. ---- Inserting the DIEs as children of the main CU puts them in scope for every PC the main CU owns; ---- including every atom PC (since `.debug_aranges` + `.debug_rnglists` already assign atom PCs to it). +--- Insert the DIEs as children of the main compilation unit. This keeps `RR_PrimCursor` and `bind_args` in scope for atom PCs. +--- Inserting the DIEs as children of the main CU puts them in scope for every PC the main CU owns, including every atom PC +--- (since `.debug_aranges` + `.debug_rnglists` already assign atom PCs to it). --- --- Layout (matches the pre-build_new_cu exactly; only the insertion point and ref4 basis change): --- 1) DW_TAG_base_type "unsigned int" (abbrev 106) @@ -1530,10 +1460,10 @@ end --- DW_AT_location = piece-chain (DW_FORM_exprloc) --- DW_AT_type = ref4 → structure_type DIE --- ---- **DOES NOT** emit the final 0 byte (root terminator). ---- build_debug_info_section splices bytes ahead of the root terminator and preserves existing DIE bytes exactly. +--- This function does NOT emit the final 0 byte (root terminator). build_debug_info_section splices bytes ahead of the root terminator +--- and preserves existing DIE bytes exactly. --- ---- **ref4 basis**: DW_FORM_ref4 is CU-relative (offset from the first byte of the CU header). +--- ref4 basis: DW_FORM_ref4 is CU-relative (offset from the first byte of the CU header). --- Our inserted DIEs live in the main CU, so every ref4 = (target section offset) - main_cu_offset. --- Per-die section offsets are tracked via the running `next_offset` cursor (= section offset of the NEXT byte to emit). --- @@ -1550,11 +1480,10 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta registries = registries or {} -- by_alias: the merged register_alias_registry filtered to aliases whose `code` is a valid MIPS GPR 0..31. - -- Aliases absent from the merged registry are not debug-visible and are skipped entirely (no fallback GPR). + -- Aliases absent from the merged registry are skipped entirely; absent aliases have no debug-visible fallback GPR. -- by_alias_order: sorted list of by_alias keys, for deterministic iteration order. - -- (Lua's pairs() order is implementation-defined and may vary between runs; - -- without sorting, the per-atom variable emission order would be non-deterministic - -- and the .debug_info bytes would differ across builds.) + -- Lua's pairs() order is implementation-defined and varies between runs; without sorting, the per-atom variable emission order + -- would be non-deterministic and the .debug_info bytes would differ across builds. local by_alias = {} for r_name, alias in pairs(registries.register_alias_registry or {}) do if alias.code and alias.code >= 0 and alias.code <= 31 then @@ -1597,6 +1526,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta S.next_offset = S.next_offset + #s end local function ref4_of(section_offset) + if section_offset == nil then return 0 end return section_offset - main_cu_offset end @@ -1607,8 +1537,8 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit("unsigned int\0") -- DW_FORM_string (DW_AT_name) emit(string.char(4)) -- DW_FORM_data1 (DW_AT_byte_size) emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding) - -- (The function body below reads S.next_offset directly via the `next_offset` function; - -- this keeps offsets synchronized with emitted data.) + -- The function body below reads S.next_offset directly via the `next_offset` function; + -- this keeps offsets synchronized with emitted data. local function next_offset() return S.next_offset end -- Typed local views. @@ -1641,13 +1571,9 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta table.sort(sorted_typed_types) -- For each non-U4 type, emit a typedef (DW_TAG_typedef) named after the type and referencing the base_type "unsigned int" (4 bytes). -- The typedef gives gdb a named anchor; the pointer_type chain wraps it. - -- We use a fresh abbrev for the typedef + the pointer_type. - -- To stay within the existing abbrev budget, we reuse the duplicated main table's abbrev 4 (DW_TAG_typedef) and abbrev 9 (DW_TAG_pointer_type) - -- via a synthetic-emit pattern: emit the abbrev code (a ULEB) + attribute bytes using DW_FORM values that match those abbrevs. - -- The cleanest path is to define new abbrevs in build_new_abbrev(). - -- We emit fresh DW_TAG_base_type DIEs (the simplest correct shape: byte_size 4, encoding unsigned) + -- The cleanest path is to define new abbrevs in build_new_abbrev(); we emit fresh DW_TAG_base_type DIEs (byte_size 4, encoding unsigned) -- and the variable's DW_AT_type references the pointer chain's outermost base_type. - -- The displayed type name is the type_name (e.g. "V4_S2") because we use DW_FORM_string on the field type. + -- The displayed type name is the type_name (e.g. "V4_S2") because we use DW_FORM_string on the field type; -- gdb walks the chain and displays the name. -- -- For each (type_name, depth), emit a real structure_type with proper member layout, @@ -1658,8 +1584,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta -- Each row is {byte_size, members}, where each member is {name, offset, byte_size, type_name}. -- `type_name` is the base type name from `type_name_registry`; "S2" / "S4" need a signed base_type emit too. -- - -- The table is small + explicit (no introspection of the struct types from production source) - -- because the prototype principle treats the typed-view struct layout as data, not derived state. + -- The table is small + explicit — the prototype principle treats the typed-view struct layout as data, not derived state. local STRUCT_MEMBER_TABLE = { -- 2-element signed short vector (rare; placeholder for future use). V2_S2 = { byte_size = 4, members = { @@ -1728,8 +1653,8 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta local depth = used_typed_views[tn] local type_info = STRUCT_MEMBER_TABLE[tn] if not type_info then - -- Unknown typed view — fall back to a generic 4-byte unsigned base_type to keep the wire valid - -- (gdb will render as the typename but `print *ptr` will only see the first 4 bytes). + -- Unknown typed view: fall back to a generic 4-byte unsigned base_type to keep the wire valid. + -- gdb renders as the typename but `print *ptr` only sees the first 4 bytes. local innermost_offset = next_offset() emit(uleb128(ABBREV_BASE_TYPE)) emit(tn .. "\0") @@ -1767,8 +1692,9 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit(elf_dwarf.write_u32_le(ref4_of(member_base_off))) -- DW_AT_type ref4 → base_type end emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of structure_type's children (DWARF5 §7.5.3) - -- Emit a single DW_TAG_pointer_type (abbrev 110, NOT 9) pointing at the structure_type. - -- For depth > 1, we'd chain pointer_type → pointer_type → ... → structure_type; not exercised. + -- Emit a single DW_TAG_pointer_type (abbrev 110) pointing at the structure_type. We picked 110 over 9 (the duplicated gcc pointer_type) because + -- abbrev 9 carries DW_AT_byte_size + DW_AT_type and gdb would misparse our ref4 as those attributes. Abbrev 110 has only DW_AT_type, so a 4-byte ref4 lands cleanly on the target type. + -- For depth > 1, we'd chain pointer_type → pointer_type → ... → structure_type; that path isn't exercised today. if depth == 1 then local outermost_offset = next_offset() emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9) @@ -1783,8 +1709,8 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta -- 1b) Emit the void* fallback chain (used by step (f) of the per-RR_ precedence chain). -- One DW_TAG_base_type DIE named "void" + one DW_TAG_pointer_type pointing at it. - -- Unannotated RR_ locals reference the outermost pointer_type; gdb displays the value as `(void *) 0x...` - -- (hex) per the prototype principle rather than `unsigned int`. + -- Unannotated RR_ locals reference the outermost pointer_type; gdb displays the value as `(void *) 0x...` (hex) + -- per the prototype principle rather than `unsigned int`. -- The void base_type is emitted BEFORE any other typed chain so its ref4 pointer remains stable. -- Follow the SAME pattern as the typed-views chain above: capture the offset BEFORE the uleb tag -- (this is the ref4 target), emit the DIE bytes, then emit the pointer_type pointing at the offset. @@ -1832,7 +1758,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta emit(uleb128(ABBREV_MEMBER)) emit(field.name .. "\0") -- DW_FORM_string (DW_AT_name) emit(uleb128(field.offset)) -- DW_FORM_udata (DW_AT_data_member_location) - -- typed field. + -- typed field: -- For a pointer-typed field, the member's DW_AT_type points at the deepest pointer_type in its chain. -- For U4 (no pointer), it points at the base_type. local field_type_offset @@ -1890,7 +1816,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta -- (d) atom_phase(