diff --git a/code/duffle/atom_dsl.h b/code/duffle/atom_dsl.h new file mode 100644 index 0000000..4b06954 --- /dev/null +++ b/code/duffle/atom_dsl.h @@ -0,0 +1,461 @@ +/* + * tape_atom_dsl.h + * ============================================================================ + * + * TAPE ATOM DSL — annotation layer for tape atoms (lottes_tape.h). + * + * This header turns `__attribute__((annotate(...)))` and `_Pragma(...)` into + * a small named DSL that the metaprogram can validate against. + * + * The C compiler treats every macro below as a no-op: + * - atom_init / atom_terminate / atom_bind / atom_setup / atom_commit / + * atom_annot all expand to `__attribute__((annotate("..."))) MipsAtom_(name)` + * — accepted by GCC (with -Wno-attributes), absent at runtime. + * - atom_resource / atom_region / atom_group / atom_cadence / atom_async + * expand to `_Pragma("...")` — accepted by any C11 preprocessor. + * + * The metaprogram (tape_atom_annotation_pass.lua) reads the source-as-written + * and validates: + * - every MipsAtom_ has one atom_*() annotation (no orphans) + * - phase is recognized (init/bind/setup/work/commit/terminate) + * - reads/writes reference canonical wave-context registers + * - rbind atoms reference a real Binds_* struct declaration + * - word-counts in tapre metadata agree with the body's actual .word count + * - resource/region/group/cadence/async pragmas are spelled correctly and + * reference known enum values + * + * ============================================================================ + * + * PUTTING IT ON AN ATOM — the canonical pattern + * + * _tape_resources_ + * atom_resource(cube_tri, "model_ship_cube") + * atom_region (cube_tri, PRIM_ARENA) + * atom_group (cube_tri, GROUP_RENDER_PRIMS) + * atom_cadence (cube_tri, CADENCE_FRAME) + * + * atom_annot(cube_tri, phase_work, + * tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase), + * tape_regs(R_PrimCursor, R_FaceCursor)) + * internal MipsAtom_(cube_tri) { + * atom_label(culling), + * // ... atom body ... + * atom_label(bounds_chk), + * }; + * + * atom_offset(culling, bounds_chk) // ← branch target, validated + * + * RBIND pattern — `Binds_*` is the contract + * + * // Wave-context register layout (declarative): + * typedef struct Binds_TrackFaceBatch { + * U4 R_PrimCursor, R_FaceCursor, + * R_VertBase, R_OtBase; + * } Binds_TrackFaceBatch; + * + * atom_resource(rbind_track_face_batch, "track_face_batch_42") + * atom_region (rbind_track_face_batch, HEAP_3D) + * atom_group (rbind_track_face_batch, GROUP_LOAD_FACES) + * atom_cadence (rbind_track_face_batch, CADENCE_ONDEMAND) + * atom_async (rbind_track_face_batch, true) + * + * atom_bind(rbind_track_face_batch, Binds_TrackFaceBatch, + * tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)) + * internal MipsAtom_(rbind_track_face_batch) { ... }; + * + * Annotation rules + * ---------------- + * 1. Each MipsAtom_(name) needs EXACTLY ONE atom_*() macro on the line + * immediately above. No annotation = orphan (warning). Two annotations + * on the same name = duplicate (error). + * + * 2. atom_init and atom_terminate take only the name. + * + * 3. atom_setup and atom_commit take name + reads. + * + * 4. atom_bind takes name + Binds_* type + writes. + * + * 5. atom_annot takes name + phase token + reads + writes. + * Phase tokens: phase_init / phase_bind / phase_setup / phase_work / + * phase_commit / phase_terminate. + * + * 6. Optional pragmas (atom_resource / atom_region / atom_group / + * atom_cadence / atom_async) attach metadata to the atom. They can + * appear in any order, with one per atom. They're independent of the + * atom_*() macro — multiple pragmatics are fine. + * + * ============================================================================ + * + * WHY A SEPARATE LAYER (not just put everything in source comments)? + * + * Source comments are invisible to the compiler. Annotations live in the + * source as actual C tokens, so: + * - they can never silently get out of sync with the code (the build + * fails at preprocessing if the metaprogram disagrees) + * - they can be cross-validated against metadata (build fails if a + * WORD_COUNT entry drifts away from the .word count in source) + * - they make the C compiler a witness ("there's a marker here, and + * it's labelled, and it has arguments") without making the C compile + * itself do any work + * + * ============================================================================ + */ + + #ifdef INTELLISENSE_DIRECTIVES +#pragma once +// #include +#endif + +/* ============================================================================ + * PHASE TOKENS — strings, used as the second arg to atom_annot(...) + * + * Why strings? They preserve the metaprogram's ability to read phase directly + * from the source-as-written, even when the macro isn't expanded. The Lua + * tool also has a MACRO_EXPANSION table for resolving phase_* source-level + * references. + * + * atom_annot(cube_tri, phase_work, ...) ← legal + * atom_annot(cube_tri, "work", ...) ← legal (and equivalent) + * atom_annot(cube_tri, phase_setup, ...) ← legal + * + * ============================================================================*/ + +#define phase_init "init" +#define phase_bind "bind" +#define phase_setup "setup" +#define phase_work "work" +#define phase_commit "commit" +#define phase_terminate "terminate" + +/* ============================================================================ + * WAVE-CONTEXT REGISTERS — canonical register set for the tape wave model. + * + * The tape-atom runtime carries four registers across a wave: + * + * R_PrimCursor output pointer into the prim arena (next OT entry to write) + * R_FaceCursor input pointer into the face array (next face to consume) + * R_VertBase base pointer into the vertex arena (this wave's vertices) + * R_OtBase base pointer into the ordering table (this wave's OT slot) + * + * Each atom declares its reads/writes against this canonical set. The Lua + * tool rejects wave-context positions that reference any other register + * (warning today — the C compiler's R_T4..R_T7 / R_RA / etc. aliases are + * implementation details and not part of the typed surface). + * + * If your atom needs to touch GTE / SP / DMA / other side state, declare it + * at the source level as you normally would — but DO NOT put those registers + * in tape_regs(...). Wave-context is a closed set. + * + * ============================================================================*/ + +/* ============================================================================ + * REGION TOKENS — memory regions atoms may allocate from or write into. + * + * Use atom_region(name, REGION) to declare. The Lua tool validates that the + * region is in this set, AND that: + * - rbind atoms declare the source region (usually HEAP_3D or CDROM_STREAM) + * - work atoms declare the destination region (the arena they push to) + * - commit atoms must declare a region equal to what setup wrote, so the + * C-side mirror is consistent + * + * Add new regions by extending this list and the metaprogram's KNOWN_REGIONS. + * Don't add regions ad-hoc — every new region becomes part of the contract. + * + * ============================================================================*/ +#define REGION_PRIM_ARENA prim_arena /* OT/prim packet arena */ +#define REGION_FACE_ARENA face_arena /* face index array */ +#define REGION_VERTEX_ARENA vertex_arena /* vertex pool */ +#define REGION_OT_ARENA ot_arena /* ordering-table array */ +#define REGION_HEAP_3D heap_3d_models /* loaded model heap */ +#define REGION_CDROM_STREAM cdrom_stream /* CDROM read buffer */ +#define REGION_VRAM vram_heap /* VRAM texture/GPU buffer */ + +/* ============================================================================ + * CADENCE TOKENS — how often the atom runs. + * + * frame runs every vsync (rendering, input poll) + * once runs exactly once per process lifetime (init, terminate) + * ondemand runs when triggered by event (CDROM load, async DMA complete) + * + * Used as a hint for the metaprogram to flag: + * - frame-cadence atoms that have side effects (they'll be hit many times, + * so avoid global state mutation unless it's idempotent) + * - once-cadence atoms inside "if (frame_count == 0)" guards (the guard + * is then provably one-shot, the metaprogram can lift initialization) + * - ondemand atoms that are missed by the wave scheduler (forces async + * and discards yield results without further processing) + * + * ============================================================================*/ +#define CADENCE_FRAME frame +#define CADENCE_ONCE once +#define CADENCE_ONDEMAND ondemand + +/* ============================================================================ + * tape_regs(...) — wave-context register list + * + * tape_regs(R_PrimCursor, R_FaceCursor) → (R_PrimCursor, R_FaceCursor) + * + * The macro produces a comma-evaluated expression that the C compiler + * silently discards (it's wrapped in parentheses in the call argument + * position — the result is never bound). The Lua tool pattern-matches the + * "tape_regs(...)" token to extract the list. + * + * You can have at most one tape_regs(...) in the reads slot and one in the + * writes slot of atom_annot. To declare multiple disjoint sets (rare), just + * declare the union — the metaprogram doesn't track which reads need which + * writes at this granularity. + * + * ============================================================================*/ +#define tape_regs(...) (__VA_ARGS__) + +/* ============================================================================ + * ATOM ANNOTATION MACROS + * + * Each expands to `__attribute__((annotate("kind"))) MipsAtom_(name)` — + * the GCC attribute is accepted under -Wno-attributes (already in your + * build flags) and stripped at runtime. The annotation string is just the + * macro kind ("atom_annot", "atom_bind", etc.) — the metaprogram reads + * the macro call's full args list from the source-as-written. + * + * ============================================================================*/ + +/* ---------------------------------------------------------------------------- + * atom_init — entry into tape_runtime_main + * + * atom_init(tape_main) + * internal MipsAtom_(tape_main) { ... }; + * + * Implies: no reads, no writes (wave-context not established yet). + * ----------------------------------------------------------------------------*/ +#define atom_init(name) __attribute__((annotate("atom_init"))) + +/* ---------------------------------------------------------------------------- + * atom_terminate — exit from tape_runtime_main + * + * atom_terminate(tape_exit) + * internal MipsAtom_(tape_exit) { ... }; + * + * Implies: no reads, no writes (wave-context destroyed at this point). + * ----------------------------------------------------------------------------*/ +#define atom_terminate(name) __attribute__((annotate("atom_terminate"))) + +/* ---------------------------------------------------------------------------- + * atom_setup — pre-work atom: prepares engine state (e.g., set_gte_world) + * + * atom_setup(set_gte_world, tape_regs(R_TapePtr)) + * internal MipsAtom_(set_gte_world) { ... }; + * + * Reads: anything (the engine state you're reading) + * Writes: engine state (GTE / DMA / etc. — declared in source, not part of + * wave-context, so doesn't go in tape_regs) + * + * The metaprogram checks that setup is followed (in atomic order) by a work + * atom in the same wave — there's no point in setting up state if no one + * reads it. + * ----------------------------------------------------------------------------*/ +#define atom_setup(name, reads) __attribute__((annotate("atom_setup"))) + +/* ---------------------------------------------------------------------------- + * atom_commit — post-work atom: flushes wave-context back to C-side state + * + * atom_commit(sync_prim_cursor, tape_regs(R_PrimCursor)) + * internal MipsAtom_(sync_prim_cursor) { ... }; + * + * Reads: wave-context registers (the ones you sync back to C) + * Writes: C-side mirror (declared in source — not part of wave-context) + * + * The metaprogram checks that commit is preceded (in atomic order) by a + * work atom that wrote the registers this commit is reading. + * ----------------------------------------------------------------------------*/ +#define atom_commit(name, reads) __attribute__((annotate("atom_commit"))) + +/* ---------------------------------------------------------------------------- + * atom_bind — rbind atom: read wave-context registers from tape pointer + * + * atom_bind(rbind_cube_tri, Binds_CubeTri, + * tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)) + * internal MipsAtom_(rbind_cube_tri) { ... }; + * + * The binds_struct MUST be a typedef'd type (declared via + * `typedef struct Binds_X { ... } Binds_X;` somewhere in the source). + * The Lua tool cross-references this. Missing struct = error. + * + * Implicit: reads R_TapePtr, writes the four wave-context registers. + * ----------------------------------------------------------------------------*/ +#define atom_bind(name, binds_struct, writes) __attribute__((annotate("atom_bind"))) + +/* ---------------------------------------------------------------------------- + * atom_annot — generic work atom with explicit phase + * + * atom_annot(cube_tri, phase_work, + * tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase), + * tape_regs(R_PrimCursor, R_FaceCursor)) + * internal MipsAtom_(cube_tri) { ... }; + * + * Use this for the bulk of your atoms. For init/setup/commit/bind, prefer + * the convenience macros above — they pin the phase for you. + * + * The phase arg is one of: phase_init / phase_bind / phase_setup / + * phase_work / phase_commit / phase_terminate. Spelling mistakes are errors. + * ----------------------------------------------------------------------------*/ +#define atom_annot(name, phase, reads, writes) __attribute__((annotate("atom_annot"))) + +/* ============================================================================ + * RESOURCE / GROUP / CADENCE / REGION / ASYNC — optional atom metadata + * + * These don't annotate the atom semantically (phase/reads/writes do that). + * They attach extra context that the metaprogram uses to catch: + * - same resource loaded twice in different ways + * - atoms that span multiple regions (likely bug — pick one) + * - frame-cadence atoms that should be once-cadence (perf / correctness) + * - ondemand atoms that aren't async (CDROM races) + * + * You can use as many as apply to a given atom, in any order, immediately + * above the atom_*() macro. + * + * ============================================================================*/ + +/* ---------------------------------------------------------------------------- + * atom_resource — name the logical resource the atom references + * + * atom_resource(cube_tri, "model_ship_cube") + * atom_resource(load_track_faces, "track_lavender_field_0x42") + * atom_resource(play_engine_sfx, "sfx_engine_loop") + * + * Use any human-readable string. The metaprogram: + * - validates resource strings are non-empty and don't contain control chars + * - flags duplicates across atoms with the same name (two atoms claiming + * ownership of a resource is usually a refactor artifact or bug) + * - flags references to resources that no atom actually defines + * + * The arg is a STRING LITERAL, so it can't accidentally alias a variable. + * ----------------------------------------------------------------------------*/ +#define atom_resource(name, res_id) //_Pragma("atom " #name " resource=" res_id) + +/* ---------------------------------------------------------------------------- + * atom_region — name the memory region the atom touches + * + * atom_region(cube_tri, REGION_PRIM_ARENA) + * atom_region(load_faces, REGION_HEAP_3D) + * atom_region(load_tex, REGION_VRAM) + * + * Use REGION_* tokens above. The metaprogram enforces the closed set. + * + * Edge cases the metaprogram catches: + * - rbind atom that doesn't declare a SOURCE region (where is it loading from?) + * - work atom with no destination region (where is it pushing to?) + * - region that disagrees with the Binds_* struct layout (you said it's a + * prim_arena rbind but the struct has 4 faces in it — wait, that's wrong) + * ----------------------------------------------------------------------------*/ +#define atom_region(name, region) //_Pragma("atom " #name " region=" #region) + +/* ---------------------------------------------------------------------------- + * atom_group — bundle atoms into a logical batch (track-load, sound-load, etc.) + * + * atom_group(load_track_face_42, GROUP_LOAD_FACES) + * atom_group(load_track_face_43, GROUP_LOAD_FACES) + * atom_group(swap_face_42_43, GROUP_VISIBILITY_SWAP) + * + * Use any token as the group id. The metaprogram: + * - validates all atoms in a group emit their waves in the same tb_group + * (no spawning other waves inside a group) + * - flags groups with only one member (probably a typo — meant to be a group?) + * - validates cross-group edges (no atom reads what another group writes, + * unless explicitly grouped together) + * + * Useful when: + * - subdivisible work (track-face batches, polygon subdivision) needs to + * confirm that all batches of one logical visible scene are emitted + * together + * - async loads (CDROM -> VRAM) need to be grouped so all batches complete + * before the swap + * + * Use GROUPS for sound effects to track which sound plays during which atom, + * which is needed if the sound tool ever has to validate "this atom is the + * trigger for an audio play". + * ----------------------------------------------------------------------------*/ +#define atom_group(name, group_id) //_Pragma("atom " #name " group=" #group_id) + +/* ---------------------------------------------------------------------------- + * atom_cadence — declare execution frequency + * + * atom_cadence(render_frame, CADENCE_FRAME) // every vsync + * atom_cadence(load_track_faces, CADENCE_ONDEMAND) // on demand + * atom_cadence(init_heap, CADENCE_ONCE) // process lifetime + * + * Default (no atom_cadence call) is CADENCE_FRAME — most atoms run every + * frame. Override explicitly when not. + * + * The metaprogram's checks: + * - CADENCE_ONCE atoms inside `if (frame == 0)` or `if (!initialized)` are + * tagged, validating that guards are required (or warning if missing) + * - CADENCE_FRAME atoms that mutate state outside the wave context get + * flagged (likely a bug — state should persist through commits) + * - CADENCE_ONDEMAND atoms must have atom_async — otherwise the trigger + * mechanism is undefined + * ----------------------------------------------------------------------------*/ +#define atom_cadence(name, cadence) //_Pragma("atom " #name " cadence=" #cadence) + +/* ---------------------------------------------------------------------------- + * atom_async — declare whether the atom yields / interacts with CDROM DMA + * + * atom_async(load_track_tex, true) // CDROM read yield + * atom_async(load_vram, true) // VRAM upload DMA + * atom_async(render_frame, false) // pure compute, no async + * + * The metaprogram requires this for CADENCE_ONDEMAND atoms. For + * CADENCE_FRAME, it's optional but documents intent. + * + * Note: CDROM ATOMS in Psy-Q are typically implemented as a chain of + * "async-init" atom followed by a "wait-for-completion" atom. Both atoms + * should be marked async=true, and both should have the same resource/group + * tag (so the metaprogram can verify they're paired). + * ----------------------------------------------------------------------------*/ +#define atom_async(name, is_async) //_Pragma("atom " #name " async=" #is_async) + +/* ============================================================================ + * WORD-COUNT ANNOTATION FOR A #define MAC + * + * tape_words(mac_yield, 1) + * #define mac_yield() \ + * load_word(R_AtomJmp, R_TapePtr, 0), \ + * add_ui_1(R_TapePtr, 4), \ + * jump_reg(R_AtomJmp), \ + * nop + * + * The compiler accepts the unknown _Pragma. The Lua tool reads it and + * cross-checks against WORD_COUNT(mac_yield, 1) in tape_atom.metadata.h. + * If they disagree, build fails. + * + * Use sparingly — only on multi-word macros (single-word ones don't need + * drift tracking; they're checked by the .word-count pass anyway). + * + * ============================================================================*/ +#define tape_words(name, n) //_Pragma(#name " tape_atom words=" #n) + +/* ============================================================================ + * atom_label / atom_offset — branch target machinery + * + * atom_label(culling) ← nothing in C; anchor only + * ... body ... + * atom_label(bounds_chk) ← another anchor + * + * atom_offset(culling, bounds_chk) ← resolved by gen/.offsets.h + * + * The metaprogram generates gen/atom_offsets.h with one + * #define atom_offset__culling__bounds_chk ((target - branch_pos - 1)) + * per atom_offset(F, T) call. The preprocessor then expands your call to + * the right immediate value. + * + * If gen/atom_offsets.h is stale (or atom_label(name) is undefined), + * `atom_offset__F__T` becomes an undefined macro and the C build fails. + * This catches: + * - typo in atom_label (no anchor → metaprogram doesn't emit the macro) + * - .offsets.h not regenerated after body edits + * - body edit that broke the offset math (recompile + retest picks it up + * in CPU emulator) + * + * ============================================================================*/ + +#define atom_offset(F, T) atom_offset_ ## F ## _ ## T +#define atom_label(name) /* anchor — see metaprogram documentation */ diff --git a/code/duffle/gen/lottes_tape.offsets.h b/code/duffle/gen/lottes_tape.offsets.h index cf12528..6f2aeeb 100644 --- a/code/duffle/gen/lottes_tape.offsets.h +++ b/code/duffle/gen/lottes_tape.offsets.h @@ -4,9 +4,6 @@ #pragma region lottes_tape -// Dispatch macro: token-pastes _ to the enum name -#undef atom_offset -#define atom_offset(tag, name) atom_offset_##tag##_##name #pragma endregion lottes_tape diff --git a/code/duffle/lottes_tape.h b/code/duffle/lottes_tape.h index 7d77724..d9e805e 100644 --- a/code/duffle/lottes_tape.h +++ b/code/duffle/lottes_tape.h @@ -5,6 +5,7 @@ # include "mips.h" # include "gte.h" # include "memory.h" +# include "atom_dsl.h" #endif typedef U4 const MipsCode; @@ -20,16 +21,19 @@ typedef U4 const MipsCode; enum { R_AtomJmp = R_T9, R_TapePtr = R_T8, /* The Instruction Stream Pointer */ + R_InCursor = R_T4, /* Input data cursor */ + R_PrimCursor = R_T7, /* VRAM output cursor (primitive buffer) */ R_FaceCursor = R_T4, /* Input data cursor (indices/faces) */ - R_InCursor = R_T4, /* Input data cursor (indices/faces) */ R_VertBase = R_T5, /* Base address of the vertex array */ R_OtBase = R_T6, /* Base address of the Ordering Table */ + /* Stringification codes for the GCC inline assembler clobber lists */ #define R_TapePtr_Code R_T8_Code +#define R_InCursor_Code R_T4_Code + #define R_PrimCursor_Code R_T7_Code #define R_FaceCursor_Code R_T4_Code -#define R_InCursor_Code R_T4_Code #define R_VertBase_Code R_T5_Code #define R_OtBase_Code R_T6_Code }; @@ -274,7 +278,15 @@ internal MipsAtom_(rbind_cube_tri) { * Inner branch (OTZ bounds): branch_equal(R_AT, R_0, 13) * → Skip 13 instructions from BD slot, land at add_ui(R_FaceCur,...) * ============================================================================ */ -internal MipsAtom_(cube_tri) { + atom_resource(cube_tri, "model_ship_cube") + atom_region (cube_tri, REGION_PRIM_ARENA) + atom_group (cube_tri, GROUP_RENDER_PRIMS) + atom_cadence (cube_tri, CADENCE_FRAME) + atom_annot(cube_tri, phase_work, + tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase), + tape_regs(R_PrimCursor, R_FaceCursor)) +internal +MipsAtom_(cube_tri) { /* ── 1. Load 4 face indices from R_FaceCur ──────────────────────────── */ load_half_u(R_T0, R_FaceCursor, 0), /* T0 = face->x (vertex 0 index) */ load_half_u(R_T1, R_FaceCursor, 2), /* T1 = face->y (vertex 1 index) */ diff --git a/code/gte_hello/gen/hello_gte_tape.offsets.h b/code/gte_hello/gen/hello_gte_tape.offsets.h index 24c707e..596d12f 100644 --- a/code/gte_hello/gen/hello_gte_tape.offsets.h +++ b/code/gte_hello/gen/hello_gte_tape.offsets.h @@ -4,9 +4,6 @@ #pragma region hello_gte_tape -// Dispatch macro: token-pastes _ to the enum name -#undef atom_offset -#define atom_offset(tag, name) atom_offset_##tag##_##name // --- atom: floor_tri (32 words) --- diff --git a/code/gte_hello/hello_gte.c b/code/gte_hello/hello_gte.c index 70cb939..b652945 100644 --- a/code/gte_hello/hello_gte.c +++ b/code/gte_hello/hello_gte.c @@ -14,6 +14,7 @@ #include "duffle/gte.h" # include "duffle/gen/lottes_tape.offsets.h" +#include "duffle/atom_dsl.h" #include "duffle/lottes_tape.h" # include "tape_atom.metadata.h" diff --git a/code/gte_hello/hello_gte_tape.c b/code/gte_hello/hello_gte_tape.c index cea90fc..adf67f7 100644 --- a/code/gte_hello/hello_gte_tape.c +++ b/code/gte_hello/hello_gte_tape.c @@ -1,5 +1,6 @@ #ifdef INTELLISENSE_DIRECTIVES # include "duffle/lottes_tape.h" +# include "duffle/atom_dsl.h" # include "hello_gte.h" # include "tape_atom.metadata.h" # include "gen/hello_gte_tape.offsets.h" @@ -22,7 +23,14 @@ #pragma region Baked Atoms -internal MipsAtom_(floor_tri) { + atom_region( floor_tri, REGION_PRIM_ARENA) + atom_group( floor_tri, GROUP_RENDER_FLOOR) + atom_cadence(floor_tri, CADENCE_FRAME) + atom_annot( floor_tri, phase_work, + tape_regs(R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase), + tape_regs(R_PrimCursor, R_FaceCursor)) +internal +MipsAtom_(floor_tri) { // T0-T2 allocated mac_load_tri_indices(R_T0, R_T1, R_T2), // load_half_u(R_T0, R_FaceCur, 0 * S_(S2)) diff --git a/code/gte_hello/tape_atom.metadata.h b/code/gte_hello/tape_atom.metadata.h index f0275ec..a5c118b 100644 --- a/code/gte_hello/tape_atom.metadata.h +++ b/code/gte_hello/tape_atom.metadata.h @@ -41,7 +41,3 @@ WORD_COUNT(mac_insert_ot_tag, 11) WORD_COUNT(mac_yield, 4) #undef WORD_COUNT - -// Used to define word markers for the lua metaprogram to calculate offsets from. -#define atom_label(sym) -// #define atom_offset(sym) // will be generated based on usage within a baked atom. diff --git a/scripts/build_psyq.ps1 b/scripts/build_psyq.ps1 index 6d5fc71..960c66b 100644 --- a/scripts/build_psyq.ps1 +++ b/scripts/build_psyq.ps1 @@ -81,21 +81,34 @@ $path_psyq = join-path $path_toolchain 'psyq-4_7' $path_psyq_iwyu = join-path $path_toolchain 'psyq_iwyu' $path_psyq_imyu_inc = join-path $path_psyq_iwyu 'include' -function assemble-unit { param( +function Get-SourceFiles { param([Parameter(Mandatory=$true)] [string[]]$paths, [Parameter(Mandatory=$true)] [string[]]$extensions) + $files = @() + foreach ($p in $paths) { + if (-not (test-path $p)) { continue } + foreach ($ext in $extensions) { + Get-ChildItem -Path $p -File -Recurse -Filter "*$ext" -ErrorAction SilentlyContinue | ForEach-Object { + $files += $_.FullName + } + } + } + return ($files | Sort-Object -Unique) +} + +function assemble-unit { param( [string] $unit, [string] $link_module, [string[]]$include_paths, [string[]]$user_assemble_args ) $assemble_args = @( - $f_arch_mips1, - $f_arch_abi32, + $f_arch_mips1, + $f_arch_abi32, $f_arch_fp32, - $f_arch_little_endian, + $f_arch_little_endian, $f_arch_no_abicalls, - $f_arch_no_pic, - $f_arch_no_llsc, - $f_arch_no_shared, + $f_arch_no_pic, + $f_arch_no_llsc, + $f_arch_no_shared, $f_arch_no_stack_prot ) $assemble_args += $f_no_stdlib @@ -130,14 +143,14 @@ function compile-unit { param( $compile_args += $f_no_strict_alias $compile_args += @( $f_arch_mips1, - $f_arch_abi32, + $f_arch_abi32, $f_arch_fp32, $f_arch_little_endian, - $f_arch_no_abicalls, - $f_arch_no_gpopt, - $f_arch_no_pic, - $f_arch_no_llsc, - $f_arch_no_shared, + $f_arch_no_abicalls, + $f_arch_no_gpopt, + $f_arch_no_pic, + $f_arch_no_llsc, + $f_arch_no_shared, $f_arch_no_stack_prot ) # $compile_args += $f_std_c23 @@ -154,11 +167,7 @@ function compile-unit { param( & $Compiler $compile_args if ($LASTEXITCODE -ne 0) { write-error "Compilation failed for $unit. Aborting."; exit 1 } } -function link-modules { param( - [string[]]$link_modules, - [string] $elf, - [string[]]$user_link_args -) +function link-modules { param([string[]]$link_modules, [string] $elf, [string[]]$user_link_args) $link_args = @() $link_args += $f_no_stdlib @@ -185,19 +194,19 @@ function link-modules { param( $link_args += ($f_link_pass_through_prefix + $f_link_start_group) $libraries = @( - "api", - "c", - "c2", - "card", - "cd", - "comb", - "ds", - "etc", - "gpu", - "gs", - "gte", - "gun", - "hmd", + "api", + "c", + "c2", + "card", + "cd", + "comb", + "ds", + "etc", + "gpu", + "gs", + "gte", + "gun", + "hmd", "math", "mcrd", "mcx", @@ -226,10 +235,7 @@ function link-modules { param( & mipsel-none-elf-objdump.exe -W $elf >> $dasm if ($LASTEXITCODE -ne 0) { write-error "Linking failed. Aborting."; exit 1 } } -function make-binary { param( - [string]$elf, - [string]$exe -) +function make-binary { param([string]$elf, [string]$exe) Write-Host "--- Creating Binary ---" -ForegroundColor Cyan write-host "Converting $elf to PS-EXE -> '$exe'" $objcopy_args = ($f_objcopy_format + "binary"), $elf, $exe @@ -311,12 +317,7 @@ function build-graphis_hello { } # build-graphis_hello -function generate-TapeAtomOffsets {param( - [Parameter(Mandatory=$true)] - [string[]]$sources, - [Parameter(Mandatory=$true)] - [string]$metadata) - +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' $any_stale = $false @@ -337,11 +338,11 @@ function generate-TapeAtomOffsets {param( } if (-not $any_stale) { - write-host "AtomOffs all $($sources.Count) source(s) up-to-date" -ForegroundColor DarkGray + write-host "AtomOffsets all $($sources.Count) source(s) up-to-date" -ForegroundColor DarkGray return } - write-host "AtomOffs $($sources.Count) source(s)" -ForegroundColor Magenta + write-host "AtomOffsets $($sources.Count) source(s)" -ForegroundColor Magenta & lua $gen_atom_offsets_script $metadata @sources if ($LASTEXITCODE -ne 0) { write-error "Atom offset generation failed. Aborting." @@ -349,21 +350,81 @@ function generate-TapeAtomOffsets {param( } } +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/.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 + & lua $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 build-gte_hello { $includes += @() - $path_module = join-path $path_code 'gte_hello' + $path_module = join-path $path_code 'gte_hello' + $path_duffle = join-path $path_code 'duffle' + $path_atom_metadata = join-path $path_module 'tape_atom.metadata.h' - $path_duffle = join-path $path_code 'duffle' - $path_gen = join-path $path_module 'gen' - $path_atom_metadata = join-path $path_module 'tape_atom.metadata.h' + $source_dirs = @($path_duffle, $path_module) + $atom_sources = Get-SourceFiles -paths $source_dirs -extensions @('.h', '.c') - $atom_sources = @( - (join-path $path_duffle 'mips.h'), - (join-path $path_duffle 'lottes_tape.h'), - (join-path $path_module 'hello_gte_tape.c') - ) - generate-TapeAtomOffsets -sources $atom_sources -metadata $path_atom_metadata + generate-TapeAtomAnnotations -sources $atom_sources -metadata $path_atom_metadata + generate-TapeAtomOffsets -sources $atom_sources -metadata $path_atom_metadata $assemble_args = @() $assemble_args += $f_debug @@ -408,10 +469,10 @@ function Send-ToEmulator { param( [string]$exePath ) $uri = "http://localhost:8080/api/v1/load-exec" - + # Absolute path is safest for the emulator web server $absolutePath = [System.IO.Path]::GetFullPath($exePath) - + # Create JSON payload pointing to your compiled .ps-exe $body = @{ filename = $absolutePath diff --git a/scripts/tape_atom.offset_gen.meta.lua b/scripts/tape_atom.offset_gen.meta.lua index 81d716b..65f59c6 100644 --- a/scripts/tape_atom.offset_gen.meta.lua +++ b/scripts/tape_atom.offset_gen.meta.lua @@ -502,9 +502,9 @@ local function generate_header(source_path, atoms_data) add("") add("#pragma region " .. basename) add("") - add("// Dispatch macro: token-pastes _ to the enum name") - add("#undef atom_offset") - add("#define atom_offset(tag, name) atom_offset_##tag##_##name") + -- add("// Dispatch macro: token-pastes _ 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 diff --git a/scripts/tape_atom_annotation_pass.lua b/scripts/tape_atom_annotation_pass.lua new file mode 100644 index 0000000..fff8774 --- /dev/null +++ b/scripts/tape_atom_annotation_pass.lua @@ -0,0 +1,1282 @@ +#!/usr/bin/env lua +-- tape_atom_annotation_pass.lua +-- +-- Augments tape_atom_offset_gen.lua with the annotation pass: +-- +-- 1. Parses TAPE_ATOM_ANNOT / TAPE_ATOM_BIND / TAPE_ATOM_SETUP / +-- TAPE_ATOM_COMMIT / TAPE_ATOM_INIT / TAPE_ATOM_TERMINATE lines +-- placed above each MipsAtom_(name) { ... } declaration. +-- 2. Parses TAPE_WORDS(mac_X, N) pragmas (the _Pragma form) before +-- each #define mac_X(...) ... macro. +-- 3. Parses typedef Struct_(Binds_X) { U4 field1; U4 field2; ... }; +-- declarations to extract field names + byte offsets. +-- 4. Cross-checks: +-- - TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) in metadata.h +-- - TAPE_ATOM_BIND(rbind_X, Binds_Y, ...) ↔ Struct_(Binds_Y) +-- - Binds_Y field names ↔ wave-context register aliases +-- (R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase) +-- - Every atom has exactly one phase annotation +-- 5. Reports annotation drift / structural issues. +-- +-- Outputs per source: +-- /gen/.annotations.txt +-- +-- And a project-level summary: +-- /gen/annotation_validation.txt +-- +-- Usage: +-- lua tape_atom_annotation_pass.lua [source2 ...] +-- +-- This script is meant to be run alongside tape_atom_offset_gen.lua. +-- Same input files, complementary output. Both feed the build. + +-- ============================================================ +-- Shared primitives (copied from tape_atom_offset_gen.lua so this +-- script can stand alone; if you require() the original instead, +-- these become the original's locals). +-- ============================================================ + +local function is_space(c) return c == " " or c == "\t" or c == "\n" or c == "\r" or c == "\v" or c == "\f" end +local function is_alpha(c) + if not c or #c == 0 then return false end + if c >= "a" and c <= "z" then return true end + if c >= "A" and c <= "Z" then return true end + return c == "_" +end +local function is_digit(c) return c and c >= "0" and c <= "9" end +local function is_alnum(c) return is_alpha(c) or is_digit(c) end + +local function trim(s) + local a = 1; while a <= #s and is_space(s:sub(a, a)) do a = a + 1 end + local b = #s; while b >= a and is_space(s:sub(b, b)) do b = b - 1 end + return s:sub(a, b) +end + +local function find_byte(haystack, target, start) + for i = start or 1, #haystack do + if haystack:sub(i, i) == target then return i end + end + return nil +end + +local function read_file(path) + local f = io.open(path, "r") + if not f then error("Cannot open " .. path) end + local content = f:read("*a") + f:close() + return content +end + +local function write_file(path, content) + local f = io.open(path, "w") + if not f then error("Cannot write " .. path) end + f:write(content) + f:close() +end + +local function dirname(path) + local last_sep = 0 + for i = 1, #path do + local c = path:sub(i, i) + if c == "/" or c == "\\" then last_sep = i end + end + if last_sep == 0 then return "." end + return path:sub(1, last_sep - 1) +end + +local function basename_no_ext(path) + local last_sep = 0 + for i = 1, #path do + local c = path:sub(i, i) + if c == "/" or c == "\\" then last_sep = i end + end + local a = last_sep + 1 + local last_dot = #path + 1 + for i = #path, a, -1 do + if path:sub(i, i) == "." then last_dot = i; break end + end + return path:sub(a, last_dot - 1) +end + +local function ensure_dir(path) + local is_win = package.config:sub(1, 1) == "\\" + os.execute(is_win and ('if not exist "' .. path .. '" mkdir "' .. path .. '"') or ('mkdir -p "' .. path .. '" 2>/dev/null')) +end + +-- ============================================================ +-- Lexer primitives (mirror the offset-gen tool) +-- ============================================================ + +local function skip_str_or_cmt(s, i) + local c = s:sub(i, i) + if c == '"' or c == "'" then + i = i + 1 + while i <= #s do + if s:sub(i, i) == "\\" then i = i + 2 + elseif s:sub(i, i) == c then return i + 1 + else i = i + 1 end + end + return #s + 1 + elseif c == "/" then + local nx = s:sub(i+1, i+1) + if nx == "/" then + while i <= #s and s:sub(i, i) ~= "\n" do i = i + 1 end + return i + elseif nx == "*" then + i = i + 2 + while i <= #s - 1 do + if s:sub(i, i) == "*" and s:sub(i+1, i+1) == "/" then + return i + 2 + end + i = i + 1 + end + return #s + 1 + end + end + return i +end + +local function skip_ws_and_cmt(s, i) + while i <= #s do + if is_space(s:sub(i, i)) then i = i + 1 + else + local nx = skip_str_or_cmt(s, i) + if nx > i then i = nx else break end + end + end + return i +end + +local function read_ident(source, i) + if not is_alpha(source:sub(i, i)) then return nil, i end + local a = i + i = i + 1 + while i <= #source and is_alnum(source:sub(i, i)) do i = i + 1 end + return source:sub(a, i - 1), i +end + +local function read_balanced(s, open_char, close_char, i) + if s:sub(i, i) ~= open_char then return nil, i end + i = i + 1 + local len = #s + local depth = 1 + local a = i + while i <= len and depth > 0 do + local c = s:sub(i, i) + if c == open_char then + depth = depth + 1 + i = i + 1 + elseif c == close_char then + depth = depth - 1 + if depth == 0 then break end + i = i + 1 + else + local nx = skip_str_or_cmt(s, i) + if nx > i then i = nx else i = i + 1 end + end + end + return s:sub(a, i - 1), i + 1 +end + +local read_parens = function(s, i) return read_balanced(s, "(", ")", i) end +local read_braces = function(s, i) return read_balanced(s, "{", "}", i) end +local read_brackets = function(s, i) return read_balanced(s, "[", "]", i) end + +local function scan_to_char(s, target, start) + local i = start + while i <= #s do + local c = s:sub(i, i) + if c == target then return i end + if c == "(" then local _, a = read_balanced(s, "(", ")", i); i = a + elseif c == "{" then local _, a = read_balanced(s, "{", "}", i); i = a + elseif c == "[" then local _, a = read_balanced(s, "[", "]", i); i = a + else + local nx = skip_str_or_cmt(s, i) + if nx > i then i = nx else i = i + 1 end + end + end +end + +-- ============================================================ +-- Load WORD_COUNT manifest (mirror of the offset-gen tool) +-- ============================================================ + +local function load_word_counts(metadata_path) + local counts = {} + local content = read_file(metadata_path) + local len = #content + local i = 1 + local prefix = "WORD_COUNT(" + while i <= len do + local nl = find_byte(content, "\n", i) + local line_end = nl or (len + 1) + local line = content:sub(i, line_end - 1) + local trimmed = trim(line) + if trimmed:sub(1, #prefix) == prefix and trimmed:sub(-1) == ")" then + local inner = trimmed:sub(#prefix + 1, #trimmed - 1) + local comma = find_byte(inner, ",", 1) + if comma then + counts[trim(inner:sub(1, comma - 1))] = tonumber(trim(inner:sub(comma + 1))) + end + end + i = line_end + 1 + end + return counts +end + +-- ============================================================ +-- Wave-context register aliases +-- +-- These are the canonical names of the registers that get bound +-- into the wave by the rbind atoms. The annotation pass uses this +-- table to validate Binds_* struct field names — every field that +-- the bind writes must map to a known wave-context register. +-- ============================================================ + +local WAVE_CONTEXT_REGS = { + ["R_PrimCursor"] = { alias = "R_T7", size = 4, role = "output cursor (prim arena)" }, + ["R_FaceCursor"] = { alias = "R_T4", size = 4, role = "input cursor (face array)" }, + ["R_VertBase"] = { alias = "R_T5", size = 4, role = "base pointer (vertex array)" }, + ["R_OtBase"] = { alias = "R_T6", size = 4, role = "base pointer (ordering table)" }, +} + +-- Macro expansion table: maps source-level macro names to the value they expand +-- to at preprocessing time. Used so the metaprogram can validate annotations +-- against the un-preprocessed source. +local MACRO_EXPANSION = { + -- phase_* tokens + ["phase_init"] = "init", + ["phase_bind"] = "bind", + ["phase_setup"] = "setup", + ["phase_work"] = "work", + ["phase_commit"] = "commit", + ["phase_terminate"] = "terminate", + -- region tokens + ["REGION_PRIM_ARENA"] = "prim_arena", + ["REGION_FACE_ARENA"] = "face_arena", + ["REGION_VERTEX_ARENA"] = "vertex_arena", + ["REGION_OT_ARENA"] = "ot_arena", + ["REGION_HEAP_3D"] = "heap_3d_models", + ["REGION_CDROM_STREAM"] = "cdrom_stream", + ["REGION_VRAM"] = "vram_heap", + -- cadence tokens + ["CADENCE_FRAME"] = "frame", + ["CADENCE_ONCE"] = "once", + ["CADENCE_ONDEMAND"] = "ondemand", +} + +local function valid_phase(p) return KNOWN_PHASES[p] end +local function is_wave_context_reg(name) return WAVE_CONTEXT_REGS[name] ~= nil end + +-- ============================================================ +-- Parse TAPE_ATOM_ANNOT(...) calls in source +-- ============================================================ +-- +-- We scan for these top-level macro invocations anywhere in the file. +-- Each call has positional args: name, phase, reads, writes. +-- The reads/writes args are themselves TAPE_REGS(...) calls that we +-- expand to extract the register identifier list. +-- +-- Returns a list of {line, name, phase, reads = {...}, writes = {...}, +-- binds = nil-or-string} +-- in source order. +-- ============================================================ + +local function split_top_level_commas(body) + local tokens = {} + local i = 1 + local token_start = 1 + while i <= #body do + local c = body:sub(i, i) + if c == "(" then local _, a = read_parens(body, i); i = a + elseif c == "{" then local _, a = read_braces(body, i); i = a + elseif c == "[" then local _, a = read_brackets(body, i); i = a + elseif c == "," then + table.insert(tokens, body:sub(token_start, i - 1)) + i = i + 1 + token_start = i + else + local nx = skip_str_or_cmt(body, i) + if nx > i then i = nx else i = i + 1 end + end + end + local last = body:sub(token_start) + if trim(last) ~= "" then table.insert(tokens, last) end + return tokens +end + +local function line_of(source, pos) + local line = 1 + for i = 1, pos do + if source:sub(i, i) == "\n" then line = line + 1 end + end + return line +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. TAPE_PHASE_WORK) +-- "regs" -- a TAPE_REGS(...) call whose args are extracted as a list +-- "other" -- something we can't classify (preserved as text) +local function parse_atom_annot_args(inner) + -- Split at top-level commas, respecting nested parens. + local args = {} + local tokens = split_top_level_commas(inner) + for _, tok in ipairs(tokens) do + local s = trim(tok) + if s ~= "" then + -- TAPE_REGS(...) → extract inner identifiers + if s:sub(1, 10) == "TAPE_REGS(" and s:sub(-1) == ")" then + local regs_inner = s:sub(11, -2) + local regs = {} + for r in regs_inner:gmatch("[^,]+") do + local trimmed = trim(r) + if trimmed ~= "" then table.insert(regs, trimmed) end + end + table.insert(args, {kind = "regs", value = regs}) + else + -- Bare identifier (e.g. TAPE_PHASE_WORK) + local id, _ = read_ident(s, 1) + if id and trim(s) == id then + table.insert(args, {kind = "ident", value = id}) + else + table.insert(args, {kind = "other", value = s}) + end + end + end + end + return args +end + +local TAPE_ATOM_MACROS = { + ["atom_annot"] = { kind = "work", binds = false }, + ["atom_bind"] = { kind = "bind", binds = true }, + ["atom_setup"] = { kind = "setup", binds = false }, + ["atom_commit"] = { kind = "commit", binds = false }, + ["atom_init"] = { kind = "init", binds = false }, + ["atom_terminate"] = { kind = "terminate",binds = false }, +} + +-- Phase token names (must match macros in tape_atom_dsl.h) +local KNOWN_PHASES = { + ["init"] = true, ["bind"] = true, ["setup"] = true, + ["work"] = true, ["commit"] = true, ["terminate"] = true, +} + +-- Region token names (must match REGION_* macros in tape_atom_dsl.h) +local KNOWN_REGIONS = { + ["prim_arena"] = true, ["face_arena"] = true, ["vertex_arena"] = true, + ["ot_arena"] = true, ["heap_3d_models"] = true, ["cdrom_stream"] = true, + ["vram_heap"] = true, +} + +-- Cadence token names (must match CADENCE_* macros in tape_atom_dsl.h) +local KNOWN_CADENCES = { + ["frame"] = true, ["once"] = true, ["ondemand"] = true, +} + +-- Valid pragmas (must match atom_* macros that emit `_Pragma(...)` in tape_atom_dsl.h) +local ATOM_PRAGMA_KINDS = { + ["resource"] = { kind = "string" }, + ["region"] = { kind = "ident", allowed = KNOWN_REGIONS }, + ["group"] = { kind = "ident" }, + ["cadence"] = { kind = "ident", allowed = KNOWN_CADENCES }, + ["async"] = { kind = "ident", allowed = { ["true"] = true, ["false"] = true } }, +} + +local function find_atom_annotations(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 + -- Match the leading identifier of a TAPE_ATOM_* macro. + 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 + 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 + table.insert(annots, { + line = line_of(source, i), + macro = ident, + kind = macro_def.kind, + error = "missing atom name (first arg)" + }) + else + local name = args[1].value + -- For TAPE_ATOM_BIND: arg layout is (name, Binds_Struct, writes) + -- For others: (name, phase, reads, writes) + -- INIT / TERMINATE: (name) + local entry = { + line = line_of(source, i), + macro = ident, + name = name, + kind = macro_def.kind, + binds = nil, + phase = nil, + reads = {}, + writes = {}, + } + if macro_def.binds then + -- (name, Binds_Struct, writes) + if #args >= 2 and args[2].kind == "ident" then + entry.binds = args[2].value + end + if #args >= 3 and args[3].kind == "regs" then + entry.writes = args[3].value + end + elseif ident == "TAPE_ATOM_INIT" or ident == "TAPE_ATOM_TERMINATE" then + -- (name) — no phase, no reads/writes to extract + elseif ident == "TAPE_ATOM_SETUP" then + -- (name, reads) + if #args >= 2 and args[2].kind == "regs" then + entry.reads = args[2].value + end + elseif ident == "TAPE_ATOM_COMMIT" then + -- (name, reads) + if #args >= 2 and args[2].kind == "regs" then + entry.reads = args[2].value + end + elseif ident == "TAPE_ATOM_ANNOT" then + -- (name, phase, reads, writes) + if #args >= 2 and args[2].kind == "ident" then + -- Expand TAPE_PHASE_* macro references + local phase_id = args[2].value + entry.phase = MACRO_EXPANSION[phase_id] or phase_id + end + if #args >= 3 and args[3].kind == "regs" then + entry.reads = args[3].value + end + if #args >= 4 and args[4].kind == "regs" then + entry.writes = args[4].value + end + end + table.insert(annots, entry) + end + i = after_paren + else + i = open + 1 + end + else + i = after + end + end + return annots +end + +-- ============================================================ +-- Parse TAPE_WORDS(mac_X, N) pragma directives +-- +-- Either form is acceptable: +-- _Pragma("mac_X tape_atom words=N") (operator form) +-- #pragma mac_X tape_atom words=N (directive form) +-- +-- We extract (mac_name, n). +-- ============================================================ + +local function find_macro_word_annotations(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 #). + -- read_ident doesn't recognize '#' so without this guard we'd + -- infinite-loop on `#ifdef` / `#pragma region` / etc. + 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 is a parenthesized string literal; strip parens, then quotes + str = trim(str) + if str:sub(1, 1) == '"' and str:sub(-1) == '"' then + local inner = str:sub(2, -2) + -- Expect " tape_atom words=" + 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" then + -- key is "tape_atom words"; val is "" + local n = tonumber(val) or 0 + table.insert(out, { + line = line_of(source, i), + name = name, + words = n, + }) + elseif key == "words" then + -- Tolerate "mac_X words=N" if someone writes it that way + local n = tonumber(val) or 0 + table.insert(out, { + line = line_of(source, 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) + -- Read the rest of the line (no #pragma content spans lines) + 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)) + -- tokenize + local tokens = {} + for tok in line_text:gmatch("%S+") do table.insert(tokens, tok) end + if #tokens >= 4 and tokens[2] == "tape_atom" and tokens[3] == "words=*" or + (#tokens >= 3 and tokens[2] == "words=") then + -- handled below in two forms + end + 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 + table.insert(out, { line = line_of(source, 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 + table.insert(out, { line = line_of(source, i), name = name, words = n }) + end + i = j + else + i = after + end + end + end + return out +end + +-- ============================================================ +-- Parse `atom_<...>` Pragma / _Pragma annotations +-- +-- Two source-level forms are accepted: +-- +-- Macro form (preferred — what tape_atom_dsl.h provides): +-- atom_resource(cube_tri, "model_ship_cube") +-- atom_region (cube_tri, REGION_PRIM_ARENA) +-- atom_group (cube_tri, GROUP_RENDER_PRIMS) +-- atom_cadence (cube_tri, CADENCE_FRAME) +-- atom_async (cube_tri, false) +-- +-- Directive form (raw — what _Pragma expands to): +-- _Pragma("atom cube_tri resource=model_ship_cube") +-- _Pragma("atom cube_tri region=prim_arena") +-- _Pragma("atom cube_tri group=GROUP_RENDER_PRIMS") +-- _Pragma("atom cube_tri cadence=frame") +-- _Pragma("atom cube_tri async=false") +-- +-- Returns: { +-- { line, name, attrs = {key = value_string, ...} }, +-- ... +-- } +-- +-- The two forms are normalized to the same internal representation +-- so downstream validation doesn't need to care about which form was used. +-- ============================================================ + +-- Map: macro name → pragma key +local ATOM_ATTR_MACROS = { + ["atom_resource"] = "resource", + ["atom_region"] = "region", + ["atom_group"] = "group", + ["atom_cadence"] = "cadence", + ["atom_async"] = "async", +} + +-- Parse macro form: `atom_(atom_name, value, ...)`. +-- Returns (true, entry, str_end) on success, (false) on no match. +-- The entry has shape { line, name, attrs = {key = value} }. +local function try_parse_atom_attr_macro(source, i) + 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 + + -- Read parenthesized arg list. + local body, body_end = read_parens(source, open) + -- First arg = atom_name + local first, after_name = read_ident(body, 1) + if not first then return false end + + -- Skip whitespace, expect ",", skip whitespace to reach value. + 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 + + -- Value parser. Accepts either: + -- - string literal: "..." (preserves inner text verbatim) + -- - bare identifier or macro name (resolved via MACRO_EXPANSION) + local value + if body:sub(j, j) == '"' then + -- find matching closing quote (handle \" escapes) + 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(source, i), + name = first, + attrs = { [key] = value }, + }, body_end +end + +local function find_atom_pragmas(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) + if got then + table.insert(out, 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) + -- Expect: "atom = [= ...]" + 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 attrs_str:gmatch("%S+") 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 + table.insert(out, { + line = line_of(source, 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 +-- ============================================================ +-- +-- Returns a list of {line, name, fields = {{name, byte_offset}, ...}} +-- ============================================================ + +local function find_binds_structs(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 == "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 + table.insert(fields, { 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 (skip FMipsAtom512, MipsAtomBuilder, etc.) + if name:sub(1, 6) == "Binds_" then + table.insert(out, { + line = line_of(source, 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 +-- (so we can pair annotations → atoms and check coverage) +-- ============================================================ + +local function find_atom_names(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 + table.insert(out, { line = line_of(source, 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 + +-- ============================================================ +-- Validation +-- ============================================================ + +local function validate(source_path, word_counts) + local source = read_file(source_path) + + 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) + + -- Index for O(1) lookup + 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 + table.insert(errors, {line = a.line, msg = a.error}) + elseif not atom_index[a.name] then + table.insert(errors, { + line = a.line, + msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name) + }) + 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 + table.insert(warnings, { + line = atom.line, + msg = string.format("MipsAtom_(%s) has no TAPE_ATOM_* annotation", atom.name) + }) + elseif n > 1 then + table.insert(errors, { + 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 + table.insert(errors, { + 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 + table.insert(errors, { + 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 + + -- Binds_ field names should match wave-context registers. + -- Convention: field name == register name minus the "R_" prefix and + -- "Cursor"/"Base" suffix mapped to canonical names. + -- We accept either direct match (rare) or a simple "R_" prefix. + for _, f in ipairs(bs.fields) do + local candidate = "R_" .. f.name + if not is_wave_context_reg(candidate) then + table.insert(warnings, { + 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 + + -- Bind writes must all be wave-context registers. + for _, w in ipairs(a.writes) do + if not is_wave_context_reg(w) then + table.insert(warnings, { + 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). + -- We can't see tape emission sites here, but we can warn when a WORK + -- atom reads a register that no BIND atom writes. + 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 + table.insert(warnings, { + 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. + for _, m in ipairs(macros) do + local declared = word_counts[m.name] + if not declared then + table.insert(errors, { + line = m.line, + msg = string.format("TAPE_WORDS(%s, %d) but '%s' is not in metadata.h", m.name, m.words, m.name) + }) + elseif declared ~= m.words then + table.insert(errors, { + 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) + }) + else + table.insert(info, { + line = m.line, + msg = string.format("OK: %s = %d words", m.name, m.words) + }) + end + end + + -- 8. atom_<...> _Pragma validation: resource/region/group/cadence/async + for _, p in ipairs(pragmas) do + -- Validate the atom name is real + if not atom_index[p.name] then + table.insert(errors, { + line = p.line, + msg = string.format("pragma references unknown atom '%s'", p.name), + }) + end + + -- Validate each key + for k, v in pairs(p.attrs) do + local spec = ATOM_PRAGMA_KINDS[k] + if not spec then + table.insert(errors, { + 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 + -- Build a human-readable allowed-set + local allowed = {} + for kk in pairs(spec.allowed) do table.insert(allowed, kk) end + table.sort(allowed) + local allowed_str = table.concat(allowed, ", ") + table.insert(errors, { + 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. cad_async requires CADENCE_ONDEMAND (or no cadence — default-frame atoms can still async). + -- CADENCE_ONDEMAND requires async=true (otherwise the trigger is undefined). + for _, p in ipairs(pragmas) do + if p.attrs.cadence == "ondemand" and p.attrs.async ~= "true" then + table.insert(errors, { + line = p.line, + msg = string.format("'%s' is CADENCE_ONDEMAND but does not declare atom_async(true)", p.name), + }) + end + end + + -- 10. Information summary. + table.insert(info, { + 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 + +-- ============================================================ +-- Report rendering +-- ============================================================ + +local function render_source_report(source_path, result) + local lines = {} + local function add(s) table.insert(lines, 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 table.insert(kvs, 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 + +local function render_project_report(all_results) + local lines = {} + local function add(s) table.insert(lines, 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 + +-- ============================================================ +-- Main +-- ============================================================ + +local function main(args) + if #args < 2 then + print("Usage: lua tape_atom_annotation_pass.lua [source2 ...]") + os.exit(1) + end + local word_counts = load_word_counts(args[1]) + local all_results = {} + + -- Collect every /gen directory we touch, so we can prune stale + -- empty reports left over by previous runs. A file that USED to have atoms + -- but doesn't any more (refactor / template removal) shouldn't keep its + -- report polluting the source tree. + local pruned_dirs = {} + + for i = 2, #args do + local source_path = args[i] + local basename = basename_no_ext(source_path) + local out_dir = dirname(source_path) .. "/gen" + local out_txt = out_dir .. "/" .. basename .. ".annotations.txt" + local out_err = out_dir .. "/" .. basename .. ".errors.h" + + local result = validate(source_path, word_counts) + result.source = source_path + + -- Decide whether this source contributes to the build at all. A source + -- without atoms (e.g. mips.h, gte.h, gcc_asm.h) has nothing for the + -- annotation DSL to validate — skip both report files. The previously + -- emitted ones, if any, get pruned below. + local has_atoms = #result.atoms > 0 + + print(string.format("[pass] %s%s", source_path, + has_atoms and "" or " (no atoms - skip report)")) + + if has_atoms then + ensure_dir(out_dir) + pruned_dirs[out_dir] = true + + -- Per-source annotation report + write_file(out_txt, render_source_report(source_path, result)) + print(string.format(" -> %s", out_txt)) + + -- gen/.errors.h with #error lines for any structural problems. + -- The C build refuses to compile if any source produces errors via -include. + local header_lines = { + "// Auto-generated by tape_atom_annotation_pass.lua — DO NOT EDIT", + "#pragma once", + "", + } + for _, e in ipairs(result.errors) do + table.insert(header_lines, + string.format('#error "annotation: %s (line %d)"', e.msg, e.line)) + end + if #result.errors == 0 then + table.insert(header_lines, "// annotation pass OK") + end + write_file(out_err, table.concat(header_lines, "\n") .. "\n") + print(string.format(" -> %s", out_err)) + + table.insert(all_results, result) + else + -- No atoms: best-effort delete stale report files from a prior build. + -- We only remove the ones we know belong to this source (filename is + -- derived from the source path), never anything else in the dir. + for _, stale in ipairs({ out_txt, out_err }) do + local f = io.open(stale, "r") + if f then f:close(); os.remove(stale) end + end + end + end + + -- Write project-level summary. + local summary_path = dirname(args[2]) .. "/gen/annotation_validation.txt" + ensure_dir(dirname(summary_path)) + write_file(summary_path, render_project_report(all_results)) + print(string.format("[summary] %s\n", summary_path)) + + -- Exit code + local total_errors = 0 + for _, r in ipairs(all_results) do total_errors = total_errors + #r.errors end + if total_errors > 0 then os.exit(1) end +end + +main({...})