10 Commits
Author SHA1 Message Date
ed 9d066ae292 nesting reduction 2026-07-09 20:10:01 -04:00
ed c9b7f8c08b almost ready for static analysis additions 2026-07-09 19:48:02 -04:00
ed 59903546d7 rework of metaprogram 2026-07-09 19:30:32 -04:00
ed 1ffdda45e5 Adjustments to formatting 2026-07-09 19:28:56 -04:00
ed 1209172649 wip: lua metaprogram rework 2026-07-09 18:45:36 -04:00
ed 98e27c2815 fixed. 2026-07-09 17:21:47 -04:00
ed 88aa1b8b59 wip 2026-07-09 16:57:52 -04:00
ed ca3dc4aff0 wip: cube_g4_face is bugged 2026-07-09 16:17:34 -04:00
ed 1fb4883138 gitignore update 2026-07-09 15:47:11 -04:00
ed 0ad609e7c2 cookin 2026-07-09 15:38:03 -04:00
20 changed files with 3495 additions and 1788 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
build
toolchain/armips
toolchain/luajit-2.1
toolchain/pcsx-redux
# toolchain/psyq_iwyu
# toolchain/PSn00bSDK
toolchain/psyq_iwyu
toolchain/PSn00bSDK
*.exe
*.elf
+128
View File
@@ -0,0 +1,128 @@
#ifdef INTELLISENSE_DIRECTIVES
#pragma once
#endif
// Auto-generated by tape_atom_annotation_pass.lua — DO NOT EDIT
// Source: C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h
// Component atoms (MipsAtomComp_(ac_*)) -> macro variants (mac_*)
// + auto word-counts (so tape_atom.metadata.h stays manual-only
// for encoding macros).
#ifndef WORD_COUNT
#define WORD_COUNT(name, count) enum { words_##name = (count) };
#endif
#define mac_yield(...) \
load_word(R_AtomJmp, R_TapePtr, 0) \
, add_ui_self( R_TapePtr, S_(MipsCode)) \
, jump_reg( R_AtomJmp) \
, nop
WORD_COUNT(mac_yield, 4)
/* 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)) \
, load_half_u(R_T1, R_FaceCursor, 1 * S_(S2)) \
, load_half_u(R_T2, R_FaceCursor, 2 * S_(S2))
WORD_COUNT(mac_load_tri_indices, 3)
/* Words: 18; Translates indices to vertex addresses and pushes them to GTE */
#define mac_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)
WORD_COUNT(mac_load_tri_verts, 18)
/* Words: 11; Correctly inserts a primitive into the Ordering Table linked list.
* Hardcoded for Poly_F3 (5 words). For Poly_G4, use ac_insert_ot_tag_g4. */
#define mac_insert_ot_tag_f3(...) \
shift_lleft( R_T1, R_T1, S_(U4)/2) /* T1 = otz * S_(U4) (otz arg is implicit R_T1) */ \
, add_u_self( R_T1, R_OtBase) /* T1 = & OrderingTable[OTZ] */ \
, load_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* AT = old_ot_head */ \
, load_upper_i(R_V0, (S_(Poly_F3)/S_(U4) - S_(PolyTag)/S_(U4)) << polytag_len_bits) /* V0 = (5 - 1) << 24 = 4 << 24 */ \
, mask_upper( R_AT, R_AT, S_(polytag_len_bits)) /* Strip upper 8 bits (length from prev cell) → keep only low 24 */ \
, or_u( R_AT, R_AT, R_V0) /* Merge length */ \
, store_word( R_AT, R_PrimCursor, O_(PolyTag,bf_addr_len)) /* prim->tag = packed(prim_length, old_addr) */ \
, shift_lleft( R_AT, R_PrimCursor, S_(polytag_len_bits)) /* AT = (prim_length << 24) | old_addr */ \
, shift_lright(R_AT, R_AT, S_(polytag_len_bits)) \
, store_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* OrderingTable[OTZ] = PrimCursor */
WORD_COUNT(mac_insert_ot_tag_f3, 11)
/* Words: 11; Correctly inserts a primitive into the Ordering Table linked list.
* Hardcoded for Poly_G4 (9 words). For Poly_F3, use ac_insert_ot_tag_f3. */
#define mac_insert_ot_tag_g4(...) \
shift_lleft( R_T1, R_T1, S_(U4)/2) /* T1 = otz * S_(U4) (otz arg is implicit R_T1) */ \
, add_u_self( R_T1, R_OtBase) /* T1 = & OrderingTable[OTZ] */ \
, load_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* AT = old_ot_head */ \
, load_upper_i(R_V0, (S_(Poly_G4)/S_(U4) - S_(PolyTag)/S_(U4)) << polytag_len_bits) /* V0 = (9 - 1) << 24 = 8 << 24 */ \
, mask_upper( R_AT, R_AT, S_(polytag_len_bits)) /* Strip upper 8 bits (length from prev cell) → keep only low 24 */ \
, or_u( R_AT, R_AT, R_V0) /* Merge length */ \
, store_word( R_AT, R_PrimCursor, O_(PolyTag,bf_addr_len)) /* prim->tag = packed(prim_length, old_addr) */ \
, shift_lleft( R_AT, R_PrimCursor, S_(polytag_len_bits)) /* AT = (prim_length << 24) | old_addr */ \
, shift_lright(R_AT, R_AT, S_(polytag_len_bits)) \
, store_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* OrderingTable[OTZ] = PrimCursor */
WORD_COUNT(mac_insert_ot_tag_g4, 11)
#define mac_pack_color_word(off, code, r, g, b) \
load_upper_i(R_AT, (code) << 8 | (b)) \
, or_i_self( R_AT, ((g) << 8) | (r)) \
, store_word( R_AT, R_PrimCursor, (off))
WORD_COUNT(mac_pack_color_word, 3)
#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)
/* 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(...) \
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))
WORD_COUNT(mac_gte_store_f3_post_rtpt, 3)
#define mac_format_g4_color(r0, g0, b0, r1, g1, b1, r2, g2, b2, r3, g3, b3) \
mac_pack_color_word(O_(Poly_G4,c0), gp0_cmd_poly_g4, r0,g0,b0) \
, mac_pack_color_word(O_(Poly_G4,c1), 0, r1,g1,b1) \
, mac_pack_color_word(O_(Poly_G4,c2), 0, r2,g2,b2) \
, mac_pack_color_word(O_(Poly_G4,c3), 0, r3,g3,b3)
WORD_COUNT(mac_format_g4_color, 12)
/* 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). MUST be called BEFORE V3-RTPS, otherwise SXY0/1/2
* get overwritten with v3 (RTPS writes only to SXY2, but to keep the
* 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. */
#define mac_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))
WORD_COUNT(mac_gte_store_g4_p012_post_rtpt_pre_rtps, 3)
/* 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
* earlier RTPT — DO NOT read SXY0 here, that's the bug this name
* prevents).
*/
#define mac_gte_store_g4_p3_post_rtps(...) \
gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p3))
WORD_COUNT(mac_gte_store_g4_p3_post_rtps, 1)
@@ -1,4 +1,4 @@
// Auto-generated by tape_atom_offset_gen.meta.lua — DO NOT EDIT
// Auto-generated by ps1_meta.lua (passes/offsets.lua) — DO NOT EDIT
// Source: C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h
#pragma once
+125 -85
View File
@@ -6,18 +6,38 @@
# include "gte.h"
# include "memory.h"
# include "atom_dsl.h"
# include "gen/duffle.macs.h"
# include "gen/duffle.offsets.h"
#endif
typedef U4 const MipsCode;
typedef Slice_(MipsCode);
typedef Slice_MipsCode MipsAtom;
#define MipsAtom_(sym) MipsCode tmpl(code,sym) [] align_(4) =
#pragma region Tape Drive
/* ---------------------------------------------------------------------------
* TAPE DRIVE ABI & REGISTER ALIASES
* ---------------------------------------------------------------------------
* We map the MIPS temporary registers to a persistent global workspace.
* The C compiler is completely unaware of these bindings.
* ---------------------------------------------------------------------------*/
// Bare form: file-scope declaration with hardcoded body.
// Used for components with no args (e.g., ac_load_tri_indices) or
// identifier-args (hardcoded register names).
// MipsAtomComp_(ac_X) { body }
// expands to:
// MipsCode ac_X[] align_(4) = { body };
#define MipsAtomComp_(sym) MipsCode sym [] align_(4) =
// Function form: function-body block that returns a MipsAtom slice.
// Used for components with value-args (e.g., ac_format_f3_color).
// FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })
// expands to:
// FI_ MipsAtom ac_X(args) { MipsCode ac_X[] align_(4) = { body }; return slice_from_array(MipsCode, ac_X); }
#define MipsAtomComp_Proc_(sym, ...) { MipsCode sym [] align_(4) = __VA_ARGS__; return slice_from_array(MipsCode, sym); }
// Auto-generated component macros (<module>/gen/<dir>/<dir>.macs.h)
// are included manually by the unity build. The metaprogram puts them
/* Register aliases (moved up from the Tape Drive region below so that
* mac_yield's body and the Mips Atom Builder functions can reference
* them. The C compiler processes the file top-to-bottom, so the enum
* must be visible before any use.) */
enum {
R_AtomJmp = R_T9,
R_TapePtr = R_T8, /* The Instruction Stream Pointer */
@@ -38,6 +58,11 @@ enum {
#define R_OtBase_Code R_T6_Code
};
#pragma region Tape Drive
/* ---------------------------------------------------------------------------
* TAPE DRIVE ABI & REGISTER ALIASES (the enum moved earlier; see below)
* ---------------------------------------------------------------------------*/
/* The 'Exit' Atom */
MipsAtom_(tape_exit) { jump_reg(rret_addr), nop };
@@ -85,71 +110,96 @@ FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Sli
* These do NOT yield. They are expanded inline inside Tape Atoms.
* ---------------------------------------------------------------------------*/
/* The 'Yield' sequence for Tape Atoms.
* Loads the next pointer from the tape, advances the tape, and jumps.
* Cost: ~ 4 cycles */
#define mac_yield() \
load_word(R_AtomJmp, R_TapePtr, 0) \
, add_ui_self( R_TapePtr, S_(MipsCode)) \
, jump_reg( R_AtomJmp) \
, nop
// The 'Yield' sequence for Tape Atoms (mac_yield).
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 */
#define mac_load_tri_indices(rId_0, rId_1, rId_2) \
load_half_u(rId_0, R_FaceCursor, 0 * S_(S2)) \
, load_half_u(rId_1, R_FaceCursor, 1 * S_(S2)) \
, load_half_u(rId_2, R_FaceCursor, 2 * S_(S2))
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 */
#define mac_load_tri_verts(rId_0, rId_1, rId_2) \
shift_lleft(R_AT, rId_0, 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, rId_1, 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, rId_2, 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)
MipsAtomComp_(ac_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),
};
/* Words: 11; Correctly inserts a primitive into the Ordering Table linked list */
#define mac_insert_ot_tag(r_otz, prim_type) \
shift_lleft( R_T1, r_otz, S_(U4)/2) /* T1 = r_otz * S_(U4) */ \
, add_u_self( R_T1, R_OtBase) /* T1 = & OrderingTable[OTZ] */ \
, load_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* AT = old_ot_head */ \
, load_upper_i(R_V0, (S_(prim_type)/S_(U4) - S_(PolyTag)/S_(U4)) << polytag_len_bits) /* V0 = S_(prim_type without tag field) */ \
, mask_upper( R_AT, R_AT, S_(polytag_len_bits)) /* Strip upper 8 bits (length from prev cell) → keep only low 24 */ \
, or_u( R_AT, R_AT, R_V0) /* Merge length */ \
, store_word( R_AT, R_PrimCursor, O_(PolyTag,bf_addr_len)) /* prim->tag = packed(prim_length, old_addr) */ \
, shift_lleft( R_AT, R_PrimCursor, S_(polytag_len_bits)) /* AT = (prim_length << 24) | old_addr */ \
, shift_lright(R_AT, R_AT, S_(polytag_len_bits)) \
, store_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)) /* OrderingTable[OTZ] = PrimCursor */
/* Words: 11; Correctly inserts a primitive into the Ordering Table linked list.
* Hardcoded for Poly_F3 (5 words). For Poly_G4, use ac_insert_ot_tag_g4. */
MipsAtomComp_(ac_insert_ot_tag_f3) {
shift_lleft( R_T1, R_T1, S_(U4)/2), // T1 = otz * S_(U4) (otz arg is implicit R_T1)
add_u_self( R_T1, R_OtBase), // T1 = & OrderingTable[OTZ]
load_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)), // AT = old_ot_head
load_upper_i(R_V0, (S_(Poly_F3)/S_(U4) - S_(PolyTag)/S_(U4)) << polytag_len_bits), // V0 = (5 - 1) << 24 = 4 << 24
mask_upper( R_AT, R_AT, S_(polytag_len_bits)), // Strip upper 8 bits (length from prev cell) → keep only low 24
or_u( R_AT, R_AT, R_V0), // Merge length
store_word( R_AT, R_PrimCursor, O_(PolyTag,bf_addr_len)), // prim->tag = packed(prim_length, old_addr)
shift_lleft( R_AT, R_PrimCursor, S_(polytag_len_bits)), // AT = (prim_length << 24) | old_addr
shift_lright(R_AT, R_AT, S_(polytag_len_bits)),
store_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)), // OrderingTable[OTZ] = PrimCursor
};
/* Words: 11; Correctly inserts a primitive into the Ordering Table linked list.
* Hardcoded for Poly_G4 (9 words). For Poly_F3, use ac_insert_ot_tag_f3. */
MipsAtomComp_(ac_insert_ot_tag_g4) {
shift_lleft( R_T1, R_T1, S_(U4)/2), // T1 = otz * S_(U4) (otz arg is implicit R_T1)
add_u_self( R_T1, R_OtBase), // T1 = & OrderingTable[OTZ]
load_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)), // AT = old_ot_head
load_upper_i(R_V0, (S_(Poly_G4)/S_(U4) - S_(PolyTag)/S_(U4)) << polytag_len_bits), // V0 = (9 - 1) << 24 = 8 << 24
mask_upper( R_AT, R_AT, S_(polytag_len_bits)), // Strip upper 8 bits (length from prev cell) → keep only low 24
or_u( R_AT, R_AT, R_V0), // Merge length
store_word( R_AT, R_PrimCursor, O_(PolyTag,bf_addr_len)), // prim->tag = packed(prim_length, old_addr)
shift_lleft( R_AT, R_PrimCursor, S_(polytag_len_bits)), // AT = (prim_length << 24) | old_addr
shift_lright(R_AT, R_AT, S_(polytag_len_bits)),
store_word( R_AT, R_T1, O_(PolyTag,bf_addr_len)), // OrderingTable[OTZ] = PrimCursor
};
/* Words: 3; Emits one (cmd|color) word to R_PrimCursor at the given
* byte offset. Internal helper used by the *_format_*_color macros.
* Args: off = U4 byte offset, code = GP0 cmd byte (0 for c1/c2/c3 of
* a Poly_G4), r/g/b = 8-bit RGB byte values. */
#define mac_pack_color_word(off, code, r,g,b) \
load_upper_i(R_AT, (code) << 8 | (b)) \
, or_i_self( R_AT, ((g) << 8) | (r)) \
, store_word( R_AT, R_PrimCursor, (off))
FI_ MipsAtom ac_pack_color_word(U4 off, U4 code, U1 r, U1 g, U1 b)
MipsAtomComp_Proc_(ac_pack_color_word, {
load_upper_i(R_AT, (code) << 8 | (b)),
or_i_self( R_AT, ((g) << 8) | (r)),
store_word( R_AT, R_PrimCursor, (off)),
})
/* 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).
* Migrated from hello_gte_tape.c; takes RGB form per the Phase 3
* convention. */
#define mac_format_f3_color(r,g,b) mac_pack_color_word(O_(Poly_F3,color), gp0_cmd_poly_f3, r,g,b)
* Migrated from hello_gte_tape.c; takes RGB form per the Phase 3 convention. */
FI_ MipsAtom ac_format_f3_color(U1 r, U1 g, U1 b)
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).
* The macro name declares the pipeline position; check #6 (GTE state-
* machine validation) verifies the call site matches the declaration. */
#define mac_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))
* PIPELINE: post-RTPT (SXY0=v0.screen, SXY1=v1.screen, SXY2=v2.screen). */
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)),
};
/* Words: 12; Emits the four (code|color) words of a Poly_G4.
* Args: rN,gN,bN are 8-bit RGB byte values for each of the 4 vertices. */
#define mac_format_g4_color(r0,g0,b0, r1,g1,b1, r2,g2,b2, r3,g3,b3) \
mac_pack_color_word(O_(Poly_G4,c0), gp0_cmd_poly_g4, r0,g0,b0) \
, mac_pack_color_word(O_(Poly_G4,c1), 0, r1,g1,b1) \
, mac_pack_color_word(O_(Poly_G4,c2), 0, r2,g2,b2) \
, mac_pack_color_word(O_(Poly_G4,c3), 0, r3,g3,b3)
FI_ MipsAtom ac_format_g4_color(
U1 r0, U1 g0, U1 b0,
U1 r1, U1 g1, U1 b1,
U1 r2, U1 g2, U1 b2,
U1 r3, U1 g3, U1 b3)
MipsAtomComp_Proc_(ac_format_g4_color, {
mac_pack_color_word(O_(Poly_G4,c0), gp0_cmd_poly_g4, r0,g0,b0),
mac_pack_color_word(O_(Poly_G4,c1), 0, r1,g1,b1),
mac_pack_color_word(O_(Poly_G4,c2), 0, r2,g2,b2),
mac_pack_color_word(O_(Poly_G4,c3), 0, r3,g3,b3),
})
/* Words: 3; Stores the 3 transformed (V2_S2 screen) vertices of the
* G4 triangle portion to p0/p1/p2.
@@ -159,25 +209,19 @@ FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Sli
* 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. */
#define mac_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))
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)),
};
/* 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
* earlier RTPT — DO NOT read SXY0 here, that's the bug this name
* prevents).
* The macro name declares the pipeline position; check #6 (GTE state-
* machine validation) verifies the call site matches the declaration.
*
* History: this macro was named `mac_gte_store_g4_p3` until 2026-07-09
* when it was discovered to be reading C2_SXY0 (which held v0.screen)
* instead of C2_SXY2 (which holds v3.screen after RTPS). The rename
* encodes the pipeline position in the name so the next bug of this
* class is impossible. */
#define mac_gte_store_g4_p3_post_rtps() gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p3))
*/
MipsAtomComp_(ac_gte_store_g4_p3_post_rtps) { gte_sw(C2_SXY2, R_PrimCursor, O_(Poly_G4,p3)) };
#pragma endregion Macro Atom Components
@@ -186,7 +230,6 @@ FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Sli
typedef Struct_(FMipsAtom512) { U4 data[512]; U4 used; };
typedef Slice_(MipsCode); typedef Slice_MipsCode MipsAtom;
// FArena Related
typedef Relative_(FArena) Struct_(MipsAtomBuilder) { U4 start; U4 capacity; U4 used; };
// Whatever the builder is writting to should most likely coresspond
@@ -219,31 +262,30 @@ enum {
};
/* Flushes the Instruction Cache (PSX A-function 0x44 via BIOS stub at 0xA0).
*
* Sequence (per MIPS ABI; arguments in arg registers, RA pushed to stack):
* 1. sp -= 8; sw $ra, 4($sp) ; save RA
* 2. $a0 = bios_flushcache (arg0)
* 3. $t0 = bios_table_addr ; t0 = &BIOS A-function table
* 4. jalr $t0, $ra ; call BIOS(flushcache)
* nop ; branch delay slot
* 5. lw $ra, 4($sp); jr $ra ; restore & return
* 4. jalr $t0, $ra ; call BIOS(flushcache)
* nop ; branch delay slot
* 5. lw $ra, 4($sp); jr $ra ; restore & return
* 6. sp += 8
*/
internal MipsAtom_(mips_flush_icache) {
add_ui(rstack_ptr, rstack_ptr, -MipsStackAlignment) /* sp -= 8 */
, store_word(rret_addr, rstack_ptr, S_(U4)) /* sw $ra, 4($sp) */
, add_ui(rret_0, rdiscard, bios_flushcache) /* addiu $a0, $0, 0x44 */
, add_ui(rtmp_0, rdiscard, bios_table_addr) /* addiu $t0, $0, 0xA0 */
, jump_link(rtmp_0, rret_addr) /* jalr $t0, $ra */
, nop /* BD slot */
, load_word(rret_addr, rstack_ptr, S_(U4)) /* lw $ra, 4($sp) */
, jump_reg(rret_addr) /* jr $ra */
, add_ui(rstack_ptr, rstack_ptr, MipsStackAlignment) /* sp += 8 (BD) */
, mac_yield()
add_ui(rstack_ptr, rstack_ptr, -MipsStackAlignment), // sp -= 8
store_word(rret_addr, rstack_ptr, S_(U4)), // sw $ra, 4($sp)
add_ui(rret_0, rdiscard, bios_flushcache), // addiu $a0, $0, 0x44
add_ui(rtmp_0, rdiscard, bios_table_addr), // addiu $t0, $0, 0xA0
jump_link(rtmp_0, rret_addr), // jalr $t0, $ra
nop, // BD slot
load_word(rret_addr, rstack_ptr, S_(U4)), // lw $ra, 4($sp)
jump_reg(rret_addr), // jr $ra
add_ui(rstack_ptr, rstack_ptr, MipsStackAlignment), // sp += 8 (BD)
mac_yield(),
};
typedef Struct_(Binds_SetGteWorld) {
U4 transform;
M3_S2* transform;
};
internal MipsAtom_(set_gte_world) {
/* Pop matrix address from tape into R_T3 ($11) */
@@ -259,8 +301,6 @@ internal MipsAtom_(set_gte_world) {
mac_yield()
};
// TODO(Ed): I'm not sure yet if the bindings are redundant with the floortri atom yet.
/* DIAGNOSTIC 1: Pure tape loop test */
internal MipsAtom_(diag_yield) { mac_yield() };
+1
View File
@@ -73,6 +73,7 @@ typedef Slice_(B1);
#define slice_iter(container, iter) (T_((container).ptr) iter = (container).ptr; iter != slice_end(container); ++ iter)
#define slice_arg_from_array(type, ...) & (tmpl(Slice,type)) { .ptr = array_decl(type,__VA_ARGS__), .len = array_len( array_decl(type,__VA_ARGS__)) }
#define slice_from_array(type, array) (tmpl(Slice,type)) { .ptr = array, .len = S_(array) }
FI_ void slice_zero_(Slice s) { slice_assert(s); mem_zero(s.ptr, s.len); }
#define slice_zero(s) slice_zero_(slice_to_ut(s))
@@ -1,4 +1,4 @@
// Auto-generated by tape_atom_offset_gen.meta.lua — DO NOT EDIT
// Auto-generated by ps1_meta.lua (passes/offsets.lua) — DO NOT EDIT
// Source: C:\projects\Pikuma\ps1\code\gte_hello\hello_gte_tape.c
#pragma once
+5 -6
View File
@@ -8,18 +8,21 @@
#include "duffle/dsl.h"
#include "duffle/memory.h"
#include "duffle/math.h"
#include "duffle/gcc_asm.h"
#include "duffle/mips.h"
#include "duffle/gp.h"
#include "duffle/gte.h"
# include "duffle/gen/lottes_tape.offsets.h"
# include "duffle/gen/duffle.macs.h"
# include "duffle/gen/duffle.offsets.h"
#include "duffle/atom_dsl.h"
#include "duffle/lottes_tape.h"
# include "tape_atom.metadata.h"
# include "gen/hello_gte_tape.offsets.h"
# include "gen/gte_hello.offsets.h"
#include "hello_gte.h"
#include "hello_gte_tape.c"
typedef U4 OrderingTable_Buffer[OrderingTbl_Len];
@@ -337,9 +340,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
m3s2_rotation (& smem.floor.rot, & smem.tform_world);
m3s2_translation(& smem.tform_world, & smem.floor.pos);
m3s2_scale (& smem.tform_world, & smem.floor.scale);
// TODO(Ed): This can either be in the tape or here...
// gte_matrix_set_rotation (& smem.tform_world);
// gte_matrix_set_translation(& smem.tform_world);
U4 prim_base = u4_(pa->buf[smem.active_buf_id]);
U4 prim_cursor = prim_base + pa->used;
@@ -350,7 +350,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
// Prepare the tape. (Push protocol to tape)
LP_ U4 mem_temp_tape[512];
TapeBuilder tb = tb_make(slice_ut_arr(mem_temp_tape)); tb_scope(& tb) {
// TODO(Ed): This is bugged.
tb_emit(& tb, code_set_gte_world);
tb_data(& tb, u4_(& smem.tform_world));
+17 -17
View File
@@ -1,26 +1,26 @@
#ifdef INTELLISENSE_DIRECTIVES
# include "duffle/lottes_tape.h"
# include "duffle/gen/duffle.macs.h"
# include "duffle/gen/duffle.offsets.h"
# include "duffle/atom_dsl.h"
# include "hello_gte.h"
# include "duffle/lottes_tape.h"
# include "tape_atom.metadata.h"
# include "gen/hello_gte_tape.offsets.h"
# include "gen/gte_hello.offsets.h"
# include "hello_gte.h"
#endif
#pragma region MACs (Mips Atom components)
/* The macros mac_format_f3_color and mac_gte_store_f3 moved to
* lottes_tape.h during the Phase 3 gp.h overhaul. Both are now RGB-form
* (mac_format_f3_color takes _r, _g, _b byte values rather than raw
* 16-bit half-words). */
#pragma endregion MACs
#pragma region Baked Atoms
typedef Struct_(Binds_CubeTri) {
U4 PrimCursor;
U4 FaceCursor;
U4 VertBase;
U4 OtBase;
U4 PrimCursor;
V4_S2* FaceCursor;
V3_S2* VertBase;
U4* OtBase;
};
internal MipsAtom_(rbind_cube_g4_face) {
/* Pop 4 arguments from the tape directly into the workspace registers */
@@ -101,7 +101,7 @@ MipsAtom_(cube_g4_face) {
branch_equal(R_AT, R_0, atom_offset(bounds_chk, cube_g4_face_exit)), nop,
/* ── 12. Insert into Ordering Table (length = 8 words for Poly_G4) ──── */
mac_insert_ot_tag(R_T1, Poly_G4),
mac_insert_ot_tag_g4(),
/* ── 13. Advance cursors & yield (both branch targets land here) ────── */
atom_label(cube_g4_face_exit)
@@ -111,10 +111,10 @@ atom_label(cube_g4_face_exit)
};
typedef Struct_(Binds_FloorTri) {
U4 PrimCursor;
U4 FaceCursor;
U4 VertBase;
U4 OtBase;
U4 PrimCursor;
V3_S2* FaceCursor;
V3_S2* VertBase;
U4* OtBase;
};
atom_region(rbind_floor_f3_face, REGION_PRIM_ARENA)
atom_group(rbind_floor_f3_face, GROUP_RENDER_FLOOR)
@@ -163,7 +163,7 @@ MipsAtom_(floor_f3_face) {
set_lt_u( R_AT, R_T1, R_AT),
branch_equal(R_AT, R_0, atom_offset(bounds_chk, floor_f3_face_exit)), nop,
/* Insert into Ordering Table Linked List */
mac_insert_ot_tag(R_T1, Poly_F3),
mac_insert_ot_tag_f3(),
add_ui_self(R_PrimCursor, S_(Poly_F3)), /* Advance Prim Cursor (5 words) */
// Note(Ed): No bounds checking, should be checked before atom runs.
+6 -10
View File
@@ -5,6 +5,12 @@
// Format: WORD_COUNT(MACRO_NAME, COUNT)
// One line per macro that appears in your atom sources.
//
// This file is encoding-macros-only. The auto-generated component
// macros (mac_X) live in duffle/gen/<dir>.macs.h (included separately
// by the unity build). The unity build should include THIS file and
// the .macs.h file in the same TU, with both wrapped (or the
// include guard order handled) to avoid WORD_COUNT redeclaration.
//
// To regenerate: hand-count the instructions in each macro definition.
// (You'll only need to do this once per macro — they don't change often.)
#define WORD_COUNT(name, count) enum { words_##name = (count) };
@@ -47,15 +53,5 @@ WORD_COUNT(gte_cmdw_nclip, 1)
WORD_COUNT(gte_avg_sort_z3, 1)
WORD_COUNT(sub_u, 1)
WORD_COUNT(nop2, 2)
WORD_COUNT(mac_load_tri_indices, 3)
WORD_COUNT(mac_load_tri_verts, 18)
WORD_COUNT(mac_format_f3_color, 3)
WORD_COUNT(mac_gte_store_f3_post_rtpt, 3)
WORD_COUNT(mac_pack_color_word, 3)
WORD_COUNT(mac_format_g4_color, 12)
WORD_COUNT(mac_gte_store_g4_p012_post_rtpt_pre_rtps, 3)
WORD_COUNT(mac_gte_store_g4_p3_post_rtps, 1)
WORD_COUNT(mac_insert_ot_tag, 11)
WORD_COUNT(mac_yield, 4)
#undef WORD_COUNT
+43 -96
View File
@@ -287,7 +287,7 @@ function build-graphis_hello {
$src_asm_crt = join-path $path_nugget_common 'crt0/crt0.s'
$module_asm_crt = join-path $path_build 'crt0.o'
# assemble-unit $src_asm_crt $module_asm_crt $includes $assemble_args
assemble-unit $src_asm_crt $module_asm_crt $includes $assemble_args
$src_asm = join-path $path_module 'hello_gpu.s'
$module_asm = join-path $path_build 'hello_gpu.o'
@@ -317,100 +317,39 @@ function build-graphis_hello {
}
# build-graphis_hello
function generate-TapeAtomOffsets {param([Parameter(Mandatory=$true)] [string[]]$sources, [Parameter(Mandatory=$true)] [string]$metadata)
$gen_atom_offsets_script = join-path $path_scripts 'tape_atom.offset_gen.meta.lua'
# ps1-meta orchestrator. Replaces generate-TapeAtomOffsets +
# generate-TapeAtomAnnotations with a single invocation. Dispatches
# the 6 passes (word-counts / components / annotation / offsets /
# static-analysis / report) in dependency-topological order.
$any_stale = $false
foreach ($src in $sources) {
$basename = [System.IO.Path]::GetFileNameWithoutExtension($src)
$dir = split-path -Path $src -Parent
$gen_dir = join-path $dir 'gen'
$out = join-path $gen_dir "$basename.offsets.h"
if (-not (test-path $out)) { $any_stale = $true; break }
$src_mtime = (get-item $src).LastWriteTimeUtc
$out_mtime = (get-item $out).LastWriteTimeUtc
$meta_mtime = (get-item $metadata).LastWriteTimeUtc
if (($src_mtime -gt $out_mtime) -or ($meta_mtime -gt $out_mtime)) {
$any_stale = $true
break
}
}
if (-not $any_stale) {
write-host "AtomOffsets all $($sources.Count) source(s) up-to-date" -ForegroundColor DarkGray
return
}
write-host "AtomOffsets $($sources.Count) source(s)" -ForegroundColor Magenta
& luajit $gen_atom_offsets_script $metadata @sources
if ($LASTEXITCODE -ne 0) {
write-error "Atom offset generation failed. Aborting."
exit 1
}
function any-stale {
param([Parameter(Mandatory=$true)][string[]]$sources,
[Parameter(Mandatory=$true)][string]$metadata,
[Parameter(Mandatory=$true)][string]$out_root)
if (-not (test-path $out_root)) { return $true }
$out_mtime = (get-item $out_root).LastWriteTimeUtc
$src_mtime = ($sources | ForEach-Object { (get-item $_).LastWriteTimeUtc } | Measure-Object -Maximum).Maximum
$meta_mtime = (get-item $metadata).LastWriteTimeUtc
return ($src_mtime -gt $out_mtime) -or ($meta_mtime -gt $out_mtime)
}
function generate-TapeAtomAnnotations {param([Parameter(Mandatory=$true)] [string[]]$sources, [Parameter(Mandatory=$true)] [string]$metadata)
# Sibling to generate-TapeAtomOffsets. Validates TAPE_ATOM_* / TAPE_WORDS
# annotations against the metadata manifest. Emits gen/<basename>.errors.h
# containing #error directives for the C build to fail on annotation drift.
$gen_atom_annot_script = join-path $path_scripts 'tape_atom_annotation_pass.lua'
$any_stale = $false
foreach ($src in $sources) {
$basename = [System.IO.Path]::GetFileNameWithoutExtension($src)
$dir = split-path -Path $src -Parent
$gen_dir = join-path $dir 'gen'
$out_txt = join-path $gen_dir "$basename.annotations.txt"
$out_err = join-path $gen_dir "$basename.errors.h"
if (-not (test-path $out_txt) -or -not (test-path $out_err)) { $any_stale = $true; break }
$src_mtime = (get-item $src).LastWriteTimeUtc
$out_txt_mtime = (get-item $out_txt).LastWriteTimeUtc
$out_err_mtime = (get-item $out_err).LastWriteTimeUtc
$out_mtime = if ($out_txt_mtime -gt $out_err_mtime) { $out_txt_mtime } else { $out_err_mtime }
$meta_mtime = (get-item $metadata).LastWriteTimeUtc
if (($src_mtime -gt $out_mtime) -or ($meta_mtime -gt $out_mtime)) {
$any_stale = $true
break
}
}
if (-not $any_stale) {
write-host "AtomAnnotations all $($sources.Count) source(s) up-to-date" -ForegroundColor DarkGray
return
}
write-host "AtomAnnotations $($sources.Count) source(s)" -ForegroundColor Magenta
& luajit $gen_atom_annot_script $metadata @sources
if ($LASTEXITCODE -ne 0) {
write-error "Atom annotation generation failed. Aborting."
exit 1
}
# If any source produced annotation errors, surface them now and halt the
# build. The errors.h files are also #include'd via -include below, so
# the C build would fail at preprocessing time anyway — failing here gives
# a more readable error in the build log.
$err_count = 0
foreach ($src in $sources) {
$basename = [System.IO.Path]::GetFileNameWithoutExtension($src)
$dir = split-path -Path $src -Parent
$gen_dir = join-path $dir 'gen'
$ann_txt = join-path $gen_dir "$basename.annotations.txt"
$err_h = join-path $gen_dir "$basename.errors.h"
if ((test-path $ann_txt) -and (test-path $err_h)) {
$txt = get-content $ann_txt -raw
if ($txt -match 'Errors:\s+([1-9]\d*)') {
$err_count += [int]$Matches[1]
write-warning "Annotation errors in $src — see $ann_txt"
}
}
}
if ($err_count -gt 0) {
write-error "Annotation pass failed: $err_count error(s) across $($sources.Count) source(s). Aborting."
exit 1
}
function ps1-meta {
param(
[Parameter(Mandatory=$true)][string[]]$sources,
[Parameter(Mandatory=$true)][string]$metadata,
[string]$out_root = (join-path $path_build 'gen'),
[string[]]$passes = @('--all')
)
$script = join-path $path_scripts 'ps1_meta.lua'
write-host "ps1-meta $($sources.Count) source(s), passes=$($passes -join ',')" `
-ForegroundColor Magenta
$arg_list = @($passes) + @('--metadata', $metadata) + @('--out-root', $out_root)
foreach ($s in $sources) { $arg_list += @('--source', $s) }
& luajit $script @arg_list
if ($LASTEXITCODE -ne 0) {
write-error "ps1-meta failed (exit $LASTEXITCODE). Aborting."
exit $LASTEXITCODE
}
}
function build-gte_hello {
@@ -423,8 +362,12 @@ function build-gte_hello {
$source_dirs = @($path_duffle, $path_module)
$atom_sources = Get-SourceFiles -paths $source_dirs -extensions @('.h', '.c')
generate-TapeAtomAnnotations -sources $atom_sources -metadata $path_atom_metadata
generate-TapeAtomOffsets -sources $atom_sources -metadata $path_atom_metadata
if (any-stale -sources $atom_sources -metadata $path_atom_metadata -out_root (join-path $path_build 'gen')) {
ps1-meta -sources $atom_sources -metadata $path_atom_metadata -out_root (join-path $path_build 'gen')
} else {
write-host "ps1-meta all $($atom_sources.Count) source(s) up-to-date" `
-ForegroundColor DarkGray
}
$assemble_args = @()
$assemble_args += $f_debug
@@ -433,7 +376,7 @@ function build-gte_hello {
$src_asm_crt = join-path $path_nugget_common 'crt0/crt0.s'
$module_asm_crt = join-path $path_build 'crt0.o'
# assemble-unit $src_asm_crt $module_asm_crt $includes $assemble_args
assemble-unit $src_asm_crt $module_asm_crt $includes $assemble_args
# $src_asm = join-path $path_module 'hello_gte.s'
# $module_asm = join-path $path_build 'hello_gte.o'
@@ -458,7 +401,11 @@ function build-gte_hello {
$link_args = @()
$link_args += $f_debug
# $link_args += $f_optimize_size
link-modules @($module_asm_crt, $module_c) $elf $link_args
$link_modules = @(
$module_asm_crt,
$module_c
)
link-modules $link_modules $elf $link_args
make-binary $elf $exe
}
build-gte_hello
+88 -9
View File
@@ -330,26 +330,105 @@ end
-- Split a brace-body into top-level comma-separated tokens. Honors nested
-- parens/braces/brackets and skips strings/comments.
--
-- FIX (2026-07-09): split at top-level NEWLINES and SEMICOLONS too, AND
-- emit a token break after a top-level comment/string. Previous behavior
-- glued the macro call after a comment into the same token, so
-- `word_count_of_token` only saw the leading ident (often nil after
-- stripping the comment), undercounting the body. See Phase 1 of the
-- branch-offset regression investigation. Pure-comment / pure-string
-- chunks (which now appear between real statements) are filtered out so
-- they contribute 0 words instead of 1.
function M.split_top_level_commas(body)
local tokens = {}
local i = 1
local len = #body
local token_start = 1
while i <= #body do
-- True iff `chunk` contains any non-whitespace, non-comment, non-string
-- content (i.e., real token material). Walks through ws + comments
-- individually so a chunk like " /* trailing */ shift_lleft(...)"
-- is correctly classified as having real content (the macro call).
local function has_real_content(chunk)
local k = 1
local klen = #chunk
while k <= klen do
if M.is_space(chunk:sub(k, k)) then
k = k + 1
else
local nx = M.skip_str_or_cmt(chunk, k)
if nx > k then
k = nx -- skipped a comment or string
else
return true -- found real content
end
end
end
return false
end
local function emit(end_pos)
if end_pos >= token_start then
local chunk = body:sub(token_start, end_pos)
if M.trim(chunk) ~= "" then
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).
tokens[#tokens] = tokens[#tokens] .. chunk
end
end
token_start = end_pos + 1
end
end
while i <= len do
local c = body:byte(i)
if c == 40 then local _, a = M.read_parens(body, i); i = a -- '('
elseif c == 123 then local _, a = M.read_braces(body, i); i = a -- '{'
elseif c == 91 then local _, a = M.read_brackets(body, i); i = a -- '['
elseif c == 44 then -- ','
tokens[#tokens + 1] = body:sub(token_start, i - 1)
if c == 40 then -- '('
local _, a = M.read_parens(body, i); i = a
elseif c == 123 then -- '{'
local _, a = M.read_braces(body, i); i = a
elseif c == 91 then -- '['
local _, a = M.read_brackets(body, i); i = a
elseif c == 44 then -- ','
emit(i - 1)
i = i + 1
token_start = i
elseif c == 59 then -- ';'
emit(i - 1)
i = i + 1
token_start = i
elseif c == 10 then -- '\n'
emit(i - 1)
i = i + 1
token_start = i
else
local nx = M.skip_str_or_cmt(body, i)
if nx > i then i = nx; token_start = nx else i = i + 1 end
if nx > i then
-- Skipped a comment or string at top level: emit token break.
i = nx
emit(i - 1)
else
i = i + 1
end
end
end
local last = body:sub(token_start)
if M.trim(last) ~= "" then tokens[#tokens + 1] = last end
emit(len)
return tokens
end
+943
View File
@@ -0,0 +1,943 @@
-- passes/annotation.lua
--
-- Validate atom annotation DSL usage in source files. Reads:
-- - atom_annot / atom_init / atom_setup / atom_commit / atom_bind
-- / atom_terminate / atom_label / atom_offset / TAPE_WORDS
-- - atom_resource / atom_region / atom_group / atom_cadence / atom_async
-- - Binds_* struct declarations
-- Writes:
-- - <ctx.out_root>/<basename>.errors.h (with #error directives on findings)
-- - <ctx.out_root>/<basename>.annotations.txt (human-readable summary)
-- Ported from scripts/tape_atom_annotation_pass.lua:78-545 + 1081-1407
-- (validation only — NOT rendering, which goes to passes/report.lua).
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path
local duffle = require("duffle")
local is_space = duffle.is_space
local is_alpha = duffle.is_alpha
local is_alnum = duffle.is_alnum
local trim = duffle.trim
local find_byte = duffle.find_byte
local read_file = duffle.read_file
local write_file = duffle.write_file
local ensure_dir = duffle.ensure_dir
local dirname = duffle.dirname
local basename_no_ext = duffle.basename_no_ext
local skip_str_or_cmt = duffle.skip_str_or_cmt
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local read_ident = duffle.read_ident
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local scan_to_char = duffle.scan_to_char
local split_top_level_commas = duffle.split_top_level_commas
-- Domain tables (single source of truth in duffle.lua).
local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS
local MACRO_EXPANSION = duffle.MACRO_EXPANSION
local KNOWN_PHASES = duffle.KNOWN_PHASES
local TAPE_ATOM_MACROS = duffle.TAPE_ATOM_MACROS
local ATOM_PRAGMA_KINDS = duffle.ATOM_PRAGMA_KINDS
local function valid_phase(p) return KNOWN_PHASES[p] or false end
local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
-- ════════════════════════════════════════════════════════════════════════════
-- Hand-rolled split helpers (no regex patterns used)
-- ════════════════════════════════════════════════════════════════════════════
--- Split a string at top-level commas. Used inside TAPE_ATOM_* macro
--- bodies where nested parens/braces/brackets are possible.
local function split_csv_top(s)
local tokens = {}
local i, start = 1, 1
local depth = 0
while i <= #s do
local c = s:sub(i, i)
if c == "(" or c == "{" or c == "[" then
depth = depth + 1
i = i + 1
elseif c == ")" or c == "}" or c == "]" then
depth = depth - 1
i = i + 1
elseif c == "," and depth == 0 then
tokens[#tokens + 1] = s:sub(start, i - 1)
i = i + 1
start = i
else
i = i + 1
end
end
local last = s:sub(start)
if trim(last) ~= "" then tokens[#tokens + 1] = last end
return tokens
end
--- Split a string into whitespace-separated tokens.
--- Hand-rolled (no regex patterns).
local function split_ws(s)
local tokens = {}
local i, n = 1, 1
local len = #s
while i <= len do
-- Skip whitespace.
while i <= len and is_space(s:sub(i, i)) do i = i + 1 end
if i > len then break end
local start = i
-- Take non-whitespace run.
while i <= len and not is_space(s:sub(i, i)) do i = i + 1 end
tokens[n] = s:sub(start, i - 1)
n = n + 1
end
return tokens
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse TAPE_ATOM_ANNOT(...) calls
-- ════════════════════════════════════════════════════════════════════════════
-- Recognize a `atom_reads(...)` or `atom_writes(...)` register-list
-- call embedded inside an annotation arg list. Returns the kind
-- ("atom_reads" / "atom_writes") and the inner content, or nil if the
-- token isn't a recognized register-list form. Flattened via a
-- prefix lookup instead of a nested if/elseif chain.
local REGS_CALL_PREFIX = {
["atom_reads("] = { kind = "atom_reads", inner_offset = 12 },
["atom_writes("] = { kind = "atom_writes", inner_offset = 13 },
}
local function parse_regs_call(s)
if s:sub(-1) ~= ")" then return nil end
local spec = REGS_CALL_PREFIX[s:sub(1, 12)] -- longest prefix first wins
if not spec then return nil end
-- The 12-char prefix "atom_reads(" also matches "atom_writes("
-- would be ambiguous; the table order above handles it.
-- (atom_reads prefix is 11 chars, atom_writes is 12; the 12-char
-- lookup matches atom_writes first.)
return spec.kind, s:sub(spec.inner_offset, -2)
end
-- Resolve any phase_* / R_* alias macros in a register list.
local function resolve_reg_aliases(regs)
for i, r in ipairs(regs) do
if MACRO_EXPANSION[r] then regs[i] = MACRO_EXPANSION[r] end
end
return regs
end
-- Parse a comma-separated inner content (e.g. inside atom_reads(...))
-- into a list of trimmed identifiers with aliases resolved.
local function parse_regs_list(inner)
local out = {}
for _, r in ipairs(split_csv_top(inner)) do
local trimmed = trim(r)
if trimmed ~= "" then out[#out + 1] = trimmed end
end
return resolve_reg_aliases(out)
end
-- Parse a single token (from split_csv_top) into an arg entry.
-- Three forms: register-list call, bare identifier (with alias),
-- "other" (preserved as text).
local function parse_arg_token(s)
local kind, inner = parse_regs_call(s)
if kind then
return { kind = kind, value = parse_regs_list(inner) }
end
local id = read_ident(s, 1)
if id and trim(s) == id then
local v = id
if MACRO_EXPANSION[v] then v = MACRO_EXPANSION[v] end
return { kind = "ident", value = v }
end
return { kind = "other", value = s }
end
--- Extract identifier args from a parenthesized group. Returns a list
--- of {kind, value} pairs where kind is one of:
--- "ident" -- a bare identifier (e.g. phase_work)
--- "atom_reads" -- an atom_reads(...) call: value is the register list
--- "atom_writes" -- an atom_writes(...) call: value is the register list
--- "other" -- something we can't classify (preserved as text)
local function parse_atom_annot_args(inner)
local args = {}
for _, tok in ipairs(split_csv_top(inner)) do
local s = trim(tok)
if s ~= "" then
args[#args + 1] = parse_arg_token(s)
end
end
return args
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse TAPE_WORDS(mac_X, N) pragma directives
-- ════════════════════════════════════════════════════════════════════════════
--- Find every TAPE_WORDS(mac_X, N) pragma in source.
--- Accepts both forms:
--- _Pragma("mac_X tape_atom words=N") (operator form)
--- #pragma mac_X tape_atom words=N (directive form)
local function find_macro_word_annotations(source)
local line_of = duffle.LineIndex(source)
local out = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
-- Skip preprocessor directives (lines starting with #).
if source:sub(i, i) == "#" then
local j = i
while j <= len and source:sub(j, j) ~= "\n" do j = j + 1 end
i = j + 1
else
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif ident == "_Pragma" then
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) == "(" then
local str, str_end = read_parens(source, open)
str = trim(str)
if str:sub(1, 1) == '"' and str:sub(-1) == '"' then
local inner = str:sub(2, -2)
local space = find_byte(inner, " ", 1)
if space then
local name = inner:sub(1, space - 1)
local rest = inner:sub(space + 1)
local eq = find_byte(rest, "=", 1)
if eq then
local key = trim(rest:sub(1, eq - 1))
local val = trim(rest:sub(eq + 1))
if key == "tape_atom words" or key == "words" then
local n = tonumber(val) or 0
out[#out + 1] = {
line = line_of(i),
name = name,
words = n,
}
end
end
end
end
i = str_end
else
i = open + 1
end
elseif ident == "pragma" then
-- Directive form: `#pragma mac_X tape_atom words=N`
local rest_start = skip_ws_and_cmt(source, after)
local j = rest_start
while j <= len and source:sub(j, j) ~= "\n" do j = j + 1 end
local line_text = trim(source:sub(rest_start, j - 1))
local tokens = split_ws(line_text)
if #tokens >= 3 and tokens[2] == "tape_atom" and tokens[3]:sub(1, 6) == "words=" then
local name = tokens[1]
local n = tonumber(tokens[3]:sub(7)) or 0
out[#out + 1] = { line = line_of(i), name = name, words = n }
elseif #tokens >= 2 and tokens[2]:sub(1, 6) == "words=" then
local name = tokens[1]
local n = tonumber(tokens[2]:sub(7)) or 0
out[#out + 1] = { line = line_of(i), name = name, words = n }
end
i = j
else
i = after
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse `atom_<...>` Pragma / _Pragma annotations
-- ════════════════════════════════════════════════════════════════════════════
local ATOM_ATTR_MACROS = {
["atom_resource"] = "resource",
["atom_region"] = "region",
["atom_group"] = "group",
["atom_cadence"] = "cadence",
["atom_async"] = "async",
}
--- Parse macro form: `atom_<key>(atom_name, value, ...)`.
--- Returns (true, entry, str_end) on success, (false) on no match.
local function try_parse_atom_attr_macro(source, i, line_of)
local ident, after = read_ident(source, i)
if not ident then return false end
local key = ATOM_ATTR_MACROS[ident]
if not key then return false end
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) ~= "(" then return false end
local body, body_end = read_parens(source, open)
local first, after_name = read_ident(body, 1)
if not first then return false end
local j = after_name
while j <= #body and is_space(body:sub(j, j)) do j = j + 1 end
if body:sub(j, j) ~= "," then return false end
j = j + 1
while j <= #body and is_space(body:sub(j, j)) do j = j + 1 end
local value
if body:sub(j, j) == '"' then
local k = j + 1
while k <= #body do
local c = body:sub(k, k)
if c == "\\" then
k = k + 2
elseif c == '"' then
break
else
k = k + 1
end
end
if body:sub(k, k) ~= '"' then return false end
value = body:sub(j + 1, k - 1)
else
local id2, after_id = read_ident(body, j)
if not id2 then return false end
value = id2
if MACRO_EXPANSION[value] then value = MACRO_EXPANSION[value] end
end
return true, {
line = line_of(i),
name = first,
attrs = { [key] = value },
}, body_end
end
local function find_atom_pragmas(source)
local line_of = duffle.LineIndex(source)
local out = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
if source:sub(i, i) == "#" then
local j = i
while j <= len and source:sub(j, j) ~= "\n" do j = j + 1 end
i = j + 1
else
local got, entry, next_i = try_parse_atom_attr_macro(source, i, line_of)
if got then
out[#out + 1] = entry
i = next_i
else
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif ident == "_Pragma" then
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) == "(" then
local str, str_end = read_parens(source, open)
str = trim(str)
if str:sub(1, 1) == '"' and str:sub(-1) == '"' then
local inner = str:sub(2, -2)
local sp1 = find_byte(inner, " ", 1)
if sp1 and trim(inner:sub(1, sp1 - 1)) == "atom" then
local rest = trim(inner:sub(sp1 + 1))
local sp2 = find_byte(rest, " ", 1)
if sp2 then
local name = trim(rest:sub(1, sp2 - 1))
local attrs_str = trim(rest:sub(sp2 + 1))
local attrs = {}
local got_any = false
for _, pair in ipairs(split_ws(attrs_str)) do
local eq = find_byte(pair, "=", 1)
if eq then
local k = trim(pair:sub(1, eq - 1))
local v = trim(pair:sub(eq + 1))
if MACRO_EXPANSION[v] then v = MACRO_EXPANSION[v] end
attrs[k] = v
got_any = true
end
end
if got_any then
out[#out + 1] = {
line = line_of(i),
name = name,
attrs = attrs,
}
end
end
end
end
i = str_end
else
i = open + 1
end
else
i = after
end
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse `typedef Struct_(Binds_X) { ... };` declarations
-- ════════════════════════════════════════════════════════════════════════════
--- Find every Binds_* struct declaration.
--- Returns a list of {line, name, fields = {{name, byte_offset}, ...}}.
local function find_binds_structs(source)
local line_of = duffle.LineIndex(source)
local out = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
if source:sub(i, i) == "#" then
local j = i
while j <= len and source:sub(j, j) ~= "\n" do j = j + 1 end
i = j + 1
else
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif ident == "typedef" then
local j = skip_ws_and_cmt(source, after)
local id2, after2 = read_ident(source, j)
if id2 ~= "Struct_" then
i = after2 or (j + 1)
elseif id2 == "Struct_" then
local open = skip_ws_and_cmt(source, after2)
if source:sub(open, open) == "(" then
local inner, after_paren = read_parens(source, open)
local name = trim(inner)
local brace = scan_to_char(source, "{", after_paren)
if brace then
local body, after_brace = read_braces(source, brace)
local fields = {}
local byte_off = 0
local k = 1
while k <= #body do
k = skip_ws_and_cmt(body, k); if k > #body then break end
local tid, tafter = read_ident(body, k)
if not tid then
k = k + 1
elseif tid == "U4" then
local fid, fafter = read_ident(body, skip_ws_and_cmt(body, tafter))
if fid then
fields[#fields + 1] = { name = fid, offset = byte_off }
byte_off = byte_off + 4
end
k = fafter or tafter + 1
else
k = tafter + 1
end
end
-- Only emit Binds_* structs.
if name:sub(1, 6) == "Binds_" then
out[#out + 1] = {
line = line_of(i),
name = name,
fields = fields,
bytes = byte_off,
}
end
i = after_brace
else
i = open + 1
end
else
i = open + 1
end
else
i = after2 or (j + 1)
end
else
i = after
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Find every MipsAtom_(name) { ... } declaration in source
-- ════════════════════════════════════════════════════════════════════════════
local function find_atom_names(source)
local line_of = duffle.LineIndex(source)
local out = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif ident == "MipsAtom_" then
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) == "(" then
local inner, after_paren = read_parens(source, open)
local a = 1
while a <= #inner and is_space(inner:sub(a, a)) do a = a + 1 end
local b = a
while b <= #inner and is_alnum(inner:sub(b, b)) do b = b + 1 end
local name = inner:sub(a, b - 1)
if name ~= "" then
out[#out + 1] = { line = line_of(i), name = name }
end
local brace = scan_to_char(source, "{", after_paren)
if brace then
local _, after_brace = read_braces(source, brace)
i = after_brace
else
i = open + 1
end
else
i = open + 1
end
else
i = after
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Find atom annotations (atom_annot / atom_init / atom_setup / atom_commit
-- / atom_bind / atom_terminate)
-- ════════════════════════════════════════════════════════════════════════════
--- True iff the parsed arg is a register-list call (any recognized form).
local function is_regs_arg(a)
return a and (a.kind == "atom_reads" or a.kind == "atom_writes" or a.kind == "regs")
end
--- Per-macro arg-shape handlers. Each takes (entry, args) and mutates
--- entry.{reads, writes, phase, binds, errors}. Replaces the 5-way
--- `if/elseif/elseif/elseif/elseif` chain inside find_atom_annotations.
local ANNOT_ARG_HANDLERS = {}
-- atom_bind(name, Binds_Struct, writes)
function ANNOT_ARG_HANDLERS.bind(entry, args)
if #args >= 2 and args[2].kind == "ident" then
entry.binds = args[2].value
end
if #args >= 3 and is_regs_arg(args[3]) then
entry.writes = args[3].value
end
end
-- atom_init(name) / atom_terminate(name): name only, no extra slots.
ANNOT_ARG_HANDLERS.init = function() end
ANNOT_ARG_HANDLERS.terminate = function() end
-- Macro name -> handler key. Replaces the `macro_def.binds` check
-- plus the 4-way ident elseif chain.
local MACRO_HANDLER_KEY = {
["atom_bind"] = "bind",
["atom_annot"] = "annot",
["atom_setup"] = "reads_only",
["atom_commit"] = "reads_only",
["atom_init"] = "init",
["atom_terminate"] = "terminate",
}
-- atom_setup(name, reads) / atom_commit(name, reads): reads from slot 2.
function ANNOT_ARG_HANDLERS.reads_only(entry, args)
if #args >= 2 and is_regs_arg(args[2]) then
entry.reads = args[2].value
end
end
-- atom_annot(name, phase, reads, writes)
function ANNOT_ARG_HANDLERS.annot(entry, args)
if #args >= 2 and args[2].kind == "ident" then
entry.phase = MACRO_EXPANSION[args[2].value] or args[2].value
end
if #args >= 3 and is_regs_arg(args[3]) then
if args[3].kind == "atom_writes" then
entry.errors[#entry.errors + 1] = "reads slot has atom_writes — swap order?"
end
entry.reads = args[3].value
end
if #args >= 4 and is_regs_arg(args[4]) then
if args[4].kind == "atom_reads" then
entry.errors[#entry.errors + 1] = "writes slot has atom_reads — swap order?"
end
entry.writes = args[4].value
end
end
-- Build a new annotation entry with the standard shape.
local function new_annot_entry(line, ident, name, kind)
return {
line = line,
macro = ident,
name = name,
kind = kind,
binds = nil,
phase = nil,
reads = {},
writes = {},
errors = {},
}
end
--- Find every TAPE_ATOM_* macro call in source and convert it to a
--- normalized annotation entry. Dispatches per-macro arg-shape via
--- ANNOT_ARG_HANDLERS (lookup table; no nested if/elseif chain).
local function find_atom_annotations(source)
local line_of = duffle.LineIndex(source)
local annots = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
-- Skip preprocessor directives (lines starting with #).
-- Without this guard, `#define atom_init(name) ...` macro
-- definitions get misinterpreted as annotation calls with
-- the literal placeholder "name" as the atom name.
if source:sub(i, i) == "#" then
local j = i
while j <= len and source:sub(j, j) ~= "\n" do j = j + 1 end
i = j + 1
goto continue
end
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif TAPE_ATOM_MACROS[ident] then
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) ~= "(" then
i = open + 1
else
local inner, after_paren = read_parens(source, open)
local args = parse_atom_annot_args(inner)
local macro_def = TAPE_ATOM_MACROS[ident]
if #args < 1 then
annots[#annots + 1] = {
line = line_of(i),
macro = ident,
kind = macro_def.kind,
error = "missing atom name (first arg)",
}
else
local entry = new_annot_entry(line_of(i), ident, args[1].value, macro_def.kind)
local handler = ANNOT_ARG_HANDLERS[MACRO_HANDLER_KEY[ident]]
if handler then handler(entry, args) end
annots[#annots + 1] = entry
end
i = after_paren
end
else
i = after
end
::continue::
end
return annots
end
-- ════════════════════════════════════════════════════════════════════════════
-- Validation (ported from tape_atom_annotation_pass.lua:1193-1405)
-- ════════════════════════════════════════════════════════════════════════════
local function validate(ctx, src)
local source = src.text
local annots = find_atom_annotations(source)
local macros = find_macro_word_annotations(source)
local pragmas = find_atom_pragmas(source)
local binds = find_binds_structs(source)
local atoms = find_atom_names(source)
local atom_index = {}
for _, a in ipairs(atoms) do atom_index[a.name] = a end
local binds_index = {}
for _, b in ipairs(binds) do binds_index[b.name] = b end
local errors = {}
local warnings = {}
local info = {}
-- 1. Every annotated atom must exist as a real MipsAtom_ declaration.
for _, a in ipairs(annots) do
if a.error then
errors[#errors + 1] = {line = a.line, msg = a.error}
elseif not atom_index[a.name] then
errors[#errors + 1] = {
line = a.line,
msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name),
}
end
if a.errors then
for _, msg in ipairs(a.errors) do
errors[#errors + 1] = {line = a.line, msg = string.format("'%s': %s", a.name, msg)}
end
end
end
-- 2. Every atom must have exactly one annotation (no orphans, no duplicates).
local count_per_atom = {}
for _, a in ipairs(annots) do
if a.name and not a.error then
count_per_atom[a.name] = (count_per_atom[a.name] or 0) + 1
end
end
for _, atom in ipairs(atoms) do
local n = count_per_atom[atom.name] or 0
if n == 0 then
warnings[#warnings + 1] = {
line = atom.line,
msg = string.format("MipsAtom_(%s) has no TAPE_ATOM_* annotation", atom.name),
}
elseif n > 1 then
errors[#errors + 1] = {
line = atom.line,
msg = string.format("MipsAtom_(%s) has %d annotations (expected 1)", atom.name, n),
}
end
end
-- 3. Phase validity.
for _, a in ipairs(annots) do
if a.name and not a.error and a.phase and not valid_phase(a.phase) then
errors[#errors + 1] = {
line = a.line,
msg = string.format("'%s' has unknown phase '%s' (expected one of init/bind/setup/work/commit/terminate)", a.name, a.phase),
}
end
end
-- 4. BIND atoms must reference a real Binds_* struct.
for _, a in ipairs(annots) do
if a.binds then
if not binds_index[a.binds] then
errors[#errors + 1] = {
line = a.line,
msg = string.format("'%s' binds '%s' but no Struct_(%s) { ... } declaration found", a.name, a.binds, a.binds),
}
end
end
end
-- 5. BIND writes must be wave-context registers that match Binds_ fields.
for _, a in ipairs(annots) do
if a.binds and binds_index[a.binds] then
local bs = binds_index[a.binds]
local field_names = {}
for _, f in ipairs(bs.fields) do field_names[f.name] = true end
for _, f in ipairs(bs.fields) do
local candidate = "R_" .. f.name
if not is_wave_context_reg(candidate) then
warnings[#warnings + 1] = {
line = bs.line,
msg = string.format("%s field '%s' doesn't match a known wave-context register (candidate '%s')", a.binds, f.name, candidate),
}
end
end
for _, w in ipairs(a.writes) do
if not is_wave_context_reg(w) then
warnings[#warnings + 1] = {
line = a.line,
msg = string.format("%s writes '%s' which is not a known wave-context register", a.name, w),
}
end
end
end
end
-- 6. WORK reads should be a subset of BIND writes (the wave contract).
for _, a in ipairs(annots) do
if a.kind == "work" then
for _, r in ipairs(a.reads) do
if not is_wave_context_reg(r) and r ~= "R_TapePtr" then
warnings[#warnings + 1] = {
line = a.line,
msg = string.format("work atom '%s' reads '%s' which is not a known wave-context register", a.name, r),
}
end
end
end
end
-- 7. TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
-- Three outcomes: missing (error), mismatch (error), match (info).
-- Flattened via early-return-style helper instead of 3-way elseif.
local function check_macro_drift(m, declared)
if not declared then
errors[#errors + 1] = {
line = m.line,
msg = string.format("TAPE_WORDS(%s, %d) but '%s' is not in metadata.h", m.name, m.words, m.name),
}
return
end
if declared ~= m.words then
errors[#errors + 1] = {
line = m.line,
msg = string.format("DRIFT: TAPE_WORDS(%s, %d) but metadata.h declares WORD_COUNT(%s, %d)", m.name, m.words, m.name, declared),
}
return
end
info[#info + 1] = {
line = m.line,
msg = string.format("OK: %s = %d words", m.name, m.words),
}
end
for _, m in ipairs(macros) do
check_macro_drift(m, ctx.shared.word_counts[m.name])
end
-- 8. atom_<...> _Pragma validation: resource/region/group/cadence/async
for _, p in ipairs(pragmas) do
if not atom_index[p.name] then
errors[#errors + 1] = {
line = p.line,
msg = string.format("pragma references unknown atom '%s'", p.name),
}
end
for k, v in pairs(p.attrs) do
local spec = ATOM_PRAGMA_KINDS[k]
if not spec then
errors[#errors + 1] = {
line = p.line,
msg = string.format("'%s' has unknown pragma key '%s' (allowed: resource/region/group/cadence/async)", p.name, k),
}
elseif spec.allowed and not spec.allowed[v] then
local allowed = {}
for kk in pairs(spec.allowed) do allowed[#allowed + 1] = kk end
table.sort(allowed)
local allowed_str = table.concat(allowed, ", ")
errors[#errors + 1] = {
line = p.line,
msg = string.format("'%s' pragma %s=%s but '%s' is not allowed (allowed: %s)", p.name, k, v, v, allowed_str),
}
end
end
end
-- 9. CADENCE_ONDEMAND requires async=true. Flattened as a guard
-- (single condition, no nested if).
for _, p in ipairs(pragmas) do
if p.attrs.cadence == "ondemand" and p.attrs.async ~= "true" then
errors[#errors + 1] = {
line = p.line,
msg = string.format("'%s' is CADENCE_ONDEMAND but does not declare atom_async(true)", p.name),
}
end
end
-- 10. Information summary.
info[#info + 1] = {
line = 0,
msg = string.format("scanned: %d atom(s), %d annotation(s), %d pragma(s), %d macro-word-decl(s), %d binds struct(s)",
#atoms, #annots, #pragmas, #macros, #binds),
}
return {
atoms = atoms,
annots = annots,
macros = macros,
pragmas = pragmas,
binds = binds,
errors = errors,
warnings = warnings,
info = info,
}
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source output: errors.h + annotations.txt
-- ════════════════════════════════════════════════════════════════════════════
--- Render <basename>.errors.h with #error directives for any structural issues.
local function emit_errors_h(ctx, src, result)
local out_path = ctx.out_root .. "/" .. src.basename .. ".errors.h"
local lines = {
"// Auto-generated by ps1_meta.lua (passes/annotation.lua) — DO NOT EDIT",
"#pragma once",
"",
}
for _, e in ipairs(result.errors) do
lines[#lines + 1] = string.format('#error "annotation: %s (line %d)"', e.msg, e.line)
end
if #result.errors == 0 then
lines[#lines + 1] = "// annotation pass OK"
end
if ctx.dry_run then return nil end
ensure_dir(ctx.out_root)
write_file(out_path, table.concat(lines, "\n") .. "\n")
return out_path
end
--- Render <basename>.annotations.txt human-readable summary.
--- Implementation lives in passes/report.lua (extracted to keep this
--- file focused on validation). We delegate via a callback set on ctx.
local function emit_annotations_txt(ctx, src, result)
-- The annotation pass emits a structured result; the report pass
-- renders it. To avoid a circular dep, the M.run below packs the
-- result into ctx.upstream.annotation for the report pass to
-- consume via ctx.flags._annot_results.
ctx.flags = ctx.flags or {}
ctx.flags._annot_results = ctx.flags._annot_results or {}
ctx.flags._annot_results[#ctx.flags._annot_results + 1] = {
source = src,
result = result,
}
return nil -- annotations.txt is written by report.lua
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
--- @class M
local M = {}
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
for _, src in ipairs(ctx.sources) do
local result = validate(ctx, src)
local err_path = emit_errors_h(ctx, src, result)
if err_path then
table.insert(outputs, { errors_h = err_path })
end
-- Stash result for report pass.
emit_annotations_txt(ctx, src, result)
for _, e in ipairs(result.errors) do
errors[#errors + 1] = { line = e.line, msg = e.msg }
end
for _, w in ipairs(result.warnings) do
warnings[#warnings + 1] = { line = w.line, msg = w.msg }
end
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M
+697
View File
@@ -0,0 +1,697 @@
-- passes/components.lua
--
-- Generate <module>/gen/<basename>.macs.h from MipsAtomComp_ declarations
-- in source files. Ported from tape_atom_annotation_pass.lua:604-1079
-- (find_component_atoms, preceding_comment_block, extract_arg_names,
-- convert_line_comments_to_block, compute_component_word_count,
-- emit_component_macros_h).
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex.
--- @class Component
--- @field name string
--- @field body string
--- @field args string|nil
--- @field line integer
--- @field comment string|nil
--- @class M
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path
local duffle = require("duffle")
local trim = duffle.trim
local read_ident = duffle.read_ident
local is_space = duffle.is_space
local is_alpha = duffle.is_alpha
local is_alnum = duffle.is_alnum
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local skip_str_or_cmt = duffle.skip_str_or_cmt
local split_top_level_commas = duffle.split_top_level_commas
local ensure_dir = duffle.ensure_dir
local basename_no_ext = duffle.basename_no_ext
local dirname = duffle.dirname
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local scan_to_char = duffle.scan_to_char
local word_count_eval = require("word_count_eval")
local count_body_words = word_count_eval.count_body_words
local M = {}
-- ════════════════════════════════════════════════════════════════════════════
-- Local helpers
-- ════════════════════════════════════════════════════════════════════════════
-- Write content to disk in binary mode so LF line endings are preserved on
-- Windows (text mode would convert LF -> CRLF, breaking byte-identical diffs
-- against git-tracked gen/*.macs.h files which are stored as LF).
local function write_file_lf(path, content)
local f = io.open(path, "wb")
if not f then error("Cannot write " .. path) end
f:write(content)
f:close()
end
-- Convert a (possibly relative) path to an absolute Windows path. The
-- pre-rework output's "// Source:" comment line used the absolute path
-- (e.g. "C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h"); if we want
-- byte-identical output, we must normalize relative -> absolute before
-- emitting that comment.
local function to_absolute_path(path)
if #path >= 2 and path:sub(2, 2) == ":" then
-- Already absolute; normalize slashes for consistency.
return (path:gsub("/", "\\"))
end
local p = io.popen("cd")
if not p then return path end
local cwd = p:read("*l")
p:close()
if not cwd then return path end
-- Normalize forward slashes to backslashes (Windows convention) on
-- both the cwd AND the relative path tail, so the join is uniform.
cwd = cwd:gsub("/", "\\")
local tail = (path:gsub("/", "\\"))
return cwd .. "\\" .. tail
end
-- ════════════════════════════════════════════════════════════════════════════
-- Ported helpers (verbatim from tape_atom_annotation_pass.lua:604-1079)
-- ════════════════════════════════════════════════════════════════════════════
-- ============================================================
-- Find the args of the function declaration that immediately precedes
-- a MipsAtomComp_Proc_ invocation of the given name. Returns the
-- args string (e.g., "U4 off, U4 code, U1 r, U1 g, U1 b") or nil
-- if no function declaration is found.
--
-- Convention: function form is
-- FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })
-- We find the LAST occurrence of "ac_X(" before before_pos and
-- extract the args from inside the parens.
--
-- No regex (per the no_regex constraint). Uses string.find with
-- plain mode (4th arg = true) to find the name + open paren.
-- ============================================================
--- @param source string
--- @param name string
--- @param before_pos integer
--- @return string|nil
local function find_function_args_for(source, name, before_pos)
local search = source:sub(1, before_pos)
local name_paren = name .. "("
local last_idx = nil
local p = 1
while true do
local s = search:find(name_paren, p, true) -- plain (no regex)
if not s then break end
last_idx = s
p = s + #name_paren
end
if not last_idx then return nil end
-- Verify the preceding context ends with "MipsAtom" (with
-- possible qualifiers between). Check the last word is
-- "MipsAtom" (or the trimmed before ends with that token).
local before = search:sub(1, last_idx - 1)
local trimmed = duffle.trim(before)
if trimmed:sub(-#"MipsAtom") ~= "MipsAtom" then
-- Preceding context is not a function declaration.
-- This shouldn't happen with the convention, but guard anyway.
return nil
end
local open_paren = last_idx + #name -- position of "("
local inner = read_parens(source, open_paren)
if not inner then return nil end
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.
-- No regex (per the no_regex constraint).
-- ============================================================
--- @param source string
--- @param pos integer
--- @return string
local function preceding_comment_block(source, pos)
local i = pos
local pieces = {}
while true do
-- Skip whitespace
local j = i - 1
while j > 0 do
local c = source:sub(j, j)
if c == " " or c == "\t" or c == "\n" or c == "\r" then
j = j - 1
else
break
end
end
if j == 0 then break end
-- Check for /* ... */ ending at j
if j >= 2 and source:sub(j-1, j) == "*/" then
local s = source:sub(1, j - 1)
local last_open = nil
for k = #s - 1, 1, -1 do
if s:sub(k, k+1) == "/*" then
last_open = k
break
end
end
if last_open then
-- Include the leading whitespace+indentation before /*
local block_start = last_open
while block_start > 1 do
local c = source:sub(block_start - 1, block_start - 1)
if c == " " or c == "\t" then
block_start = block_start - 1
else
break
end
end
table.insert(pieces, 1, source:sub(block_start, j))
i = block_start
else
break
end
-- Check for // comment ending at j (j is at end of line, j-1 is \n)
elseif j >= 1 and (source:sub(j, j) == "\n" or source:sub(j, j) == "\r") then
-- Walk back to the start of the line
local line_start = j
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, j)
if line:sub(1, 2) == "//" then
table.insert(pieces, 1, line)
i = line_start - 1
else
break
end
else
break
end
end
if #pieces == 0 then return "" end
return table.concat(pieces, "\n")
end
-- ============================================================
-- Extract just the parameter NAMES from a function-args string
-- (stripping type annotations). E.g.,
-- "U4 off, U4 code, U1 r, U1 g, U1 b" -> {"off", "code", "r", "g", "b"}
-- "U4 *ptr" -> {"ptr"}
-- "" -> nil
-- No regex — uses duffle.is_alnum + plain string ops.
-- ============================================================
--- @param args_str string|nil
--- @return string[]|nil
local function extract_arg_names(args_str)
if not args_str or args_str == "" then return nil end
local names = {}
local tokens = duffle.split_top_level_commas(args_str)
for _, tok in ipairs(tokens) do
local trimmed = duffle.trim(tok)
if trimmed ~= "" then
-- Walk backwards from end of trimmed arg, skipping
-- trailing whitespace / asterisks / brackets.
local i = #trimmed
while i > 0 do
local c = trimmed:sub(i, i)
if c == " " or c == "\t" or c == "*" or c == "]" or c == "[" then
i = i - 1
else
break
end
end
-- Now find the end of the last identifier (the param name).
local j = i
while j > 0 do
local c = trimmed:sub(j, j)
if duffle.is_alnum(c) or c == "_" then
j = j - 1
else
break
end
end
local name = trimmed:sub(j + 1, i)
if name ~= "" then
names[#names + 1] = name
end
end
end
if #names == 0 then return nil end
return names
end
-- ============================================================
-- Find every MipsAtomComp_(ac_<X>) { body } declaration in source.
-- Supports BOTH the bare form and the function form:
-- Bare: MipsAtomComp_(ac_X) { body }
-- Function: MipsAtomComp_Proc_(ac_X, { body }) (with a preceding
-- "FI_ MipsAtom ac_X(args)" function declaration)
-- Returns: {line, name, body, args} where args is the function-args
-- string (or nil for the bare form).
-- ============================================================
-- WORD_COUNT entry in gen/<dir_basename>.components.h)
-- ============================================================
--- @param source string
--- @return Component[]
local function find_component_atoms(source)
local line_of = duffle.LineIndex(source)
local out = {}
local len = #source
local i = 1
while i <= len do
i = skip_ws_and_cmt(source, i); if i > len then break end
local ident, after = read_ident(source, i)
if not ident then
i = i + 1
elseif ident == "MipsAtomComp_Proc_"
or ident == "MipsAtomComp_" then
local open = skip_ws_and_cmt(source, after)
if source:sub(open, open) == "(" then
local inner, after_paren = read_parens(source, open)
-- Parse args: 1 arg = bare form, 2 args = function form
local tokens = duffle.split_top_level_commas(inner)
local name, body = nil, nil
if #tokens == 1 then
name = duffle.trim(tokens[1])
elseif #tokens == 2 then
name = duffle.trim(tokens[1])
local body_raw = duffle.trim(tokens[2])
-- Strip leading { and trailing } if present
if #body_raw >= 2
and body_raw:sub(1, 1) == "{"
and body_raw:sub(-1) == "}" then
body = duffle.trim(body_raw:sub(2, -2))
else
body = body_raw
end
end
if name and name:sub(1, 3) == "ac_" then
-- Find the function args (preceding function decl).
-- For the bare form this returns nil (no function).
local args = find_function_args_for(source, name, open)
-- Capture the preceding comment block (signature doc).
-- Walk back from `i` (position of the identifier start)
-- so the walk-back goes through whitespace+comment and
-- stops AT the comment (not at the identifier chars).
local comment = preceding_comment_block(source, i)
if body == nil then
-- Bare form: body is the brace block AFTER the parens.
local brace = scan_to_char(source, "{", after_paren)
if brace then
local body_content, after_brace = read_braces(source, brace)
out[#out + 1] = {
line = line_of(i),
name = name:sub(4), -- strip "ac_" prefix
body = body_content,
args = args,
comment = comment,
}
i = after_brace
else
i = open + 1
end
else
-- Function form: body is the second arg (already extracted).
out[#out + 1] = {
line = line_of(i),
name = name:sub(4), -- strip "ac_" prefix
body = body,
args = args,
comment = comment,
}
i = after_paren
end
else
i = open + 1
end
else
i = open + 1
end
else
i = after
end
end
return out
end
-- ============================================================
-- Emit a per-directory generated header with mac_X(...) macros
-- derived from MipsAtomComp_ declarations + auto word-counts.
-- Output: <source_dir>/gen/<dir_basename>.macs.h
-- ============================================================
-- Convert `//` line comments to `/* */` block comments in a token.
-- C macros use `\` line-continuations; a `//` comment before `\` would
-- consume the continuation, breaking the macro. We convert `//` to
-- `/* */` so the multi-line macro structure is preserved.
-- Skips `//` sequences that are inside string or character literals
-- (a rough heuristic — sufficient for component bodies which don't
-- have those constructs).
--- @param s string
--- @return string
local function convert_line_comments_to_block(s)
local result = s
local i = 1
while i <= #result do
local c = result:byte(i)
if c == 47 and i + 1 <= #result and result:byte(i + 1) == 47 then
-- Found `//`. Find end of line.
local eol = i
while eol <= #result and result:byte(eol) ~= 10 do
eol = eol + 1
end
local before = result:sub(1, i - 1)
local comment = result:sub(i + 2, eol - 1) -- skip the `//`
local after
if eol <= #result and result:byte(eol) == 10 then
after = " */" .. result:sub(eol) -- keep the newline
else
after = " */"
end
result = before .. "/*" .. comment .. after
i = #before + 2 + #comment + 3 -- skip past converted comment
else
i = i + 1
end
end
return result
end
-- Compute the word count of a component body, accounting for
-- macro expansion. Each comma-separated entry in the body is a
-- "slot" that contributes its own word count. For most entries
-- (regular MIPS instructions) the count is 1. For `mac_Y(...)`
-- calls, the count is the word count of mac_Y (recursive lookup
-- through `components`). For encoding macros with a known multi-word
-- count (e.g. `mask_upper` = 2), the count is taken from `word_counts`.
--
-- The lookup is memoized via `rec()` to avoid infinite recursion
-- (e.g. if two components referenced each other). This is the
-- same algorithm as the original tape_atom_annotation_pass.lua
-- (commit 7d20a4d) — without it, a fresh build with no pre-existing
-- *.macs.h files in gen/ would compute wrong counts: e.g.
-- `mac_format_f3_color` calls `mac_pack_color_word` which isn't
-- in `wc` yet, so the lookup falls through to the "1 word" default
-- and produces `WORD_COUNT(mac_format_f3_color, 1)` instead of 3.
--
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
--- @return integer
local function compute_component_word_count(c, components, wc)
-- Build component lookup table once per call.
local comp_by_name = {}
for _, cc in ipairs(components) do
comp_by_name[cc.name] = cc
end
local cache = {}
local function rec(name)
if cache[name] ~= nil then return cache[name] end
cache[name] = -1 -- mark in-progress (cycle detection)
local cc = comp_by_name[name]
local n
if cc then
local body_tokens = {}
for _, t in ipairs(split_top_level_commas(cc.body)) do
local trimmed = trim(t)
if trimmed ~= "" then body_tokens[#body_tokens + 1] = trimmed end
end
n = 0
for _, t in ipairs(body_tokens) do
-- Read the first identifier from the token.
local ident = read_ident(t, 1)
-- Components are stored without the `mac_` prefix
-- (e.g. "format_f3_color"). The token has `mac_format_f3_color(...)`,
-- so strip the `mac_` prefix to look up the component.
local comp_name = ident
if comp_name and comp_name:sub(1, 4) == "mac_" then
comp_name = comp_name:sub(5)
end
if comp_name and comp_by_name[comp_name] then
-- It's a `mac_X(...)` call. Recurse.
n = n + rec(comp_name)
elseif comp_name and wc and wc[comp_name] then
-- Encoding macro or pseudo-instruction (e.g. mask_upper = 2,
-- nop2 = 2). Trust the metadata — tape_atom.metadata.h is the
-- single source of truth for word counts.
n = n + wc[comp_name]
else
-- Unrecognized token. Fall back to 1 word.
n = n + 1
end
end
else
-- Not a known component: assume 1 word (regular instruction).
n = 1
end
cache[name] = n
return n
end
return rec(c.name)
end
-- ============================================================
-- Per-component emit logic. Returns the body of lines for one
-- component (signature comment, #define mac_X(...) line with
-- backslash-continued tokens, then WORD_COUNT(mac_X, N) entry).
--
-- Extracted from emit_component_macros_h so M.run can call it
-- once per component AND extend ctx.shared.word_counts.
-- ============================================================
-- ============================================================
-- Per-component emit logic. Returns the body of lines for one
-- component (signature comment, #define mac_X(...) line with
-- backslash-continued tokens, then WORD_COUNT(mac_X, N) entry).
--
-- Extracted from emit_component_macros_h so M.run can call it
-- once per component AND extend ctx.shared.word_counts.
-- ============================================================
--- Split a (possibly multi-line) comment into per-line entries.
--- Hand-rolled (no regex patterns used).
--- @param s string
--- @return string[]
local function split_comment_lines(s)
local out = {}
local i = 1
local len = #s
while i <= len do
local nl = s:find("\n", i, true)
if not nl then
out[#out + 1] = s:sub(i)
break
end
out[#out + 1] = s:sub(i, nl - 1)
i = nl + 1
end
return out
end
--- Split an atom body by top-level commas; drop empty tokens.
--- @param body string
--- @return string[]
local function tokens_from_body(body)
local out = {}
for _, t in ipairs(split_top_level_commas(body)) do
local trimmed = trim(t)
if trimmed ~= "" then out[#out + 1] = trimmed end
end
return out
end
--- Determine the macro signature: function-args list (function form)
--- or variadic-ignored (bare form).
--- @param args_str string|nil
--- @return string
local function signature_from_args(args_str)
local arg_names = extract_arg_names(args_str)
if arg_names and #arg_names > 0 then
return table.concat(arg_names, ", ")
end
return "..."
end
--- Strip the trailing " \" (space + backslash) line continuation
--- from the last body line. The last 2 chars are always that pair.
local function strip_trailing_continuation(lines)
local last = lines[#lines]
if last:sub(-2) == " \\" then
lines[#lines] = last:sub(1, -3)
end
end
--- Emit the `#define mac_X(sig) \<newline>\t<tok1> \<newline>,\t<tok2> ...`
--- block. Converts `//` line comments to `/* */` block comments in
--- each token so they don't break the C macro `\` line continuations.
local function emit_macro_body(lines, c, sig, tokens)
for j = 1, #tokens do
tokens[j] = convert_line_comments_to_block(tokens[j])
end
lines[#lines + 1] = "#define mac_" .. c.name .. "(" .. sig .. ") \\"
lines[#lines + 1] = "\t" .. tokens[1] .. " \\"
for j = 2, #tokens do
lines[#lines + 1] = ",\t" .. tokens[j] .. " \\"
end
strip_trailing_continuation(lines)
end
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
--- @return string[] -- list of lines for this component
local function build_component_lines(c, components, wc)
local lines = {}
if c.comment and c.comment ~= "" then
for _, line in ipairs(split_comment_lines(c.comment)) do
lines[#lines + 1] = line
end
end
local tokens = tokens_from_body(c.body)
local sig = signature_from_args(c.args)
local n = compute_component_word_count(c, components, wc)
if n > 0 then
emit_macro_body(lines, c, sig, tokens)
end
-- Emit the WORD_COUNT(mac_<X>, N) entry.
lines[#lines + 1] = "WORD_COUNT(mac_" .. c.name .. ", " .. n .. ")"
lines[#lines + 1] = ""
return lines
end
-- ============================================================
-- Emit a per-source .macs.h header with the mac_X macros +
-- WORD_COUNT entries. Writes in BINARY mode so LF line endings
-- are preserved (the git blob is LF; Windows text-mode would
-- emit CRLF and break the byte-identical diff).
--
-- Honors ctx.dry_run: prints the intended path but does not
-- write the file.
-- ============================================================
--- @param ctx PassCtx
--- @param src SourceFile
--- @param components Component[]
--- @return string|nil -- path to the written file (nil on dry-run)
local function emit_component_macros_h(ctx, src, components)
if #components == 0 then return nil end
-- Output path: <src.dir>/gen/<src.dir's basename>.macs.h
-- The pre-rework convention uses the *directory* basename (not
-- the source file basename) — e.g. `code/duffle/lottes_tape.h`
-- produces `code/duffle/gen/duffle.macs.h`. This matches what
-- the C codebase #includes.
local out_dir = src.dir .. "/gen"
local out_path = out_dir .. "/" .. basename_no_ext(src.dir) .. ".macs.h"
local lines = {
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching
-- the convention in lottes_tape.h. The build does manual unity
-- includes (the user controls include order), so the pragma
-- is only active for IDE/tooling.
"#ifdef INTELLISENSE_DIRECTIVES",
"#pragma once",
"#endif",
"// Auto-generated by tape_atom_annotation_pass.lua — DO NOT EDIT",
"// Source: " .. to_absolute_path(src.path),
"// Component atoms (MipsAtomComp_(ac_*)) -> macro variants (mac_*)",
"// + auto word-counts (so tape_atom.metadata.h stays manual-only",
"// for encoding macros).",
"",
-- Self-contained: define WORD_COUNT if not already defined.
-- The metadata file (tape_atom.metadata.h) defines it as
-- enum { words_##name = (count) };
-- We use the same definition here so the auto-generated
-- entries below expand to compile-time constants whether
-- the metadata file is included first or not.
"#ifndef WORD_COUNT",
"#define WORD_COUNT(name, count) enum { words_##name = (count) };",
"#endif",
"",
}
local wc = ctx.shared.word_counts
for _, c in ipairs(components) do
local comp_lines = build_component_lines(c, components, wc)
for _, l in ipairs(comp_lines) do lines[#lines + 1] = l end
end
local content = table.concat(lines, "\n") .. "\n"
if ctx.dry_run then
print(string.format(" -> %s (dry-run)", out_path))
return out_path
end
ensure_dir(out_dir)
write_file_lf(out_path, content)
print(string.format(" -> %s", out_path))
return out_path
end
-- ════════════════════════════════════════════════════════════════════════════
-- Pass entry
-- ════════════════════════════════════════════════════════════════════════════
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
for _, src in ipairs(ctx.sources) do
-- find_component_atoms operates on src.text
local components = find_component_atoms(src.text)
if #components > 0 then
local macs_path = emit_component_macros_h(ctx, src, components)
if macs_path then
table.insert(outputs, { macs_h = macs_path })
-- Extend the shared word_counts table so offsets sees the
-- component macros without re-reading the file.
local wc = ctx.shared.word_counts
for _, c in ipairs(components) do
wc["mac_" .. c.name] = compute_component_word_count(c, components, wc)
end
end
end
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M
+395
View File
@@ -0,0 +1,395 @@
-- passes/offsets.lua
--
-- Generate <module>/gen/<basename>.offsets.h with branch offset
-- immediates for every atom_offset(F, T) reference in atom bodies.
-- Ported from scripts/tape_atom.offset_gen.meta.lua:148-389.
--
-- The branch offset regression we just fixed in commit 98e27c2 must
-- NOT return. The fix was in duffle.lua's split_top_level_commas +
-- tape_atom_annotation_pass.lua's compute_component_word_count.
-- word_count_eval.count_token_words preserves the fix.
--
-- THIS MODULE ALSO REQUIRES the recent fix to duffle.lua's
-- split_top_level_commas (the second-half of the 98e27c2 fix):
-- top-level comments must be appended to the previous token, not
-- stripped, so the emit path preserves `// trailing comment` text
-- for convert_line_comments_to_block to convert to `/* */`.
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path
local duffle = require("duffle")
local trim = duffle.trim
local read_ident = duffle.read_ident
local is_space = duffle.is_space
local is_alpha = duffle.is_alpha
local is_alnum = duffle.is_alnum
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local skip_str_or_cmt = duffle.skip_str_or_cmt
local split_top_level_commas = duffle.split_top_level_commas
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local write_file = duffle.write_file
local dirname = duffle.dirname
local basename_no_ext = duffle.basename_no_ext
local ensure_dir = duffle.ensure_dir
local word_count_eval = require("word_count_eval")
local count_token_words = word_count_eval.count_token_words
-- ════════════════════════════════════════════════════════════════════════════
-- Local helpers (ported from offset_gen.meta.lua lines 67-93)
-- ════════════════════════════════════════════════════════════════════════════
local function starts_with(s, prefix)
if #s < #prefix then return false end
for i = 1, #prefix do
if s:sub(i, i) ~= prefix:sub(i, i) then return false end
end
return true
end
local function to_upper(s) return s:upper() end
local function to_alnum_underscore(s)
local out = ""
for i = 1, #s do
local c = s:sub(i, i)
if is_alnum(c) then out = out .. c
else out = out .. "_" end
end
return out
end
local function pad_right(s, w) return s .. string.rep(" ", w - #s) end
-- ════════════════════════════════════════════════════════════════════════════
-- Marker-call helpers (ported from offset_gen.meta.lua lines 148-205)
-- ════════════════════════════════════════════════════════════════════════════
--- Extract comma-separated identifier args from a parenthesized group
--- after a function-like macro call.
local function extract_ident_args(token, after_ident)
local arg_start = skip_ws_and_cmt(token, after_ident)
if token:sub(arg_start, arg_start) ~= "(" then return {}, nil end
local inner, after_paren = read_parens(token, arg_start)
local args = {}
local n = 1
local len = #inner
while n <= len do
n = skip_ws_and_cmt(inner, n)
if n > len then break end
local ident, after = read_ident(inner, n)
if ident and ident ~= "" then
table.insert(args, ident)
n = after
else
n = n + 1
end
n = skip_ws_and_cmt(inner, n)
if n <= len and inner:sub(n, n) == "," then n = n + 1 end
end
return args, after_paren
end
--- Scan a single token for atom_label/atom_offset markers, walking through
--- balanced groups transparently (so nested calls are found).
local function scan_for_atom_markers(token, at_pos, labels, branches)
local i = 1
local len = #token
while i <= len do
i = skip_ws_and_cmt(token, i)
if i > len then break end
local c = token:sub(i, i)
if is_alpha(c) then
local ident, after = read_ident(token, i)
if ident == "atom_label" then
local args, after_paren = extract_ident_args(token, after)
if #args >= 1 then labels[args[1]] = at_pos end
if after_paren then i = after_paren else i = after end
elseif ident == "atom_offset" then
local args, after_paren = extract_ident_args(token, after)
if #args >= 2 then table.insert(branches, {pos = at_pos, target = args[2], tag = args[1]}) end
if after_paren then i = after_paren else i = after end
else
i = after
end
else
local nx = skip_str_or_cmt(token, i)
if nx > i then i = nx else i = i + 1 end
end
end
end
--- Find the end position (just past the closing ')') of the first
--- atom_label/atom_offset call in `tok`. Returns 0 if no such call.
local function find_marker_call_end(tok)
local i = 1
local len = #tok
while i <= len do
i = skip_ws_and_cmt(tok, i)
if i > len then break end
local c = tok:sub(i, i)
if is_space(c) then
i = i + 1
elseif c == "/" then
-- comment — skip past it (delegated to duffle.skip_str_or_cmt)
local nx = skip_str_or_cmt(tok, i)
if nx > i then i = nx else i = i + 1 end
else
local ident, after = read_ident(tok, i)
if ident == "atom_label" or ident == "atom_offset" then
local j = skip_ws_and_cmt(tok, after)
if tok:sub(j, j) == "(" then
local _, end_paren = read_parens(tok, j)
return end_paren - 1
end
return 0
end
i = after or (i + 1)
end
end
return 0
end
-- ════════════════════════════════════════════════════════════════════════════
-- Atom scanner (ported from offset_gen.meta.lua lines 245-321)
-- ════════════════════════════════════════════════════════════════════════════
--- Skip C qualifier keywords (static, const, etc.) and return the position
--- past the last qualifier.
local function skip_qualifiers(source, i)
local keywords = {
["static"] = true, ["const"] = true, ["volatile"] = true,
["extern"] = true, ["register"] = true, ["auto"] = true,
["inline"] = true, ["typedef"] = true,
["internal"]= true, ["LP_"] = true, ["global"] = true, ["gkknown"] = true,
}
while true do
i = skip_ws_and_cmt(source, i)
local ident, after = read_ident(source, i)
if not ident then return i end
if keywords[ident] then i = after else return i end
end
end
--- Find every MipsAtom_(name) { ... } in a source.
local function find_atoms(source_text)
local atoms = {}
local len = #source_text
local i = 1
local function try_wrapped(after_pos)
local paren_pos = skip_ws_and_cmt(source_text, after_pos)
if source_text:sub(paren_pos, paren_pos) ~= "(" then return nil end
local inner, after_paren = read_parens(source_text, paren_pos)
local n = 1
while n <= #inner and is_space(inner:sub(n, n)) do n = n + 1 end
local ns = n
while n <= #inner and is_alnum(inner:sub(n, n)) do n = n + 1 end
local name = inner:sub(ns, n - 1)
if name == "" then return nil end
-- Find the brace after the parens.
local brace_pos = duffle.scan_to_char(source_text, "{", after_paren)
if not brace_pos then return nil end
local body, after_brace = read_braces(source_text, brace_pos)
return {name = name, body = body, after_brace = after_brace}
end
local function try_raw(after_pos)
local next_pos = skip_ws_and_cmt(source_text, after_pos)
local next_ident, next_after = read_ident(source_text, next_pos)
if not next_ident then return nil end
if not starts_with(next_ident, "code_") then return nil end
if #next_ident <= 5 then return nil end
local atom_name = next_ident:sub(6)
local brace_pos = duffle.scan_to_char(source_text, "{", next_after)
if not brace_pos then return nil end
local body, after_brace = read_braces(source_text, brace_pos)
return {name = atom_name, body = body, after_brace = after_brace}
end
while i <= len do
i = skip_ws_and_cmt(source_text, i); if i > len then break end
i = skip_qualifiers(source_text, i); if i > len then break end
local ident, after = read_ident(source_text, i)
if not ident then
i = i + 1
elseif ident == "MipsAtom_" then
local atom = try_wrapped(after)
if atom then
table.insert(atoms, {name = atom.name, body = atom.body})
i = atom.after_brace
else
i = i + 1
end
elseif ident == "MipsCode" then
local atom = try_raw(after)
if atom then
table.insert(atoms, {name = atom.name, body = atom.body})
i = atom.after_brace
else
i = after
end
else
i = after
end
end
return atoms
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-atom body scan (ported from offset_gen.meta.lua lines 207-239)
-- ════════════════════════════════════════════════════════════════════════════
--- Scan an atom body for labels + branches, count total words.
--- Returns (labels, branches, total_words).
local function scan_atom_body(body, word_counts)
local pos = 0
local labels = {}
local branches = {}
for _, tok in ipairs(split_top_level_commas(body)) do
local k = 1
local tlen = #tok
while k <= tlen and is_space(tok:sub(k, k)) do k = k + 1 end
local leading_ident = read_ident(tok, k)
if leading_ident == "atom_label" or leading_ident == "atom_offset" then
-- Marker call: record at the current pos, do NOT advance pos.
-- But the source pattern may bundle the marker with the next
-- instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is
-- a real instruction that must still be counted.
scan_for_atom_markers(tok, pos, labels, branches)
local marker_end = find_marker_call_end(tok)
if marker_end > 0 and marker_end < #tok then
local rest = trim(tok:sub(marker_end + 1))
if rest ~= "" then
local rest_words = count_token_words(rest, word_counts)
pos = pos + rest_words
end
end
else
local words = count_token_words(tok, word_counts)
scan_for_atom_markers(tok, pos, labels, branches)
pos = pos + words
end
end
return labels, branches, pos
end
-- ════════════════════════════════════════════════════════════════════════════
-- Offset computation + header generation (ported lines 327-383)
-- ════════════════════════════════════════════════════════════════════════════
--- Compute branch offsets as (target_word - branch_word - 1).
local function compute_offsets(labels, branches)
local results = {}
for _, br in ipairs(branches) do
local target = labels[br.target]
if not target then
error("Branch target '" .. br.target .. "' has no atom_label (at word " .. br.pos .. ")")
end
table.insert(results, {target = br.target, tag = br.tag, offset = target - br.pos - 1})
end
return results
end
--- Generate the per-source .offsets.h header.
local function generate_header(source_path, atoms_data)
local basename = basename_no_ext(source_path)
local lines = {}
local function add(s) table.insert(lines, s) end
add("// Auto-generated by ps1_meta.lua (passes/offsets.lua) — DO NOT EDIT")
add("// Source: " .. source_path)
add("#pragma once")
add("")
add("#pragma region " .. basename)
add("")
add("")
for _, atom in ipairs(atoms_data) do
if #atom.offsets > 0 then
add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---")
add("")
local consts = {}
for _, r in ipairs(atom.offsets) do
table.insert(consts, {
macro_name = "_atom_offset_" .. r.tag .. "_" .. r.target,
enum_name = "atom_offset_" .. r.tag .. "_" .. r.target,
value = r.offset,
})
end
for _, c in ipairs(consts) do
add("#define " .. pad_right(c.macro_name, 44) .. " " .. c.value)
end
add("")
add("enum {")
for _, c in ipairs(consts) do
add(" " .. c.enum_name .. " = " .. c.macro_name .. ",")
end
add("};")
add("")
end
end
add("#pragma endregion " .. basename)
add("")
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
--- @class M
local M = {}
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
for _, src in ipairs(ctx.sources) do
local atoms = find_atoms(src.text)
if #atoms > 0 then
local atoms_data = {}
for _, atom in ipairs(atoms) do
local labels, branches, total = scan_atom_body(atom.body, ctx.shared.word_counts)
local offsets = compute_offsets(labels, branches)
table.insert(atoms_data, {
name = atom.name,
total_words = total,
offsets = offsets,
})
end
local out_path = src.dir .. "/gen/" .. basename_no_ext(src.dir) .. ".offsets.h"
if not ctx.dry_run then
ensure_dir(dirname(out_path))
write_file(out_path, generate_header(src.path, atoms_data))
end
table.insert(outputs, { offsets_h = out_path })
end
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M
+207
View File
@@ -0,0 +1,207 @@
-- passes/report.lua
--
-- Render the per-project summary (build/gen/annotation_validation.txt)
-- + the per-source annotation reports (build/gen/<basename>.annotations.txt).
-- Aggregates errors + warnings from upstream annotation pass results.
--
-- The annotation pass stashes its per-source results in ctx.flags._annot_results
-- (set by passes/annotation.lua). This report pass renders them.
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path
local duffle = require("duffle")
local ensure_dir = duffle.ensure_dir
local write_file = duffle.write_file
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source annotation report (ported from tape_atom_annotation_pass.lua:1411-1486)
-- ════════════════════════════════════════════════════════════════════════════
local function render_source_report(source_path, result)
local lines = {}
local function add(s) lines[#lines + 1] = s end
add("========================================================")
add("ANNOTATION PASS — " .. source_path)
add("========================================================")
add("")
add(string.format("Atoms: %d Annotations: %d Pragmas: %d Binds structs: %d Macro decls: %d",
#result.atoms, #result.annots,
(result.pragmas and #result.pragmas or 0),
#result.binds, #result.macros))
add("")
add("── Atoms ────────────────────────────────────────────────")
for _, a in ipairs(result.atoms) do
add(string.format(" MipsAtom_(%s) line %d", a.name, a.line))
end
add("")
add("── Annotations ──────────────────────────────────────────")
for _, a in ipairs(result.annots) do
if a.error then
add(string.format(" ✗ line %d %s [ERROR: %s]", a.line, a.macro or "?", a.error))
else
local line = string.format(" %s line %d %s phase=%s",
a.kind == "work" and "" or (a.kind == "bind" and "" or ""),
a.line, a.name, a.phase or a.kind)
if a.binds then line = line .. " binds=" .. a.binds end
if #a.reads > 0 then line = line .. " reads={" .. table.concat(a.reads, ",") .. "}" end
if #a.writes > 0 then line = line .. " writes={" .. table.concat(a.writes, ",") .. "}" end
add(line)
end
end
add("")
add("── Binds_* structs ──────────────────────────────────────")
for _, b in ipairs(result.binds) do
add(string.format(" %s line %d %d bytes", b.name, b.line, b.bytes))
for _, f in ipairs(b.fields) do
add(string.format(" +%2d: %s", f.offset, f.name))
end
end
add("")
add("── Macro word-count declarations ─────────────────────────")
for _, m in ipairs(result.macros) do
add(string.format(" %s line %d words=%d", m.name, m.line, m.words))
end
add("")
add("── Atom pragmas (resource / region / group / cadence / async) ─")
if not result.pragmas or #result.pragmas == 0 then add(" (none)") end
for _, p in ipairs(result.pragmas or {}) do
local kvs = {}
for k, v in pairs(p.attrs) do kvs[#kvs + 1] = k .. "=" .. v end
table.sort(kvs)
add(string.format(" ◇ line %d %s {%s}", p.line, p.name, table.concat(kvs, ", ")))
end
add("")
add("── Errors ──────────────────────────────────────────────")
if #result.errors == 0 then add(" (none)") end
for _, e in ipairs(result.errors) do
add(string.format(" ✗ line %d %s", e.line, e.msg))
end
add("")
add("── Warnings ────────────────────────────────────────────")
if #result.warnings == 0 then add(" (none)") end
for _, w in ipairs(result.warnings) do
add(string.format(" ⚠ line %d %s", w.line, w.msg))
end
add("")
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-project summary (ported from tape_atom_annotation_pass.lua:1488-1528)
-- ════════════════════════════════════════════════════════════════════════════
local function render_project_report(all_results)
local lines = {}
local function add(s) lines[#lines + 1] = s end
local total_atoms, total_annots, total_macros, total_binds = 0, 0, 0, 0
local total_errors, total_warnings = 0, 0
for _, r in ipairs(all_results) do
total_atoms = total_atoms + #r.atoms
total_annots = total_annots + #r.annots
total_macros = total_macros + #r.macros
total_binds = total_binds + #r.binds
total_errors = total_errors + #r.errors
total_warnings = total_warnings + #r.warnings
end
add("========================================================")
add("ANNOTATION VALIDATION — project summary")
add("========================================================")
add("")
add(string.format("Atoms: %d", total_atoms))
add(string.format("Annotations: %d", total_annots))
add(string.format("Macros: %d", total_macros))
add(string.format("Binds: %d", total_binds))
add("")
add(string.format("Errors: %d", total_errors))
add(string.format("Warnings: %d", total_warnings))
add("")
if total_errors > 0 then
add("Per-source error counts:")
for _, r in ipairs(all_results) do
if #r.errors > 0 then
add(string.format(" %s : %d error(s)", r.source, #r.errors))
end
end
add("")
end
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
--- @class M
local M = {}
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
-- The annotation pass stashes per-source results in ctx.flags._annot_results.
-- Render each as build/gen/<basename>.annotations.txt and aggregate into
-- build/gen/annotation_validation.txt.
local annot_results = (ctx.flags and ctx.flags._annot_results) or {}
-- Render per-source reports.
for _, entry in ipairs(annot_results) do
local src = entry.source
local result = entry.result
local out_path = ctx.out_root .. "/" .. src.basename .. ".annotations.txt"
if not ctx.dry_run then
ensure_dir(ctx.out_root)
write_file(out_path, render_source_report(src.path, result))
end
table.insert(outputs, { annotations_txt = out_path })
end
-- Render project summary.
if not ctx.dry_run then
-- The project report references each source by its absolute path.
-- Augment the entries with a .source field for the per-source error counts.
local all_results = {}
for _, entry in ipairs(annot_results) do
entry.result.source = entry.source.path
table.insert(all_results, entry.result)
end
ensure_dir(ctx.out_root)
local summary_path = ctx.out_root .. "/annotation_validation.txt"
write_file(summary_path, render_project_report(all_results))
table.insert(outputs, { summary_txt = summary_path })
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M
+16
View File
@@ -0,0 +1,16 @@
-- passes/static_analysis.lua
--
-- [FUTURE] Per-atom static-analysis checks. Stub for now; the upcoming
-- static_analysis_atoms_20260708 track will deliver the 5 (+1) checks.
--- @class M
local M = {}
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
return { outputs = {}, errors = {}, warnings = {} }
end
return M
+573
View File
@@ -0,0 +1,573 @@
-- ps1_meta.lua
--
-- Orchestrator entry point for the tape-atom metaprogram pipeline.
-- Dispatches to pass modules under scripts/passes/, resolving dependencies
-- topologically. Single CLI surface (`--<pass>` flags + auto-dep + --dry-run).
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex,
-- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "?.lua;" .. script_dir .. "?/init.lua;" .. package.path
package.cpath = "C:\\projects\\Pikuma\\ps1\\toolchain\\luajit-2.1\\lib\\lua\\5.1\\?.dll;" .. package.cpath
local duffle = require("duffle")
local dirname = duffle.dirname
local basename_no_ext = duffle.basename_no_ext
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class PassDescriptor
--- @field module string -- module name passed to require()
--- @field kind string -- "shared" | "header-output" | "validation" | "report"
--- @field deps string[] -- names of upstream passes
--- @field desc string -- human description (used by --help + ASCII graph)
--- @field out PassOutput[] -- output paths (used by --dry-run + report)
--- @class PassOutput
--- @field kind string -- "header" | "report"
--- @field path_template string -- e.g. "<source_dir>/gen/<basename>.macs.h"
--- @class SourceFile
--- @field path string
--- @field text string
--- @field dir string
--- @field basename string
--- @class PassCtx
--- @field sources SourceFile[]
--- @field metadata_path string
--- @field shared table
--- @field shared.word_counts table<string, integer>
--- @field out_root string
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
--- @field outputs table[]
--- @field errors table[]
--- @field warnings table[]
--- @class ParsedArgs
--- @field requested_set string[] -- pass names to run (explicit --all expanded)
--- @field sources string[] -- --source values
--- @field metadata string -- --metadata value
--- @field out_root string -- --out-root value (default "build/gen")
--- @field project_root string -- --project-root value (default dirname(metadata))
--- @field dry_run boolean
--- @field verbose boolean
-- ════════════════════════════════════════════════════════════════════════════
-- PASSES table (data, not code) — the orchestrator's dep graph
-- ════════════════════════════════════════════════════════════════════════════
local PASSES = {
["word-counts"] = {
module = "word_count_eval",
kind = "shared",
deps = {},
desc = "Build the shared metadata table (metadata.h + .macs.h)",
out = {},
},
components = {
module = "passes.components",
kind = "header-output",
deps = {"word-counts"},
desc = "Emit mac_X macros from MipsAtomComp_ declarations",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.macs.h" } },
},
annotation = {
module = "passes.annotation",
kind = "validation",
deps = {"word-counts"},
desc = "Validate atom DSL usage; emit errors.h + annotations.txt",
out = {
{ kind = "report", path_template = "<out_root>/<basename>.errors.h" },
{ kind = "report", path_template = "<out_root>/<basename>.annotations.txt" },
},
},
offsets = {
module = "passes.offsets",
kind = "header-output",
deps = {"word-counts", "components"},
desc = "Compute branch offsets for atom_label / atom_offset",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.offsets.h" } },
},
["static-analysis"] = {
module = "passes.static_analysis",
kind = "validation",
deps = {"word-counts", "components"},
desc = "[FUTURE] GTE pipeline-fill, mac_yield uniformity, etc.",
out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } },
},
report = {
module = "passes.report",
kind = "report",
deps = {"annotation", "static-analysis"},
desc = "Render the per-project summary",
out = { { kind = "report", path_template = "<out_root>/annotation_validation.txt" } },
},
}
-- Pass-kind taxonomy: which kinds stop the build on errors?
local PASS_KIND_STOP_ON_ERROR = {
["shared"] = false,
["header-output"] = true,
["validation"] = true,
["report"] = false,
}
-- Closed set of CLI flags -> pass names.
local PASS_FLAG_TO_NAME = {
["--word-counts"] = "word-counts",
["--components"] = "components",
["--validate"] = "annotation",
["--offsets"] = "offsets",
["--static-analysis"] = "static-analysis",
["--report"] = "report",
["--all"] = "__all__",
}
local ALL_PASS_NAMES = {
"word-counts", "components", "annotation",
"offsets", "static-analysis", "report",
}
--- Append every pass name to args.requested_set. Used by --all and
--- by the "default to --all if no pass flags were given" fallback.
local function request_all_passes(args)
for _, n in ipairs(ALL_PASS_NAMES) do
args.requested_set[#args.requested_set + 1] = n
end
end
-- Per-flag handlers. Each handler takes (args, argv, i) and returns
-- the new i (so multi-arg flags like --source FILE advance it).
-- Returning nil + os.exit() handles termination flags (--help).
-- This replaces the 8-way `if/elseif/elseif...` chain that nested
-- 4 levels deep and made the dispatch logic hard to scan.
local FLAG_HANDLERS = {}
-- ════════════════════════════════════════════════════════════════════════════
-- CLI parsing
-- ════════════════════════════════════════════════════════════════════════════
--- Print the CLI usage to stdout and exit 0.
local function print_help()
io.write([[
ps1_meta.lua - Tape-atom metaprogram orchestrator
USAGE:
ps1_meta.lua [PASS_FLAGS] [COMMON_FLAGS]
PASS_FLAGS (pick one or more, or use --all):
--word-counts Load metadata.h + scan for existing .macs.h
--components Generate <module>/gen/<basename>.macs.h
--validate Run atom annotation DSL validation
--offsets Generate <module>/gen/<basename>.offsets.h
--static-analysis [FUTURE] GTE pipeline-fill, mac_yield uniformity
--report Render per-project summary
--all Equivalent to all 6 flags above (default)
COMMON_FLAGS:
--source FILE Source file to process (repeatable)
--metadata PATH Path to metadata.h (required)
--out-root DIR Output root for reports (default: build/gen)
--project-root DIR Project root for .macs.h scan (default: dirname(metadata))
--dry-run Print dep order + ASCII graph; exit 0 without running
--verbose Print per-pass debug output
--help Show this help and exit
EXIT CODES:
0 All requested passes succeeded
1 Validation errors found
2 Metaprogram internal error
EXAMPLE:
ps1_meta.lua --all --metadata metadata.h --source code/foo.c --source code/bar.c
]])
end
-- Per-flag handlers. Each takes (args, argv, i) and returns the new i
-- (so multi-arg flags like --source FILE advance it). Termination
-- flags like --help call os.exit() instead. This replaces the 8-way
-- `if/elseif/elseif...` chain that nested 4 levels deep and made the
-- dispatch logic hard to scan.
--
-- Populated AFTER print_help so the --help handler can reference it
-- as an upvalue (Lua resolves locals at closure-call time, but if the
-- closure is defined before the local, it falls back to _G).
FLAG_HANDLERS["--help"] = function(args)
print_help()
os.exit(0)
end
FLAG_HANDLERS["--dry-run"] = function(args)
args.dry_run = true
end
FLAG_HANDLERS["--verbose"] = function(args)
args.verbose = true
end
FLAG_HANDLERS["--source"] = function(args, argv, i)
args.sources[#args.sources + 1] = argv[i + 1]
return i + 1
end
FLAG_HANDLERS["--metadata"] = function(args, argv, i)
args.metadata = argv[i + 1]
return i + 1
end
FLAG_HANDLERS["--out-root"] = function(args, argv, i)
args.out_root = argv[i + 1]
return i + 1
end
FLAG_HANDLERS["--project-root"] = function(args, argv, i)
args.project_root = argv[i + 1]
return i + 1
end
-- Pass-flag handler. Reads the closed-set table, expands --all,
-- appends to requested_set. Single-statement, no nesting.
FLAG_HANDLERS["__pass__"] = function(args, a)
local name = PASS_FLAG_TO_NAME[a]
if name == "__all__" then
request_all_passes(args)
return
end
args.requested_set[#args.requested_set + 1] = name
end
--- Parse argv into a structured table. Validates against a closed enum.
---
--- @param argv string[]
--- @return ParsedArgs
local function parse_args(argv)
local args = {
requested_set = {},
sources = {},
metadata = nil,
out_root = "build/gen",
project_root = nil,
dry_run = false,
verbose = false,
}
local i = 1
while i <= #argv do
local a = argv[i]
local handler = FLAG_HANDLERS[a]
if handler then
i = handler(args, argv, i) or i
elseif PASS_FLAG_TO_NAME[a] then
FLAG_HANDLERS["__pass__"](args, a)
else
io.stderr:write("ps1_meta: unknown flag '" .. a .. "'\n")
io.stderr:write("Run with --help for usage.\n")
os.exit(2)
end
i = i + 1
end
-- Default: --all if no explicit pass flags.
if #args.requested_set == 0 then
request_all_passes(args)
end
-- Defaults: project_root = dirname(metadata).
if args.metadata and not args.project_root then
local d = dirname(args.metadata)
if #d > 0 and (d:sub(-1) == "/" or d:sub(-1) == "\\") then
d = d:sub(1, -2)
end
args.project_root = dirname(d)
end
if not args.metadata then
io.stderr:write("ps1_meta: --metadata PATH is required\n")
os.exit(2)
end
if #args.sources == 0 then
io.stderr:write("ps1_meta: at least one --source FILE is required\n")
os.exit(2)
end
return args
end
-- ════════════════════════════════════════════════════════════════════════════
-- Build ctx from parsed args
-- ════════════════════════════════════════════════════════════════════════════
--- Build the PassCtx from parsed args. Reads each source file once at startup;
--- passes consume `src.text`, not the path (path is preserved for error reporting).
---
--- @param args ParsedArgs
--- @return PassCtx
local function build_ctx(args)
local sources = {}
for _, path in ipairs(args.sources) do
local f = io.open(path, "r")
if not f then
io.stderr:write("ps1_meta: cannot open --source " .. path .. "\n")
os.exit(2)
end
local text = f:read("*a")
f:close()
local dir = dirname(path)
local basename = basename_no_ext(path)
if #dir > 0 and (dir:sub(-1) == "/" or dir:sub(-1) == "\\") then
dir = dir:sub(1, -2)
end
sources[#sources + 1] = {
path = path,
text = text,
dir = dir,
basename = basename,
}
end
return {
sources = sources,
metadata_path = args.metadata,
shared = {},
upstream = {},
out_root = args.out_root,
project_root = args.project_root,
flags = {},
dry_run = args.dry_run,
verbose = args.verbose,
}
end
-- ════════════════════════════════════════════════════════════════════════════
-- Topological sort (Kahn's algorithm + cycle detection)
-- ════════════════════════════════════════════════════════════════════════════
--- Topologically sort the requested pass set, augmented with all transitive deps.
--- Detects cycles and errors out with details.
---
--- @param passes table<string, PassDescriptor>
--- @param requested_set string[]
--- @return string[] -- execution order
local function topo_sort(passes, requested_set)
-- Step 1: dep-closure.
local needed = {}
for _, name in ipairs(requested_set) do needed[name] = true end
local changed = true
while changed do
changed = false
for name, _ in pairs(needed) do
local pass = passes[name]
if not pass then
error("unknown pass '" .. name .. "' requested")
end
for _, dep in ipairs(pass.deps) do
if not needed[dep] then
needed[dep] = true
changed = true
end
end
end
end
-- Step 2: Kahn's algorithm.
local in_degree = {}
for name, _ in pairs(needed) do in_degree[name] = 0 end
for name, _ in pairs(needed) do
for _, dep in ipairs(passes[name].deps) do
if needed[dep] then
in_degree[name] = (in_degree[name] or 0) + 1
end
end
end
-- Seed with passes that have no unmet deps. Sort for determinism.
local ready = {}
for name, deg in pairs(in_degree) do
if deg == 0 then ready[#ready + 1] = name end
end
table.sort(ready)
local order = {}
while #ready > 0 do
local n = table.remove(ready, 1)
order[#order + 1] = n
for name, _ in pairs(needed) do
if name ~= n then
for _, dep in ipairs(passes[name].deps) do
if dep == n then
in_degree[name] = in_degree[name] - 1
if in_degree[name] == 0 then
ready[#ready + 1] = name
table.sort(ready)
end
end
end
end
end
end
-- Cycle detection: if order doesn't include all needed passes,
-- some are stuck with in_degree > 0 (the cycle closed on itself
-- before Kahn could process them). Without this check, a fully-
-- closed cycle (e.g. A -> B -> A) would silently return an empty
-- order list, leaving the orchestrator to dispatch nothing.
--
-- Note: `#needed` returns 0 for hash tables (needed is a set,
-- not a sequence), so we count entries explicitly.
local needed_count = 0
for _ in pairs(needed) do needed_count = needed_count + 1 end
if #order ~= needed_count then
for name, deg in pairs(in_degree) do
if deg > 0 then
error("dependency cycle detected involving pass '" .. name .. "'")
end
end
end
return order
end
-- ════════════════════════════════════════════════════════════════════════════
-- ASCII dep graph renderer (Decision 6 in the spec)
-- ════════════════════════════════════════════════════════════════════════════
--- Render the dep graph as ASCII art. Output width capped at 78 columns.
--- Falls back to the simpler "Resolved dependency order" list only if
--- graph width exceeds terminal width.
---
--- @param passes table<string, PassDescriptor>
--- @param requested string[] -- originally-requested passes (subset of closed)
--- @param closed string[] -- dep-closed execution order
--- @return string
local function render_dep_graph(passes, requested, closed)
local lines = {}
local function add(s) lines[#lines + 1] = s end
add("[ps1_meta] Resolved dependency order (closed under deps):")
for i, name in ipairs(closed) do
local p = passes[name]
local deps_str = (#p.deps == 0) and "(no deps)" or
"(deps: " .. table.concat(p.deps, ", ") .. ")"
add(string.format(" %d. %-22s %-45s [%s]",
i, name, deps_str, p.kind))
end
add("")
add("[ps1_meta] Pass graph (read top-to-bottom):")
add("")
add(" metadata.h")
add(" |")
add(" v")
add(" +-----------+ +-----------------+ +-----------------+")
add(" | word- |-->| components |-->| offsets |")
add(" | counts | +-----------------+ +-----------------+")
add(" | (load) | | ^")
add(" +-----------+ | |")
add(" | v |")
add(" | code/<module>/gen/<basename>.macs.h |")
add(" | (header - co-located for #include) |")
add(" | |")
add(" | +-----------------+ |")
add(" +---------->| annotation |--------------+")
add(" | +-----------------+ |")
add(" | | |")
add(" | v |")
add(" | build/gen/<basename>.errors.h |")
add(" | build/gen/<basename>.annotations.txt |")
add(" | (report - NOT #included) |")
add(" | |")
add(" | +-----------------+ |")
add(" +---------->| static-analysis |--------------+")
add(" +-----------------+")
add(" |")
add(" v")
add(" +---------------+")
add(" | report |")
add(" +---------------+")
add(" |")
add(" v")
add(" build/gen/annotation_validation.txt")
add(" (project summary)")
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- Main orchestrator
-- ════════════════════════════════════════════════════════════════════════════
--- Main entry point. Runs the requested passes in dep-topological order.
--- @param argv string[]
local function main(argv)
local ok, err = pcall(function()
local args = parse_args(argv)
local ctx = build_ctx(args)
-- 1. Compute requested set + dep-closed set.
local requested = args.requested_set
local closed = topo_sort(PASSES, requested)
-- 2. --dry-run: print dep order + ASCII graph, exit 0.
if args.dry_run then
io.write(render_dep_graph(PASSES, requested, closed))
os.exit(0)
end
-- 3. Run passes in topological order.
ctx.shared = {}
for _, pass_name in ipairs(closed) do
local pass = PASSES[pass_name]
local mod = require(pass.module)
local result = mod.run(ctx)
-- Collect outputs + warnings into ctx.upstream.
ctx.upstream[pass_name] = ctx.upstream[pass_name] or {}
for _, out in ipairs(result.outputs or {}) do
table.insert(ctx.upstream[pass_name], out)
end
for _, warn in ipairs(result.warnings or {}) do
table.insert(ctx.upstream[pass_name], warn)
end
-- Stop on errors (unless this is a non-stopping pass like report).
if (result.errors and #result.errors > 0) and PASS_KIND_STOP_ON_ERROR[pass.kind] then
for _, e in ipairs(result.errors) do
io.stderr:write(string.format("[%s] line %d: %s\n",
pass_name, e.line or 0, e.msg or ""))
end
os.exit(1)
end
end
end)
if not ok then
io.stderr:write("[ps1_meta] internal error: " .. tostring(err) .. "\n")
os.exit(2)
end
os.exit(0)
end
main({...})
-437
View File
@@ -1,437 +0,0 @@
#!/usr/bin/env lua
-- tape_atom_offset_gen.lua
--
-- Finds every `MipsAtom_(name) { ... }` declaration in the given sources,
-- counts the words in each body using the WORD_COUNT manifest, computes
-- branch offsets for atom_label(name) / atom_offset(tag, name) markers,
-- and writes one header per source into <source_dir>/gen/<basename>.offsets.h
--
-- Generated header layout (per source):
-- #pragma region <basename>
-- #undef atom_offset
-- #define atom_offset(tag, name) atom_offset_##tag##_##name
-- // --- atom: <name> (<n> words) ---
-- #define atom_offset_<tag>_<target> (N) // preprocessor form
-- #undef atom_offset_<tag>_<target> // (so enum can reuse)
-- enum {
-- atom_offset_<tag>_<target> = N, // C enum form
-- };
-- #define atom_offset_<tag>_<target> (N) // re-define for preprocessor
-- #pragma endregion <basename>
--
-- Usage:
-- luajit gen_atom_offsets.lua <metadata.h> <source1> [source2 ...]
-- Make require("duffle") resolve to the sibling duffle.lua in this dir,
-- AND make require("lpeg") find the vendored LPeg DLL in the toolchain.
-- Both prepends are explicit (no :match / no Lua pattern — plain byte scan).
local script_path = arg[0]
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "?.lua;" .. script_dir .. "?/init.lua;" .. package.path
package.cpath = "C:\\projects\\Pikuma\\ps1\\toolchain\\luajit-2.1\\lib\\lua\\5.1\\?.dll;" .. package.cpath
-- Shared primitives + domain tables live in scripts/duffle.lua.
local duffle = require("duffle")
-- Local aliases so the rest of this file reads cleanly. These resolve
-- to the same functions in duffle.lua (5.3-compatible, no regex).
local is_space = duffle.is_space
local is_alpha = duffle.is_alpha
local is_alnum = duffle.is_alnum
local trim = duffle.trim
local find_byte = duffle.find_byte
local read_file = duffle.read_file
local write_file = duffle.write_file
local ensure_dir = duffle.ensure_dir
local dirname = duffle.dirname
local basename_no_ext = duffle.basename_no_ext
local skip_str_or_cmt = duffle.skip_str_or_cmt
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local read_ident = duffle.read_ident
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local read_brackets = duffle.read_brackets
local scan_to_char = duffle.scan_to_char
local split_top_level_commas = duffle.split_top_level_commas
local load_word_counts = duffle.load_word_counts
-- ============================================================
-- Offset-gen-specific helpers (not in duffle.lua)
-- ============================================================
local function starts_with(s, prefix)
if #s < #prefix then return false end
for i = 1, #prefix do
if s:sub(i, i) ~= prefix:sub(i, i) then return false end
end
return true
end
local function ends_with(s, suffix)
if #s < #suffix then return false end
local off = #s - #suffix
for i = 1, #suffix do
if s:sub(off + i, off + i) ~= suffix:sub(i, i) then return false end
end
return true
end
local function to_upper(s) return s:upper() end
local function to_alnum_underscore(s)
local out = ""
for i = 1, #s do
local c = s:sub(i, i)
if is_alnum(c) then out = out .. c
else out = out .. "_" end
end
return out
end
local function pad_right(s, w) return s .. string.rep(" ", w - #s) end
-- ============================================================
-- Extract comma-separated identifier args from a parenthesized group
-- after a function-like macro call.
-- ============================================================
local function extract_ident_args(token, after_ident)
local arg_start = skip_ws_and_cmt(token, after_ident)
if token:sub(arg_start, arg_start) ~= "(" then return {}, nil end
local inner, after_paren = read_parens(token, arg_start)
local args = {}
local n = 1
local len = #inner
while n <= len do
n = skip_ws_and_cmt(inner, n)
if n > len then break end
local ident, after = read_ident(inner, n)
if ident and ident ~= "" then
table.insert(args, ident)
n = after
else
n = n + 1
end
n = skip_ws_and_cmt(inner, n)
if n <= len and inner:sub(n, n) == "," then n = n + 1 end
end
return args, after_paren
end
-- ============================================================
-- Count words for a single comma-separated token
-- ============================================================
local function word_count_of_token(token, wc)
local s = trim(token)
if s == "" then return 0 end
local name, after = read_ident(s, 1)
if not name then return 1 end
if wc[name] then return wc[name] end
local j = skip_ws_and_cmt(s, after)
if s:sub(j, j) == "(" then
io.stderr:write(" warning: unknown macro '" .. name .. "', assuming 1 word\n")
end
return 1
end
-- ============================================================
-- Scan token for atom_label/atom_offset markers, walking through
-- balanced groups transparently (so nested calls are found)
-- ============================================================
local function scan_for_atom_markers(token, at_pos, labels, branches)
local i = 1
local len = #token
while i <= len do
i = skip_ws_and_cmt(token, i)
if i > len then break end
local c = token:sub(i, i)
if is_alpha(c) then
local ident, after = read_ident(token, i)
if ident == "atom_label" then
local args, after_paren = extract_ident_args(token, after)
if #args >= 1 then labels[args[1]] = at_pos end
if after_paren then i = after_paren else i = after end
elseif ident == "atom_offset" then
local args, after_paren = extract_ident_args(token, after)
if #args >= 2 then table.insert(branches, {pos = at_pos, target = args[2], tag = args[1]}) end
if after_paren then i = after_paren else i = after end
else
i = after
end
else
local nx = skip_str_or_cmt(token, i)
if nx > i then i = nx else i = i + 1 end
end
end
end
-- ============================================================
-- Scan atom body, count words, find markers
-- ============================================================
-- Find the end position (just past the closing ')') of the first
-- atom_label/atom_offset call in `tok`. Returns 0 if no such call.
local function find_marker_call_end(tok)
local i = 1
local len = #tok
while i <= len do
i = skip_ws_and_cmt(tok, i)
if i > len then break end
local c = tok:sub(i, i)
if is_alpha(c) then
local ident, after = read_ident(tok, i)
if ident == "atom_label" or ident == "atom_offset" then
local j = skip_ws_and_cmt(tok, after)
if tok:sub(j, j) == "(" then
local _, end_paren = read_parens(tok, j)
return end_paren - 1
end
return 0
end
i = after
else
local nx = skip_str_or_cmt(tok, i)
if nx > i then i = nx else i = i + 1 end
end
end
return 0
end
local function scan_atom_body(body, word_counts)
local pos = 0
local labels = {}
local branches = {}
for _, tok in ipairs(split_top_level_commas(body)) do
local k = 1
local tlen = #tok
while k <= tlen and is_space(tok:sub(k, k)) do k = k + 1 end
local leading_ident = read_ident(tok, k)
if leading_ident == "atom_label" or leading_ident == "atom_offset" then
-- Marker call: record at the current pos, do NOT advance pos.
-- But the source pattern may bundle the marker with the next
-- instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is a
-- real instruction that must still be counted.
scan_for_atom_markers(tok, pos, labels, branches)
local marker_end = find_marker_call_end(tok)
if marker_end > 0 and marker_end < #tok then
local rest = trim(tok:sub(marker_end + 1))
if rest ~= "" then
local rest_words = word_count_of_token(rest, word_counts)
pos = pos + rest_words
end
end
else
local words = word_count_of_token(tok, word_counts)
scan_for_atom_markers(tok, pos, labels, branches)
pos = pos + words
end
end
return labels, branches, pos
end
-- ============================================================
-- Find every MipsAtom_(name) { ... } in a source
-- ============================================================
local function skip_qualifiers(source, i)
local keywords = {
["static"] = true, ["const"] = true, ["volatile"] = true,
["extern"] = true, ["register"] = true, ["auto"] = true,
["inline"] = true, ["typedef"] = true,
["internal"]= true, ["LP_"] = true, ["global"] = true, ["gkknown"] = true
}
while true do
i = skip_ws_and_cmt(source, i)
local ident, after = read_ident(source, i)
if not ident then return i end
if keywords[ident] then i = after else return i end
end
end
local function find_atoms(source_text)
local atoms = {}
local len = #source_text
local i = 1
local function try_wrapped(after_pos)
local paren_pos = skip_ws_and_cmt(source_text, after_pos)
if source_text:sub(paren_pos, paren_pos) ~= "(" then return nil end
local inner, after_paren = read_parens(source_text, paren_pos)
local n = 1
while n <= #inner and is_space(inner:sub(n, n)) do n = n + 1 end
local ns = n
while n <= #inner and is_alnum(inner:sub(n, n)) do n = n + 1 end
local name = inner:sub(ns, n - 1)
if name == "" then return nil end
local brace_pos = scan_to_char(source_text, "{", after_paren)
if not brace_pos then return nil end
local body, after_brace = read_braces(source_text, brace_pos)
return {name = name, body = body, after_brace = after_brace}
end
local function try_raw(after_pos)
local next_pos = skip_ws_and_cmt(source_text, after_pos)
local next_ident, next_after = read_ident(source_text, next_pos)
if not next_ident then return nil end
if not starts_with(next_ident, "code_") then return nil end
if #next_ident <= 5 then return nil end
local atom_name = next_ident:sub(6)
local brace_pos = scan_to_char(source_text, "{", next_after)
if not brace_pos then return nil end
local body, after_brace = read_braces(source_text, brace_pos)
return {name = atom_name, body = body, after_brace = after_brace}
end
while i <= len do
i = skip_ws_and_cmt(source_text, i); if i > len then break end
i = skip_qualifiers(source_text, i); if i > len then break end
local ident, after = read_ident(source_text, i)
if not ident then
i = i + 1
elseif ident == "MipsAtom_" then
local atom = try_wrapped(after)
if atom then
table.insert(atoms, {name = atom.name, body = atom.body})
i = atom.after_brace
else
i = i + 1
end
elseif ident == "MipsCode" then
local atom = try_raw(after)
if atom then
table.insert(atoms, {name = atom.name, body = atom.body})
i = atom.after_brace
else
i = after
end
else
i = after
end
end
return atoms
end
-- ============================================================
-- Compute branch offsets (target - branch - 1)
-- ============================================================
local function compute_offsets(labels, branches)
local results = {}
for _, br in ipairs(branches) do
local target = labels[br.target]
if not target then
error("Branch target '" .. br.target .. "' has no atom_label (at word " .. br.pos .. ")")
end
table.insert(results, {target = br.target, tag = br.tag, offset = target - br.pos - 1 })
end
return results
end
-- ============================================================
-- Generate header for one source
-- ============================================================
local function generate_header(source_path, atoms_data)
local basename = basename_no_ext(source_path)
local guard = to_alnum_underscore(to_upper(basename)) .. "_OFFSETS_H"
local lines = {}
local function add(s) table.insert(lines, s) end
add("// Auto-generated by tape_atom_offset_gen.meta.lua — DO NOT EDIT")
add("// Source: " .. source_path)
add("#pragma once")
add("")
add("#pragma region " .. basename)
add("")
-- add("// Dispatch macro: token-pastes <tag>_<target> to the enum name")
-- add("#undef atom_offset")
-- add("#define atom_offset(tag, name) atom_offset_##tag##_##name")
add("")
for _, atom in ipairs(atoms_data) do
if #atom.offsets > 0 then
add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---")
add("")
local consts = {}
for _, r in ipairs(atom.offsets) do
table.insert(consts, {
macro_name = "_atom_offset_" .. r.tag .. "_" .. r.target,
enum_name = "atom_offset_" .. r.tag .. "_" .. r.target,
value = r.offset
})
end
for _, c in ipairs(consts) do add("#define " .. pad_right(c.macro_name, 44) .. " " .. c.value .. "") end
add("")
add("enum {")
for _, c in ipairs(consts) do add(" " .. c.enum_name .. " = " .. c.macro_name .. ",") end
add("};")
add("")
end
end
add("#pragma endregion " .. basename)
add("")
return table.concat(lines, "\n") .. "\n"
end
-- ============================================================
-- Process one source
-- ============================================================
local function process_source(source_path, word_counts)
local source = read_file(source_path)
local atoms_raw = find_atoms(source)
if #atoms_raw == 0 then
-- io.stderr:write(" note: no MipsAtom_ declarations in " .. source_path .. "\n")
return
end
local atoms_data = {}
for _, atom in ipairs(atoms_raw) do
local labels, branches, total = scan_atom_body(atom.body, word_counts)
local offsets = compute_offsets(labels, branches)
table.insert(atoms_data, {
name = atom.name,
total_words = total,
offsets = offsets
})
end
local basename = basename_no_ext(source_path)
local out_dir = dirname(source_path) .. "/gen"
ensure_dir(out_dir)
local out_path = out_dir .. "/" .. basename .. ".offsets.h"
write_file(out_path, generate_header(source_path, atoms_data))
local total_branches = 0
for _, a in ipairs(atoms_data) do total_branches = total_branches + #a.offsets end
print(" " .. basename .. ": " .. #atoms_data .. " atom(s), " .. total_branches .. " branch(es)")
for _, a in ipairs(atoms_data) do
for _, r in ipairs(a.offsets) do
print(" " .. a.name .. " -> " .. r.tag .. ":" .. r.target .. " : " .. r.offset)
end
end
end
-- ============================================================
-- Main
-- ============================================================
local function main(args)
if #args < 2 then
print("Usage: luajit gen_atom_offsets.lua <metadata.h> <source1> [source2 ...]")
os.exit(1)
end
local word_counts = load_word_counts(args[1])
for i = 2, #args do process_source(args[i], word_counts) end
end
main({...})
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
-- word_count_eval.lua
--
-- Word-counting logic for the tape-atom metaprogram pipeline.
-- Used by:
-- - passes/components.lua (compute_component_word_count)
-- - passes/offsets.lua (scan_atom_body)
-- - passes/annotation.lua (TAPE_WORDS <-> WORD_COUNT drift check)
--
-- This module ALSO exposes M.run(ctx) — the "word-counts" pass entry in
-- the PASSES table — which loads metadata.h + scans for existing
-- *.macs.h files into ctx.shared.word_counts.
--
-- Coding standard: tabs (1/level), EmmyLua annotations, no regex,
-- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "?.lua;" .. script_dir .. "?/init.lua;" .. package.path
package.cpath = "C:\\projects\\Pikuma\\ps1\\toolchain\\luajit-2.1\\lib\\lua\\5.1\\?.dll;" .. package.cpath
local duffle = require("duffle")
local trim = duffle.trim
local read_ident = duffle.read_ident
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local load_word_counts = duffle.load_word_counts
local split_top_level_commas = duffle.split_top_level_commas
local is_space = duffle.is_space
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class WordCounts
--- @field [string] integer -- macro name -> word count
--- @class PassCtx
--- @field sources SourceFile[]
--- @field metadata_path string
--- @field shared table
--- @field shared.word_counts WordCounts
--- @field out_root string
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
--- @field outputs table[]
--- @field errors table[]
--- @field warnings table[]
--- @class SourceFile
--- @field path string
--- @field text string
--- @field dir string
--- @field basename string
-- ════════════════════════════════════════════════════════════════════════════
-- Module exports
-- ════════════════════════════════════════════════════════════════════════════
local M = {}
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: count_token_words │
-- └────────────────────────────────────────────────────────────────────┘
--- Count words emitted by a single comma-separated token inside an atom body.
--- For most tokens (regular MIPS instructions) this returns 1.
--- For `mac_X(...)` calls, this returns the resolved word count from `wc`
--- (recursively if needed). For `nop2` etc., returns wc[name].
--- For unknown macros, returns 1 and (optionally) warns.
---
--- PORT NOTE: taken verbatim from tape_atom.offset_gen.meta.lua:130-141
--- (`word_count_of_token`). Behavior is identical to preserve the
--- branch-offset fix from commit 98e27c2.
---
--- @param token string -- a single token from split_top_level_commas
--- @param wc WordCounts -- the shared word-count table
--- @return integer
function M.count_token_words(token, wc)
local s = trim(token)
if s == "" then return 0 end
local name, after = read_ident(s, 1)
if not name then return 1 end
if wc[name] then return wc[name] end
local j = skip_ws_and_cmt(s, after)
if s:sub(j, j) == "(" then
io.stderr:write(" warning: unknown macro '" .. name .. "', assuming 1 word\n")
end
return 1
end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: scan_dir │
-- └────────────────────────────────────────────────────────────────────┘
--- Recursively scan a directory for files matching a glob suffix.
--- No regex per the no_regex constraint — uses plain byte matching
--- via `dir /b /s` on Windows.
---
--- PORT NOTE: taken from tape_atom.offset_gen.meta.lua:432-443
--- (`scan_dir`). Adapted: removed the hardcoded project_root derivation;
--- the caller passes `dir` explicitly.
---
--- @param dir string -- directory to scan (absolute or relative)
--- @param suffix string -- file pattern, e.g. "*.macs.h"
--- @return string[]
function M.scan_dir(dir, suffix)
local results = {}
local p = io.popen('dir /b /s "' .. dir .. '\\' .. suffix .. '" 2>nul')
if not p then return results end
for raw_line in p:lines() do
local path = raw_line:gsub("\\", "/")
results[#results + 1] = path
end
p:close()
return results
end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: count_body_words │
-- └────────────────────────────────────────────────────────────────────┘
--- Count words emitted by an entire atom body (a brace-delimited block).
--- Splits by top-level commas; for each token, delegates to count_token_words.
--- Handles `atom_label(name)` / `atom_offset(tag, name)` markers (record at
--- current pos, do NOT advance pos; if the marker call bundles an instruction
--- after it, count that instruction too).
---
--- PORT NOTE: taken verbatim from tape_atom.offset_gen.meta.lua:207-239
--- (`scan_atom_body`). Behavior is identical to preserve the branch-offset
--- fix from commit 98e27c2.
---
--- @param body string -- brace-delimited atom body (without braces)
--- @param wc WordCounts -- the shared word-count table
--- @return integer -- total words
function M.count_body_words(body, wc)
local pos = 0
for _, tok in ipairs(split_top_level_commas(body)) do
local k = 1
local tlen = #tok
while k <= tlen and is_space(tok:sub(k, k)) do k = k + 1 end
local leading_ident = read_ident(tok, k)
if leading_ident == "atom_label" or leading_ident == "atom_offset" then
-- Marker call: record at current pos, do NOT advance pos.
-- But the source pattern may bundle the marker with the next
-- instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is
-- a real instruction that must still be counted.
local marker_end = M.find_marker_call_end(tok)
if marker_end > 0 and marker_end < #tok then
local rest = trim(tok:sub(marker_end + 1))
if rest ~= "" then
local rest_words = M.count_token_words(rest, wc)
pos = pos + rest_words
end
end
else
pos = pos + M.count_token_words(tok, wc)
end
end
return pos
end
--- Find the end position (just past the closing ')') of the first
--- atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- Internal helper for count_body_words.
---
--- PORT NOTE: taken from tape_atom.offset_gen.meta.lua:181-205
--- (`find_marker_call_end`).
---
--- @param tok string
--- @return integer -- 0 if no marker call found
function M.find_marker_call_end(tok)
local i = 1
local len = #tok
while i <= len do
i = skip_ws_and_cmt(tok, i)
if i > len then break end
local c = tok:sub(i, i)
if is_space(c) then
i = i + 1
elseif c == "/" then
-- comment — skip past it (delegated to duffle.skip_str_or_cmt)
local nx = duffle.skip_str_or_cmt(tok, i)
if nx > i then i = nx else i = i + 1 end
else
local ident, after = read_ident(tok, i)
if ident == "atom_label" or ident == "atom_offset" then
local j = skip_ws_and_cmt(tok, after)
if tok:sub(j, j) == "(" then
local _, end_paren = duffle.read_parens(tok, j)
return end_paren - 1
end
return 0
end
i = after or (i + 1)
end
end
return 0
end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Pass entry: M.run(ctx) — "word-counts" pass │
-- └────────────────────────────────────────────────────────────────────┘
--- Load metadata.h + scan for existing *.macs.h files into
--- ctx.shared.word_counts. Loading the .macs.h files is idempotent:
--- entries from later (current-build) .macs.h files override
--- metadata.h entries of the same name.
---
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local wc = {}
-- 1. Load metadata.h (the encoding-macro source of truth).
local meta_counts = load_word_counts(ctx.metadata_path)
for name, count in pairs(meta_counts) do wc[name] = count end
-- 2. Scan project_root recursively for *.macs.h files (component-macro source).
local macs_files = M.scan_dir(ctx.project_root, "*.macs.h")
for _, macs_path in ipairs(macs_files) do
local ok, mc = pcall(load_word_counts, macs_path)
if ok and type(mc) == "table" then
for name, count in pairs(mc) do wc[name] = count end
end
end
ctx.shared.word_counts = wc
return { outputs = {}, errors = {}, warnings = {} }
end
return M