7 Commits
Author SHA1 Message Date
ed 590ff1e2ec Curation pass: reduce nested conditional branching in some defnitions. 2026-07-25 13:00:36 -04:00
ed 653e18ee28 remove code related to dry run and dep graph rendering (ps1 meta) 2026-07-25 11:59:41 -04:00
ed ebb876fe89 report.lua: Remove redudnant section formatting/header 2026-07-25 11:25:12 -04:00
ed 1b40b16c0e Review pass. 2026-07-25 11:20:53 -04:00
ed 9ffd6592bc Better static analysis for C0 <-> C2 data race hazards. 2026-07-25 04:09:48 -04:00
ed d56adab38f branch delay slot better support.
Still reviewing. Need to see if gte is handled properly.
2026-07-23 18:35:02 -04:00
ed 08af73d0d2 Lua Metaprogram: Improvements to static analysis + others. 2026-07-23 10:18:30 -04:00
21 changed files with 4933 additions and 2483 deletions
+4 -3
View File
@@ -60,7 +60,7 @@ enum {
MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }; MipsAtom_(tape_exit) { jump_reg(rret_addr), nop };
/* Generalized Tape Engine Runner */ /* Generalized Tape Engine Runner */
FI_ void tape_run(Slice_U4 tape) { register U4* tp rgcc(R_TapePtr) = tape.ptr; asm volatile( FI_ void tape_run(Slice_MipsCode tape) { register U4* tp rgcc(R_TapePtr) = u4_r(tape.ptr); asm volatile(
asm_words( asm_words(
add_ui( R_SP, R_SP, -MipsStackAlignment) /* Allocate stack space */ add_ui( R_SP, R_SP, -MipsStackAlignment) /* Allocate stack space */
, store_word( R_RA, R_SP, 0) /* Safely backup $ra to the stack */ , store_word( R_RA, R_SP, 0) /* Safely backup $ra to the stack */
@@ -91,8 +91,8 @@ FI_ TapeBuilder tb_make(Slice mem) { return (TapeBuilder){ mem.ptr, mem.len, 0 }
FI_ void tb_emit(TapeBuilder* tb, MipsCode* atom) { u4_r(tb->ptr)[tb->used] = u4_(atom); ++ tb->used; } FI_ void tb_emit(TapeBuilder* tb, MipsCode* atom) { u4_r(tb->ptr)[tb->used] = u4_(atom); ++ tb->used; }
FI_ void tb_data(TapeBuilder* tb, U4 data) { u4_r(tb->ptr)[tb->used] = u4_(data); ++ tb->used; } FI_ void tb_data(TapeBuilder* tb, U4 data) { u4_r(tb->ptr)[tb->used] = u4_(data); ++ tb->used; }
FI_ Slice_U4 tb_end (TapeBuilder* tb) { tb_emit(tb,tape_exit); return (Slice_U4){ C_(U4*,tb->ptr), tb->used }; } FI_ Slice_MipsCode tb_end (TapeBuilder* tb) { tb_emit(tb,tape_exit); return (Slice_MipsCode){ C_(U4*,tb->ptr), tb->used }; }
FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Slice_U4){ C_(U4*,tb.ptr), tb.used }; } FI_ Slice_MipsCode tb_slice(TapeBuilder tb) { return (Slice_MipsCode){ C_(U4*,tb.ptr), tb.used }; }
#define tb_scope(tb) for(U4 tbs_once=0;tbs_once==0;++tbs_once,tb_emit(tb,tape_exit)) #define tb_scope(tb) for(U4 tbs_once=0;tbs_once==0;++tbs_once,tb_emit(tb,tape_exit))
#pragma endregion Tape Drive #pragma endregion Tape Drive
@@ -104,6 +104,7 @@ FI_ Slice_U4 tb_slice(TapeBuilder tb) { return (Sli
* ---------------------------------------------------------------------------*/ * ---------------------------------------------------------------------------*/
// The 'Yield' sequence for Tape Atoms (mac_yield). // The 'Yield' sequence for Tape Atoms (mac_yield).
atom_dbg_skip_over()
MipsAtomComp_(ac_yield) { MipsAtomComp_(ac_yield) {
load_word(R_AtomJmp, R_TapePtr, 0), load_word(R_AtomJmp, R_TapePtr, 0),
add_ui_self( R_TapePtr, S_(MipsCode)), add_ui_self( R_TapePtr, S_(MipsCode)),
+1 -1
View File
@@ -103,7 +103,7 @@ FI_ void farena_init(FArena_R arena, Slice mem) { assert(arena != nullptr);
arena->used = 0; arena->used = 0;
} }
FI_ FArena farena_make(Slice mem) { FArena a; farena_init(& a, mem); return a; } FI_ FArena farena_make(Slice mem) { FArena a; farena_init(& a, mem); return a; }
I_ Slice farena_push(FArena_R arena, U4 amount, Opt_farena o) { I_ Slice farena_push(FArena_R arena, U4 amount, Opt_farena o) {
if (amount == 0) { return (Slice){}; } if (amount == 0) { return (Slice){}; }
U4 desired = amount * (o.type_width == 0 ? 1 : o.type_width); U4 desired = amount * (o.type_width == 0 ? 1 : o.type_width);
U4 to_commit = align_pow2(desired, o.alignment ? o.alignment : MEM_ALIGNMENT_DEFAULT); U4 to_commit = align_pow2(desired, o.alignment ? o.alignment : MEM_ALIGNMENT_DEFAULT);
+2 -2
View File
@@ -15,9 +15,9 @@ enum {
atom_offset_bounds_chk_cube_g4_face_exit = _atom_offset_bounds_chk_cube_g4_face_exit, atom_offset_bounds_chk_cube_g4_face_exit = _atom_offset_bounds_chk_cube_g4_face_exit,
}; };
// --- atom: floor_f3_face (66 words) --- // --- atom: floor_f3_face (58 words) ---
#define _atom_offset_culling_floor_f3_face_exit 29 #define _atom_offset_culling_floor_f3_face_exit 25
#define _atom_offset_bounds_chk_floor_f3_face_exit 13 #define _atom_offset_bounds_chk_floor_f3_face_exit 13
enum { enum {
+14 -17
View File
@@ -1,6 +1,6 @@
#include "stdio.h" #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include "assert.h" #include <assert.h>
// #include "libgpu.h" // #include "libgpu.h"
// #include "libetc.h" // #include "libetc.h"
// #include "libgte.h" // #include "libgte.h"
@@ -99,8 +99,13 @@ typedef Struct_(Ent_Floor) {
A2_V3_S2 faces; A2_V3_S2 faces;
}; };
enum { scratchpad_size = 1024, }; enum {
Scratchpad_Len = 1024,
MemTape_Len = 512,
};
typedef Struct_(SMemory) { typedef Struct_(SMemory) {
U4 MemTape[MemTape_Len];
DoubleBuffer screen_buf; DoubleBuffer screen_buf;
A2_OrderingTable_Buffer ordering_tbl; A2_OrderingTable_Buffer ordering_tbl;
PrimitiveArena primitives; PrimitiveArena primitives;
@@ -207,6 +212,8 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
A2_S2 p; //??? A2_S2 p; //???
S4 flag; //???? S4 flag; //????
TapeBuilder tb = tb_make(slice_ut_arr(smem.MemTape));
// Draw Cube // Draw Cube
if (0) if (0)
{ {
@@ -259,8 +266,7 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
U4 prim_base = u4_(pa->buf[smem.active_buf_id]); U4 prim_base = u4_(pa->buf[smem.active_buf_id]);
U4 prim_cursor = prim_base + pa->used; U4 prim_cursor = prim_base + pa->used;
LP_ U4 mem_temp_tape[512]; tb.used = 0; tb_scope(& tb) {
TapeBuilder tb = tb_make(slice_ut_arr(mem_temp_tape)); tb_scope(& tb) {
tb_emit(& tb, rbind_cube_g4_face); tb_emit(& tb, rbind_cube_g4_face);
tb_data(& tb, prim_cursor); tb_data(& tb, prim_cursor);
tb_data(& tb, u4_(smem.cube.faces)); tb_data(& tb, u4_(smem.cube.faces));
@@ -344,12 +350,11 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
U4 prim_base = u4_(pa->buf[smem.active_buf_id]); U4 prim_base = u4_(pa->buf[smem.active_buf_id]);
U4 prim_cursor = prim_base + pa->used; U4 prim_cursor = prim_base + pa->used;
// TODO(Ed): We should do a bounds check beforehand to confirm pa can hold all tris. // TODO(Ed): We should do a bounds check beforehand to confirm pa can hold all tris?
// The tape atoms in-flight should not need to care. // The tape atoms in-flight should not need to care.
// Prepare the tape. (Push protocol to tape) // Prepare the tape. (Push protocol to tape)
LP_ U4 mem_temp_tape[512]; tb.used = 0; tb_scope(& tb) {
TapeBuilder tb = tb_make(slice_ut_arr(mem_temp_tape)); tb_scope(& tb) {
tb_emit(& tb, set_gte_world); tb_emit(& tb, set_gte_world);
tb_data(& tb, u4_(& smem.tform_world)); tb_data(& tb, u4_(& smem.tform_world));
@@ -367,25 +372,18 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
tb_data(& tb, u4_(& pa->used)); tb_data(& tb, u4_(& pa->used));
tb_data(& tb, prim_base); tb_data(& tb, prim_base);
} }
tape_run(tb_slice(tb));// Fire off the tape. tape_run(tb_slice(tb));// Fire off the tape.
// C-side state (pa->used) has already been updated by the tape! // C-side state (pa->used) has already been updated by the tape!
smem.floor.rot.y += 5; smem.floor.rot.y += 5;
} }
// --- TAPE DIAGNOSTICS --- // --- TAPE DIAGNOSTICS ---
if (1) if (0)
{ {
LP_ U4 mem_temp_tape[512]; FArena tape_arena; farena_init(& tape_arena, slice_ut_arr(mem_temp_tape)); LP_ U4 mem_temp_tape[512]; FArena tape_arena; farena_init(& tape_arena, slice_ut_arr(mem_temp_tape));
TapeBuilder tb = tb_make_old(& tape_arena); tb_scope(& tb) { TapeBuilder tb = tb_make_old(& tape_arena); tb_scope(& tb) {
// Skip set_gte_world atom for diagnostics to isolate the triangle loop // Skip set_gte_world atom for diagnostics to isolate the triangle loop
for (U4 i = 0; i < Floor_num_faces; i++) { for (U4 i = 0; i < Floor_num_faces; i++) {
// =======================================================
// SWAP EMIT TO TEST DIFFERENT PARTS OF THE PIPELINE:
// =======================================================
// 1. code_diag_yield -> Tests Tape Engine jump logic
// 2. code_diag_color -> Tests OT and Prim Arena memory
// 3. code_diag_gte -> Tests Vertex arrays and GTE Math
// tb_emit(& tb, code_diag_yield); // tb_emit(& tb, code_diag_yield);
// tb_emit(& tb, code_diag_color); // tb_emit(& tb, code_diag_color);
// tb_emit(& tb, code_diag_gte); // tb_emit(& tb, code_diag_gte);
@@ -394,7 +392,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
B1* prim_cursor = (B1*)r_(pa->buf)[smem.active_buf_id] + pa->used; B1* prim_cursor = (B1*)r_(pa->buf)[smem.active_buf_id] + pa->used;
tape_run(tb_slice(tb)); tape_run(tb_slice(tb));
pa->used = (U4)prim_cursor - (U4)r_(pa->buf)[smem.active_buf_id]; pa->used = (U4)prim_cursor - (U4)r_(pa->buf)[smem.active_buf_id];
smem.floor.rot.y += 5;
} }
} }
+8 -9
View File
@@ -103,28 +103,27 @@ MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri), atom_phase(f
mac_yield() mac_yield()
}; };
// atom_dbg_skip_over()
internal internal
atom_dbg_skip_over()
MipsAtom_(floor_f3_face) atom_info(atom_phase(floor_f3) MipsAtom_(floor_f3_face) atom_info(atom_phase(floor_f3)
, atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase) , atom_reads( R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase)
, atom_writes(R_PrimCursor, R_FaceCursor) , atom_writes(R_PrimCursor, R_FaceCursr)
) { ) {
mac_load_tri_indices( R_T0, R_T1, R_T2), mac_load_tri_indices( R_T0, R_T1, R_T2),
mac_gte_load_tri_verts(R_T0, R_T1, R_T2), mac_gte_load_tri_verts(R_T0, R_T1, R_T2),
nop2, gte_cmdw_rotate_translate_perspective_triple, nop2, gte_cmdw_rotate_translate_perspective_triple, // 2 nops retire the final cpu -> gte writes before RTPT
nop2, gte_cmdw_nclip, gte_cmdw_nclip,
/* Culling (Branch forward if Backface) */ /* Culling (Branch forward if Backface) */
nop2, gte_mv_from_data_r(R_T0, C2_MAC0), gte_mv_from_data_r(R_T0, C2_MAC0),
nop, nop, branch_le_zero(R_T0, atom_offset(culling, floor_f3_face_exit)), nop, // required gte -> cpu load-delay slot.
branch_le_zero(R_T0, atom_offset(culling, floor_f3_face_exit)), nop,
/* Format Primitive */ /* Format Primitive */
mac_format_f3_color(0xFF, 0xFF, 0xFF), // RGB-form (R=FF, G=FF, B=FF = white) mac_format_f3_color(0xFF, 0xFF, 0xFF), // RGB-form (R=FF, G=FF, B=FF = white)
mac_gte_store_f3_post_rtpt(), mac_gte_store_f3_post_rtpt(),
/* Calculate Depth */ /* Calculate Depth */
nop2, gte_avg_sort_z3, gte_avg_sort_z3,
nop2, gte_mv_from_data_r(R_T1, C2_OTZ), gte_mv_from_data_r(R_T1, C2_OTZ),
/* Bounds Check OTZ < 2048 (Branch forward to skip insertion) */ /* Bounds Check OTZ < 2048 (Branch forward to skip insertion) */
add_ui( R_AT, R_0, OrderingTbl_Len), add_ui( R_AT, R_0, OrderingTbl_Len),
set_lt_u( R_AT, R_T1, R_AT), set_lt_u( R_AT, R_T1, R_AT),
+62 -58
View File
@@ -81,19 +81,6 @@ $path_psyq = join-path $path_toolchain 'psyq-4_7'
$path_psyq_iwyu = join-path $path_toolchain 'psyq_iwyu' $path_psyq_iwyu = join-path $path_toolchain 'psyq_iwyu'
$path_psyq_imyu_inc = join-path $path_psyq_iwyu 'include' $path_psyq_imyu_inc = join-path $path_psyq_iwyu 'include'
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( function assemble-unit { param(
[string] $unit, [string] $unit,
[string] $link_module, [string] $link_module,
@@ -243,6 +230,52 @@ function make-binary { param([string]$elf, [string]$exe)
if ($LASTEXITCODE -ne 0) { Write-Error "Objcopy failed. Aborting."; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Error "Objcopy failed. Aborting."; exit 1 }
} }
function ps1-meta { param(
[string]$unity_root,
[string[]]$sources,
[Parameter(Mandatory=$true)][string]$metadata,
[string]$out_root = (join-path $path_build 'gen'),
[string[]]$passes = @('--pre-link'),
[string[]]$extra_args = @()
)
# `--unity-root` and `--source` are
# mutually exclusive. Exactly one of `$unity_root` / `$sources` must
# be supplied; the other must be absent.
if ($null -ne $unity_root -and $unity_root -ne '')
{
if ($null -ne $sources -and $sources.Count -gt 0) {
write-error 'ps1-meta: -unity_root and -sources are mutually exclusive'
exit 2
}
}
elseif ($null -eq $sources -or $sources.Count -eq 0) {
write-error 'ps1-meta: either -unity_root <file> or -sources <file...> is required'
exit 2
}
$script = join-path $path_scripts 'ps1_meta.lua'
$input_summary = if ($null -ne $unity_root -and $unity_root -ne '') {
"unity=$unity_root"
}
else {
"$($sources.Count) source(s)"
}
write-host "ps1-meta $input_summary, passes=$($passes -join ',')" ` -ForegroundColor Magenta
$arg_list = @($passes) + @('--metadata', $metadata) + @('--out-root', $out_root) + @($extra_args)
if ($null -ne $unity_root -and $unity_root -ne '') {
$arg_list += @('--unity-root', $unity_root)
}
else {
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-hello_psyqo { function build-hello_psyqo {
$includes += @() $includes += @()
@@ -317,34 +350,16 @@ function build-graphis_hello {
} }
# build-graphis_hello # build-graphis_hello
function ps1-meta { param(
[Parameter(Mandatory=$true)][string[]]$sources,
[Parameter(Mandatory=$true)][string]$metadata,
[string]$out_root = (join-path $path_build 'gen'),
[string[]]$passes = @('--pre-link'),
[string[]]$extra_args = @()
)
$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) + @($extra_args)
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 { function build-gte_hello {
$includes += @() $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_duffle = join-path $path_code 'duffle'
$path_atom_metadata = join-path $path_duffle 'word_count.metadata.h' $path_atom_metadata = join-path $path_duffle 'word_count.metadata.h'
$path_build_gen = join-path $path_build 'gen'
$source_dirs = @($path_duffle, $path_module) $src_c = join-path $path_module 'hello_gte.c'
$atom_sources = Get-SourceFiles -paths $source_dirs -extensions @('.h', '.c') ps1-meta -unity_root $src_c -metadata $path_atom_metadata -out_root $path_build_gen
ps1-meta -sources $atom_sources -metadata $path_atom_metadata -out_root (join-path $path_build 'gen')
$assemble_args = @() $assemble_args = @()
$assemble_args += $f_debug $assemble_args += $f_debug
@@ -360,7 +375,6 @@ function build-gte_hello {
# assemble-unit $src_asm $module_asm $includes $assemble_args # assemble-unit $src_asm $module_asm $includes $assemble_args
$src_c = join-path $path_module 'hello_gte.c'
$module_c = join-path $path_build 'hello_gte_c.o' $module_c = join-path $path_build 'hello_gte_c.o'
$compile_args = @() $compile_args = @()
@@ -386,27 +400,17 @@ function build-gte_hello {
make-binary $elf $exe make-binary $elf $exe
# Post-link: gdb-runtime + dwarf-injection in a single Lua invocation (one luajit cold start). # Post-link: gdb-runtime + dwarf-injection in a single Lua invocation (one luajit cold start).
ps1-meta -sources $atom_sources -metadata $path_atom_metadata ` ps1-meta -unity_root $src_c -metadata $path_atom_metadata -out_root $path_build_gen -passes @('--post-link') ` -extra_args @('--elf', $elf)
-out_root (join-path $path_build 'gen') `
-passes @('--post-link') `
-extra_args @('--elf', $elf)
# F' + G' splice: collapse 9 objcopy subprocess invocations into 3. $dwarfLineBin = join-path $path_build_gen 'hello_gte.dwarf_line.bin'
# - 1 call: 3x --update-section for F' (line / aranges / rnglists) $dwarfArangesBin = join-path $path_build_gen 'hello_gte.dwarf_aranges.bin'
# - 1 call: 3x --update-section for G' (info / abbrev / str) $dwarfRnglistsBin = join-path $path_build_gen 'hello_gte.dwarf_rnglists.bin'
# - 1 call: 2x --add-section for G' (loc / loclists — these don't exist in the source ELF)
# - 1 call: 1x --set-section-flags (.rodata / .data enable code flag)
# = 4 objcopy calls (was 9; saved 5 spawns).
$dwarfLineBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_line.bin'
$dwarfArangesBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_aranges.bin'
$dwarfRnglistsBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_rnglists.bin'
$injectElf = join-path $path_build 'hello_gte.dwarf-injected.elf' $injectElf = join-path $path_build 'hello_gte.dwarf-injected.elf'
if ((Test-Path $dwarfLineBin) -and (Test-Path $dwarfArangesBin) -and (Test-Path $dwarfRnglistsBin)) if ((Test-Path $dwarfLineBin) -and (Test-Path $dwarfArangesBin) -and (Test-Path $dwarfRnglistsBin))
{ {
Write-Host "[build] DWARF-injecting $elf -> $injectElf" Write-Host "[build] DWARF-injecting $elf -> $injectElf"
Copy-Item -LiteralPath $elf -Destination $injectElf -Force Copy-Item -LiteralPath $elf -Destination $injectElf -Force
# Objcopy call: 3x --update-section for (line, aranges, rnglists).
# Single objcopy call: 3x --update-section for F' (line, aranges, rnglists).
$f_args = @( $f_args = @(
"--update-section=.debug_line=$dwarfLineBin", "--update-section=.debug_line=$dwarfLineBin",
"--update-section=.debug_aranges=$dwarfArangesBin", "--update-section=.debug_aranges=$dwarfArangesBin",
@@ -419,12 +423,11 @@ function build-gte_hello {
return; return;
} }
# G' 5-section splice: 3 update-section (info / abbrev / str) + 2 add-section (loc / loclists). $dwarfInfoBin = join-path $path_build_gen 'hello_gte.dwarf_info.bin'
$dwarfInfoBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_info.bin' $dwarfAbbrevBin = join-path $path_build_gen 'hello_gte.dwarf_abbrev.bin'
$dwarfAbbrevBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_abbrev.bin' $dwarfStrBin = join-path $path_build_gen 'hello_gte.dwarf_str.bin'
$dwarfStrBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_str.bin' $dwarfLocBin = join-path $path_build_gen 'hello_gte.dwarf_loc.bin'
$dwarfLocBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_loc.bin' $dwarfLoclistsBin = join-path $path_build_gen 'hello_gte.dwarf_loclists.bin'
$dwarfLoclistsBin = join-path (join-path $path_build 'gen') 'hello_gte.dwarf_loclists.bin'
$g_args = @( $g_args = @(
"--update-section=.debug_info=$dwarfInfoBin", "--update-section=.debug_info=$dwarfInfoBin",
"--update-section=.debug_abbrev=$dwarfAbbrevBin", "--update-section=.debug_abbrev=$dwarfAbbrevBin",
@@ -441,7 +444,7 @@ function build-gte_hello {
# Baked atoms execute from RAM but are emitted as C data arrays, so their ELF sections lack SHF_EXECINSTR. # Baked atoms execute from RAM but are emitted as C data arrays, so their ELF sections lack SHF_EXECINSTR.
# GDB discards line rows for non-code sections. Mark only the debug-copy sections executable. # GDB discards line rows for non-code sections. Mark only the debug-copy sections executable.
# The shipping ELF and PS-EXE remain byte/flag unchanged. # The original ELF and PS-EXE remain byte/flag unchanged.
& $Objcopy ` & $Objcopy `
--set-section-flags ".rodata=alloc,load,readonly,code,contents" ` --set-section-flags ".rodata=alloc,load,readonly,code,contents" `
--set-section-flags ".data=alloc,load,data,code,contents" ` --set-section-flags ".data=alloc,load,data,code,contents" `
@@ -449,7 +452,8 @@ function build-gte_hello {
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Warning "[build] atom-section flag update failed (exit $LASTEXITCODE); removing $injectElf" Write-Warning "[build] atom-section flag update failed (exit $LASTEXITCODE); removing $injectElf"
Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue Remove-Item -LiteralPath $injectElf -ErrorAction SilentlyContinue
} else { }
else {
Write-Host "[build] DWARF-injected ELF: $injectElf" Write-Host "[build] DWARF-injected ELF: $injectElf"
} }
} }
+1762 -200
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -11,11 +11,10 @@
--- ``` --- ```
--- ---
--- That small bootstrap: (a) locates this helper via `arg[0]` / `debug.getinfo`, --- That small bootstrap: (a) locates this helper via `arg[0]` / `debug.getinfo`,
--- (b) loads it (which sets `package.path` + `package.cpath` via cached `git rev-parse`), --- (b) loads it (which sets `package.path` + `package.cpath`),
--- (c) at the bottom calls `require("duffle")` (now resolvable since `package.path` was just set) and returns the duffle M. --- (c) at the bottom calls `require("duffle")` (now resolvable since `package.path` was just set) and returns the duffle M.
--- Net effect: the caller gets the duffle module in one statement; no separate `dofile(...)` + `require("duffle")` dance. --- Net effect: the caller gets the duffle module in one statement; no separate `dofile(...)` + `require("duffle")` dance.
--- ---
--- Replaces the prior 2-line (entry) or 4-line (pass) pattern that had the call site do its own path resolution + duplicated setup.
local M = {} local M = {}
@@ -27,9 +26,6 @@ local CACHE_KEY = "__duffle_repo_root__"
--- parent of the directory containing this script. We derive it directly from `debug.getinfo(1, "S").source` --- parent of the directory containing this script. We derive it directly from `debug.getinfo(1, "S").source`
--- (returns `@<path>` for the currently-running chunk). --- (returns `@<path>` for the currently-running chunk).
--- ---
--- Replaces the prior `io.popen("git rev-parse --show-toplevel")` approach, which cost ~100-180ms per
--- LuaJIT process on Windows due to git's CLI startup. The path-derive approach costs <1ms.
---
--- If `debug.getinfo` can't parse this script's path (shouldn't happen — dofile always populates source), --- If `debug.getinfo` can't parse this script's path (shouldn't happen — dofile always populates source),
--- return nil and let `M.setup()` fail loud. --- return nil and let `M.setup()` fail loud.
--- @return string|nil --- @return string|nil
@@ -61,7 +57,12 @@ end
function M.setup() function M.setup()
local repo_root = find_repo_root() local repo_root = find_repo_root()
if not repo_root then if not repo_root then
io.stderr:write("[duffle_paths] git rev-parse failed -- not in a git repo?\n") -- Unreachable in practice: find_repo_root() derives the repo root from this script's
-- own source path via debug.getinfo(1, "S").source (no subprocess, no git CLI, <1ms).
-- A nil return means the source path did not match the expected
-- <repo>/scripts/duffle_paths.lua layout — a packaging bug, not a "missing git repo"
-- condition. os.exit(2) is retained so a real failure surfaces loud rather than
-- silently producing an unconfigured module table.
os.exit(2) os.exit(2)
end end
+6 -137
View File
@@ -240,7 +240,7 @@ end
-- Pure-Lua 5.3 LEB128 readers (no `bit` library). `2^shift` arithmetic matches the existing parser. -- Pure-Lua 5.3 LEB128 readers (no `bit` library). `2^shift` arithmetic matches the existing parser.
-- Offsets are 0-based; returns (value, next_pos). -- Offsets are 0-based; returns (value, next_pos).
-- Track A Task 10: promoted from `local function` to M.* exports so passes/dwarf_injection.lua -- Promoted from `local function` to M.* exports so passes/dwarf_injection.lua
-- can import them as file-scope locals per the 2nd-caller lift precedent -- can import them as file-scope locals per the 2nd-caller lift precedent
-- (the uleb128 + sleb128 encoders were promoted the same way). -- (the uleb128 + sleb128 encoders were promoted the same way).
function M.read_uleb128_at(buf, pos) function M.read_uleb128_at(buf, pos)
@@ -404,10 +404,8 @@ local function read_form_value(buf, str_buf, pos, form)
end end
--- Read a `DW_FORM_ref_sig8` value at 0-based offset `pos` from `buf`. --- Read a `DW_FORM_ref_sig8` value at 0-based offset `pos` from `buf`.
--- Returns the low 4 bytes (LE) as `low`, the high 4 bytes (LE) as `high`, and --- Returns the low 4 bytes (LE) as `low`, the high 4 bytes (LE) as `high`, and the cursor position after the 8-byte value as `next_pos`.
--- the cursor position after the 8-byte value as `next_pos`. --- Callers that need the full type-unit + type-offset pair (e.g. to resolve a type identifier embedded as a signature)
--- Callers that need the full type-unit + type-offset pair
--- (e.g. to resolve a type identifier embedded as a signature)
--- should use this directly rather than going through `read_form_value`, --- should use this directly rather than going through `read_form_value`,
--- which only exposes the low 4 bytes to preserve its existing (value, next_pos) return shape. --- which only exposes the low 4 bytes to preserve its existing (value, next_pos) return shape.
--- @param buf string --- @param buf string
@@ -427,8 +425,7 @@ end
-- --
-- Unit header layout (from pos 0): -- Unit header layout (from pos 0):
-- unit_length(4) + version(2) + unit_type(1) + address_size(1) + debug_abbrev_offset(4) -- unit_length(4) + version(2) + unit_type(1) + address_size(1) + debug_abbrev_offset(4)
-- -- followed by type_unit_specific fields: -- followed by type_unit_specific fields: type_signature(8) + type_offset(4)
-- type_signature(8) + type_offset(4)
-- The type_signature is at byte offset 8 of the body (right after debug_abbrev_offset). -- The type_signature is at byte offset 8 of the body (right after debug_abbrev_offset).
-- @param info string -- the .debug_info section bytes -- @param info string -- the .debug_info section bytes
-- @param target_sig_lo integer -- low 4 bytes (LE) of the desired signature -- @param target_sig_lo integer -- low 4 bytes (LE) of the desired signature
@@ -619,7 +616,7 @@ end
--- - ELF32 symtab entry = 16 bytes (`st_name:4 + st_value:4 + st_size:4 + st_info:1 + st_other:1 + st_shndx:2`); offsets within each entry are zero-based wire offsets. --- - ELF32 symtab entry = 16 bytes (`st_name:4 + st_value:4 + st_size:4 + st_info:1 + st_other:1 + st_shndx:2`); offsets within each entry are zero-based wire offsets.
--- - Direct Lua `string.byte`/`string.sub`/`string.find` boundaries receive `+ 1`. --- - Direct Lua `string.byte`/`string.sub`/`string.find` boundaries receive `+ 1`.
--- - We filter on STB_GLOBAL (high nibble of st_info = 1) to match `nm`'s default (external symbols only). STB_WEAK excluded. --- - We filter on STB_GLOBAL (high nibble of st_info = 1) to match `nm`'s default (external symbols only). STB_WEAK excluded.
--- - We strip the `code_` prefix to match the previous `read_nm` output. --- - The `code_` prefix is stripped (MipsAtom_ macros emit bare atom names, no `code_` prefix).
--- - `st_size > 0` filter excludes undefined/imported symbols. --- - `st_size > 0` filter excludes undefined/imported symbols.
--- @param elf_path Path --- @param elf_path Path
--- @return table<string, {integer, integer}> --- @return table<string, {integer, integer}>
@@ -657,7 +654,7 @@ function M.read_nm(elf_path)
-- Extract the name from .strtab (null-terminated C string). -- Extract the name from .strtab (null-terminated C string).
local name_end = strtab:find("\0", st_name_off + 1, true) or (st_name_off + 1) local name_end = strtab:find("\0", st_name_off + 1, true) or (st_name_off + 1)
local name = strtab:sub(st_name_off + 1, name_end - 1) local name = strtab:sub(st_name_off + 1, name_end - 1)
-- Filter: keep all symbol-table symbols (atoms emit their name as the bare `<name>` since the `code_` prefix was removed from the MipsAtom_ macro). -- Filter: keep all symbol-table symbols (atoms emit their name as the bare `<name>` — MipsAtom_ macros strip the `code_` prefix).
-- The atoms_source_map pass already filters out non-atom symbols via the source-map.txt cross-ref. -- The atoms_source_map pass already filters out non-atom symbols via the source-map.txt cross-ref.
if name and #name > 0 then if name and #name > 0 then
local st_value = M.read_u32_le(symtab, entry_off + SYM_ST_VALUE) local st_value = M.read_u32_le(symtab, entry_off + SYM_ST_VALUE)
@@ -794,132 +791,4 @@ end
-- I/O helpers: atoms source-map + native directory glob -- I/O helpers: atoms source-map + native directory glob
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Parse a FORMAT_VERSION <expected_version> atoms-meta file (sourcemap or provenance).
--- Shared by M.parse_source_map_file + M.parse_provenance_file.
--- The two callers differ only in how they parse WORD lines; that's `extract_word(line)`.
--- Returns the standard `{name -> {total, words}}` shape.
--- Returns `{}` on format-version mismatch (and logs to stderr).
--- @param path string
--- @param expected_version integer
--- @param extract_word fun(line: string): table|nil -- caller-supplied per-line parser
--- @return table<string, table>
function M.parse_atom_records(path, expected_version, extract_word)
local out = {}
local cur_name, cur_words = nil, {}
for raw in io.lines(path) do
local line = raw
if line:match("^#") then
local ver = line:match("^# FORMAT_VERSION%s+(%d+)")
if ver and tonumber(ver) ~= expected_version then
io.stderr:write(string.format(
"[elf_dwarf.parse_atom_records] version mismatch (got %s, expected %d) in %s\n",
ver, expected_version, path))
return {}
end
-- skip other comments
elseif line:sub(1, 4) == "ATOM" then
-- ATOM <name> "<abs-source-path>" <total>
local _, _, name = line:find("ATOM%s+(%S+)%s+\"[^\"]*\"%s+(%d+)")
if name then
cur_name = name
cur_words = {}
out[name] = { total = 0, words = cur_words }
end
elseif line == "ENDATOM" then
-- Update the recorded total from the entries count
-- (matches the `lines[1] = lines[1]:gsub(" 0$", " " .. total)` patch in atoms_source_map.lua:170).
if cur_name and out[cur_name] then
out[cur_name].total = #cur_words
end
cur_name, cur_words = nil, {}
elseif line:sub(1, 4) == "WORD" and cur_name then
local field = extract_word(line)
if field then
cur_words[#cur_words + 1] = field
end
end
end
return out
end
--- Parse a FORMAT_VERSION <expected_version> `*.atoms.sourcemap.txt` file.
--- Returns `{name -> {total = N, words = {{pos, line}, ...}}}`.
--- Returns `{}` on format-version mismatch (and logs to stderr).
---
--- **Wire format** (emitted by `passes/atoms_source_map.lua`):
--- ```
--- # FORMAT_VERSION <n>
--- ATOM <name> "<abs-source-path>" <total>
--- WORD <n> LINE <line> TEXT <text...>
--- ...
--- ENDATOM
--- ```
---
--- **Conventions:** the in-memory shape uses `{pos, line, text}`
--- (`atoms_source_map.lua:142`); the `.txt` file uses `WORD <n>` so the parser maps `n` → `pos` field name.
--- @param sm_path Path
--- @param expected_version integer -- expected FORMAT_VERSION line
--- @return table<string, table>
function M.parse_source_map_file(sm_path, expected_version)
return M.parse_atom_records(sm_path, expected_version, function(line)
local _, n, _, src_line = line:find("WORD%s+(%d+)%s+LINE%s+(%d+)")
if n and src_line then
return { pos = tonumber(n), line = tonumber(src_line) }
end
end)
end
--- Parse a FORMAT_VERSION <expected_version> `*.atoms.provenance.txt` file.
--- Returns `{name -> {total = N, words = {{pos, call_file, call_line, comp_name, comp_file, comp_line}, ...}}}`.
--- Returns `{}` on format-version mismatch (and logs to stderr).
---
--- **Wire format** (emitted by `passes/atoms_source_map.lua`):
--- ```
--- # FORMAT_VERSION <n>
--- ATOM <name> "<abs-source-path>" <total>
--- WORD <n> CALL <src-file>:<src-line> RAW
--- WORD <n> CALL <src-file>:<src-line> MACRO <comp_name> "<comp-file>:<comp-line>"
--- ...
--- ENDATOM
--- ```
---
--- **Used by** `passes/dwarf_injection.lua` to:
--- - group consecutive MACRO rows into component invocations (one `DW_TAG_inlined_subroutine` each)
--- - emit abstract `DW_TAG_subprogram` per unique component name
--- - extend `.debug_line` so stepping into a `mac_X(...)` lands on the component's source line.
--- @param prov_path string -- path to *.atoms.provenance.txt
--- @param expected_version integer -- expected FORMAT_VERSION line
--- @return table<string, table>
function M.parse_provenance_file(prov_path, expected_version)
return M.parse_atom_records(prov_path, expected_version, function(line)
-- Two accepted shapes:
-- WORD <n> CALL <call-file>:<call-line> RAW
-- WORD <n> CALL <call-file>:<call-line> MACRO <comp_name> "<comp-file>:<comp-line>"
local pos, call_file, call_line, comp_name, comp_file, comp_line =
line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+MACRO%s+(%S+)%s+"([^"]*):(%d+)"')
if pos then
return {
pos = tonumber(pos),
call_file = call_file,
call_line = tonumber(call_line),
comp_name = comp_name,
comp_file = comp_file,
comp_line = tonumber(comp_line),
}
end
-- RAW row.
local raw_pos, raw_file, raw_line = line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+RAW')
if raw_pos then
return {
pos = tonumber(raw_pos),
call_file = raw_file,
call_line = tonumber(raw_line),
comp_name = nil,
comp_file = nil,
comp_line = nil,
}
end
end)
end
return M return M
+97 -63
View File
@@ -7,7 +7,7 @@
--- ---
--- Writes: --- Writes:
--- - `<ctx.out_root>/<dir_basename>.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error) --- - `<ctx.out_root>/<dir_basename>.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error)
--- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results` --- - The annotations.txt report is rendered by `passes/report.lua` from the canonical `corpus.sources_by_dir` projection (re-validating each source via `M.validate()`).
--- ---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible
@@ -21,7 +21,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local write_file = duffle.write_file local write_file = duffle.write_file
local ensure_dir = duffle.ensure_dir local ensure_dir = duffle.ensure_dir
-- The annotation pass now consults the source-derived registries built by scan_source: -- The annotation pass reads the source-derived registries from scan_source:
-- * pipe_ctx.register_alias_registry — for atom_dbg_reg_default(R_X, ...) and atom_reg_types(R_X, ...) member-identity checks -- * pipe_ctx.register_alias_registry — for atom_dbg_reg_default(R_X, ...) and atom_reg_types(R_X, ...) member-identity checks
-- * pipe_ctx.type_name_registry — for atom_dbg_reg_default(<T>, ...) and atom_reg_types(<T>, ...) type-identity checks -- * pipe_ctx.type_name_registry — for atom_dbg_reg_default(<T>, ...) and atom_reg_types(<T>, ...) type-identity checks
@@ -45,8 +45,6 @@ local ensure_dir = duffle.ensure_dir
--- @field project_root string --- @field project_root string
--- @field upstream table<string, table> --- @field upstream table<string, table>
--- @field flags table --- @field flags table
--- @field flags._annot_results table[] -- stashed by annotation pass; consumed by report.lua
--- @field dry_run boolean
--- @field verbose boolean --- @field verbose boolean
--- @class PassResult --- @class PassResult
@@ -108,12 +106,11 @@ local ensure_dir = duffle.ensure_dir
-- --
-- Each check has a uniform `append_to_findings` shape (errors[] / warnings[] / info[]). -- Each check has a uniform `append_to_findings` shape (errors[] / warnings[] / info[]).
-- The dispatcher in `validate()` decides which findings list each check writes to — by convention, -- The dispatcher in `validate()` decides which findings list each check writes to — by convention,
-- "existence" checks (declaration must exist, struct must exist) write errors[]; "shape" checks -- "existence" checks (declaration must exist, struct must exist) write errors[]; "shape" checks (writes/reads must be wave-context) write warnings[].
-- (writes/reads must be wave-context) write warnings[].
-- The `macro_word_drift` check writes both errors[] (missing/mismatch) and info[] (match). -- The `macro_word_drift` check writes both errors[] (missing/mismatch) and info[] (match).
--- Check: every annotated atom must have a matching MipsAtom_(name) declaration. --- Check: every annotated atom must have a matching MipsAtom_(name) declaration.
--- @param a AtomAnnotation --- @param a AtomAnnotation
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_atom_decl_exists(a, pipe_ctx, findings) local function check_atom_decl_exists(a, pipe_ctx, findings)
@@ -144,7 +141,7 @@ end
--- Emitting a warning here keeps the annotation pass from being stop-on-error for the common test-fixture case, --- Emitting a warning here keeps the annotation pass from being stop-on-error for the common test-fixture case,
--- while still surfacing the issue in the report. --- while still surfacing the issue in the report.
--- The static-analysis report remains the source of truth for build-stopping errors. --- The static-analysis report remains the source of truth for build-stopping errors.
--- @param a AtomAnnotation --- @param a AtomAnnotation
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_binds_struct_exists(a, pipe_ctx, findings) local function check_binds_struct_exists(a, pipe_ctx, findings)
@@ -160,7 +157,7 @@ end
--- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift. --- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
--- Three outcomes: missing (error), mismatch (error), match (info). --- Three outcomes: missing (error), mismatch (error), match (info).
--- @param m MacroEntry --- @param m MacroEntry
--- @param wc table<string, integer> -- the shared word-count table (from ctx.shared.word_counts) --- @param wc table<string, integer> -- the shared word-count table (from ctx.shared.word_counts)
--- @param findings Findings --- @param findings Findings
local function check_macro_word_drift(m, wc, findings) local function check_macro_word_drift(m, wc, findings)
@@ -188,7 +185,7 @@ end
--- Check: atom_dbg_reg_default(R_X, <type>) must target a register declared as a debug-visible alias in `pipe_ctx.register_alias_registry`, --- Check: atom_dbg_reg_default(R_X, <type>) must target a register declared as a debug-visible alias in `pipe_ctx.register_alias_registry`,
--- with a type name found in `pipe_ctx.type_name_registry`. --- with a type name found in `pipe_ctx.type_name_registry`.
--- Pointer depth is still bounded to 0 or 1. Duplicate defaults are still detected. --- Pointer depth is still bounded to 0 or 1. Duplicate defaults are still detected.
--- @param _src SourceFile -- unused (kept for the per_source shape) --- @param _src SourceFile -- unused (kept for the per_source shape)
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_semantic_reg_defaults(_src, pipe_ctx, findings) local function check_semantic_reg_defaults(_src, pipe_ctx, findings)
@@ -240,7 +237,7 @@ end
--- The alias ident `R_<n>` now encodes the GPR identity only for entries that are explicitly opted in via the bare `atom_reg` marker. --- The alias ident `R_<n>` now encodes the GPR identity only for entries that are explicitly opted in via the bare `atom_reg` marker.
--- R_T0..R_T3 are intentionally NOT auto-included (per the prototype principle: no auto-include of wave-context; explicit opt-in only). --- R_T0..R_T3 are intentionally NOT auto-included (per the prototype principle: no auto-include of wave-context; explicit opt-in only).
--- The check fires for any R_T0..R_T3 reference that hasn't been opted in via `#define atom_reg`. --- The check fires for any R_T0..R_T3 reference that hasn't been opted in via `#define atom_reg`.
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_atom_reg_types(_src, pipe_ctx, findings) local function check_atom_reg_types(_src, pipe_ctx, findings)
@@ -271,7 +268,7 @@ local function check_atom_reg_types(_src, pipe_ctx, findings)
end end
--- Check: atom_view(Binds_X) entries must reference a real Binds_* struct and that struct must declare at least one field. --- Check: atom_view(Binds_X) entries must reference a real Binds_* struct and that struct must declare at least one field.
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_atom_view_layout(_src, pipe_ctx, findings) local function check_atom_view_layout(_src, pipe_ctx, findings)
@@ -385,15 +382,14 @@ local function check_skip_marker(marker, _pipe_ctx, findings)
end end
end end
--- Migration warning emitted alongside the new registry-membership check. --- Warn when a source references an unregistered alias.
--- ---
--- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase --- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase are the context aliases opted in via `#define atom_reg` in lottes_tape.h.
--- are the wave-context aliases opted in via `#define atom_reg` in lottes_tape.h (Task 21). --- A source referencing an unregistered R_X emits one pass-level info entry
--- Any source referencing an R_X that's NOT in the registry will trip the new check; a single pass-level info entry
--- (emitted only when at least one such rejection lands in this source) tells users where to look. --- (emitted only when at least one such rejection lands in this source) tells users where to look.
--- ---
--- This check is a stop-gap until users migrate off raw C-ABI register names. --- This check directs raw C-ABI register names to explicit alias registration.
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
local function check_wave_context_migration(_src, pipe_ctx, findings) local function check_wave_context_migration(_src, pipe_ctx, findings)
@@ -446,14 +442,66 @@ local CHECK_RULES = {
-- Validation -- Validation
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- --
-- Pure check: read from src.scan, run validations, emit findings. -- Pure check: read from src.scan, run validations, emit findings. The scan was done once upstream.
-- No source walking; no parsing. The scan was done once upstream.
--- Validate one source against its pre-scanned SourceScan payload. --- Build the corpus-wide pipe_ctx ONCE per pass run.
--- Reads the merged `corpus.*` registries (canonical cross-source lookups),
--- and the corpus-wide `atom_infos` list (preserving source order + duplicates).
--- The corpus is the source of truth; per-source scans retain body / declaration
--- ownership via `src.scan` and the per-source `atoms` / `atom_infos` projections.
---
--- Canonical ownership: a context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message.
--- No per-source fallback synthesis is performed; callers MUST construct a canonical ctx through `build_ctx`.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @param src SourceFile --- @return PipeCtx
local function build_corpus_pipe_ctx(ctx)
local corpus = ctx.shared and ctx.shared.corpus
if not corpus then
error("annotation requires ctx.shared.corpus "
.. "(the canonical corpus is the source of truth; "
.. "no per-source fallback is supported)", 0)
end
-- Corpus atom_infos preserves source-order + duplicates;
-- the per-check `check_unique_annotation` post-rule still flags duplicate annotation
-- names within this list. We pre-compute the annot_counts map here so the per_source checks can iterate it without re-walking.
local annot_counts = {}
for _, info in ipairs(corpus.atom_infos or {}) do
if info and info.atom_name then
annot_counts[info.atom_name] = (annot_counts[info.atom_name] or 0) + 1
end
end
-- The pipe_ctx views REFERENCE the corpus tables directly (no copies).
-- Every consumer of these fields observes mutations via the canonical corpus without independently mutable registry construction.
return {
-- Cross-source lookup tables (canonical corpus projections).
register_alias_registry = corpus.register_alias_registry or {},
type_name_registry = corpus.type_name_registry or {},
atom_views = corpus.atom_views or {},
atom_ctxs = corpus.atom_ctxs or {},
atom_phases = corpus.atom_phases or {},
binds_by_name = corpus.binds_by_name or {},
atoms_by_name = corpus.atoms_by_name or {},
-- Corpus-wide ordered list of atom_info records (source-order + duplicates).
atom_infos_list = corpus.atom_infos or {},
-- Corpus-wide annotation count aggregation (post-rule consumes this).
annot_counts = annot_counts,
-- Corpus-wide collisions (recorded by scan_source.merge_corpus_registries).
collisions = corpus.collisions or {},
-- wc still consumed by check_macro_word_drift; reads from the canonical
-- `corpus.word_counts` table (built by word_count_eval.run).
word_counts = corpus.word_counts or {},
}
end
--- Validate one source against its pre-scanned SourceScan payload + the corpus-wide pipe_ctx.
--- @param ctx PassCtx
--- @param src SourceFile
--- @param corpus_pipe_ctx PipeCtx|nil -- built once per pass from corpus registries; nil = self-build (canonical projection).
--- @return AnnotatedResult --- @return AnnotatedResult
local function validate(ctx, src) local function validate(ctx, src, corpus_pipe_ctx)
corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx)
local scan = src.scan local scan = src.scan
-- Project the pre-scanned atoms to the AtomEntry shape this pass needs. -- Project the pre-scanned atoms to the AtomEntry shape this pass needs.
@@ -479,9 +527,11 @@ local function validate(ctx, src)
} }
end end
-- Build pipe_ctx (Fleury: expose structure). Pre-compute everything the per-check functions need. -- Build the per-source pipe_ctx (Fleury: expose structure).
-- Single source of truth for atom / binds / annotation-count lookups. -- Cross-source visibility comes from `corpus_pipe_ctx`;
-- pipe_ctx.types / pipe_ctx.atom_views / pipe_ctx.seen_defaults are projected from the scan payload so per_source check rules can iterate. -- per-source declaration / body ownership comes from `src.scan`.
-- pipe_ctx.types / pipe_ctx.atom_views / pipe_ctx.seen_defaults / pipe_ctx.type_occurrences
-- are projected from the per-source scan so the per_source check rules can iterate the source-local occurrences.
local seen_defaults = {} local seen_defaults = {}
for reg, _ in pairs(scan.types or {}) do for reg, _ in pairs(scan.types or {}) do
seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 seen_defaults[reg] = (seen_defaults[reg] or 0) + 1
@@ -494,25 +544,20 @@ local function validate(ctx, src)
local pipe_ctx = { local pipe_ctx = {
atom_index = {}, atom_index = {},
binds_index = {}, binds_index = {},
annot_counts = {}, annot_counts = corpus_pipe_ctx.annot_counts,
types = scan.types or {}, types = scan.types or {},
type_occurrences = scan.type_occurrences or {}, type_occurrences = scan.type_occurrences or {},
atom_views = scan.atom_views or {}, atom_views = scan.atom_views or {},
seen_defaults = seen_defaults, seen_defaults = seen_defaults,
atom_infos_list = atom_infos_list, atom_infos_list = atom_infos_list,
binds_list = scan.binds or {}, binds_list = scan.binds or {},
-- Project the source-derived registries from the scan payload so per_source checks consult them instead of the deleted -- Source-derived registries: still populated from the scan payload as a convenience for callers that want source-local visibility.
-- SEMANTIC_DEFAULT_REGS / KNOWN_REG_DEFAULT_TYPES / etc. -- The canonical cross-source lookup tables live in corpus_pipe_ctx.
register_alias_registry = scan.register_alias_registry or {}, register_alias_registry = corpus_pipe_ctx.register_alias_registry,
type_name_registry = scan.type_name_registry or {}, type_name_registry = corpus_pipe_ctx.type_name_registry,
} }
for _, a in ipairs(atoms) do pipe_ctx.atom_index [a.name] = a end for _, a in ipairs(atoms) do pipe_ctx.atom_index [a.name] = a end
for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end
for _, a in ipairs(annots) do
if a.name then
pipe_ctx.annot_counts[a.name] = (pipe_ctx.annot_counts[a.name] or 0) + 1
end
end
-- Findings live in a single struct with three lists (errors / warnings / info). -- Findings live in a single struct with three lists (errors / warnings / info).
-- Each check writes to the list appropriate for its severity. -- Each check writes to the list appropriate for its severity.
@@ -544,8 +589,8 @@ local function validate(ctx, src)
if rule.post then rule.post(pipe_ctx, findings) end if rule.post then rule.post(pipe_ctx, findings) end
end end
-- Per-skip-marker rules. -- Per-skip-marker rules.
-- Each raw marker recorded by scan_source (in scan.skip_over.markers) is validated independently; -- Each raw marker recorded by scan_source (in scan.skip_over.markers) is validated independently;
-- the check emits at most one error per marker. -- the check emits at most one error per marker.
-- Valid markers stay attached to scan.skip_over.atoms /.components for dwarf_injection.lua consumer. -- Valid markers stay attached to scan.skip_over.atoms /.components for dwarf_injection.lua consumer.
local skip_markers = scan.skip_over and scan.skip_over.markers or {} local skip_markers = scan.skip_over and scan.skip_over.markers or {}
@@ -556,7 +601,7 @@ local function validate(ctx, src)
end end
-- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift). -- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift).
local wc = ctx.shared.word_counts local wc = corpus_pipe_ctx.word_counts
for _, m in ipairs(scan.macros) do for _, m in ipairs(scan.macros) do
for _, rule in ipairs(CHECK_RULES) do for _, rule in ipairs(CHECK_RULES) do
if rule.per_macro then rule.per_macro(m, wc, findings) end if rule.per_macro then rule.per_macro(m, wc, findings) end
@@ -572,8 +617,8 @@ local function validate(ctx, src)
-- Information summary (always emitted). -- Information summary (always emitted).
findings.info[#findings.info + 1] = { findings.info[#findings.info + 1] = {
line = 0, line = 0,
msg = string.format("scanned: %d atom(s), %d annotation(s), %d macro-word-decl(s), %d binds struct(s)", msg = string.format("scanned: %d atom(s), %d annotation(s), %d macro-word-decl(s), %d binds struct(s)"
#atoms, #annots, #scan.macros, #scan.binds), , #atoms, #annots, #scan.macros, #scan.binds),
} }
return { return {
@@ -594,7 +639,6 @@ end
--- Render `<dir_basename>.errors.h` with `#error` directives for every error found across all sources in the directory. --- Render `<dir_basename>.errors.h` with `#error` directives for every error found across all sources in the directory.
--- Empty directories (no errors, no atoms) produce no file. --- Empty directories (no errors, no atoms) produce no file.
local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources) local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources)
if ctx.dry_run then return nil end
if atoms_count == 0 and #errors == 0 then if atoms_count == 0 and #errors == 0 then
return nil return nil
end end
@@ -622,17 +666,6 @@ local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sour
return out_path return out_path
end end
--- Stash aggregated per-module results for the report pass to consume.
local function emit_module_annotations_stub(ctx, dir, dir_basename, atoms_count)
ctx.flags = ctx.flags or {}
ctx.flags._annot_results = ctx.flags._annot_results or {}
ctx.flags._annot_results[#ctx.flags._annot_results + 1] = {
dir = dir,
dir_basename = dir_basename,
atoms_count = atoms_count,
}
end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry -- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -651,22 +684,25 @@ function M.run(ctx)
local errors = {} local errors = {}
local warnings = {} local warnings = {}
-- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`, validate every source in the dir, then emit ONE errors.h per dir. -- Build the corpus-wide pipe_ctx ONCE per pass run.
-- `ctx.by_dir` is pre-computed in build_ctx (shared across all passes). -- The corpus owns the canonical cross-source registries; per-source scans retain body / declaration ownership.
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources) -- The pipe_ctx is shared across every validate() invocation in this M.run so cross-source visibility is constant.
local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx)
local corpus = ctx.shared.corpus
-- Per-DIRECTORY (per-module) aggregation.
-- Group sources by `src.dir`, validate every source in the dir, then emit ONE errors.h per dir.
-- The corpus owns `sources_by_dir`; this pass reads the corpus bucket directly.
local by_dir = (corpus and corpus.sources_by_dir) or {}
for dir, dir_sources in pairs(by_dir) do for dir, dir_sources in pairs(by_dir) do
local dir_basename = dir:match("([^/\\]+)$") or dir local dir_basename = dir:match("([^/\\]+)$") or dir
local dir_atoms = 0 local dir_atoms = 0
local dir_errors = {} local dir_errors = {}
local dir_warnings = {} local dir_warnings = {}
-- Per-source validate() results, cached for the report pass (it reads from this instead of re-validating each source).
ctx.flags = ctx.flags or {}
ctx.flags._annot_source_results = ctx.flags._annot_source_results or {}
for _, src in ipairs(dir_sources) do for _, src in ipairs(dir_sources) do
local result = validate(ctx, src) local result = validate(ctx, src, corpus_pipe_ctx)
result.source = src.path -- tag for downstream rendering result.source = src.path -- tag for downstream rendering
ctx.flags._annot_source_results[src.path] = result -- stash so report.lua reads from cache instead of re-running validate()
dir_atoms = dir_atoms + #result.atoms dir_atoms = dir_atoms + #result.atoms
for _, e in ipairs(result.errors) do for _, e in ipairs(result.errors) do
dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path } dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path }
@@ -682,8 +718,6 @@ function M.run(ctx)
if err_path then if err_path then
table.insert(outputs, { errors_h = err_path }) table.insert(outputs, { errors_h = err_path })
end end
emit_module_annotations_stub(ctx, dir, dir_basename, dir_atoms)
end end
return { outputs = outputs, errors = errors, warnings = warnings } return { outputs = outputs, errors = errors, warnings = warnings }
+134 -342
View File
@@ -1,11 +1,8 @@
--- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms. --- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms.
--- ---
--- Reads the pre-scanned SourceScan payload (produced once upstream by `duffle.scan_source`) --- Reads the canonical `atom.paths` projection produced by the upstream `emission_model` pass.
--- for `MipsAtom_(name)` (kind="atom"), `MipsAtomComp_` / `MipsAtomComp_Proc_` (kind="comp_*"), --- The ordered `items` stream, dense `word_events`, and `invocations` views are the only semantic inputs to this pass;
--- and `MipsCode code_<name>` (kind="raw_atom") declarations. --- it emits one `WORD N LINE L TEXT T` line per emitted `.word`.
--- Walks each atom's pre-tokenized body (`{{tok=string, rel=integer}, ...}` from `duffle.tokenize_body`),
--- counts per-token word contributions via `ctx.shared.word_counts`, and emits one
--- `WORD N LINE L TEXT T` line per `.word` to `<out_root>/<basename>.atoms.sourcemap.txt`.
--- ---
--- **Two output forms** (per the workspace's per-emission-form pattern from --- **Two output forms** (per the workspace's per-emission-form pattern from
--- `guide_metaprogram_ssdl.md`): --- `guide_metaprogram_ssdl.md`):
@@ -34,10 +31,8 @@
--- ENDATOM --- ENDATOM
--- ``` --- ```
--- ---
--- Marker calls (`atom_label(...)`, `atom_offset(...)`) emit 0 `.word`s. --- Marker records are zero-width in `atom.paths.items`; they do not appear in
--- They share the same walking convention as `passes/offsets.lua :: scan_atom_body`: --- the dense word view and therefore emit no WORD rows.
--- Markers do NOT advance the word-offset counter, but if a marker is bundled on the same token with a trailing instruction
--- (e.g. `atom_label(foo) load_half_u(...)`), the trailing instruction's word count is added. This matches `offsets.lua :: count_marker_rest`.
--- ---
--- **Conventions:** tabs (1/level), EmmyLua annotations, no regex, --- **Conventions:** tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. --- Lua 5.3 compatible.
@@ -50,10 +45,8 @@
-- (works both standalone + when require'd). `duffle_paths.lua` sets package.path then returns `require("duffle")` -- (works both standalone + when require'd). `duffle_paths.lua` sets package.path then returns `require("duffle")`
-- at the bottom, so the dofile value IS the duffle module. -- at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local elf_dwarf = require("elf_dwarf") local elf_dwarf = require("elf_dwarf")
local word_count_eval = require("word_count_eval")
local count_token_words = word_count_eval.count_token_words
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Constants -- Constants
@@ -63,199 +56,89 @@ local count_token_words = word_count_eval.count_token_words
-- the gdb runtime loader rejects mismatches (E2). -- the gdb runtime loader rejects mismatches (E2).
local FORMAT_VERSION = 1 local FORMAT_VERSION = 1
-- Marker-call identifiers (mirrors offsets.lua:33-34).
local LABEL_MARKER = "atom_label"
local OFFSET_MARKER = "atom_offset"
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Type declarations -- Type declarations
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class AtomSourceMapCtx --- @class AtomSourceMapCtx
--- @field sources table[] -- SourceScan payload per source (from `ctx.sources`)
--- @field shared table -- `ctx.shared` --- @field shared table -- `ctx.shared`
--- @field shared.word_counts table -- macro name -> word count (populated by word-counts + components passes) --- @field shared.corpus table -- canonical source-order corpus
--- @field shared.word_counts table
--- @field out_root string -- output root (e.g. "build/gen") --- @field out_root string -- output root (e.g. "build/gen")
--- @field dry_run boolean -- if true, compute but don't write
--- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path` --- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path`
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Helpers -- Canonical atom-path renderers
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- ════════════════════════════════════════════════════════════════════════════ --- Join canonical words to canonical word items. `items` supplies the ordered
-- Provenance emission --- word boundaries, while `word_events` supplies call text and source lines.
-- ════════════════════════════════════════════════════════════════════════════
-- Component-macro invocation prefix (mirrors components.lua's MAC_PREFIX).
local MAC_PREFIX = "mac_"
local MAC_PREFIX_LEN = 4
--- Strip the `mac_` prefix from a token's leading identifier.
--- Returns nil if the identifier doesn't start with `mac_`
--- (so non-component tokens like `load_half_u`, `nop2`, `gte_cmdw_*` fall through cleanly).
--- @param tok string
--- @return string|nil
local function strip_mac_prefix_from_token(tok)
local leading = duffle.read_ident(tok, 1)
if not leading then return nil end
if leading:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
return leading:sub(MAC_PREFIX_LEN + 1)
end
return nil
end
--- Fetch the per-word body lines for a `mac_X(...)` invocation.
--- Walks the component's pre-tokenized body in lockstep with `count_token_words` and attributes each emitted `.word`
--- to a source line via `idx.line_of(...)`.
--- Atom labels (`atom_label(...)`) emit 0 `.word`s and are skipped.
--- @param bare string|nil -- the bare component name (e.g. `gte_load_tri_verts`)
--- @param comp_body_index table
--- @param wc table
--- @return table|nil -- list of source lines, 1-based by word position
local function fetch_body_lines(bare, comp_body_index, wc)
if not (bare and comp_body_index) then return nil end
local idx = comp_body_index[bare]
if not (idx and idx.body_tokens and idx.line_of) then return nil end
local lines = {}
for _, bt in ipairs(idx.body_tokens) do
local bt_tok = duffle.trim(bt.tok or "")
if bt_tok ~= "" then
local leading = duffle.read_ident(bt_tok, 1)
local bt_words
if leading == "atom_label" or leading == "atom_offset" then
bt_words = 0
else
bt_words = count_token_words(bt_tok, wc)
end
if bt_words > 0 then
local body_line = idx.line_of(idx.body_off + bt.rel)
for _ = 1, bt_words do lines[#lines + 1] = body_line end
end
end
end
return lines
end
--- Unified per-word entry walker. `mode` is "sourcemap" (3 fields) or "provenance" (8 fields including component + body-line lookup).
--- Returns (entries, total_words). Markers contribute 0 entries.
--- @param atom table --- @param atom table
--- @param src table
--- @param wc table
--- @param mode string -- "sourcemap" | "provenance"
--- @param comp table|nil -- shared.components map (provenance only)
--- @param comp_body_index table|nil -- per-source body index (provenance only)
--- @return table[], integer --- @return table[], integer
local function compute_word_entries(atom, src, wc, mode, comp, comp_body_index) local function canonical_word_entries(atom)
local entries = {} local paths = atom.paths or {}
local pos = 0 local events = paths.word_events or {}
for _, t in ipairs(atom.body_tokens) do local word_items = {}
local tok = t.tok for _, item in ipairs(paths.items or {}) do
local rel = t.rel if item.kind == "word" then word_items[#word_items + 1] = item end
local words
if duffle.is_marker_token(tok) then
words = duffle.count_marker_rest(tok, wc, count_token_words)
else
words = count_token_words(tok, wc)
end
-- Provenance-only: resolve component + body_lines (one fetch per token).
local comp_name, comp_line, comp_path, comp_kind
local body_lines
if mode == "provenance" then
local bare = strip_mac_prefix_from_token(tok)
if bare and comp and comp[bare] then
comp_name = bare
comp_line = comp[bare].line
comp_path = comp[bare].path
comp_kind = comp[bare].kind
end
if comp_name then body_lines = fetch_body_lines(bare, comp_body_index, wc) end
end
if words > 0 then
local line = src.scan.line_of(atom.body_off + rel)
local text = duffle.trim(tok):gsub("[\t\r\n]+", " ")
for i = 1, words do
local entry
if mode == "provenance" then
entry = {
pos = pos,
line = line,
text = text,
comp_name = comp_name,
comp_line = comp_line,
comp_path = comp_path,
comp_kind = comp_kind,
body_line = body_lines and body_lines[i],
}
else -- "sourcemap" (default)
entry = { pos = pos, line = line, text = text }
end
entries[#entries + 1] = entry
pos = pos + 1
end
end
end end
return entries, pos
local entries = {}
for index, event in ipairs(events) do
local item = word_items[index] or {}
entries[#entries + 1] = {
pos = event.i or (index - 1),
line = event.call_line or item.line or 0,
text = event.call_text or item.call_text or "",
body_line = event.body_line or item.body_line or item.line or 0,
invocation = (event.outermost_invocation_id
and paths.invocations
and paths.invocations[event.outermost_invocation_id]) or nil,
}
end
return entries, #events
end end
--- Render one atom's provenance stanza. Format: --- Render one atom's provenance stanza. Format 1 remains:
--- `WORD N CALL <src-path>:<src-line> MACRO <name> "<def-path>:<def-line>" [BODY <line>]` (for component words) --- `WORD N CALL <src-path>:<src-line> MACRO <name> "<def-path>:<def-line>" BODY <line>`
--- `WORD N CALL <src-path>:<src-line> RAW` (for direct instructions) --- `WORD N CALL <src-path>:<src-line> RAW`
--- `BODY <line>` is the source line of THIS specific word within the macro body --- Component identity comes from the canonical outermost invocation record;
--- (lottes_tape.h:N where N is the per-word body line). --- the count-table lookup is the canonical component declaration witness.
--- Absent for RAW rows and for component rows whose component declaration could not be indexed (older pass combinations / external macros). --- @param src table
--- Downstream consumers (dwarf_injection, tests) fall back to DefLine / comp_line when BODY is absent. --- @param atom table
--- Returns (lines, total_words). --- @param wc table -- identity alias of corpus.word_counts
--- @param src table
--- @param atom table
--- @param wc table
--- @param comp table -- shared.components map
--- @param comp_body_index table -- per-source component body index: bare_name -> {body_off, body_tokens, line_of}
--- @return string[], integer --- @return string[], integer
local function emit_provenance_stanza(src, atom, wc, comp, comp_body_index) local function emit_provenance_stanza(src, atom, wc)
local lines = {} local lines = {}
local rel_path = src.path:gsub("\\", "/") local rel_path = src.path:gsub("\\\\", "/")
local entries, total = compute_word_entries(atom, src, wc, "provenance", comp, comp_body_index) local entries, total = canonical_word_entries(atom)
-- ATOM header line with placeholder total (patched after we know it).
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path) lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
for _, pe in ipairs(entries) do for _, entry in ipairs(entries) do
if pe.comp_name then local inv = entry.invocation
local body_suffix = "" local macro_count = inv and wc["mac_" .. inv.component_name]
if pe.body_line then if inv and macro_count ~= nil then
body_suffix = " BODY " .. tostring(pe.body_line) lines[#lines + 1] = string.format(
end 'WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d',
lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d"%s', entry.pos, rel_path, entry.line, inv.component_name,
pe.pos, rel_path, pe.line, pe.comp_name, pe.comp_path, pe.comp_line, body_suffix) inv.def_path or "", inv.def_line or 0, entry.body_line)
else else
lines[#lines + 1] = string.format("WORD %d CALL %s:%d RAW", pe.pos, rel_path, pe.line) lines[#lines + 1] = string.format(
"WORD %d CALL %s:%d RAW", entry.pos, rel_path, entry.line)
end end
end end
-- Patch the placeholder total in the ATOM header line.
lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total)) lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total))
lines[#lines + 1] = "ENDATOM" lines[#lines + 1] = "ENDATOM"
return lines, total return lines, total
end end
--- Build a per-source component body index keyed by the bare component name (e.g. `gte_load_tri_verts`). --- Render the full provenance file content for one source.
--- Each entry holds the data we need to map each emitted `.word` to its actual source line within the macro body:
--- body_off -- byte offset of the `{` (start of body) in the component's source file.
--- body_tokens -- list of {tok, rel} pairs; `rel` is the byte offset within the body.
--- line_of -- closure resolving byte offsets in the component's source file to lines.
--- Only `comp_bare` + `comp_proc` declarations contribute (a macro invocation can only resolve to one of those).
--- First declaration wins (subsequent redeclarations would collide; today's sources declare each component exactly once).
--- Render the full provenance file content for one source (one `.atoms.provenance.txt` per source).
--- @param src table --- @param src table
--- @param wc table --- @param wc table
--- @param comp table -- shared.components map
--- @param comp_body_index table -- cross-source component body index (built once in M.run; may be empty)
--- @return string --- @return string
local function render_provenance(src, wc, comp, comp_body_index) local function render_provenance(src, wc)
local lines = {} local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION 1" lines[#lines + 1] = "# FORMAT_VERSION 1"
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
@@ -265,16 +148,15 @@ local function render_provenance(src, wc, comp, comp_body_index)
lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word" lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word"
lines[#lines + 1] = "# line program rows for native source-level step into component bodies." lines[#lines + 1] = "# line program rows for native source-level step into component bodies."
-- The cross-source component body index is passed in from M.run (one global lookup shared across every source's provenance file). local function append(atom)
-- A per-source lookup would miss every component whose declaration is in another source (e.g. `gte_load_tri_verts` is declared in `lottes_tape.h` but invoked from `hello_gte_tape.c`). local stanza = emit_provenance_stanza(src, atom, wc)
for _, atom in ipairs(src.scan.atoms or {}) do
local stanza = emit_provenance_stanza(src, atom, wc, comp, comp_body_index)
for _, line in ipairs(stanza) do lines[#lines + 1] = line end for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end end
for _, atom in ipairs(src.scan.atoms or {}) do
if atom.paths then append(atom) end
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do for _, atom in ipairs(src.scan.raw_atoms or {}) do
local stanza = emit_provenance_stanza(src, atom, wc, comp, comp_body_index) if atom.paths then append(atom) end
for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end end
return table.concat(lines, "\n") .. "\n" return table.concat(lines, "\n") .. "\n"
@@ -286,19 +168,16 @@ end
--- @param atom table --- @param atom table
--- @param wc table --- @param wc table
--- @return string[], integer --- @return string[], integer
local function emit_atom_stanza(src, atom, wc) local function emit_atom_stanza(src, atom)
local lines = {} local lines = {}
local rel_path = src.path:gsub("\\", "/") local rel_path = src.path:gsub("\\\\", "/")
local entries, total = compute_word_entries(atom, src, wc) local entries, total = canonical_word_entries(atom)
-- ATOM header line with placeholder total (patched after we know it).
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path) lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
for _, we in ipairs(entries) do for _, entry in ipairs(entries) do
lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s", lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s",
we.pos, we.line, we.text) entry.pos, entry.line, entry.text)
end end
-- Patch the placeholder total in the ATOM header line.
lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total)) lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total))
lines[#lines + 1] = "ENDATOM" lines[#lines + 1] = "ENDATOM"
return lines, total return lines, total
@@ -309,18 +188,20 @@ end
--- @param src table --- @param src table
--- @param wc table --- @param wc table
--- @return string --- @return string
local function render_source_map(src, wc) local function render_source_map(src)
local lines = {} local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
for _, atom in ipairs(src.scan.atoms or {}) do local function append(atom)
local stanza = emit_atom_stanza(src, atom, wc) local stanza = emit_atom_stanza(src, atom)
for _, line in ipairs(stanza) do lines[#lines + 1] = line end for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end end
for _, atom in ipairs(src.scan.raw_atoms or {}) do for _, atom in ipairs(src.scan.atoms or {}) do
local stanza = emit_atom_stanza(src, atom, wc) if atom.paths then append(atom) end
for _, line in ipairs(stanza) do lines[#lines + 1] = line end end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
if atom.paths then append(atom) end
end end
return table.concat(lines, "\n") .. "\n" return table.concat(lines, "\n") .. "\n"
@@ -343,55 +224,35 @@ end
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries} --- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries}
local function build_atom_table(ctx) local function build_atom_table(ctx)
local wc = (ctx.shared and ctx.shared.word_counts) or {}
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local corpus = ctx.shared and ctx.shared.corpus
local matched = {} local matched = {}
for _, src in ipairs(ctx.sources) do
if src.scan then for _, src in ipairs(corpus.source_order or {}) do
local file_base = src.path:match("([^/\\]+)$") or src.path local file_base = src.path:match("([^/\\\\]+)$") or src.path
for _, atom in ipairs(src.scan.atoms or {}) do local function append(atom)
if atom.kind == nil or atom.kind == "atom" then if not atom.paths then return end
local name = atom.raw_name or atom.name local name = atom.raw_name or atom.name
local info = addrs[name] local info = addrs[name]
if info then if not info then return end
local entries, total = compute_word_entries(atom, src, wc) local entries, total = canonical_word_entries(atom)
matched[#matched + 1] = { matched[#matched + 1] = {
name = name, name = name,
src_path = src.path, src_path = src.path,
file_base = file_base, file_base = file_base,
addr = info[1], addr = info[1],
size_bytes = info[2], size_bytes = info[2],
words = total, words = total,
entries = entries, entries = entries,
} }
end
end
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
local name = atom.name
local info = addrs[name]
if info then
local entries, total = compute_word_entries(atom, src, wc)
matched[#matched + 1] = {
name = name,
src_path = src.path,
file_base = file_base,
addr = info[1],
size_bytes = info[2],
words = total,
entries = entries,
}
end
end
end end
for _, atom in ipairs((src.scan or {}).atoms or {}) do append(atom) end
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do append(atom) end
end end
-- Deterministic order: sort by address (matches `nm` output ordering). -- Deterministic order: sort by address (matches `nm` output ordering).
table.sort(matched, function(a, b) return a.addr < b.addr end) table.sort(matched, function(a, b) return a.addr < b.addr end)
for i, a in ipairs(matched) do for i, a in ipairs(matched) do a.idx = i - 1 end
a.idx = i - 1
end
return matched return matched
end end
@@ -529,31 +390,6 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = "end" lines[#lines + 1] = "end"
lines[#lines + 1] = "" lines[#lines + 1] = ""
-- ── show_c2 ──
-- GTE data regs (COP2). pcsx-redux's gdb stub doesn't expose COP2 (only 72 regs: 32 GPR + COP0 + FPR).
-- curl http://localhost:8080/api/v1/lua/gte
-- We keep the command definition as a stub that points the user at the plugin.
lines[#lines + 1] = "define show_c2"
lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2: gdb stub does not expose COP2 in this build."'
lines[#lines + 1] = ' echo "[gdb_tape_atoms] Use scripts/pcsx_debug_helper.zip + curl http://localhost:8080/api/v1/lua/gte"'
lines[#lines + 1] = ' echo "[gdb_tape_atoms] (or pcsx-redux Debug > Registers window for a native view)"'
lines[#lines + 1] = "end"
lines[#lines + 1] = "document show_c2"
lines[#lines + 1] = " Stub. The gdb stub in this pcsx-redux build does not expose COP2 regs."
lines[#lines + 1] = " For GTE data + control state, use the pcsx_debug_helper Lua plugin or the"
lines[#lines + 1] = " pcsx-redux Debug > Registers window."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── show_c2ctl ──
lines[#lines + 1] = "define show_c2ctl"
lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2ctl: see show_c2 for the same workaround."'
lines[#lines + 1] = "end"
lines[#lines + 1] = "document show_c2ctl"
lines[#lines + 1] = " Stub. Same workaround as show_c2."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── wave_ctx ── -- ── wave_ctx ──
lines[#lines + 1] = "define wave_ctx" lines[#lines + 1] = "define wave_ctx"
lines[#lines + 1] = ' printf "$t4 = R_FaceCursor 0x%08x\\n", $t4' lines[#lines + 1] = ' printf "$t4 = R_FaceCursor 0x%08x\\n", $t4'
@@ -627,10 +463,8 @@ local function emit_gdb_runtime(ctx)
lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path' lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path'
local out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb" local out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb"
if not ctx.dry_run then duffle.ensure_dir(duffle.dirname(out_path))
duffle.ensure_dir(duffle.dirname(out_path)) duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
end
io.stderr:write(string.format( io.stderr:write(string.format(
"[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched)) "[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched))
end end
@@ -641,42 +475,6 @@ end
local M = {} local M = {}
--- Build the cross-source component body index used by `render_provenance` to attribute each emitted `.word` to its actual line within the macro body.
---
--- Components are declared in one source (the header that contains `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- but invoked from many source files (every atom body that calls `mac_X(...)`).
--- The body_offset + body_tokens + line_of live with the declaration source, so a per-source index would miss invocations from other sources.
---
--- The cross-source index is keyed by the bare component name (`gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`)
--- `strip_mac_prefix_from_token` strips the `mac_` prefix from call-site identifiers and yields that exact bare name;
--- matching it here keeps the lookup aligned with the `ctx.shared.components` map's keying convention.
--- First declaration wins (subsequent redeclarations would collide; today's sources declare each component exactly once).
--- @param ctx PassCtx
--- @return table<string, table> -- {[comp_name] = {body_off, body_tokens, line_of}}
local function build_cross_source_component_body_index(ctx)
local index = {}
for _, src in ipairs(ctx.sources or {}) do
if src.scan and src.scan.atoms then
local line_of = src.scan.line_of
for _, atom in ipairs(src.scan.atoms) do
if atom.kind == "comp_bare" or atom.kind == "comp_proc" then
-- Prefer `atom.name` (stripped of `ac_` prefix); fall back to `raw_name`
-- only if the stripped name is absent (defensive — current scan-source always sets both).
local name = atom.name or atom.raw_name
if name and not index[name] then
index[name] = {
body_off = atom.body_off,
body_tokens = atom.body_tokens,
line_of = line_of,
}
end
end
end
end
end
return index
end
--- Pass entry: emit one `<out_root>/<basename>.atoms.sourcemap.txt` per source file that contains at least one `MipsAtom_(name)` / `MipsCode code_<name>` declaration. --- Pass entry: emit one `<out_root>/<basename>.atoms.sourcemap.txt` per source file that contains at least one `MipsAtom_(name)` / `MipsCode code_<name>` declaration.
--- Also emits `<out_root>/<basename>.atoms.provenance.txt`: --- Also emits `<out_root>/<basename>.atoms.provenance.txt`:
--- per-.word provenance with `mac_X(...)` component resolution back to the component's definition file:line + the per-word body line. --- per-.word provenance with `mac_X(...)` component resolution back to the component's definition file:line + the per-word body line.
@@ -688,54 +486,48 @@ function M.run(ctx)
local errors = {} local errors = {}
local warnings = {} local warnings = {}
-- word-counts + components passes must have populated shared.word_counts. local corpus = ctx.shared and ctx.shared.corpus
-- If absent, the orchestrator wired the deps wrong — fail loud. if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then
local wc = (ctx.shared and ctx.shared.word_counts) or {} error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
if not wc or not next(wc) then end
-- Word counts are owned by `corpus.word_counts`.
-- The canonical owner is `corpus.word_counts` (populated by `passes/word_count_eval.lua` + `passes/components.lua`).
local wc = corpus.word_counts or {}
if not next(wc) then
warnings[#warnings + 1] = { warnings[#warnings + 1] = {
line = 0, line = 0,
msg = "atoms_source_map: ctx.shared.word_counts is empty; the word-counts + components passes may not have populated it. Check the PASSES dep edges.", msg = "atoms_source_map: corpus.word_counts is empty; the word-counts + components passes may not have populated it. Check the PASSES dep edges.",
} }
end end
-- shared.components map is populated by `passes/components.lua`.
-- Used to attribute each emitted `.word` to either a component macro or the enclosing atom body.
-- If absent, all words fall through as RAW (correct behavior — provenance is additive).
local comp = (ctx.shared and ctx.shared.components) or {}
-- Cross-source component body index.
-- Built ONCE so every source's provenance writer can resolve `mac_X(...)` invocations back to the macro's body tokens (regardless of which source declared the component).
-- Per-source copies were insufficient — the atom file (`hello_gte_tape.c`) does not contain the `MipsAtomComp_(...)` declarations,
-- so the body data would be missing for every component invocation the atom file emitted.
local comp_body_index = build_cross_source_component_body_index(ctx)
-- Always emit the canonical text form (per-source). -- Always emit the canonical text form (per-source).
for _, src in ipairs(ctx.sources) do for _, src in ipairs(corpus.source_order) do
if src.scan then local has_projection = false
local n_atoms = src.scan.atoms and #src.scan.atoms or 0 for _, atom in ipairs((src.scan or {}).atoms or {}) do
local n_raw_atoms = src.scan.raw_atoms and #src.scan.raw_atoms or 0 if (atom.kind == "atom" or atom.kind == "raw_atom") and atom.paths then
if n_atoms + n_raw_atoms > 0 then has_projection = true; break
local basename = duffle.basename_no_ext(src.path)
-- (1) atoms.sourcemap.txt — per-.word line map (unchanged contract).
local sourcemap_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt"
local sourcemap_body = render_source_map(src, wc)
-- (2) atoms.provenance.txt — per-.word provenance with `mac_X(...)` component resolution back to the component's definition file:line.
-- Consumed by `passes/dwarf_injection.lua` to synthesize `DW_TAG_inlined_subroutine` instances for source-level Step Into on component invocations.
local prov_path = ctx.out_root .. "/" .. basename .. ".atoms.provenance.txt"
local prov_body = render_provenance(src, wc, comp, comp_body_index)
if not ctx.dry_run then
duffle.ensure_dir(duffle.dirname(sourcemap_path))
duffle.write_file_lf(sourcemap_path, sourcemap_body)
duffle.write_file_lf(prov_path, prov_body)
end
outputs[#outputs + 1] = { kind = "report", path = sourcemap_path }
outputs[#outputs + 1] = { kind = "report", path = prov_path }
end end
end end
if not has_projection then
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do
if atom.paths then has_projection = true; break end
end
end
if has_projection then
local basename = duffle.basename_no_ext(src.path)
-- (1) atoms.sourcemap.txt — format-1 per-word call-site map.
local sourcemap_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt"
local sourcemap_body = render_source_map(src)
-- (2) atoms.provenance.txt — format-1 per-word definition/body map.
local prov_path = ctx.out_root .. "/" .. basename .. ".atoms.provenance.txt"
local prov_body = render_provenance(src, wc)
duffle.ensure_dir(duffle.dirname(sourcemap_path))
duffle.write_file_lf(sourcemap_path, sourcemap_body)
duffle.write_file_lf(prov_path, prov_body)
outputs[#outputs + 1] = { kind = "report", path = sourcemap_path }
outputs[#outputs + 1] = { kind = "report", path = prov_path }
end
end end
-- Optionally emit the gdb-runtime form (post-link, one file per build). -- Optionally emit the gdb-runtime form (post-link, one file per build).
+156 -120
View File
@@ -11,15 +11,6 @@
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. --- Lua 5.3 compatible.
--- @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 -- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -31,7 +22,6 @@
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. -- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local word_count_eval = require("word_count_eval")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Constants -- Constants
@@ -74,7 +64,6 @@ local GEN_SUBDIR = "gen"
--- @field project_root string -- project root (e.g. "code/") --- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs --- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags --- @field flags table -- CLI flags
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- log diagnostic info --- @field verbose boolean -- log diagnostic info
--- @class PassResult --- @class PassResult
@@ -108,8 +97,8 @@ local M = {}
--- We then verify the preceding context ends with `MipsAtom` --- We then verify the preceding context ends with `MipsAtom`
--- (the function-decl keyword with possible qualifiers between). --- (the function-decl keyword with possible qualifiers between).
--- ---
--- @param source string --- @param source string
--- @param name string --- @param name string
--- @param before_pos integer --- @param before_pos integer
--- @return string|nil --- @return string|nil
local function find_function_args_for(source, name, before_pos) local function find_function_args_for(source, name, before_pos)
@@ -149,14 +138,13 @@ end
--- Used to copy signature comments from the source declaration (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) --- 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. --- over to the generated `mac_X` macro, so LSP/IntelliSense displays the args doc.
--- @param source string --- @param source string
--- @param pos integer --- @param pos integer
--- @return string --- @return string
local function preceding_comment_block(source, pos) local function preceding_comment_block(source, pos)
local scan_pos = pos local scan_pos = pos
local pieces = {} local pieces = {}
while true do while true do
-- Skip whitespace (space/tab/newline/CR) backward from `scan_pos`, -- skip whitespace backward; land on the next non-ws character.
-- returning the position of the first non-whitespace char.
local non_ws = scan_pos - 1 local non_ws = scan_pos - 1
while non_ws > 0 do while non_ws > 0 do
local ch = source:sub(non_ws, non_ws) local ch = source:sub(non_ws, non_ws)
@@ -167,13 +155,9 @@ local function preceding_comment_block(source, pos)
end end
end end
if non_ws == 0 then break end if non_ws == 0 then break end
if non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/" then
local is_block_close = non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/" -- block comment close: find the opening /* by walking back over /* candidates
local is_line_end = source:sub(non_ws, non_ws) == "\n" or source:sub(non_ws, non_ws) == "\r" -- in source[1..non_ws-1].
if is_block_close then
-- Find the opening `/*` for a block comment whose `*/` ends at `non_ws`.
-- Walk back from `non_ws` over `/*` candidates.
local prefix = source:sub(1, non_ws - 1) local prefix = source:sub(1, non_ws - 1)
local open_at = nil local open_at = nil
for scan = #prefix - 1, 1, -1 do for scan = #prefix - 1, 1, -1 do
@@ -183,33 +167,28 @@ local function preceding_comment_block(source, pos)
end end
end end
if not open_at then break end if not open_at then break end
-- Walk back from `open_at` over leading spaces + tabs to include the indentation before the `/*`. -- include the indentation before the /* by walking back over leading spaces + tabs.
local block_start = open_at local block_start = open_at
while block_start > 1 do while block_start > 1 do
local ch = source:sub(block_start - 1, block_start - 1) local ch = source:sub(block_start - 1, block_start - 1)
if ch == " " or ch == "\t" then if ch ~= " " and ch ~= "\t" then break end
block_start = block_start - 1 block_start = block_start - 1
else
break
end
end end
table.insert(pieces, 1, source:sub(block_start, non_ws)) table.insert(pieces, 1, source:sub(block_start, non_ws))
scan_pos = block_start scan_pos = block_start
elseif is_line_end then else
-- Walk back from `non_ws` to the start of the source line (the most recent `\n` or position 1). -- line comment path: must end in newline, must start with //.
local ch = source:sub(non_ws, non_ws)
if ch ~= "\n" and ch ~= "\r" then break end
-- walk back from non_ws to the start of the source line (most recent \n or position 1).
local line_start = non_ws local line_start = non_ws
while line_start > 1 and source:sub(line_start - 1, line_start - 1) ~= "\n" do while line_start > 1 and source:sub(line_start - 1, line_start - 1) ~= "\n" do
line_start = line_start - 1 line_start = line_start - 1
end end
local line = source:sub(line_start, non_ws) local line = source:sub(line_start, non_ws)
if line:sub(1, 2) == "//" then if line:sub(1, 2) ~= "//" then break end
table.insert(pieces, 1, line) table.insert(pieces, 1, line)
scan_pos = line_start - 1 scan_pos = line_start - 1
else
break
end
else
break
end end
end end
if #pieces == 0 then return "" end if #pieces == 0 then return "" end
@@ -235,7 +214,6 @@ local function extract_arg_names(args_str)
if trimmed ~= "" then if trimmed ~= "" then
-- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`), -- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`),
-- then walk back over the identifier chars (alnum + `_`). -- then walk back over the identifier chars (alnum + `_`).
-- Plex: inlined the 2 single-caller helpers (no 2-caller rule met).
local ident_end = #trimmed local ident_end = #trimmed
while ident_end > 0 do while ident_end > 0 do
local ch = trimmed:sub(ident_end, ident_end) local ch = trimmed:sub(ident_end, ident_end)
@@ -248,7 +226,7 @@ local function extract_arg_names(args_str)
local ident_start = ident_end local ident_start = ident_end
while ident_start > 0 do while ident_start > 0 do
local ch = trimmed:sub(ident_start, ident_start) local ch = trimmed:sub(ident_start, ident_start)
if duffle.is_alnum(ch) or ch == "_" then if duffle.is_alnum_byte(string.byte(ch)) or ch == "_" then
ident_start = ident_start - 1 ident_start = ident_start - 1
else else
break break
@@ -267,12 +245,12 @@ end
-- Component projection (read from pre-scanned SourceScan) -- Component projection (read from pre-scanned SourceScan)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Project pre-scanned MipsAtomComp_ / MipsAtomComp_Proc_ entries into Component shape. --- Project pre-scanned MipsAtomComp_ / MipsAtomComp_Proc_ entries into Component shape.
-- Does per-source backward lookups for args (preceding function decl) and comment (preceding comment block). --- Does per-source backward lookups for args (preceding function decl) and comment (preceding comment block).
-- Carries `body_tokens` forward from scan-source so word_count_rec reads from the precomputed table instead of calling duffle.tokenize_body again. --- Carries `body_tokens` forward from scan-source so word_count_rec reads from the precomputed table instead of calling duffle.tokenize_body again.
-- @param source string -- the full source text (needed for backward lookups) --- @param source string -- the full source text (needed for backward lookups)
-- @param scan table -- SourceScan from duffle.scan_source --- @param scan table -- SourceScan from duffle.scan_source
-- @return Component[] --- @return Component[]
local function project_components(source, scan) local function project_components(source, scan)
local out = {} local out = {}
for _, a in ipairs(scan.atoms) do for _, a in ipairs(scan.atoms) do
@@ -283,6 +261,7 @@ local function project_components(source, scan)
line = a.line, line = a.line,
name = a.name, name = a.name,
body = a.body, body = a.body,
body_off = a.body_off,
body_tokens = a.body_tokens, body_tokens = a.body_tokens,
args = args, args = args,
comment = comment, comment = comment,
@@ -340,11 +319,11 @@ end
-- Word-count computation (memoized recursive lookup) -- Word-count computation (memoized recursive lookup)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table. --- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table.
-- Returns the ident unchanged if it doesn't start with the prefix --- Returns the ident unchanged if it doesn't start with the prefix
-- (so a non-component ident like `mask_upper` falls through to the wc-table branch). --- (so a non-component ident like `mask_upper` falls through to the wc-table branch).
-- @param ident string|nil --- @param ident string|nil
-- @return string|nil --- @return string|nil
local function strip_mac_prefix(ident) local function strip_mac_prefix(ident)
if not ident then return nil end if not ident then return nil end
if ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then if ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
@@ -353,13 +332,13 @@ local function strip_mac_prefix(ident)
return ident return ident
end end
-- (internal) Recursive word-count lookup. `cache` is the memoization table shared across all components --- (internal) Recursive word-count lookup. `cache` is the memoization table shared across all components
-- in a single source's `count_all_components` pass; the in-progress -1 sentinel detects cycles (A -> B -> A). --- in a single source's `count_all_components` pass; the in-progress -1 sentinel detects cycles (A -> B -> A).
-- @param name string -- the component name (without `mac_`) --- @param name string -- the component name (without `mac_`)
-- @param comp_by_name table<string, Component> --- @param comp_by_name table<string, Component>
-- @param wc table<string, integer> --- @param wc table<string, integer>
-- @param cache table<string, integer> --- @param cache table<string, integer>
-- @return integer --- @return integer
local function word_count_rec(name, comp_by_name, wc, cache) local function word_count_rec(name, comp_by_name, wc, cache)
if cache[name] ~= nil then return cache[name] end if cache[name] ~= nil then return cache[name] end
cache[name] = -1 -- mark in-progress (cycle detection) cache[name] = -1 -- mark in-progress (cycle detection)
@@ -398,7 +377,7 @@ end
--- references hit memoized values instead of re-walking the body. --- references hit memoized values instead of re-walking the body.
--- Cycle detection (A -> B -> A) is preserved via the in-progress `-1` sentinel in `cache`. --- Cycle detection (A -> B -> A) is preserved via the in-progress `-1` sentinel in `cache`.
--- @param components Component[] --- @param components Component[]
--- @param wc table<string, integer> --- @param wc table<string, integer>
--- @return table<string, integer> -- map of component name (without `mac_`) -> word count --- @return table<string, integer> -- map of component name (without `mac_`) -> word count
local function count_all_components(components, wc) local function count_all_components(components, wc)
local comp_by_name = {} local comp_by_name = {}
@@ -471,9 +450,9 @@ end
--- Build the list of lines for one component --- Build the list of lines for one component
--- (signature comment, `#define mac_X(...)` line with backslash-continued tokens, then `WORD_COUNT(mac_X, N)` entry). --- (signature comment, `#define mac_X(...)` line with backslash-continued tokens, then `WORD_COUNT(mac_X, N)` entry).
--- @param c Component --- @param c Component
--- @param components Component[] --- @param components Component[]
--- @param wc table<string, integer> --- @param wc table<string, integer>
--- @return string[] -- list of lines for this component --- @return string[] -- list of lines for this component
local function build_component_lines(c, counts) local function build_component_lines(c, counts)
local lines = {} local lines = {}
@@ -505,10 +484,10 @@ end
-- Per-source emit logic -- Per-source emit logic
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES` block, --- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES` block,
-- the `// Auto-generated` comment, the `// Source:` line, and the self-contained `WORD_COUNT` macro definition). --- the `// Auto-generated` comment, the `// Source:` line, and the self-contained `WORD_COUNT` macro definition).
-- @param src SourceFile --- @param src SourceFile
-- @return string[] --- @return string[]
local function header_boilerplate(src) local function header_boilerplate(src)
return { return {
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h. -- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h.
@@ -530,13 +509,13 @@ local function header_boilerplate(src)
} }
end end
-- Compute the output path for one source's `.macs.h` file. --- Compute the output path for one source's `.macs.h` file.
-- The pre-rework convention uses the *directory* basename --- The pre-rework convention uses the *directory* basename (not the source file basename)
-- (not the source file basename) e.g. `code/duffle/lottes_tape.h` produces `code/duffle/gen/duffle.macs.h`. --- e.g. `code/duffle/lottes_tape.h` produces `code/duffle/gen/duffle.macs.h`.
-- This matches what the C codebase #includes. --- This matches what the C codebase #includes.
-- @param src SourceFile --- @param src SourceFile
-- @return string -- the output directory --- @return string -- the output directory
-- @return string -- the full output path --- @return string -- the full output path
local function compute_macs_h_path(src) local function compute_macs_h_path(src)
local out_dir = src.dir .. "/" .. GEN_SUBDIR local out_dir = src.dir .. "/" .. GEN_SUBDIR
local out_path = out_dir .. "/" .. duffle.basename_no_ext(src.dir) .. ".macs.h" local out_path = out_dir .. "/" .. duffle.basename_no_ext(src.dir) .. ".macs.h"
@@ -545,11 +524,10 @@ end
--- Emit a per-source `.macs.h` header with the `mac_X` macros + `WORD_COUNT` entries. --- 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). --- 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 ctx PassCtx --- @param src SourceFile
--- @param src SourceFile
--- @param components Component[] --- @param components Component[]
--- @param counts table<string, integer> -- precomputed word counts (from count_all_components) --- @param counts table<string, integer> -- precomputed word counts (from count_all_components)
--- @return string|nil -- path to the written file (nil if no components) --- @return string|nil -- path to the written file (nil if no components)
local function emit_component_macros_h(ctx, src, components, counts) local function emit_component_macros_h(ctx, src, components, counts)
if #components == 0 then return nil end if #components == 0 then return nil end
@@ -563,11 +541,6 @@ local function emit_component_macros_h(ctx, src, components, counts)
end end
local content = table.concat(lines, "\n") .. "\n" local content = table.concat(lines, "\n") .. "\n"
if ctx.dry_run then
print(string.format(" -> %s (dry-run)", out_path))
return out_path
end
duffle.ensure_dir(out_dir) duffle.ensure_dir(out_dir)
duffle.write_file_lf(out_path, content) duffle.write_file_lf(out_path, content)
print(string.format(" -> %s", out_path)) print(string.format(" -> %s", out_path))
@@ -578,41 +551,86 @@ end
-- Pass entry -- Pass entry
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- (internal) Extend `ctx.shared.word_counts` with this source's component macros so offsets sees them without re-reading the file. --- (internal) Extend the canonical `corpus.word_counts` with this source's component macros so offsets sees them without re-reading the file.
-- @param ctx PassCtx --- First declaration wins: a later caller's count is dropped (the existing entry from the first source is preserved).
-- @param components Component[] --- @param corpus table -- the canonical corpus
-- @param counts table<string, integer> -- precomputed word counts (from count_all_components) --- @param components Component[]
local function update_shared_word_counts(ctx, components, counts) --- @param counts table<string, integer> -- precomputed word counts (from count_all_components)
local wc = ctx.shared.word_counts local function update_canonical_word_counts(corpus, components, counts)
local wc = corpus.word_counts
for _, c in ipairs(components) do for _, c in ipairs(components) do
wc["mac_" .. c.name] = counts[c.name] local key = "mac_" .. c.name
if wc[key] == nil then
wc[key] = counts[c.name]
end
end end
end end
--- @class ComponentDef --- @class ComponentDef
--- @field name string -- bare name (without ac_/mac_ prefix) --- @field name string -- bare name (without ac_/mac_ prefix)
--- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`) --- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- @field path string -- absolute source path of the definition --- @field path string -- absolute source path of the definition
--- @field kind string -- "comp_bare" | "comp_proc" --- @field kind string -- "comp_bare" | "comp_proc"
--- (internal) Extend `ctx.shared.components` with this source's components-by-name map so downstream passes --- (internal) Populate the canonical `corpus.components` projection with this source's components-by-name map.
--- (atoms_source_map, dwarf_injection) can resolve `mac_X(...)` invocations back to their component definition file:line. --- First declaration wins; later declarations of the same bare name are dropped and recorded as a collision via `corpus.collisions` (kind = "component").
--- provenance emission uses this to attribute each emitted `.word` to either a component macro or the enclosing atom body. --- The pass does NOT write to `ctx.shared.components` (ownership follows the canonical contract).
-- @param ctx PassCtx --- @param corpus table -- the canonical corpus
-- @param src SourceFile --- @param src SourceFile
-- @param components Component[] --- @param components Component[]
local function update_shared_components(ctx, src, components) local function update_canonical_components(corpus, src, components)
ctx.shared.components = ctx.shared.components or {}
local rel_path = src.path:gsub("\\", "/") local rel_path = src.path:gsub("\\", "/")
for _, c in ipairs(components) do for _, c in ipairs(components) do
-- Keyed by bare name (e.g. `yield`, `load_tri_indices`). -- Keyed by bare name (e.g. `yield`, `load_tri_indices`).
-- The atoms_source_map pass strips the `mac_` prefix from the call site identifier before lookup. -- The atoms_source_map pass looks up components by bare name from the canonical corpus;
ctx.shared.components[c.name] = { -- `mac_` prefix lives at the call-site identifier and is stripped before lookup.
name = c.name, if corpus.components[c.name] == nil then
line = c.line, corpus.components[c.name] = {
path = rel_path, name = c.name,
kind = c.kind or "comp_bare", line = c.line,
} path = rel_path,
kind = c.kind or "comp_bare",
}
else
-- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it.
-- Identical-shape declarations (same path + line) do NOT record a collision (the first-wins entry already covers the case).
local existing = corpus.components[c.name]
if existing.path ~= rel_path or existing.line ~= c.line then
local kind = c.kind or "comp_bare"
local first_kind = existing.kind or "comp_bare"
corpus.collisions[#corpus.collisions + 1] = {
kind = "component",
name = c.name,
first_site = { path = existing.path, line = existing.line },
conflicting_site = { path = rel_path, line = c.line },
first_shape = "kind=" .. first_kind,
conflicting_shape = "kind=" .. kind,
}
end
end
end
end
--- (internal) Populate the canonical `corpus.component_body_index` projection with this source's body index entries.
--- First declaration wins; later declarations are dropped (no separate collision record: the components collision is already surfaced by `update_canonical_components`).
--- The pass does NOT write to `ctx.shared.component_body_index` (the corpus owns this projection).
--- @param corpus table -- the canonical corpus
--- @param src SourceFile
--- @param components Component[]
--- @param scan table -- the SourceScan payload (for line_of)
local function update_canonical_component_body_index(corpus, src, components, scan)
local line_of = scan and scan.line_of
for _, c in ipairs(components) do
if corpus.component_body_index[c.name] == nil then
corpus.component_body_index[c.name] = {
body_tokens = c.body_tokens,
body_off = c.body_off,
line_of = line_of,
source = src.path,
declaration = c.line,
kind = c.kind,
}
end
end end
end end
@@ -623,24 +641,42 @@ function M.run(ctx)
local errors = {} local errors = {}
local warnings = {} local warnings = {}
-- Initialize shared component map. -- Canonical-corpus ownership gate.
-- The atoms_source_map and dwarf_injection passes consume `ctx.shared.components` to resolve `mac_X(...)` local corpus = ctx.shared and ctx.shared.corpus
-- invocations back to the component's definition file:line. if type(corpus) ~= "table" then
ctx.shared.components = ctx.shared.components or {} error("components.run requires ctx.shared.corpus (canonical corpus).", 0)
end
if type(corpus.source_order) ~= "table" then
error("components.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
end
if type(corpus.word_counts) ~= "table" then
error("components.run requires ctx.shared.corpus.word_counts; "
.. "word_count_eval.run must run before components.run "
.. "(see PASSES deps).", 0)
end
for _, src in ipairs(ctx.sources) do -- Canonical projection ownership:
-- * `corpus.word_counts["mac_"..name]` — current component count
-- * `corpus.components[name]` — bare-name component definition
-- * `corpus.component_body_index[name]` — body / line_of / source index
-- The pass does NOT mutate `ctx.shared.components` or `ctx.shared.component_body_index`
-- (ownership follows the canonical corpus; consumers read from the corpus directly).
for _, src in ipairs(corpus.source_order) do
-- project_components reads from src.scan + does backward lookups on src.text -- project_components reads from src.scan + does backward lookups on src.text
local components = project_components(src.text, src.scan) local components = project_components(src.text, src.scan)
if #components > 0 then if #components > 0 then
-- Compute word counts for ALL components once (was: rebuilt per call inside the helpers). -- Compute all component word counts once per source.
local counts = count_all_components(components, ctx.shared.word_counts) -- Use `corpus.word_counts` (the canonical count table) so the recursive lookup sees both authored-metadata entries
-- (loaded by word_count_eval.run) AND same-source component entries (populated earlier in this loop by `update_canonical_word_counts`).
local counts = count_all_components(components, corpus.word_counts)
local macs_path = emit_component_macros_h(ctx, src, components, counts) local macs_path = emit_component_macros_h(ctx, src, components, counts)
if macs_path then if macs_path then
outputs[#outputs + 1] = { macs_h = macs_path } outputs[#outputs + 1] = { macs_h = macs_path }
update_shared_word_counts(ctx, components, counts) -- Populate the canonical projections AFTER disk emission (so the byte-identical `.macs.h` contract is preserved before any current-count mutation).
-- share component definitions with downstream passes. update_canonical_word_counts(corpus, components, counts)
-- `mac_X(...)` invocations in atom bodies resolve back to (path, line) via this map. update_canonical_components(corpus, src, components)
update_shared_components(ctx, src, components) update_canonical_component_body_index(corpus, src, components, src.scan)
end end
end end
end end
+221 -295
View File
@@ -20,8 +20,8 @@
--- Splice step runs from PowerShell — no Lua subprocess; no cmd /c parsing issues. --- Splice step runs from PowerShell — no Lua subprocess; no cmd /c parsing issues.
--- objcopy's --update-section works fine in PowerShell even though Lua's `os.execute`/`io.popen` would mangle the `=` on Windows.) --- objcopy's --update-section works fine in PowerShell even though Lua's `os.execute`/`io.popen` would mangle the `=` on Windows.)
--- ---
--- Result: VSCode's source gutter follows per-stepi inside atom bodies, AND the Variables pane shows the wave-context regs as atom-scoped locals. --- Result: source stepping follows atom-body lines, and wave-context registers appear as atom-scoped locals.
--- Native VSCode UX (gutter arrow + highlighted line + Run to Cursor + conditional BPs by source line + per-atom locals). --- Native VSCode stepping, line highlighting, run-to-cursor, conditional breakpoints, and per-atom locals.
--- No VSCode plugin, no Python, no pyelftools — pure Lua + objcopy. --- No VSCode plugin, no Python, no pyelftools — pure Lua + objcopy.
--- ---
--- **Conventions:** tabs (1/level), EmmyLua annotations, Lua 5.3 compatible. --- **Conventions:** tabs (1/level), EmmyLua annotations, Lua 5.3 compatible.
@@ -37,23 +37,17 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ELF32 / DWARF / atoms-source-map utilities (post-link debug-info injection). -- ELF32 / DWARF / atoms-source-map utilities (post-link debug-info injection).
-- Sister module to duffle.lua — contains the format-constant tables (ELF32 byte offsets, DWARF opcodes, etc.) and the I/O helpers -- Sister module to duffle.lua — contains the format-constant tables (ELF32 byte offsets, DWARF opcodes, etc.) and the I/O helpers
-- (read_elf_sections, nm, source-map parser, LE byte r/w). `list_dir` lives in duffle.lua as a general I/O primitive (lifted out during F''). -- (read_elf_sections, nm, source-map parser, LE byte r/w). `list_dir` is the general directory primitive in duffle.lua.
local elf_dwarf = require("elf_dwarf") local elf_dwarf = require("elf_dwarf")
-- word-counting helper shared with passes/atoms_source_map.lua. -- Per-word body lines come from the canonical `atom.paths` projection.
-- Used here to walk a component's body_tokens in lockstep with their word-count allocation
-- when we propagate per-word body lines into each invocation's `body_lines` array.
local word_count_eval = require("word_count_eval")
local count_token_words = word_count_eval.count_token_words
local lfs = require("lfs") local lfs = require("lfs")
-- File-scope aliases to elf_dwarf helpers; the canonical implementations live in scripts/elf_dwarf.lua. -- File-scope aliases to elf_dwarf helpers; the canonical implementations live in scripts/elf_dwarf.lua.
-- (2-caller lift: these were duplicated file-locals; the canonical is in elf_dwarf.lua, used by parse_abbrev_table + read_form_value.) -- ELF decoding helpers come from `elf_dwarf.lua`.
local read_uleb128_at = elf_dwarf.read_uleb128_at
local read_sleb128_at = elf_dwarf.read_sleb128_at
local find_abbrev_table_end = elf_dwarf.find_abbrev_table_end local find_abbrev_table_end = elf_dwarf.find_abbrev_table_end
-- Note: uleb128 + sleb128 are encoders; read_uleb128_at + read_sleb128_at are decoders. Different functions. -- Local DWARF opcode constants + length-prefixed integers (uleb128 + sleb128 encoders are in elf_dwarf.lua).
local uleb128 = elf_dwarf.uleb128 local uleb128 = elf_dwarf.uleb128
local sleb128 = elf_dwarf.sleb128 local sleb128 = elf_dwarf.sleb128
@@ -97,7 +91,7 @@ local ATOM_SOURCE_FILE_INDEX = 11
-- New abbreviation codes (100+ to avoid collision with gcc's existing 1-60+ codes). -- New abbreviation codes (100+ to avoid collision with gcc's existing 1-60+ codes).
local ABBREV_CU = 0x64 -- 100: DW_TAG_compile_unit local ABBREV_CU = 0x64 -- 100: DW_TAG_compile_unit
local ABBREV_SUBPROGRAM = 0x65 -- 101: DW_TAG_subprogram local ABBREV_SUBPROGRAM = 0x65 -- 101: DW_TAG_subprogram
local ABBREV_VARIABLE = 0x66 -- 102: DW_TAG_variable (DW_AT_type = ref4 to U4; was missing pre-2026-07-13 → gdb resolved R_PrimCursor against the C-level enum, not the register) local ABBREV_VARIABLE = 0x66 -- 102: DW_TAG_variable with DW_AT_type = ref4 to U4
local ABBREV_STRUCT_TYPE = 0x67 -- 103: DW_TAG_structure_type with children (Binds_X mirror) local ABBREV_STRUCT_TYPE = 0x67 -- 103: DW_TAG_structure_type with children (Binds_X mirror)
local ABBREV_MEMBER = 0x68 -- 104: DW_TAG_member no children (DW_AT_type = ref4 to U4 base) local ABBREV_MEMBER = 0x68 -- 104: DW_TAG_member no children (DW_AT_type = ref4 to U4 base)
local ABBREV_BIND_VAR = 0x69 -- 105: DW_TAG_variable no children + DW_AT_type = ref4 (the bind_args variable) local ABBREV_BIND_VAR = 0x69 -- 105: DW_TAG_variable no children + DW_AT_type = ref4 (the bind_args variable)
@@ -150,10 +144,10 @@ local DW_AT_language = 0x13
local DW_AT_location = 0x02 local DW_AT_location = 0x02
local DW_AT_comp_dir = 0x1B local DW_AT_comp_dir = 0x1B
local DW_AT_byte_size = 0x0B local DW_AT_byte_size = 0x0B
local DW_AT_encoding = 0x3E -- DWARF5 §7.7.1: DW_AT_encoding (for DW_ATE_unsigned base type; was 0x13 = DW_AT_language in prior slice - semantically wrong) local DW_AT_encoding = 0x3E -- DWARF5 §7.7.1: DW_AT_encoding for the DW_ATE_unsigned base type
local DW_AT_data_member_location = 0x38 local DW_AT_data_member_location = 0x38
local DW_AT_type = 0x49 local DW_AT_type = 0x49
local DW_AT_linkage_name = 0x6E -- DWARF5 §7.7.1: DW_AT_linkage_name (standard form; 0x200027 was the GNU extension form - wrong vs DW_FORM_string abbrev) local DW_AT_linkage_name = 0x6E -- DWARF5 §7.7.1: DW_AT_linkage_name with DW_FORM_string
local DW_AT_external = 0x3F -- marks a variable/function as externally visible local DW_AT_external = 0x3F -- marks a variable/function as externally visible
-- Inlined_subroutine + abstract_origin attributes. -- Inlined_subroutine + abstract_origin attributes.
local DW_AT_abstract_origin = 0x31 local DW_AT_abstract_origin = 0x31
@@ -211,14 +205,13 @@ local DW_FORM_udata = 0x0F -- ULEB128 (DW_AT_byte_size for struct_typ
local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker) local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker)
local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists) local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists)
local DW_OP_reg0 = 0x50 -- DW_OP_reg0 + DW_OP_piece are declared canonically above (lines 114-116) alongside the other DWARF5 §7.7.3 loclist opcodes.
-- DW_OP_regN = 0x50 + N; the variable's value resides in that register.
local DW_OP_piece = 0x93 -- followed by ULEB128 byte count (per DWARF5 §7.7.5)
local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used for U4 base type) local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used for U4 base type)
local DW_LANG_C99 = 0x0C -- = 12 (C99); use as a safe "C-like" placeholder
-- (DW_LANG_Mips_Assembler = 0x8001 was used in the, but we want this CU to look like a C TU so VSCode's Variables pane treats it as code.) -- (DW_LANG_Mips_Assembler = 0x8001 was used in the, but we want this CU to look like a C TU so VSCode's Variables pane treats it as code.)
-- No DW_AT_language attribute is emitted (see build_debug_info_section's abbrev 100).
-- R_<name> → MIPS GPR lookups go through the merged register_alias_registry -- R_<name> → MIPS GPR lookups go through the merged register_alias_registry
-- (collected by collect_per_source_registries from ctx.sources[*].scan.register_alias_registry). -- (collected by collect_per_source_registries from ctx.sources[*].scan.register_alias_registry).
@@ -341,7 +334,7 @@ end
local DEFAULT_CU_NAME = "tape_atom_locals" local DEFAULT_CU_NAME = "tape_atom_locals"
local DEFAULT_CU_COMP_DIR = "." local DEFAULT_CU_COMP_DIR = "."
-- Path templates for the .bin outputs are now in SECTION_WRITERS (see below). -- SECTION_WRITERS owns the .bin output path templates.
-- Default basename if not provided via ctx. -- Default basename if not provided via ctx.
local DEFAULT_BASENAME = "hello_gte" local DEFAULT_BASENAME = "hello_gte"
@@ -367,11 +360,12 @@ end
--- Consume the per-source scanner associations without naming any atom or component in production. --- Consume the per-source scanner associations without naming any atom or component in production.
--- Whole atoms remain symbol-keyed; components are file-qualified internally so a source marker associates --- Whole atoms remain symbol-keyed; components are file-qualified internally so a source marker associates
--- with its exact component definition even though GDB 12 requires function-only skip entries for the resulting synthetic inline frame. --- with its exact component definition even though GDB 12 requires function-only skip entries for the resulting synthetic inline frame.
--- @param ctx DwarfInjectionCtx --- Iterates `corpus.source_order` (the canonical corpus projection).
--- @param corpus table -- the canonical corpus from `ctx.shared.corpus`
--- @return table -- {atoms = {[symbol] = association}, components = {[file|name] = association}} --- @return table -- {atoms = {[symbol] = association}, components = {[file|name] = association}}
local function collect_skip_over(ctx) local function collect_skip_over(corpus)
local skip_over = { atoms = {}, components = {} } local skip_over = { atoms = {}, components = {} }
for _, src in ipairs(ctx.sources or {}) do for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan_skip = src.scan and src.scan.skip_over local scan_skip = src.scan and src.scan.skip_over
if scan_skip then if scan_skip then
for atom_name, association in pairs(scan_skip.atoms or {}) do for atom_name, association in pairs(scan_skip.atoms or {}) do
@@ -396,53 +390,50 @@ local function collect_skip_over(ctx)
return skip_over return skip_over
end end
--- Merge the per-source scanner registries (register_alias_registry, type_name_registry, atom_views) --- Project the canonical corpus registries into the shape the section builders expect.
--- into a single set of tables that downstream consumers can read from without re-iterating ctx.sources. --- The corpus already owns the merged `register_alias_registry`, `type_name_registry`, `atom_views`, `atom_ctxs`, `atom_phases`, and `atom_infos` projections (populated by `passes.scan_source.lua`).
--- This helper just references them so the rest of `dwarf_injection.lua` keeps the same `registries.<key>` access shape it has always used.
--- ---
--- Every `R_*` lookup and per-atom type override resolution in this file goes through this merged table. --- Every `R_*` lookup and per-atom type override resolution in this file goes through this merged table.
--- Aliases without `atom_reg` adjacent are absent; the absence is treated as "not debug-visible" (see build_inserted_children for the precedence chain). --- Aliases without `atom_reg` adjacent are absent; the absence is treated as "not debug-visible" (see build_inserted_children for the precedence chain).
--- ---
--- When two sources register the same key, the last-writer wins (later sources override earlier). --- When two sources register the same key, the last-writer wins (later sources override earlier).
--- Today only one source declares wave-context enums, so collisions are absent. --- Today only one source declares wave-context enums, so collisions are absent.
--- @param ctx DwarfInjectionCtx --- @param corpus table -- the canonical corpus from `ctx.shared.corpus`
--- @return table -- { --- @return table -- {
--- register_alias_registry = {[R_Name] = AliasEntry}, --- register_alias_registry = {[R_Name] = AliasEntry},
--- type_name_registry = {[T] = TypeEntry}, --- type_name_registry = {[T] = TypeEntry},
--- atom_views = {[atom_name] = AtomViewEntry}, --- atom_views = {[atom_name] = AtomViewEntry},
--- } --- }
local function collect_per_source_registries(ctx) local function collect_per_source_registries(corpus)
local merged = { -- The corpus already holds the merged registries; reference them directly.
register_alias_registry = {}, -- No per-source iteration is needed because `passes.scan_source.lua` has already folded every per-source scan into the canonical tables.
type_name_registry = {}, -- `atom_infos` is preserved byte-for-byte with no filtering; consumers consult `corpus.atoms_by_name`
atom_views = {}, -- themselves when they need to know whether a particular atom_info corresponds to an actual atom record.
local atom_infos_list = {}
for _, ai in ipairs((corpus and corpus.atom_infos) or {}) do
atom_infos_list[#atom_infos_list + 1] = ai
end
return {
register_alias_registry = (corpus and corpus.register_alias_registry) or {},
type_name_registry = (corpus and corpus.type_name_registry) or {},
atom_views = (corpus and corpus.atom_views) or {},
-- Per-atom atom_ctx declarations: atom_name -> {rbind_atom, ...} -- Per-atom atom_ctx declarations: atom_name -> {rbind_atom, ...}
-- (populated by scan_source from `atom_ctx(<atom_name>)` sub-calls inside `atom_info`) -- (populated by scan_source from `atom_ctx(<atom_name>)` sub-calls inside `atom_info`)
atom_ctxs = {}, atom_ctxs = (corpus and corpus.atom_ctxs) or {},
-- Per-phase atom groups: phase_label -> {atoms = {atom_name1, ...}} -- Per-phase atom groups: phase_label -> {atoms = {atom_name1, ...}}
-- (populated by scan_source from `atom_phase(<label>)` sub-calls inside `atom_info`; cross-source merged) -- (populated by scan_source from `atom_phase(<label>)` sub-calls inside `atom_info`; cross-source merged)
atom_phases = {}, atom_phases = (corpus and corpus.atom_phases) or {},
-- Per-source already-resolved atom_infos (used by the precedence chain's ctx/phase steps) -- Corpus-wide atom_infos list, byte-for-byte.
atom_infos = {}, atom_infos = atom_infos_list,
} }
for _, src in ipairs(ctx.sources or {}) do
local scan = src.scan
if scan then
for k, v in pairs(scan.register_alias_registry or {}) do merged.register_alias_registry[k] = v end
for k, v in pairs(scan.type_name_registry or {}) do merged.type_name_registry[k] = v end
for k, v in pairs(scan.atom_views or {}) do merged.atom_views[k] = v end
for k, v in pairs(scan.atom_ctxs or {}) do merged.atom_ctxs[k] = v end
for k, v in pairs(scan.atom_phases or {}) do merged.atom_phases[k] = v end
for _, ai in ipairs(scan.atom_infos or {}) do merged.atom_infos[#merged.atom_infos + 1] = ai end
end
end
return merged
end end
--- Render deterministic debugger skip commands. Ordering is stable by category: --- Render deterministic debugger skip commands.
--- exact atom symbols first (lexicographic), then exact component function names (lexicographic full command). --- Ordering is stable by category: exact atom symbols first (lexicographic), then exact component function names (lexicographic full command).
--- Atom commands come from the matched nm/source-map table so the emitted name is the actual ELF symbol. --- Atom commands come from the matched nm/source-map table so the emitted name is the actual ELF symbol.
--- The scanner tables and command set both deduplicate repeated source observations. --- The scanner tables and command set both deduplicate repeated source observations.
--- @param skip_over table --- @param skip_over table
--- @param atom_table table[] -- nm/source-map cross-reference; names are actual ELF symbols --- @param atom_table table[] -- nm/source-map cross-reference; names are actual ELF symbols
--- @return string --- @return string
local function build_gdbinit(skip_over, atom_table) local function build_gdbinit(skip_over, atom_table)
@@ -474,7 +465,7 @@ end
-- LEB128 encoders -- LEB128 encoders
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- --
-- Lifted to `elf_dwarf.uleb128` + `elf_dwarf.sleb128` (F'' refactor). -- Uses `elf_dwarf.uleb128` and `elf_dwarf.sleb128`.
-- See those helpers for the bit-layout documentation + named constants (LEB_CONT_BIT, LEB_DATA_MASK, SLEB_SIGN_BIT). -- See those helpers for the bit-layout documentation + named constants (LEB_CONT_BIT, LEB_DATA_MASK, SLEB_SIGN_BIT).
-- File-scope `local uleb128` + `local sleb128` aliases live near the module top so they're resolvable by every function below. -- File-scope `local uleb128` + `local sleb128` aliases live near the module top so they're resolvable by every function below.
@@ -622,13 +613,14 @@ local function build_atom_sequence(atom)
-- If entry 1 is the first word of a component invocation, emit the component-definition row at the same PC immediately after. -- If entry 1 is the first word of a component invocation, emit the component-definition row at the same PC immediately after.
-- The def line is always a statement target unless the inv is explicitly skip-over (atom_dbg_skip_over); -- The def line is always a statement target unless the inv is explicitly skip-over (atom_dbg_skip_over);
-- the whole-atom skip path takes the early-return at line ~497 above so atom_is_stmt is irrelevant here. -- the whole-atom skip path takes the early-return at line ~497 above so atom_is_stmt is irrelevant here.
-- For the body row, prefer `body_lines[1]` (the actual source line of the first body word in the macro's expansion) -- For the body row, prefer `body_lines[1]` (the actual source line of the first body word in the macro's expansion)
-- over `comp_line` (the macro signature line). -- over `comp_line` (the macro signature line).
-- When `body_lines` is absent (the component was not indexed by the scan, e.g. an external macro), -- Canonical contract: the emission-model pass populates `body_lines` for every invocation;
-- fall back to `comp_line` so the line program remains valid. -- absence now is an error (older emitters / external macros are no longer supported).
if inv1 and 1 == inv1.start_pos + 1 then if inv1 and 1 == inv1.start_pos + 1 then
local comp_file_idx_1 = resolve_provenance_file_index(inv1.comp_file) local comp_file_idx_1 = resolve_provenance_file_index(inv1.comp_file)
local body_line_1 = (inv1.body_lines and inv1.body_lines[1]) or inv1.comp_line assert(inv1.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_1 = inv1.body_lines[1]
emit_row(comp_file_idx_1, body_line_1, not inv1.skip_over) emit_row(comp_file_idx_1, body_line_1, not inv1.skip_over)
end end
@@ -654,18 +646,20 @@ local function build_atom_sequence(atom)
emit_row(call_file_idx, inv.call_line, atom_is_stmt) emit_row(call_file_idx, inv.call_line, atom_is_stmt)
-- 2) component body row at this PC. -- 2) component body row at this PC.
-- Prefer `body_lines[1]` over `comp_line` so gdb's `step` lands on the macro's actual body line (not the signature line above `{ ... }`). -- Prefer `body_lines[1]` over `comp_line` so gdb's `step` lands on the macro's actual body line (not the signature line above `{ ... }`).
-- Fallback to `comp_line` when the component isn't indexed. -- Canonical contract: `body_lines` is always populated by the emission-model pass.
local body_file_idx = resolve_provenance_file_index(inv.comp_file) local body_file_idx = resolve_provenance_file_index(inv.comp_file)
local body_line_1 = (inv.body_lines and inv.body_lines[1]) or inv.comp_line assert(inv.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_1 = inv.body_lines[1]
emit_row(body_file_idx, body_line_1, not inv.skip_over) emit_row(body_file_idx, body_line_1, not inv.skip_over)
elseif inv then elseif inv then
-- Subsequent word of an invocation: 1-based offset into body_lines: -- Subsequent word of an invocation: 1-based offset into body_lines:
-- word 1 of the invocation corresponds to body_lines[1], word 2 -> body_lines[2], etc. -- word 1 of the invocation corresponds to body_lines[1], word 2 -> body_lines[2], etc.
-- 1-based offset = idx - inv.start_pos (since idx = inv.start_pos + i for the i-th body word). -- 1-based offset = idx - inv.start_pos (since idx = inv.start_pos + i for the i-th body word).
-- When `body_lines` is missing (older emitters / external macros), fall back to comp_line. -- Canonical contract: `body_lines` is always populated by the emission-model pass.
local body_file_idx = resolve_provenance_file_index(inv.comp_file) local body_file_idx = resolve_provenance_file_index(inv.comp_file)
local words_into = idx - inv.start_pos -- 1-based word position in invocation local words_into = idx - inv.start_pos -- 1-based word position in invocation
local body_line_i = (inv.body_lines and inv.body_lines[words_into]) or inv.comp_line assert(inv.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_i = inv.body_lines[words_into]
emit_row(body_file_idx, body_line_i, not inv.skip_over) emit_row(body_file_idx, body_line_i, not inv.skip_over)
else else
-- RAW word: single call-site row. emit_row restores is_stmt after a selected component range before exposing this adjacent row. -- RAW word: single call-site row. emit_row restores is_stmt after a selected component range before exposing this adjacent row.
@@ -686,173 +680,117 @@ end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Build the atom table the section builders consume. --- Build the atom table the section builders consume.
--- Cross-references nm symbols with source-map.txt entries; sorted by addr. --- Cross-references nm symbols with `corpus.atoms_by_name` and derives word rows + format-1 outermost invocation rows from `atom.paths`.
--- Also consumes the provenance file to record per-component invocations.
--- Each atom gains an `invocations` field with one entry per `mac_X(...)` call site:
--- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos, body_lines}`.
--- ---
--- `body_lines` is the per-word source line within the macro body --- The atom table is built entirely from in-memory state — disk source-map and provenance text artifacts are NOT consulted.
--- (lottes_tape.h:N where N is the actual line of this `.word` in the macro expansion). --- Those artifacts are diagnostic outputs, not semantic inputs; the DWARF injection pass must remain correct regardless of their on-disk content.
--- Without this field, the line program emits `comp_line` for EVERY body word,
--- so gdb's `step` from a `mac_X(...)` call lands on the macro signature line and immediately
--- returns without traversing the body (since no PC reports a different line).
--- ---
--- The data is computed by walking the component's pre-tokenized body in `ctx.sources[i].scan.atoms[j]` --- The result shape (one entry per ELF symbol matched against the corpus):
--- (the MipsAtomComp_/MipsAtomComp_Proc_ declaration). --- `{name, addr, size_bytes, words, entries, invocations, skip_over?}`
--- Atom labels (`atom_label(...)`) emit 0 `.word`s and are ignored (matching `passes/atoms_source_map.lua :: is_marker_token` + `count_marker_rest`). --- where:
--- @param ctx DwarfInjectionCtx --- * `entries[i].pos` — 0-based `.word` position (matches the source-map format-1 row layout; downstream DWARF builders compare against this).
--- @param skip_over table -- {atoms = {[symbol] = association}, components = {[file|name] = association}} --- * `entries[i].line` — call-site line for that word.
--- * `entries[i].text` — trimmed encoder token text from `atom.paths.word_events`.
--- * `invocations[j]` — one entry per format-1 outermost `mac_X(...)` invocation with
--- `{comp_name, call_file, call_line, comp_file, comp_line, start_pos, end_pos, body_lines, skip_over}`. `body_lines[k]`
--- is the k-th word's source line within the component body.
---
--- @param corpus table -- the canonical corpus from `ctx.shared.corpus`
--- @param addrs table -- ELF symbols keyed by atom name from `elf_dwarf.read_nm`
--- @param skip_over table -- {atoms = {[symbol] = association}, components = {[file|name] = association}}
--- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, skip_over?} --- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, skip_over?}
local function build_atom_table(ctx, skip_over) local function build_atom_table(corpus, addrs, skip_over)
local basename = ctx.basename or DEFAULT_BASENAME -- Cross-ref: keep only atoms present in BOTH the nm symbol table AND
-- Source-map path: convention matches the α MVP's emission location. -- the canonical corpus projection. Output is sorted by ascending addr.
-- writes `<out_root>/<basename>.atoms.sourcemap.txt` (e.g. `build/gen/hello_gte_tape.atoms.sourcemap.txt`). local atoms_by_name = corpus.atoms_by_name or {}
-- But ctx.out_root is `build/gen` (the per-build output root) and basename defaults to `hello_gte`.
-- The actual file emitted today is per-source; we look for any `*.atoms.sourcemap.txt` in out_root.
local sm_files = duffle.list_dir(ctx.out_root, "%.atoms.sourcemap%.txt$")
if #sm_files == 0 then
io.stderr:write(string.format(
"[dwarf_injection] no *.atoms.sourcemap.txt in %s; need atoms-source-map pass first\n",
ctx.out_root))
return {}
end
-- Read nm + merge all source-map files. -- Per-atom ingest. Returns nil if the atom is absent from the corpus
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) -- (caller skips it via the `if atom then ...` guard).
local merged = {} local function ingest_atom(name, info)
for _, sm_path in ipairs(sm_files) do local atom_record = atoms_by_name[name]
local sm = elf_dwarf.parse_source_map_file(sm_path, 1) if not atom_record then return nil end
for name, sm_data in pairs(sm) do
merged[name] = sm_data local paths = atom_record.paths or {}
local word_events = paths.word_events or {}
local invocations_proj = paths.invocations or {}
-- Build the dense entries list from `word_events`.
-- `word_events[i].i` = the 0-based `.word` position
-- `call_line` = the root atom's physical source line for that word
-- (stamped by emission_model)
local entries = {}
for idx, ev in ipairs(word_events) do
entries[#entries + 1] = {
pos = ev.i or (idx - 1),
line = ev.call_line or 0,
text = ev.call_text or "",
}
end end
end local atom = {
name = name,
addr = info[1],
size_bytes = info[2],
words = #word_events,
entries = entries,
skip_over = skip_over.atoms[name] ~= nil,
}
-- Also read *.atoms.provenance.txt to extract per-component invocations. -- Group consecutive `word_events` rows whose outermost invocation is the SAME
-- Files are merged by atom name; entries carry the original {pos, call_file, call_line, comp_name, comp_file, comp_line} shape. -- format-1 invocation into a single `atom.invocations` entry. Rows sharing the
local prov_files = duffle.list_dir(ctx.out_root, "%.atoms.provenance%.txt$") -- same comp_name / call_file / call_line / comp_file / comp_line are part of
local prov_merged = {} -- the same group. Rows outside any invocation flush cur_inv.
for _, prov_path in ipairs(prov_files) do if #invocations_proj > 0 then
local prov = elf_dwarf.parse_provenance_file(prov_path, 1) local invocations = {}
for name, prov_data in pairs(prov) do -- Process one word_event row against the current group state.
prov_merged[name] = prov_data -- Returns the (possibly updated) cur_inv.
end local function row(ev, cur_inv)
end local outer_id = ev.outermost_invocation_id
local outer_inv = outer_id and invocations_proj[outer_id] or nil
-- Build a per-source component index keyed by the bare component name (e.g. `gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`). if not (outer_inv and outer_inv.component_name) then
-- The bare name matches the provenance row's `comp_name` field (which is `strip_mac_prefix_from_token(tok)` — strips `mac_`, leaves the rest). -- raw row: flush any pending cur_inv; no new group starts.
-- Each entry holds the data we need to walk the component body's tokens in lockstep with their word counts: if cur_inv then invocations[#invocations + 1] = cur_inv end
-- body_off -- byte offset of the `{` (start of body) in the component's source file. return nil
-- body_tokens -- list of {tok, rel} pairs; `rel` is the byte offset within the body.
-- line_of -- closure resolving byte offsets in the component's source file to lines.
-- The data is consumed by `compute_invocation_body_lines` per invocation.
local component_index = {}
for _, src in ipairs(ctx.sources or {}) do
if src.scan and src.scan.atoms then
local line_of = src.scan.line_of
for _, atom in ipairs(src.scan.atoms) do
if atom.kind == "comp_bare" or atom.kind == "comp_proc" then
-- Prefer `atom.name` (stripped of `ac_` prefix); fall back to `raw_name`
-- only if `name` is missing (defensive; scan-source always sets both).
local name = atom.name or atom.raw_name
if name and not component_index[name] then
component_index[name] = {
body_off = atom.body_off,
body_tokens = atom.body_tokens,
line_of = line_of,
source_path = src.path,
}
end
end end
local inv_key = outer_inv.component_name
.. "|" .. (outer_inv.call_path or "")
.. "|" .. tostring(outer_inv.call_line or 0)
.. "|" .. (outer_inv.def_path or "")
.. "|" .. tostring(outer_inv.def_line or 0)
local ev_pos = ev.i or 0
if cur_inv and cur_inv.key == inv_key then
-- same group: extend range + append body line.
cur_inv.end_pos = ev_pos
cur_inv.body_lines[#cur_inv.body_lines + 1] = ev.body_line or 0
return cur_inv
end
-- key changed (or no current group): flush + start new.
if cur_inv then invocations[#invocations + 1] = cur_inv end
return {
key = inv_key,
comp_name = outer_inv.component_name,
call_file = outer_inv.call_path or "",
call_line = outer_inv.call_line or 0,
comp_file = outer_inv.def_path or "",
comp_line = outer_inv.def_line or 0,
start_pos = ev_pos,
end_pos = ev_pos,
skip_over = skip_over.components[normalize_debug_path(outer_inv.def_path or ""):lower() .. "\0" .. outer_inv.component_name] ~= nil,
body_lines = { ev.body_line or 0 },
}
end end
end local cur_inv = nil
end for _, ev in ipairs(word_events) do
cur_inv = row(ev, cur_inv)
-- Per-word line lookup for a component body: walk body_tokens, count each token's emitted .words via count_token_words, attribute that count the same source line.
-- Atom labels (atom_label/atom_offset) emit 0 .words; their lines are skipped to stay aligned with `passes/atoms_source_map.lua :: count_marker_rest`.
-- @param comp_name string -- the bare name (e.g. `gte_load_tri_verts`)
-- @return table -- list of source lines, 1-based by word position; empty if no data
local wc = (ctx.shared and ctx.shared.word_counts) or {}
local function compute_invocation_body_lines(comp_name)
local comp_idx = component_index[comp_name]
if not (comp_idx and comp_idx.body_tokens and comp_idx.line_of) then return {} end
local lines = {}
for _, bt in ipairs(comp_idx.body_tokens) do
local tok = duffle.trim(bt.tok or "")
if tok ~= "" then
-- Match atoms_source_map.lua's marker check (no public export; duplicated for independence).
local leading = duffle.read_ident(tok, 1)
local words
if leading == "atom_label" or leading == "atom_offset" then
words = 0 -- markers emit 0 .words; do not advance the body line counter.
else
words = count_token_words(tok, wc)
end
if words > 0 then
local body_line = comp_idx.line_of(comp_idx.body_off + bt.rel)
for _ = 1, words do lines[#lines + 1] = body_line end
end
end end
if cur_inv then invocations[#invocations + 1] = cur_inv end
atom.invocations = invocations
end end
return lines return atom
end end
-- Cross-ref; keep atoms that exist in both.
local out = {} local out = {}
for name, info in pairs(addrs) do for name, info in pairs(addrs) do
local sm = merged[name] local atom = ingest_atom(name, info)
if sm then if atom then out[#out + 1] = atom end
local atom = {
name = name,
addr = info[1],
size_bytes = info[2],
words = sm.total,
entries = sm.words,
skip_over = skip_over.atoms[name] ~= nil,
}
-- Group consecutive MACRO rows in this atom's provenance into invocations.
-- An invocation = one `mac_X(...)` call site spanning N consecutive .word rows.
-- Two consecutive rows with the same (comp_name, call_file, call_line, comp_file, comp_line) are part of the same invocation.
local prov_data = prov_merged[name]
if prov_data and prov_data.words then
local invocations = {}
local cur_inv = nil
for _, w in ipairs(prov_data.words) do
if w.comp_name then
local inv_key = w.comp_name .. "|" .. w.call_file .. "|" .. w.call_line .. "|" .. w.comp_file .. "|" .. w.comp_line
if cur_inv and cur_inv.key == inv_key then
-- Same invocation as the previous word — extend its range.
cur_inv.end_pos = w.pos
else
-- New invocation: flush the previous one and start fresh.
if cur_inv then invocations[#invocations + 1] = cur_inv end
cur_inv = {
key = inv_key,
comp_name = w.comp_name,
call_file = w.call_file,
call_line = w.call_line,
comp_file = w.comp_file,
comp_line = w.comp_line,
start_pos = w.pos,
end_pos = w.pos,
-- component_skip_key: case-insensitive Windows path + "\0" separator + exact component-name.
-- (Inlined from `component_skip_key`; the lookup is in skip_over.components keyed by the result.)
skip_over = skip_over.components[normalize_debug_path(w.comp_file):lower() .. "\0" .. w.comp_name] ~= nil,
}
-- Capture the per-word body lines for THIS invocation, indexed by 1-based word position within the invocation.
-- body_lines[1] is the line of the first body word (= the line of the first macro-body token, NOT the comp def line).
-- Downstream consumers (the line program emitter) fall back to comp_line when this is empty.
cur_inv.body_lines = compute_invocation_body_lines(w.comp_name)
end
else
-- RAW row: flush the current invocation.
if cur_inv then invocations[#invocations + 1] = cur_inv; cur_inv = nil end
end
end
if cur_inv then invocations[#invocations + 1] = cur_inv end
atom.invocations = invocations
end
out[#out + 1] = atom
end
end end
table.sort(out, function(a, b) return a.addr < b.addr end) table.sort(out, function(a, b) return a.addr < b.addr end)
return out return out
@@ -889,7 +827,6 @@ end
-- We use the SourceScan payload populated by `passes/scan_source.lua` (the dep-closed upstream pass). -- We use the SourceScan payload populated by `passes/scan_source.lua` (the dep-closed upstream pass).
-- That pass walks each source once and populates `src.scan.atom_infos` (the atom_info sub-call parse) and `src.scan.binds` (the Binds_X struct field parse). -- That pass walks each source once and populates `src.scan.atom_infos` (the atom_info sub-call parse) and `src.scan.binds` (the Binds_X struct field parse).
-- --
-- The prior local `reparse_binds_body` fallback (and the 2nd source walk inside `parse_rbind_atoms`) is REMOVED.
-- parse_rbind_atoms consumes `scan.binds[i].fields` directly, which is populated by the scan-source pass with the typed-field record ({name, type_name, pointer_depth, offset, byte_size}). -- parse_rbind_atoms consumes `scan.binds[i].fields` directly, which is populated by the scan-source pass with the typed-field record ({name, type_name, pointer_depth, offset, byte_size}).
--- Find every `load_word(R_<reg>, R_TapePtr, O_(Binds_X, FieldName))` call in the atom body and return ordered (reg_index, field_name) pairs. --- Find every `load_word(R_<reg>, R_TapePtr, O_(Binds_X, FieldName))` call in the atom body and return ordered (reg_index, field_name) pairs.
@@ -904,10 +841,9 @@ end
--- ---
--- Pre-tokenized: `body_tokens` is the scan-source pass's pre-split list of top-level --- Pre-tokenized: `body_tokens` is the scan-source pass's pre-split list of top-level
--- statements (each entry is a single `load_word(...)` call or other statement). --- statements (each entry is a single `load_word(...)` call or other statement).
--- Plex: prepass move — use the pre-computed data instead of re-walking the body text.
--- @param body_tokens table[] -- the atom's pre-tokenized body statements (from atom.body_tokens) --- @param body_tokens table[] -- the atom's pre-tokenized body statements (from atom.body_tokens)
--- @param binds_name string -- expected Binds_X name (skip pairs with mismatching binds) --- @param binds_name string -- expected Binds_X name (skip pairs with mismatching binds)
--- @param registries table -- merged registries from collect_per_source_registries --- @param registries table -- merged registries from collect_per_source_registries
--- @return table[] -- list of {reg = <MIPS index>, field = <field name>} --- @return table[] -- list of {reg = <MIPS index>, field = <field name>}
local function parse_body_load_pairs(body_tokens, binds_name, registries) local function parse_body_load_pairs(body_tokens, binds_name, registries)
local pairs = {} local pairs = {}
@@ -939,9 +875,7 @@ end
--- Collect every rbind atom + the matching Binds_X struct + (reg, field) pairs. --- Collect every rbind atom + the matching Binds_X struct + (reg, field) pairs.
--- ---
--- Inputs come from the dep-closed `scan-source` pass: --- Inputs come from the dep-closed `scan-source` pass (the per-source `src.scan` payload is preserved on each `corpus.source_order` entry).
--- ctx.sources[i].scan.atom_infos -- list of {atom_name, binds, reads, writes, info_line}
--- ctx.sources[i].scan.binds -- list of {line, name, fields, bytes}
--- ---
--- Returns: --- Returns:
--- rbind_atoms = {[atom_name] = {binds, fields, regs, byte_size, info_line}} --- rbind_atoms = {[atom_name] = {binds, fields, regs, byte_size, info_line}}
@@ -950,22 +884,22 @@ end
--- The `regs` list per atom is ordered: each entry is the MIPS reg index that holds the matching field in the source-order pop sequence. --- The `regs` list per atom is ordered: each entry is the MIPS reg index that holds the matching field in the source-order pop sequence.
--- The piece chain uses (DW_OP_regN, DW_OP_piece, ULEB128(field_size)). --- The piece chain uses (DW_OP_regN, DW_OP_piece, ULEB128(field_size)).
--- ---
--- The 2nd source walk (the body-text `text:find("typedef Struct_(...)")` re-walk) is removed; --- Binds fields come from `scan.binds`; no body-text source walk is needed.
--- per-source `scan.binds[i].fields` already carries the typed-field record after the scan-source generalization. --- per-source `scan.binds[i].fields` already carries the typed-field record after the scan-source generalization.
--- @param ctx DwarfInjectionCtx --- @param corpus table -- the canonical corpus from `ctx.shared.corpus`
--- @param atom_table table[] -- the cross-ref'd atom table from build_atom_table --- @param atom_table table[] -- the cross-ref'd atom table from build_atom_table
--- @param registries table -- merged registries from collect_per_source_registries --- @param registries table -- merged registries from collect_per_source_registries
--- @return table, table -- (rbind_atoms, rbind_structs) --- @return table, table -- (rbind_atoms, rbind_structs)
local function parse_rbind_atoms(ctx, atom_table, registries) local function parse_rbind_atoms(corpus, atom_table, registries)
registries = registries or {} registries = registries or {}
local rbind_atoms = {} local rbind_atoms = {}
local rbind_structs = {} local rbind_structs = {}
-- Index binds by struct name; consume `scan.binds[i].fields` directly (no body-text re-walk). -- Index binds by struct name; consume `scan.binds[i].fields` directly (no body-text re-walk).
-- The scan-source pass emits each Binds_X's fields as {[type_name, pointer_depth, offset, byte_size, ...]} -- The scan-source pass emits each Binds_X's fields as {[type_name, pointer_depth, offset, byte_size, ...]}
-- so this pass can build the rbind_structs entry without re-parsing. -- so this pass can build the rbind_structs entry without re-parsing.
local binds_by_name = {} local binds_by_name = {}
for _, src in ipairs(ctx.sources or {}) do for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan local scan = src.scan
if scan then if scan then
for _, b in ipairs(scan.binds or {}) do for _, b in ipairs(scan.binds or {}) do
@@ -986,7 +920,7 @@ local function parse_rbind_atoms(ctx, atom_table, registries)
-- Walk every atom_info; if `binds` is set, find the atom body_tokens + parse load_word pairs. -- Walk every atom_info; if `binds` is set, find the atom body_tokens + parse load_word pairs.
local body_tokens_by_atom = {} local body_tokens_by_atom = {}
for _, src in ipairs(ctx.sources or {}) do for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan local scan = src.scan
if scan then if scan then
for _, atom in ipairs(scan.atoms or {}) do for _, atom in ipairs(scan.atoms or {}) do
@@ -996,7 +930,7 @@ local function parse_rbind_atoms(ctx, atom_table, registries)
end end
local ai_by_atom = {} local ai_by_atom = {}
for _, src in ipairs(ctx.sources or {}) do for _, src in ipairs((corpus and corpus.source_order) or {}) do
local scan = src.scan local scan = src.scan
if scan then if scan then
for _, ai in ipairs(scan.atom_infos or {}) do for _, ai in ipairs(scan.atom_infos or {}) do
@@ -1014,9 +948,9 @@ local function parse_rbind_atoms(ctx, atom_table, registries)
if #pairs > 0 then if #pairs > 0 then
rbind_atoms[atom_name] = { rbind_atoms[atom_name] = {
binds = ai.binds, binds = ai.binds,
fields = struct.fields, -- {name, offset} from scan.binds fields = struct.fields, -- {name, offset} from scan.binds
bytes = struct.bytes, bytes = struct.bytes,
regs = pairs, -- ordered list of {reg, field} regs = pairs, -- ordered list of {reg, field}
info_line = ai.info_line, info_line = ai.info_line,
} }
table.insert(struct.atom_names, atom_name) table.insert(struct.atom_names, atom_name)
@@ -1042,12 +976,12 @@ end
--- Append per-atom line-program sequences to the existing main .debug_line unit --- Append per-atom line-program sequences to the existing main .debug_line unit
--- (the final unit, referenced by the main CU's DW_AT_stmt_list). --- (the final unit, referenced by the main CU's DW_AT_stmt_list).
--- ---
--- The old implementation appended a new Unit 3. --- This builder extends the main compilation unit.
--- No compilation unit pointed at it through DW_AT_stmt_list, so gdb ignored it. --- No compilation unit pointed at it through DW_AT_stmt_list, so gdb ignored it.
--- It also encoded byte 13 as the extended-opcode marker; byte 13 is actually the first special opcode. --- It also encoded byte 13 as the extended-opcode marker; byte 13 is actually the first special opcode.
--- The existing final unit already contains hello_gte_tape.c as file index 11 and ends with a valid end_sequence. --- The existing final unit already contains hello_gte_tape.c as file index 11 and ends with a valid end_sequence.
--- We preserve its bytes, append independent atom sequences, and increase only that unit's DWARF32 unit_length. --- We preserve its bytes, append independent atom sequences, and increase only that unit's DWARF32 unit_length.
--- @param existing string -- existing section bytes (verbatim) --- @param existing string -- existing section bytes, byte-for-byte
--- @param atom_table table -- list of {name, addr, size_bytes, words, entries} --- @param atom_table table -- list of {name, addr, size_bytes, words, entries}
--- @return string --- @return string
local function build_dwarf_line_section(existing, atom_table) local function build_dwarf_line_section(existing, atom_table)
@@ -1095,7 +1029,7 @@ end
--- segment_size (1 byte) -- = 0 --- segment_size (1 byte) -- = 0
--- [entries...] -- address(4) + length(4) per entry --- [entries...] -- address(4) + length(4) per entry
--- terminator -- address=0 + length=0 (8 zero bytes) --- terminator -- address=0 + length=0 (8 zero bytes)
--- @param existing string --- @param existing string
--- @param atom_table table --- @param atom_table table
--- @return string --- @return string
local function build_dwarf_aranges_section(existing, atom_table) local function build_dwarf_aranges_section(existing, atom_table)
@@ -1133,7 +1067,7 @@ local function build_dwarf_aranges_section(existing, atom_table)
return existing return existing
end end
local unit_start = i local unit_start = i
local unit_end_excl = i + 4 + ul local unit_end_excl = i + 4 + ul
is_last_unit = (unit_end_excl == #existing) is_last_unit = (unit_end_excl == #existing)
@@ -1361,7 +1295,7 @@ end
--- Abbrev 100 (DW_TAG_compile_unit, with children): --- Abbrev 100 (DW_TAG_compile_unit, with children):
--- DW_AT_name (DW_FORM_strp) -- CU name --- DW_AT_name (DW_FORM_strp) -- CU name
--- DW_AT_comp_dir (DW_FORM_strp) -- compilation dir --- DW_AT_comp_dir (DW_FORM_strp) -- compilation dir
--- DW_AT_language (DW_FORM_data1) -- DW_LANG_C99 --- DW_AT_language (DW_FORM_data1) -- one byte language code
--- Abbrev 101 (DW_TAG_subprogram, with children): --- Abbrev 101 (DW_TAG_subprogram, with children):
--- DW_AT_name (DW_FORM_string) -- atom function name --- DW_AT_name (DW_FORM_string) -- atom function name
--- DW_AT_low_pc (DW_FORM_addr) -- atom.addr --- DW_AT_low_pc (DW_FORM_addr) -- atom.addr
@@ -1452,7 +1386,7 @@ local function build_new_abbrev()
-- Component step-into abstract + inline DIE abbreviations. -- Component step-into abstract + inline DIE abbreviations.
local DW_INL_declared_inlined = 0x03 -- DWARF5 §3.33.3: "this subroutine was declared inline" local DW_INL_declared_inlined = 0x03 -- DWARF5 §3.33.3: "this subroutine was declared inline"
-- Abstract subprograms now carry DW_AT_decl_file + DW_AT_decl_line so consumers can resolve the abstract origin back to its definition site -- Abstract subprograms carry DW_AT_decl_file and DW_AT_decl_line for definition-site resolution.
-- even when no inlined_subroutine instance currently maps to it. -- even when no inlined_subroutine instance currently maps to it.
-- DW_FORM_udata is consistent with the call_file/call_line forms on abbrev 108. -- DW_FORM_udata is consistent with the call_file/call_line forms on abbrev 108.
local abbrev_abstract_subprogram = abbrev(ABBREV_ABSTRACT_SUBPROGRAM, DW_TAG_subprogram, false, -- DW_CHILDREN_no local abbrev_abstract_subprogram = abbrev(ABBREV_ABSTRACT_SUBPROGRAM, DW_TAG_subprogram, false, -- DW_CHILDREN_no
@@ -1540,8 +1474,7 @@ local function build_new_strings(atom_table, registries)
-- Register names (one per unique debug-visible R_Name alias from the merged registry, -- Register names (one per unique debug-visible R_Name alias from the merged registry,
-- filtered to MIPS GPR 0..31 — the same filter that build_inserted_children applies -- filtered to MIPS GPR 0..31 — the same filter that build_inserted_children applies
-- for the RR_<name> locals, so .debug_str entries stay in sync with .debug_info). -- for the RR_<name> locals, so .debug_str entries stay in sync with .debug_info).
-- Plex: Lua's pairs() is non-deterministic; sort the alias names first so the emitted -- Lua's pairs() is non-deterministic; sort the alias names first so the emitted$ .debug_str bytes are byte-identical across runs.
-- .debug_str bytes are byte-identical across runs.
local sorted_alias_names = {} local sorted_alias_names = {}
for r_name, alias in pairs(registries.register_alias_registry or {}) do for r_name, alias in pairs(registries.register_alias_registry or {}) do
if alias.code and alias.code >= 0 and alias.code <= 31 then if alias.code and alias.code >= 0 and alias.code <= 31 then
@@ -1563,8 +1496,8 @@ end
--- Build the DWARF DIE bytes to insert into the MAIN CU as children, immediately --- Build the DWARF DIE bytes to insert into the MAIN CU as children, immediately
--- before the main CU's root children-terminator (the final 0 byte of the CU). --- before the main CU's root children-terminator (the final 0 byte of the CU).
--- ---
--- The same content was emitted as a DETACHED synthetic CU appended after the main CU. --- Insert the DIEs as children of the main compilation unit.
--- GDB's PC lookup selects the main CU, so the synthetic CU was out of scope and `RR_PrimCursor` + `bind_args` never appeared in the current frame. --- This keeps `RR_PrimCursor` and `bind_args` in scope for atom PCs.
--- Inserting the DIEs as children of the main CU puts them in scope for every PC the main CU owns; --- Inserting the DIEs as children of the main CU puts them in scope for every PC the main CU owns;
--- including every atom PC (since `.debug_aranges` + `.debug_rnglists` already assign atom PCs to it). --- including every atom PC (since `.debug_aranges` + `.debug_rnglists` already assign atom PCs to it).
--- ---
@@ -1598,7 +1531,7 @@ end
--- DW_AT_type = ref4 → structure_type DIE --- DW_AT_type = ref4 → structure_type DIE
--- ---
--- **DOES NOT** emit the final 0 byte (root terminator). --- **DOES NOT** emit the final 0 byte (root terminator).
--- build_debug_info_section splices our bytes between the existing DIE bytes and that terminator, which is preserved verbatim. --- build_debug_info_section splices bytes ahead of the root terminator and preserves existing DIE bytes exactly.
--- ---
--- **ref4 basis**: DW_FORM_ref4 is CU-relative (offset from the first byte of the CU header). --- **ref4 basis**: DW_FORM_ref4 is CU-relative (offset from the first byte of the CU header).
--- Our inserted DIEs live in the main CU, so every ref4 = (target section offset) - main_cu_offset. --- Our inserted DIEs live in the main CU, so every ref4 = (target section offset) - main_cu_offset.
@@ -1648,7 +1581,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
local insertion_start = main_cu_end_excl - 1 local insertion_start = main_cu_end_excl - 1
-- Closure state: bytes + next_offset + the typed-view/structure/abstract offset caches. -- Closure state: bytes + next_offset + the typed-view/structure/abstract offset caches.
-- All step-emitters mutate this state in place. Plex: Fleury expose structure over interface. -- All step-emitters mutate this state in place.
local S = { local S = {
bytes = {}, bytes = {},
next_offset = insertion_start, -- 0-based section offset of the NEXT byte to emit next_offset = insertion_start, -- 0-based section offset of the NEXT byte to emit
@@ -1658,7 +1591,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
member_base_type_offsets = {}, -- {[tn.."|"..byte_size.."|"..encoding] = section_offset} member_base_type_offsets = {}, -- {[tn.."|"..byte_size.."|"..encoding] = section_offset}
base_type_section_offset = nil, -- set by emit_unsigned_int_base_type base_type_section_offset = nil, -- set by emit_unsigned_int_base_type
} }
-- Plex helpers.
local function emit(s) local function emit(s)
S.bytes[#S.bytes + 1] = s S.bytes[#S.bytes + 1] = s
S.next_offset = S.next_offset + #s S.next_offset = S.next_offset + #s
@@ -1675,7 +1608,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
emit(string.char(4)) -- DW_FORM_data1 (DW_AT_byte_size) emit(string.char(4)) -- DW_FORM_data1 (DW_AT_byte_size)
emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding) emit(string.char(DW_ATE_unsigned)) -- DW_FORM_data1 (DW_AT_encoding)
-- (The function body below reads S.next_offset directly via the `next_offset` function; -- (The function body below reads S.next_offset directly via the `next_offset` function;
-- the old code used a stale local snapshot that stayed at base_type_section_offset.) -- this keeps offsets synchronized with emitted data.)
local function next_offset() return S.next_offset end local function next_offset() return S.next_offset end
-- Typed local views. -- Typed local views.
@@ -1876,9 +1809,9 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
-- reusing the pre-emitted base type keeps the wire consistent. -- reusing the pre-emitted base type keeps the wire consistent.
-- Once this chain is registered as `type_chain_offsets["U4|1"]`, step (e) of the per-RR_<R_Name> precedence chain will resolve `atom_type(U4 *)` -- Once this chain is registered as `type_chain_offsets["U4|1"]`, step (e) of the per-RR_<R_Name> precedence chain will resolve `atom_type(U4 *)`
-- declarations on aliases like `R_PrimCursor` and `R_OtBase` to `U4 *` (gdb renders as `(unsigned int *)` with the value displayed in hex). -- declarations on aliases like `R_PrimCursor` and `R_OtBase` to `U4 *` (gdb renders as `(unsigned int *)` with the value displayed in hex).
emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9; U4 chain target) emit(uleb128(ABBREV_TYPED_VIEW_POINTER)) -- DW_TAG_pointer_type (abbrev 110; NOT 9; U4 chain target)
emit(elf_dwarf.write_u32_le(ref4_of(base_type_section_offset))) -- 4-byte ref4 → "unsigned int" base_type emit(elf_dwarf.write_u32_le(ref4_of(base_type_section_offset))) -- 4-byte ref4 → "unsigned int" base_type
local u4_chain_offset = next_offset() - 5 -- 1 (uleb tag) + 4 (ref4) = 5 bytes; capture the pointer_type's start offset local u4_chain_offset = next_offset() - 5 -- 1 (uleb tag) + 4 (ref4) = 5 bytes; capture the pointer_type's start offset
type_chain_offsets["U4|1"] = u4_chain_offset type_chain_offsets["U4|1"] = u4_chain_offset
-- 2) Emit one DW_TAG_structure_type per unique Binds_X. -- 2) Emit one DW_TAG_structure_type per unique Binds_X.
@@ -1939,7 +1872,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
end end
-- 4) Emit per-atom DW_TAG_subprograms (children of main CU). -- 4) Emit per-atom DW_TAG_subprograms (children of main CU).
-- Subprograms are named `<name>` (matching the nm symbol; the `code_` prefix was removed from the MipsAtom_ macro in code/duffle/lottes_tape.h). -- Subprogram names match nm symbols without a `code_` prefix.
-- The gcc global `<name>[]` is a DW_TAG_variable without children; our subprogram has the wave-context var children. -- The gcc global `<name>[]` is a DW_TAG_variable without children; our subprogram has the wave-context var children.
-- gdb's symbol resolution picks our subprogram (it has low_pc/high_pc + children) over the gcc global for function-context lookups. -- gdb's symbol resolution picks our subprogram (it has low_pc/high_pc + children) over the gcc global for function-context lookups.
for _, atom in ipairs(atom_table) do for _, atom in ipairs(atom_table) do
@@ -2023,9 +1956,9 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
end end
end end
-- 5-step precedence chain. Plex: data table; the dispatch loop runs the first step that yields a non-nil offset. -- 5-step precedence chain. The dispatch loop runs the first step that yields a non-nil offset.
-- Each step returns the type's section offset or nil if it missed. -- Each step returns the type's section offset or nil if it missed.
-- Adding a step = 1 row in the table + 1 function. The 5-level nested if/else is gone. -- Each precedence rule is one table row and one function.
-- Per-atom precomputed state is captured in upvalues: atom_view, reg_to_field_ctx, atom_view_ctx_fields, -- Per-atom precomputed state is captured in upvalues: atom_view, reg_to_field_ctx, atom_view_ctx_fields,
-- reg_to_field_phase, atom_view_phase_fields, field_type_by_name, reg_to_field, alias, type_chain_offsets. -- reg_to_field_phase, atom_view_phase_fields, field_type_by_name, reg_to_field, alias, type_chain_offsets.
local PRECEDENCE_STEPS = { local PRECEDENCE_STEPS = {
@@ -2068,7 +2001,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
end, end,
} }
-- Plex: iterate `by_alias` in sorted order (Lua's pairs() is non-deterministic; -- Iterate `by_alias` in sorted order (Lua's pairs() is non-deterministic;
-- sorting ensures byte-identical DWARF output across builds). -- sorting ensures byte-identical DWARF output across builds).
for _, r_name in ipairs(by_alias_order) do for _, r_name in ipairs(by_alias_order) do
local alias = by_alias[r_name] local alias = by_alias[r_name]
@@ -2078,10 +2011,8 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
emit(rr_name .. "\0") -- DW_FORM_string (DW_AT_name) emit(rr_name .. "\0") -- DW_FORM_string (DW_AT_name)
-- DW_FORM_exprloc: ULEB byte count + DW_OP_regN byte. -- DW_FORM_exprloc: ULEB byte count + DW_OP_regN byte.
-- DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f; DW_OP_reg15 is 0x5f. -- DW_OP_reg0..reg31 occupy opcodes 0x50..0x6f; DW_OP_reg15 is 0x5f.
-- Inlined from `reg_exprloc` (single caller; the function was 3 LOC). -- DW_FORM_exprloc: ULEB byte count + DW_OP_regN byte.
-- DW_FORM_exprloc: ULEB byte count + DW_OP_regN byte. -- The `1` is the length prefix — DW_OP_regN occupies exactly 1 byte (the base opcode is 0x50; regN = 0x50 + N).
-- Inlined from `reg_exprloc` (was at lines 1185-1188, dwarf_injection.lua; the function was 3 LOC and 1 caller).
-- The `1` is the length prefix — DW_OP_regN occupies exactly 1 byte (the base opcode is 0x50; regN = 0x50 + N).
-- `alias_code` is the MIPS GPR index (0..31) from the merged register_alias_registry. -- `alias_code` is the MIPS GPR index (0..31) from the merged register_alias_registry.
emit(uleb128(1) .. string.char(DW_OP_reg0 + alias_code)) -- DW_FORM_exprloc (DW_OP_regN from registry code) emit(uleb128(1) .. string.char(DW_OP_reg0 + alias_code)) -- DW_FORM_exprloc (DW_OP_regN from registry code)
-- Precedence chain (a..e); step (f) is the void* fallback (initial value). -- Precedence chain (a..e); step (f) is the void* fallback (initial value).
@@ -2096,7 +2027,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
-- If rbind, emit bind_args variable with PC-ranged location list. -- If rbind, emit bind_args variable with PC-ranged location list.
-- The loclist is in .debug_loclists, indexed by `DW_FORM_sec_offset` (4-byte section-relative offset). -- The loclist is in .debug_loclists, indexed by `DW_FORM_sec_offset` (4-byte section-relative offset).
-- The piece chain is replaced by two PC ranges: [atom.addr, last_load+8) where every field is described as a tape-memory -- The location list uses two PC ranges: [atom.addr, last_load+8) describes every field as tape memory
-- (DW_OP_bregN + offset) piece, and [last_load+8, atom.end) where every field is described as a GPR (DW_OP_regN) piece. -- (DW_OP_bregN + offset) piece, and [last_load+8, atom.end) where every field is described as a GPR (DW_OP_regN) piece.
if atom.rbind then if atom.rbind then
local binds_name = atom.rbind.binds local binds_name = atom.rbind.binds
@@ -2110,8 +2041,8 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
-- Per-component invocation inlined_subroutine instances. -- Per-component invocation inlined_subroutine instances.
-- Each invocation covers a contiguous .word range [start_pos, end_pos] within the atom. -- Each invocation covers a contiguous .word range [start_pos, end_pos] within the atom.
-- We compute the corresponding PC range from the atom's start + .word offsets × MIPS_BYTES_PER_WORD. -- We compute the corresponding PC range from the atom's start + .word offsets × MIPS_BYTES_PER_WORD.
-- call_file now resolves inv.call_file to the line-unit file index (previously hardcoded to ATOM_SOURCE_FILE_INDEX; -- Resolve `inv.call_file` to the line-unit file index;
-- that lost the call-site attribution for any invocation whose call site was NOT the atom's source file). -- this preserves call-site attribution across source files.
if atom.invocations and not atom.skip_over then if atom.invocations and not atom.skip_over then
for _, inv in ipairs(atom.invocations) do for _, inv in ipairs(atom.invocations) do
local inv_low = atom.addr + inv.start_pos * MIPS_BYTES_PER_WORD local inv_low = atom.addr + inv.start_pos * MIPS_BYTES_PER_WORD
@@ -2128,7 +2059,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of subprogram's children (DWARF5 §7.5.3) emit(string.char(DIE_CHILDREN_TERMINATOR)) -- end of subprogram's children (DWARF5 §7.5.3)
end end
-- DO NOT emit a final 0 here — that's the main CU's root terminator, which build_debug_info_section preserves verbatim. -- Do not emit a final 0 here; build_debug_info_section preserves the root terminator byte.
return table.concat(S.bytes) return table.concat(S.bytes)
end end
@@ -2150,7 +2081,7 @@ end
--- ---
--- Fails safely by returning existing sections unchanged if the table walker can't find the table terminator (malformed input). --- Fails safely by returning existing sections unchanged if the table walker can't find the table terminator (malformed input).
--- ---
--- @param existing string -- existing .debug_abbrev bytes (verbatim) --- @param existing string -- existing .debug_abbrev bytes, byte-for-byte
--- @param main_abbrev_offset integer -- 0-based offset into `existing` of the main CU's abbrev table --- @param main_abbrev_offset integer -- 0-based offset into `existing` of the main CU's abbrev table
--- @return string, integer -- (new_abbrev_bytes, offset_where_duplicate_table_starts = #existing) --- @return string, integer -- (new_abbrev_bytes, offset_where_duplicate_table_starts = #existing)
local function build_debug_abbrev_section(existing, main_abbrev_offset) local function build_debug_abbrev_section(existing, main_abbrev_offset)
@@ -2169,7 +2100,7 @@ local function build_debug_abbrev_section(existing, main_abbrev_offset)
end end
--- Build the new .debug_str: existing strings + new strings appended. --- Build the new .debug_str: existing strings + new strings appended.
--- @param existing string -- existing .debug_str bytes (verbatim) --- @param existing string -- existing .debug_str bytes, byte-for-byte
--- @param atom_table table[] --- @param atom_table table[]
--- @param registries table -- merged registries from collect_per_source_registries --- @param registries table -- merged registries from collect_per_source_registries
--- @return string, integer, table -- (new_str_bytes, new_strings_offset, string_map) --- @return string, integer, table -- (new_str_bytes, new_strings_offset, string_map)
@@ -2180,8 +2111,6 @@ end
--- Build the new .debug_info: SPLICE inserted DIEs into the MAIN CU as children. --- Build the new .debug_info: SPLICE inserted DIEs into the MAIN CU as children.
--- ---
--- This function appended a DETACHED synthetic CU to the end of .debug_info.
--- That put every atom DIE in a separate CU from the one GDB selected for PC lookup, so `RR_PrimCursor` + `bind_args` never appeared in scope.
--- This implementation instead: --- This implementation instead:
--- 1. Builds the inserted-children bytes (base_type, struct_types, subprograms with their RR_* + bind_args children) via build_inserted_children. --- 1. Builds the inserted-children bytes (base_type, struct_types, subprograms with their RR_* + bind_args children) via build_inserted_children.
--- 2. Patches the main CU's `unit_length` field to account for the inserted bytes. --- 2. Patches the main CU's `unit_length` field to account for the inserted bytes.
@@ -2190,7 +2119,7 @@ end
--- 5. Replaces the main CU's final byte (the root children-terminator, 0) with: inserted_children_bytes + a single 0 byte (root terminator preserved). --- 5. Replaces the main CU's final byte (the root children-terminator, 0) with: inserted_children_bytes + a single 0 byte (root terminator preserved).
--- ---
--- The crt CU (everything before main_cu_start) is preserved. --- The crt CU (everything before main_cu_start) is preserved.
--- **No detached synthetic CU is appended.**
--- @param existing string -- existing .debug_info section bytes --- @param existing string -- existing .debug_info section bytes
--- @param main_cu_start integer -- 0-based offset of the main CU's unit_length field --- @param main_cu_start integer -- 0-based offset of the main CU's unit_length field
--- @param main_cu_end_excl integer -- 0-based offset of the first byte AFTER the main CU --- @param main_cu_end_excl integer -- 0-based offset of the first byte AFTER the main CU
@@ -2215,44 +2144,33 @@ local function build_debug_info_section(existing, main_cu_start, main_cu_end_exc
-- 4) Splice. All offsets below are 0-based; existing:sub is 1-indexed inclusive. -- 4) Splice. All offsets below are 0-based; existing:sub is 1-indexed inclusive.
-- Byte ranges (0-based, inclusive): -- Byte ranges (0-based, inclusive):
-- [0 .. main_cu_start - 1] crt CU (verbatim) -- [0 .. main_cu_start - 1] crt CU, unchanged
-- [main_cu_start + 0 .. + 3] unit_length (PATCHED) -- [main_cu_start + 0 .. + 3] unit_length (PATCHED)
-- [main_cu_start + 4 .. + 7] version + unit_type + address_size (verbatim) -- [main_cu_start + 4 .. + 7] version + unit_type + address_size, unchanged
-- [main_cu_start + 8 .. + 11] debug_abbrev_offset (PATCHED) -- [main_cu_start + 8 .. + 11] debug_abbrev_offset (PATCHED)
-- [main_cu_start + 12 .. main_cu_end_excl - 2] existing DIE bytes (verbatim) -- [main_cu_start + 12 .. main_cu_end_excl - 2] existing DIE bytes, unchanged
-- [main_cu_end_excl - 1] root children-terminator (verbatim 0) -- [main_cu_end_excl - 1] root children-terminator, unchanged 0
local pre_end = main_cu_end_excl - 2 -- 0-based end of existing DIE bytes (inclusive) local pre_end = main_cu_end_excl - 2 -- 0-based end of existing DIE bytes (inclusive)
local root_terminator = main_cu_end_excl - 1 -- 0-based position of the final 0 byte local root_terminator = main_cu_end_excl - 1 -- 0-based position of the final 0 byte
return existing:sub(1, main_cu_start) -- crt CU return existing:sub(1, main_cu_start) -- crt CU
.. new_unit_length_bytes -- patched unit_length (4 bytes) .. new_unit_length_bytes -- patched unit_length (4 bytes)
.. existing:sub(main_cu_start + 5, main_cu_start + 8) -- version(2) + unit_type(1) + address_size(1) verbatim .. existing:sub(main_cu_start + 5, main_cu_start + 8) -- version(2) + unit_type(1) + address_size(1), unchanged
.. new_abbrev_offset_bytes -- patched debug_abbrev_offset (4 bytes) .. new_abbrev_offset_bytes -- patched debug_abbrev_offset (4 bytes)
.. existing:sub(main_cu_start + 13, pre_end + 1) -- existing DIE bytes verbatim .. existing:sub(main_cu_start + 13, pre_end + 1) -- existing DIE bytes, unchanged
.. inserted -- our inserted children .. inserted -- our inserted children
.. existing:sub(root_terminator + 1, main_cu_end_excl) -- root children-terminator (verbatim 0) .. existing:sub(root_terminator + 1, main_cu_end_excl) -- root children-terminator, unchanged 0
end end
--- Build the .debug_loc: just a terminator. --- Build the .debug_loc: just a terminator.
--- Atoms don't have stack frames. The .debug_loc section describes per-instruction location adjustments for call-frame-based variables; --- Atoms don't have stack frames. The .debug_loc section describes per-instruction location adjustments for call-frame-based variables;
--- We use DW_OP_regN which is register-based and doesn't need .debug_loc entries). --- We use DW_OP_regN which is register-based and doesn't need .debug_loc entries).
--- The section itself must not be empty OR gdb may complain; the DW_LLE_end_of_list marker (per DWARF5 §7.7) is a single byte 0x00. --- The section itself must not be empty OR gdb may complain; the DW_LLE_end_of_list marker (per DWARF5 §7.7) is a single byte 0x00.
--- Single-caller (M.run's SECTION_BUILDERS table below); replaced by a literal at the call site. --
-- local function build_debug_loc_section() return string.char(0x00) end -- The actual .debug_loc emission is `string.char(DW_LLE_end_of_list)` inlined
-- at the per-section writers below (the dispatch is in M.run, not a table).
local SECTION_BUILDERS = { -- Per-section output path resolver.
debug_line = build_dwarf_line_section,
debug_aranges = build_dwarf_aranges_section,
debug_rnglists = build_dwarf_rnglists_section,
debug_abbrev = build_debug_abbrev_section,
debug_info = build_debug_info_section,
debug_str = build_debug_str_section,
debug_loc = function() return string.char(DW_LLE_end_of_list) end,
-- debug_loclists is fed directly in M.run (it needs the merged registries for the R_TapePtr GPR lookup;
-- the SECTION_BUILDERS table cannot carry that context, so the dispatch is inlined in the per-run writers loop).
}
-- Per-section output path resolver (mirrors SECTION_BUILDERS).
-- Returns the on-disk path for the section's `.bin` blob. -- Returns the on-disk path for the section's `.bin` blob.
local SECTION_WRITERS = { local SECTION_WRITERS = {
debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end, debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end,
@@ -2326,10 +2244,10 @@ function M.run(ctx)
-- Read the existing DWARF sections directly (no subprocess; lfs + io.open + manual ELF32 section-header walk). -- Read the existing DWARF sections directly (no subprocess; lfs + io.open + manual ELF32 section-header walk).
-- We need all 8 sections: .debug_line / .debug_aranges / .debug_rnglists get extended -- We need all 8 sections: .debug_line / .debug_aranges / .debug_rnglists get extended
-- (additional rows appended to the existing unit), and .debug_info / .debug_abbrev / .debug_str / .debug_loc / .debug_loclists -- (additional rows appended to the existing unit), and .debug_info / .debug_abbrev / .debug_str / .debug_loc / .debug_loclists
-- get spliced (the main CU's unit_length is patched; -- get spliced (the main CU's unit_length is patched;
-- no new compile unit is appended; debug_loc/debug_loclists may not exist in the source ELF so we add-section them on splice). -- no new compile unit is appended; debug_loc/debug_loclists may not exist in the source ELF so we add-section them on splice).
-- The dispatch (SECTION_BUILDERS) handles each. -- The per-section dispatch is inlined in the writers loop below.
local existing_sections = elf_dwarf.read_elf_sections(elf_path, { local existing_sections = elf_dwarf.read_elf_sections(elf_path, {
".debug_line", ".debug_aranges", ".debug_rnglists", ".debug_line", ".debug_aranges", ".debug_rnglists",
".debug_info", ".debug_abbrev", ".debug_str", ".debug_info", ".debug_abbrev", ".debug_str",
@@ -2353,14 +2271,21 @@ function M.run(ctx)
-- then build the atom/provenance table with those generic selections. -- then build the atom/provenance table with those generic selections.
-- Whole atoms remain symbol-keyed; components are file-qualified internally so a source marker associates -- Whole atoms remain symbol-keyed; components are file-qualified internally so a source marker associates
-- with its exact component definition even though GDB 12 requires function-only skip entries for the resulting synthetic inline frame. -- with its exact component definition even though GDB 12 requires function-only skip entries for the resulting synthetic inline frame.
local skip_over = collect_skip_over(ctx) -- `corpus` is the sole canonical source projection;
local registries = collect_per_source_registries(ctx) -- the sole source of truth; no `ctx.sources` / `ctx.by_dir` aliases).
local atom_table = build_atom_table(ctx, skip_over) local corpus = (ctx.shared and ctx.shared.corpus) or {}
io.stderr:write(string.format("[dwarf_injection] matched %d atoms between nm + source-map\n", #atom_table)) local skip_over = collect_skip_over(corpus)
local registries = collect_per_source_registries(corpus)
-- Read nm symbols (the ONLY disk-side input to the atom table) and join
-- them against `corpus.atoms_by_name` + `atom.paths` for word rows + invocation ancestry.
-- Disk source-map/provenance text is NOT consulted (those are diagnostic artifacts; semantic inputs are in memory).
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local atom_table = build_atom_table(corpus, addrs, skip_over)
io.stderr:write(string.format("[dwarf_injection] matched %d atoms between nm + corpus.atoms_by_name\n", #atom_table))
-- Detect rbind atoms + index Binds_* struct fields (from ctx.sources[i].scan, populated by scan-source pass). -- Detect rbind atoms + index Binds_* struct fields (from ctx.sources[i].scan, populated by scan-source pass).
-- The merged registries are threaded through so parse_body_load_pairs resolves R_<reg> via register_alias_registry. -- The merged registries are threaded through so parse_body_load_pairs resolves R_<reg> via register_alias_registry.
local _rbind_atoms, rbind_structs = parse_rbind_atoms(ctx, atom_table, registries) local _rbind_atoms, rbind_structs = parse_rbind_atoms(corpus, atom_table, registries)
local rbind_count = 0 local rbind_count = 0
for _ in pairs(_rbind_atoms) do rbind_count = rbind_count + 1 end for _ in pairs(_rbind_atoms) do rbind_count = rbind_count + 1 end
io.stderr:write(string.format("[dwarf_injection] matched %d rbind atoms across %d Binds_* structs\n", io.stderr:write(string.format("[dwarf_injection] matched %d rbind atoms across %d Binds_* structs\n",
@@ -2369,7 +2294,7 @@ function M.run(ctx)
-- Write the .bin files. The build_psyq.ps1 post-link hook splices these into a copy of the ELF via objcopy --update-section. -- Write the .bin files. The build_psyq.ps1 post-link hook splices these into a copy of the ELF via objcopy --update-section.
-- Build order: -- Build order:
-- 0. Validate .debug_info layout (crT CU + DWARF5 main CU + final 0 root terminator). -- 0. Validate .debug_info layout (crT CU + DWARF5 main CU + final 0 root terminator).
-- If validation fails, FAIL SAFELY by writing the existing sections verbatim (no malformed output, no synthetic CU append, no header patch). -- If validation fails, write existing sections unchanged and emit no synthetic data.
-- 1. Build new .debug_abbrev using the main CU's abbrev offset → returns the offset of the duplicate main table (= #existing_abbrev). -- 1. Build new .debug_abbrev using the main CU's abbrev offset → returns the offset of the duplicate main table (= #existing_abbrev).
-- 2. Build new .debug_info by splicing inserted children into the main CU (patches main CU's unit_length + debug_abbrev_offset; -- 2. Build new .debug_info by splicing inserted children into the main CU (patches main CU's unit_length + debug_abbrev_offset;
-- preserves all original DIE bytes; does NOT append a synthetic CU). -- preserves all original DIE bytes; does NOT append a synthetic CU).
@@ -2378,6 +2303,7 @@ function M.run(ctx)
-- --
-- .debug_str now also receives the new RR_<R_Name> entries -- .debug_str now also receives the new RR_<R_Name> entries
-- (the merged registry drives the strings table to keep .debug_str and .debug_info in sync). -- (the merged registry drives the strings table to keep .debug_str and .debug_info in sync).
-- (the per-section dispatch is inlined in the writers loop below)
local basename = ctx.basename or duffle.basename_no_ext(elf_path) or DEFAULT_BASENAME local basename = ctx.basename or duffle.basename_no_ext(elf_path) or DEFAULT_BASENAME
if ctx.out_root and ctx.out_root ~= "" then if ctx.out_root and ctx.out_root ~= "" then
duffle.ensure_dir(ctx.out_root) duffle.ensure_dir(ctx.out_root)
@@ -2447,7 +2373,7 @@ function M.run(ctx)
return { outputs = {}, errors = {}, warnings = {} } return { outputs = {}, errors = {}, warnings = {} }
end end
-- Test-only re-exports: keep the module's main M table lean while letting scratch -- Test-only exports expose the emission and offset paths.
-- tests drive the real emission and offset computation paths. -- tests drive the real emission and offset computation paths.
M.compute_loclists_offsets_for_test = compute_loclists_offsets M.compute_loclists_offsets_for_test = compute_loclists_offsets
M.build_debug_loclists_section_for_test = build_debug_loclists_section M.build_debug_loclists_section_for_test = build_debug_loclists_section
+184
View File
@@ -0,0 +1,184 @@
--- passes/emission_model.lua: Per-atom emission projection.
---
--- The `emission-model` pass owns `atom.paths` (the canonical per-atom mutable surface)
--- for every atom-with-body and every raw atom-with-body declared in `ctx.shared.corpus.source_order`.
--- For each such atom, the pass invokes `duffle.project_emission(body_text, component_index, word_counts)`
--- and stores the ordered `items` stream plus the dense `word_events` / `markers` / `invocations` views on `atom.paths`.
---
--- Public boundary:
--- * `M.run(ctx)` is the only entry point.
--- * The pass returns `{outputs = {}, errors = ..., warnings = ...}`.
--- Pass kind = `validation` → `PASS_KIND_STOP_ON_ERROR.validation` keeps build-stopping semantics (no policy change in this task).
---
--- Source-order discipline:
--- * `corpus.source_order` is the canonical ordering of source records.
--- * For each source, the pass iterates `src.scan.atoms` and `src.scan.raw_atoms` IN SOURCE ORDER, preserving declaration order.
---
--- Per-atom projection fields on `atom.paths`:
--- `tokens`, `line_in_body`, `items`, `word_events`, `markers`, `invocations`, `errors`, `warnings`.
--- The dense views are built from `items` only; the pass never re-walks source text or tokens.
---
--- Component expansion and construction validation:
--- * known `mac_X(...)` calls recursively expand component bodies;
--- * invocation records retain monotonic IDs, parent IDs, immediate call text, and the immutable outermost root call text;
--- * component cycles retain balanced invocation boundaries and emit a `cycle` construction error without recursing indefinitely;
--- * declared-vs-measured component word counts emit `count_mismatch` construction errors; opaque uncounted macros emit warnings.
---
--- The pass does NOT consult `_code_macros` / `_code_macro_bodies`. Those private tables are owned by `passes.scan_source` and stripped before this pass runs.
local M = {}
-- ─────────────────────────────────────────────────────────────────────────
-- Bootstrap: load `duffle_paths.lua` via debug.getinfo so the module works standalone (run as `luajit passes/emission_model.lua`) and when require'd from the orchestrator.
-- ─────────────────────────────────────────────────────────────────────────
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ─────────────────────────────────────────────────────────────────────────
-- Helpers
-- ─────────────────────────────────────────────────────────────────────────
-- Convert the recursive walk's body-relative line numbers into physical source lines once.
-- Consumers read these canonical fields rather than rebuilding line state or tokenizing source again.
local function stamp_root_provenance(projection, atom_record, src, corpus)
local root_line_of = src.scan and src.scan.line_of
local root_body_line = root_line_of and root_line_of((atom_record.body_off or 1) - 1)
or atom_record.line or 0
local component_index = corpus.component_body_index or {}
local word_items = {}
for _, item in ipairs(projection.items) do
if item.kind == "word" then word_items[#word_items + 1] = item end
end
local function body_line_for(event, item)
local body_line_of = root_line_of
local body_off = atom_record.body_off or 0
local ids = event.invocation_ids or {}
local inner_id = ids[#ids]
local inner_inv = inner_id and projection.invocations[inner_id]
if inner_inv then
local component = component_index[inner_inv.component_name]
if component and component.line_of then
-- Component-body walkers already receive the declaration source's full line index, so their item.line is physical.
return item.line or 0
end
end
local first_line = body_line_of and body_line_of(math.max(1, body_off - 1)) or root_body_line
return (first_line or 0) + (item.line or 1) - 1
end
-- Stamp root-source path onto invocation records whose `call_path` was left empty by the walker.
-- The walker passes `body_entry.source` to `emit_invoke_begin` as the call_path argument; for the root body_entry created by `M.project_emission` that source is ""
-- (the caller passes only the body text).
-- After this stamp every invocation record has a physical call_path that matches what `passes/atoms_source_map.lua` matches the in-memory provenance projection.
local root_path = src.path or ""
for _, inv in ipairs(projection.invocations) do
if inv.call_path == nil or inv.call_path == "" then
inv.call_path = root_path
end
end
for index, we in ipairs(projection.word_events) do
local item = word_items[index] or {}
local body_line = body_line_for(we, item)
item.line = body_line
we.body_line = body_line
local call_line = body_line
local outer_id = we.outermost_invocation_id or 0
local outer_inv = projection.invocations[outer_id]
if outer_inv then call_line = (root_body_line or 0) + (outer_inv.call_line or 1) - 1 end
we.call_line = call_line
if we.def_path == nil or we.def_path == "" then we.def_path = src.path or "" end
if we.def_line == nil or we.def_line == 0 then we.def_line = atom_record.line or 0 end
if we.call_path == nil or we.call_path == "" then we.call_path = src.path or "" end
end
end
-- Project one atom record into `atom.paths`.
-- Mutates the atom record in-place and returns the projection (for pass-level error/warning accumulation).
local function project_atom(atom_record, src, corpus)
local body = atom_record.body or ""
local wc = corpus.word_counts or {}
local cbi = corpus.component_body_index or {}
local proj = duffle.project_emission(body, cbi, wc)
local paths = {
tokens = atom_record.body_tokens or {},
line_in_body = duffle.build_body_line_index(body),
items = proj.items,
word_events = proj.word_events,
markers = proj.markers,
invocations = proj.invocations,
errors = proj.errors,
warnings = proj.warnings,
}
stamp_root_provenance(proj, atom_record, src, corpus)
atom_record.paths = paths
return proj
end
-- ─────────────────────────────────────────────────────────────────────────
-- Run the emission-model pass.
-- ─────────────────────────────────────────────────────────────────────────
--- @param ctx PassCtx -- { shared = { corpus = ... }, out_root, ... }
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
local corpus = ctx and ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then error("emission_model: ctx.shared.corpus is required (canonical projection)", 0) end
if type(corpus.source_order) ~= "table" then error("emission_model: ctx.shared.corpus.source_order is required", 0) end
-- Project once, collect errors + warnings for one atom.
-- Kind must be one of: atom | raw_atom | comp_bare | comp_proc.
local function process_atom(atom, src)
if not (atom and atom.body) then return end
local kind = atom.kind
if kind ~= "atom" and kind ~= "raw_atom" and kind ~= "comp_bare" and kind ~= "comp_proc" then
return
end
local proj = project_atom(atom, src, corpus)
for _, e in ipairs(proj.errors) do
-- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers can dispatch on the diagnostic class without re-parsing the message string.
errors[#errors + 1] = {
kind = e.kind,
line = e.line,
msg = e.msg,
source = e.source or src.path,
}
end
for _, w in ipairs(proj.warnings) do
warnings[#warnings + 1] = {
kind = w.kind,
line = w.line,
msg = w.msg,
}
end
end
-- Walk every source in canonical order; for each source, iterate atoms + raw_atoms.
-- Recognized kinds (atom | raw_atom | comp_bare | comp_proc) each receive the atom.paths projection via duffle.project_emission.
-- Components are macros inlined into atom bodies; focused tests and isolated component analyses consume atom.paths directly.
for _, src in ipairs(corpus.source_order) do
local scan = src.scan or {}
for _, atom in ipairs(scan.atoms or {}) do
process_atom(atom, src)
end
for _, atom in ipairs(scan.raw_atoms or {}) do
process_atom(atom, src)
end
end
return {
outputs = outputs,
errors = errors,
warnings = warnings,
}
end
return M
+88 -197
View File
@@ -21,18 +21,12 @@
-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd). -- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. -- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local word_count_eval = require("word_count_eval")
local count_token_words = word_count_eval.count_token_words
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Constants -- Constants
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Marker-call identifiers inside atom bodies.
local LABEL_MARKER = "atom_label"
local OFFSET_MARKER = "atom_offset"
-- Offset macro/enum naming prefixes (the emitted header uses these). -- Offset macro/enum naming prefixes (the emitted header uses these).
local OFFSET_MACRO_PREFIX = "_atom_offset_" local OFFSET_MACRO_PREFIX = "_atom_offset_"
local OFFSET_ENUM_PREFIX = "atom_offset_" local OFFSET_ENUM_PREFIX = "atom_offset_"
@@ -52,16 +46,10 @@ local OFFSET_MACRO_COL = 44
--- @field scan table -- pre-scanned SourceScan payload (from duffle.scan_source) --- @field scan table -- pre-scanned SourceScan payload (from duffle.scan_source)
--- @class PassCtx --- @class PassCtx
--- @field sources SourceFile[] -- all source files in the build
--- @field metadata_path string -- path to word_count.metadata.h
--- @field shared table -- cross-pass shared state --- @field shared table -- cross-pass shared state
--- @field shared.word_counts table -- macro name -> word count --- @field shared.corpus table -- canonical corpus projection
--- @field shared.word_counts table
--- @field out_root string -- output root (e.g. "build/gen") --- @field out_root string -- output root (e.g. "build/gen")
--- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- log diagnostic info
--- @class PassResult --- @class PassResult
--- @field outputs table[] -- {kind=, path=} entries describing emit files --- @field outputs table[] -- {kind=, path=} entries describing emit files
@@ -69,10 +57,10 @@ local OFFSET_MACRO_COL = 44
--- @field warnings table[] -- {line=, msg=} entries; build-succeeds --- @field warnings table[] -- {line=, msg=} entries; build-succeeds
--- @class BranchOffset --- @class BranchOffset
--- @field tag string -- the marker tag (e.g. "F" in `atom_offset(F, T)`) --- @field tag string -- the marker tag (e.g. "F" in `atom_offset(F, T)`)
--- @field target string -- the target label name (e.g. "T" in `atom_offset(F, T)`) --- @field target string -- the target label name (e.g. "T" in `atom_offset(F, T)`)
--- @field pos integer -- the branch's word position within the atom body --- @field branch_word integer -- branch word position within the atom body
--- @field offset integer -- computed `target_word - branch_word - 1` --- @field offset integer -- computed `target_word - branch_word - 1`
--- @class AtomData --- @class AtomData
--- @field name string -- atom name --- @field name string -- atom name
@@ -80,169 +68,85 @@ local OFFSET_MACRO_COL = 44
--- @field offsets BranchOffset[] -- per-branch offset list --- @field offsets BranchOffset[] -- per-branch offset list
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Per-token marker-call helpers (atom_label / atom_offset inside bodies) -- Canonical marker projection
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Extract comma-separated identifier args from a parenthesized group after a function-like macro call. -- MARKER_PROJECTORS is the marker-kind data table.
-- Returns (args, after_paren) where `after_paren` is the position just past the closing `)`, or nil if `token` did not start with `(`. -- The emission-model pass already records marker word positions;
-- @param token string -- this pass only projects those records into the label/branch lookup shape needed by offset computation.
-- @param after_ident integer local MARKER_PROJECTORS = {
-- @return string[], integer|nil label = function(state, marker)
local function extract_ident_args(token, after_ident) state.labels[marker.name] = marker.word_index
local arg_start = duffle.skip_ws_and_cmt(token, after_ident) end,
if token:sub(arg_start, arg_start) ~= "(" then return {}, nil end offset = function(state, marker)
local inner, after_paren = duffle.read_parens(token, arg_start) state.branches[#state.branches + 1] = {
-- scan: <marker>(<args>) tag = marker.name,
target = marker.target,
local args = {} branch_word = marker.word_index,
local pos = 1 }
local inner_len = #inner end,
while pos <= inner_len do
pos = duffle.skip_ws_and_cmt(inner, pos)
if pos > inner_len then break end
local ident, after = duffle.read_ident(inner, pos)
if ident and ident ~= "" then
table.insert(args, ident)
pos = after
else
pos = pos + 1
end
pos = duffle.skip_ws_and_cmt(inner, pos)
if pos <= inner_len and inner:sub(pos, pos) == "," then pos = pos + 1 end
end
return args, after_paren
end
-- (internal) Record a `atom_label(name)` marker — `at_pos` is the branch-free word position within the atom body.
-- @param labels table<string, integer>
-- @param args string[]
-- @param at_pos integer
local function record_label_marker(labels, args, at_pos)
if #args >= 1 then labels[args[1]] = at_pos end
end
-- (internal) Record a `atom_offset(tag, target)` marker.
-- @param branches table[] -- list of {pos=, target=, tag=}
-- @param args string[]
-- @param at_pos integer
local function record_offset_marker(branches, args, at_pos)
if #args >= 2 then
table.insert(branches, { pos = at_pos, target = args[2], tag = args[1] })
end
end
-- MARKER_TO_HANDLER — data-driven marker dispatch (the plex pattern).
-- Maps the marker ident to its recorder function. Each handler takes (out_table, args, at_pos).
-- Adding a new marker type = 1 row + 1 recorder function.
local MARKER_TO_HANDLER = {
[LABEL_MARKER] = record_label_marker,
[OFFSET_MARKER] = record_offset_marker,
} }
--- Scan a single token for atom_label/atom_offset markers, walking through balanced groups transparently (so nested calls are found). --- Project canonical marker records into the two lookup tables used by the offset renderer.
--- @param token string --- No source text, body text, or body token is inspected.
--- @param at_pos integer -- the branch-free word position of this token in the body --- @param markers table[] -- atom.paths.markers
--- @param labels table<string, integer> --- @return table<string, integer>, table[]
--- @param branches table[] local function project_markers(markers)
local function scan_for_atom_markers(token, at_pos, labels, branches) local state = { labels = {}, branches = {} }
local pos = 1 for _, marker in ipairs(markers or {}) do
local tok_len = #token local project = MARKER_PROJECTORS[marker.kind]
while pos <= tok_len do if project then project(state, marker) end
pos = duffle.skip_ws_and_cmt(token, pos)
if pos > tok_len then break end
local ch = token:sub(pos, pos)
if duffle.is_alpha(ch) then
local ident, after = duffle.read_ident(token, pos)
local handler = MARKER_TO_HANDLER[ident]
if handler then
local args, after_paren = extract_ident_args(token, after)
-- Marker found — dispatch to its recorder. markers share labels and branches as
-- out-tables; the recorder picks which one(s) to write to based on its semantics.
-- (record_label_marker writes to labels; record_offset_marker writes to branches.)
handler(ident == LABEL_MARKER and labels or branches, args, at_pos)
pos = after_paren or after
else
pos = after
end
else
local nx = duffle.skip_str_or_cmt(token, pos)
pos = (nx > pos) and nx or (pos + 1)
end
end end
end return state.labels, state.branches
--- Scan an atom body for labels + branches, count total words.
--- Returns (labels, branches, total_words).
--- @param body string
--- @param word_counts table
--- @return table<string, integer>, table[], integer
-- scan_atom_body: walk pre-tokenized body for atom_label/atom_offset markers + word counts.
-- Uses `atom.body_tokens` from the SourceScan payload (pre-tokenized by scan-source pass).
-- @param body_tokens table[] -- {{tok=string, rel=integer}, ...} from duffle.tokenize_body
-- @param word_counts table
-- @return table, table, integer -- labels, branches, total_words
local function scan_atom_body(body_tokens, word_counts)
local pos = 0
local labels = {}
local branches = {}
for _, t in ipairs(body_tokens) do
local tok = t.tok
if duffle.is_marker_token(tok) then
-- Marker call: record at the current pos, do NOT advance pos.
scan_for_atom_markers(tok, pos, labels, branches)
pos = pos + duffle.count_marker_rest(tok, word_counts, count_token_words)
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 end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Offset computation + header generation -- Offset computation + header generation
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Compute branch offsets as `target_word - branch_word - 1` (the standard MIPS branch-immediate encoding). --- Compute branch offsets as `target_word - branch_word - 1` (the standard MIPS branch-immediate encoding).
-- @param labels table<string, integer> --- @param labels table<string, integer>
-- @param branches table[] --- @param branches table[]
-- @return BranchOffset[] --- @return BranchOffset[]
local function compute_offsets(labels, branches) local function compute_offsets(labels, branches)
local results = {} local results = {}
for _, br in ipairs(branches) do for _, br in ipairs(branches) do
local target = labels[br.target] local target = labels[br.target]
if not target then if not target then
error("Branch target '" .. br.target .. "' has no atom_label (at word " .. br.pos .. ")") error("Branch target '" .. br.target .. "' has no atom_label (at word " .. br.branch_word .. ")")
end end
results[#results + 1] = { target = br.target, tag = br.tag, offset = target - br.pos - 1 } results[#results + 1] = {
target = br.target,
tag = br.tag,
branch_word = br.branch_word,
offset = target - br.branch_word - 1,
}
end end
return results return results
end end
-- Right-pad `s` with spaces to width `w`. If `s` is already `w` or wider, no padding is added. --- Right-pad `s` with spaces to width `w`. If `s` is already `w` or wider, no padding is added.
-- @param s string --- @param s string
-- @param w integer --- @param w integer
-- @return string --- @return string
local function pad_right(s, w) local function pad_right(s, w)
return s .. string.rep(" ", math.max(0, w - #s)) return s .. string.rep(" ", math.max(0, w - #s))
end end
-- (internal) Build a constant-table entry `{macro_name, enum_name, value}` from a BranchOffset. --- (internal) Build a constant-table entry `{macro_name, enum_name, value}` from a BranchOffset.
-- @param r BranchOffset --- @param bo BranchOffset
-- @return table --- @return table
local function make_offset_const(r) local function make_offset_const(bo)
return { return {
macro_name = OFFSET_MACRO_PREFIX .. r.tag .. "_" .. r.target, macro_name = OFFSET_MACRO_PREFIX .. bo.tag .. "_" .. bo.target,
enum_name = OFFSET_ENUM_PREFIX .. r.tag .. "_" .. r.target, enum_name = OFFSET_ENUM_PREFIX .. bo.tag .. "_" .. bo.target,
value = r.offset, value = bo.offset,
} }
end end
-- (internal) Emit one atom's offset constants + enum into the lines buffer. --- (internal) Emit one atom's offset constants + enum into the lines buffer.
-- @param add fun(s: string) --- @param add fun(s: string)
-- @param atom AtomData --- @param atom AtomData
local function emit_atom_offsets(add, atom) local function emit_atom_offsets(add, atom)
if #atom.offsets == 0 then return end if #atom.offsets == 0 then return end
add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---") add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---")
@@ -263,10 +167,10 @@ local function emit_atom_offsets(add, atom)
add("") add("")
end end
-- Generate the per-source .offsets.h header. --- Generate the per-source .offsets.h header.
-- @param source_path string --- @param source_path string
-- @param atoms_data AtomData[] --- @param atoms_data AtomData[]
-- @return string --- @return string
local function generate_header(source_path, atoms_data) local function generate_header(source_path, atoms_data)
local basename = duffle.basename_no_ext(source_path) local basename = duffle.basename_no_ext(source_path)
@@ -288,59 +192,41 @@ local function generate_header(source_path, atoms_data)
return table.concat(lines, "\n") .. "\n" return table.concat(lines, "\n") .. "\n"
end end
-- ════════════════════════════════════════════════════════════════════════════
-- M — module exports
-- ════════════════════════════════════════════════════════════════════════════
local M = {} local M = {}
-- Project the pre-scanned SourceScan entries into the {name, body, body_tokens} shape this pass needs. --- (internal) Process one source: render offsets from canonical atom paths.
-- MipsAtom_ entries have kind="atom"; MipsCode code_<name> entries have kind="raw_atom". --- Returns the offsets_h path if a header was written, or nil.
-- `body_tokens` is set by scan-source on every `scan.atoms[i]` / `scan.raw_atoms[i]`; we carry it forward --- @param ctx PassCtx
-- so `scan_atom_body` reads from the precomputed table directly (no per-atom tokenize_body fallback). --- @param src SourceFile
-- @param scan table -- SourceScan from duffle.scan_source --- @return string|nil -- the offsets_h path
-- @return table[] -- list of {name=, body=, body_tokens=}
local function project_atoms(scan)
local out = {}
for _, a in ipairs(scan.atoms) do
out[#out + 1] = { name = a.raw_name, body = a.body, body_tokens = a.body_tokens }
end
for _, a in ipairs(scan.raw_atoms) do
out[#out + 1] = { name = a.name, body = a.body, body_tokens = a.body_tokens }
end
return out
end
-- (internal) Process one source: project atoms from scan, scan bodies, write header.
-- Returns the offsets_h path if a header was written, or nil.
-- @param ctx PassCtx
-- @param src SourceFile
-- @return string|nil -- the offsets_h path
local function process_source(ctx, src) local function process_source(ctx, src)
local atoms = project_atoms(src.scan)
if #atoms == 0 then return nil end
local atoms_data = {} local atoms_data = {}
for _, atom in ipairs(atoms) do local scan = src.scan or {}
local labels, branches, total = scan_atom_body(atom.body_tokens, ctx.shared.word_counts)
local function append_atom(atom)
local paths = atom and atom.paths
if not paths then return end
local labels, branches = project_markers(paths.markers)
atoms_data[#atoms_data + 1] = { atoms_data[#atoms_data + 1] = {
name = atom.name, name = atom.raw_name or atom.name,
total_words = total, total_words = #(paths.word_events or {}),
offsets = compute_offsets(labels, branches), offsets = compute_offsets(labels, branches),
} }
end end
for _, atom in ipairs(scan.atoms or {}) do append_atom(atom) end
for _, atom in ipairs(scan.raw_atoms or {}) do append_atom(atom) end
if #atoms_data == 0 then return nil end
local out_path = src.dir .. "/gen/" .. duffle.basename_no_ext(src.dir) .. ".offsets.h" local out_path = src.dir .. "/gen/" .. duffle.basename_no_ext(src.dir) .. ".offsets.h"
if not ctx.dry_run then duffle.ensure_dir(duffle.dirname(out_path))
duffle.ensure_dir(duffle.dirname(out_path)) duffle.write_file(out_path, generate_header(src.path:gsub("/", "\\"), atoms_data))
duffle.write_file(out_path, generate_header(src.path, atoms_data))
end
return out_path return out_path
end end
--- Run the offsets pass. --- Run the offsets pass.
--- For each source, emits a per-module `<dir_basename>.offsets.h` containing `#define _atom_offset_F_T = N` constants --- For each canonical source, emits a per-module `<dir_basename>.offsets.h`
--- for every `atom_offset(F, T)` reference in the source's atoms. --- containing constants for every marker recorded in atom.paths.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
@@ -348,7 +234,12 @@ function M.run(ctx)
local errors = {} local errors = {}
local warnings = {} local warnings = {}
for _, src in ipairs(ctx.sources) do local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then
error("offsets.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
end
for _, src in ipairs(corpus.source_order) do
local out_path = process_source(ctx, src) local out_path = process_source(ctx, src)
if out_path then if out_path then
outputs[#outputs + 1] = { offsets_h = out_path } outputs[#outputs + 1] = { offsets_h = out_path }
+53 -47
View File
@@ -5,8 +5,10 @@
--- - `build/gen/<dir_basename>.annotations.txt` — one per source-directory containing atoms; aggregates across all sources in the directory. --- - `build/gen/<dir_basename>.annotations.txt` — one per source-directory containing atoms; aggregates across all sources in the directory.
--- - `build/gen/annotation_validation.txt` — the project summary. --- - `build/gen/annotation_validation.txt` — the project summary.
--- ---
--- The annotation pass stashes per-MODULE summary entries in `ctx.flags._annot_results` (set by `passes/annotation.lua`). --- The annotation pass emits `errors.h` files per module and the canonical
--- This pass re-validates each source via `annotation.validate()` to get the detailed per-source results needed for the report. --- `corpus.sources_by_dir` projection groups sources by directory. This pass
--- iterates the canonical dir projection directly and re-validates each source
--- via `annotation.validate()` to get the detailed per-source results.
--- ---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. --- Lua 5.3 compatible.
@@ -25,6 +27,13 @@
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- Load the annotation pass so we can re-validate each source against the
-- canonical corpus projection. The annotation pass exposes `M.validate`,
-- which returns the per-source AnnotationResult (atoms / annots / macros /
-- binds / errors / warnings) that the report pass renders into the
-- per-module `<dir_basename>.annotations.txt` output.
local annotation = dofile(_bootstrap_dir .. "annotation.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Constants -- Constants
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -67,8 +76,6 @@ local PASS_NAME = "report"
--- @field project_root string -- project root (e.g. "code/") --- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs --- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags + per-pass stash --- @field flags table -- CLI flags + per-pass stash
--- @field flags._annot_results ModuleEntry[] -- stashed by annotation pass
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info --- @field verbose boolean -- if true, log diagnostic info
--- @class PassResult --- @class PassResult
@@ -138,31 +145,31 @@ local PASS_NAME = "report"
-- Per-MODULE annotation report (aggregated across all sources in a dir) -- Per-MODULE annotation report (aggregated across all sources in a dir)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Extract the basename (last path segment) of a forward- or back-slash separated path. Returns the input unchanged if no separator is found. --- Extract the basename (last path segment) of a forward- or back-slash separated path. Returns the input unchanged if no separator is found.
-- @param path string --- @param path string
-- @return string --- @return string
local function source_basename(path) local function source_basename(path)
return path:match(BASENAME_PATTERN) or path return path:match(BASENAME_PATTERN) or path
end end
-- (internal) Format a single annotation entry as one rendered line. --- (internal) Format a single annotation entry as one rendered line.
-- @param a AnnotEntry --- @param a AnnotEntry
-- @param src_name string --- @param src_name string
-- @return string --- @return string
local function format_annot_line(a, src_name) local function format_annot_line(a, src_name)
if a.error then if a.error then
return string.format(" ✗ line %d %s [ERROR: %s] [%s]", a.line, a.macro or "?", a.error, src_name) return string.format(" ✗ line %d %s [ERROR: %s] [%s]", a.line, a.macro or "?", a.error, src_name)
end end
local line = string.format(" ● line %d %s [%s]", a.line, a.name, src_name) local line = string.format(" ● line %d %s [%s]", a.line, a.name, src_name)
if a.binds then line = line .. " binds=" .. a.binds end 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.reads > 0 then line = line .. " reads={" .. table.concat(a.reads, ",") .. "}" end
if #a.writes > 0 then line = line .. " writes={" .. table.concat(a.writes, ",") .. "}" end if #a.writes > 0 then line = line .. " writes={" .. table.concat(a.writes, ",") .. "}" end
return line return line
end end
-- (internal) Tally totals across all results in a module. --- (internal) Tally totals across all results in a module.
-- @param results AnnotationResult[] --- @param results AnnotationResult[]
-- @return integer, integer, integer, integer, integer, integer --- @return integer, integer, integer, integer, integer, integer
local function tally_module_totals(results) local function tally_module_totals(results)
local total_atoms, total_annots, total_binds, total_macros = 0, 0, 0, 0 local total_atoms, total_annots, total_binds, total_macros = 0, 0, 0, 0
local total_errors, total_warnings = 0, 0 local total_errors, total_warnings = 0, 0
@@ -315,9 +322,7 @@ local function render_module_report(dir, sources, results)
-- Each renderer writes its header + content via the `add` closure (pre-bound above). -- Each renderer writes its header + content via the `add` closure (pre-bound above).
-- Adding a new section = 1 row here + 1 render_<thing>_section function. -- Adding a new section = 1 row here + 1 render_<thing>_section function.
for _, section in ipairs(SECTION_RENDERERS) do for _, section in ipairs(SECTION_RENDERERS) do
add(section.header)
section.render(add, results, totals) section.render(add, results, totals)
add("")
end end
return table.concat(lines, "\n") .. "\n" return table.concat(lines, "\n") .. "\n"
@@ -377,21 +382,22 @@ end
-- Orchestration helpers -- Orchestration helpers
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- (internal) Pull per-source validate() results from the annotation pass's stash. --- (internal) Re-validate every source in a directory against the canonical
-- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`; --- corpus projection. Calls `annotation.validate()` per source to produce the
-- we read from there instead of re-validating each source. --- per-source AnnotationResult (atoms / annots / macros / binds / errors /
-- Returns the list of module results + the flat list of all results (for the project-wide summary). --- warnings) that the report renderer consumes. This is the canonical path —
-- @param ctx PassCtx --- no private stash; each report pass run is reproducible from the corpus.
-- @param dir_sources SourceFile[] --- Returns the list of module results + the flat list of all results (for the project-wide summary).
-- @return AnnotationResult[], AnnotationResult[] --- @param ctx PassCtx
--- @param dir_sources SourceFile[]
--- @return AnnotationResult[], AnnotationResult[]
local function lookup_module_results(ctx, dir_sources) local function lookup_module_results(ctx, dir_sources)
local src_cache = (ctx.flags and ctx.flags._annot_source_results) or {}
local module_results = {} local module_results = {}
local all_results = {} local all_results = {}
for _, src in ipairs(dir_sources) do for _, src in ipairs(dir_sources) do
local result = src_cache[src.path] if src.scan then
if result then local result = annotation.validate(ctx, src, nil)
result.source = src.path -- defensive (annotation tags it too; this guards against cache misses from earlier iterations) result.source = src.path -- tag for downstream rendering
module_results[#module_results + 1] = result module_results[#module_results + 1] = result
all_results[#all_results + 1] = result all_results[#all_results + 1] = result
end end
@@ -399,9 +405,9 @@ local function lookup_module_results(ctx, dir_sources)
return module_results, all_results return module_results, all_results
end end
-- (internal) Does this module's results contain anything worth emitting? --- (internal) Does this module's results contain anything worth emitting?
-- @param module_results AnnotationResult[] --- @param module_results AnnotationResult[]
-- @return boolean --- @return boolean
local function module_has_content(module_results) local function module_has_content(module_results)
for _, r in ipairs(module_results) do for _, r in ipairs(module_results) do
if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0 if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0
@@ -412,8 +418,8 @@ local function module_has_content(module_results)
return false return false
end end
-- (internal) Log a debug message if `_G[DEBUG_FLAG]` is truthy. --- (internal) Log a debug message if `_G[DEBUG_FLAG]` is truthy.
-- @param fmt string --- @param fmt string
local function debug_log(fmt, ...) local function debug_log(fmt, ...)
if _G[DEBUG_FLAG] then if _G[DEBUG_FLAG] then
io.stderr:write(string.format("[%s] " .. fmt, PASS_NAME, ...)) io.stderr:write(string.format("[%s] " .. fmt, PASS_NAME, ...))
@@ -435,27 +441,27 @@ function M.run(ctx)
local errors = {} local errors = {}
local warnings = {} local warnings = {}
local module_entries = (ctx.flags and ctx.flags._annot_results) or {} -- Module grouping comes from `corpus.sources_by_dir` (the canonical projection).
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources) -- Iterate it directly; no private cache, no per-pass stash.
local corpus = ctx.shared and ctx.shared.corpus
local by_dir = (corpus and corpus.sources_by_dir) or {}
if not ctx.dry_run then duffle.ensure_dir(ctx.out_root) end duffle.ensure_dir(ctx.out_root)
local all_results_for_summary = {} local all_results_for_summary = {}
for _, entry in ipairs(module_entries) do for dir, dir_sources in pairs(by_dir) do
debug_log("entry: dir=%s basename=%s atoms_count=%d dir_sources=%d\n", entry.dir, entry.dir_basename, entry.atoms_count, #(by_dir[entry.dir] or {})) local dir_basename = dir:match("([^/\\]+)$") or dir
debug_log("dir=%s basename=%s sources=%d\n", dir, dir_basename, #dir_sources)
if entry.atoms_count > 0 or #(by_dir[entry.dir] or {}) > 0 then if #dir_sources > 0 then
local dir_sources = by_dir[entry.dir] or {}
local module_results, all_results = lookup_module_results(ctx, dir_sources) local module_results, all_results = lookup_module_results(ctx, dir_sources)
for _, r in ipairs(all_results) do for _, r in ipairs(all_results) do
all_results_for_summary[#all_results_for_summary + 1] = r all_results_for_summary[#all_results_for_summary + 1] = r
end end
if module_has_content(module_results) then if module_has_content(module_results) then
local out_path = ctx.out_root .. "/" .. entry.dir_basename .. ".annotations.txt" local out_path = ctx.out_root .. "/" .. dir_basename .. ".annotations.txt"
if not ctx.dry_run then duffle.write_file(out_path, render_module_report(dir, dir_sources, module_results))
duffle.write_file(out_path, render_module_report(entry.dir, dir_sources, module_results))
end
outputs[#outputs + 1] = { annotations_txt = out_path } outputs[#outputs + 1] = { annotations_txt = out_path }
else else
debug_log(" -> no content; skipping\n") debug_log(" -> no content; skipping\n")
@@ -463,7 +469,7 @@ function M.run(ctx)
end end
end end
if not ctx.dry_run and #all_results_for_summary > 0 then if #all_results_for_summary > 0 then
local summary_path = ctx.out_root .. "/annotation_validation.txt" local summary_path = ctx.out_root .. "/annotation_validation.txt"
duffle.write_file(summary_path, render_project_report(all_results_for_summary)) duffle.write_file(summary_path, render_project_report(all_results_for_summary))
outputs[#outputs + 1] = { summary_txt = summary_path } outputs[#outputs + 1] = { summary_txt = summary_path }
+465 -150
View File
@@ -25,6 +25,13 @@
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- Forward declarations for helpers used by earlier parsers (parse_enum_body_fields needs parse_enum_int_literal;
-- parse_typedef_binds needs duffle.find_byte).
-- Lua local scoping rules require explicit forward declarations because locals are visible only AFTER their declaration site.
-- The actual assignments happen later in this file;
-- the closures captured by the early parsers resolve the upvalue at call time (Lua 5.3 / LuaJIT upvalue semantics).
local parse_enum_int_literal
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Type declarations -- Type declarations
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -110,7 +117,6 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @field project_root string --- @field project_root string
--- @field upstream table<string, table> --- @field upstream table<string, table>
--- @field flags table --- @field flags table
--- @field dry_run boolean
--- @field verbose boolean --- @field verbose boolean
--- @class PassResult --- @class PassResult
@@ -286,13 +292,13 @@ local POINTER_BYTE_SIZE = 4
-- Maximum chain depth when resolving typedef / TSet_ chains (cycle guard). -- Maximum chain depth when resolving typedef / TSet_ chains (cycle guard).
local TYPE_CHAIN_MAX_DEPTH = 8 local TYPE_CHAIN_MAX_DEPTH = 8
-- Walk a `Struct_` / `Enum_` body, calling `build_field(first, first_end, after_first)` for each entry. --- Walk a `Struct_` / `Enum_` body, calling `build_field(first, first_end, after_first)` for each entry.
-- The builder returns either: --- The builder returns either:
-- - (record, new_pos) -- append record to fields; advance body_pos to new_pos --- - (record, new_pos) -- append record to fields; advance body_pos to new_pos
-- - (nil, new_pos) -- skip this entry; advance body_pos to new_pos --- - (nil, new_pos) -- skip this entry; advance body_pos to new_pos
-- After each entry, the walker skips a single trailing `,` or `;`. --- After each entry, the walker skips a single trailing `,` or `;`.
-- The 2 body-field parsers in this file (struct + enum) share this body-walk loop. --- The 2 body-field parsers in this file (struct + enum) share this body-walk loop.
-- @param body string --- @param body string
--- @param build_field fun(first: string, first_end: integer, after_first: integer): (table|nil, integer) --- @param build_field fun(first: string, first_end: integer, after_first: integer): (table|nil, integer)
--- @return table[] --- @return table[]
local function walk_body_fields(body, build_field) local function walk_body_fields(body, build_field)
@@ -328,7 +334,7 @@ local function parse_struct_body_fields(body)
-- Parse the trailing `*` chain to derive pointer_depth. -- Parse the trailing `*` chain to derive pointer_depth.
local depth, cursor = 0, after_type local depth, cursor = 0, after_type
while cursor <= #body and body:sub(cursor, cursor) == "*" do while cursor <= #body and body:sub(cursor, cursor) == "*" do
depth = depth + 1 depth = depth + 1
cursor = cursor + 1 cursor = cursor + 1
cursor = duffle.skip_ws_and_cmt(body, cursor) cursor = duffle.skip_ws_and_cmt(body, cursor)
end end
@@ -385,14 +391,12 @@ local function resolve_typedef_byte_size(type_name, type_name_registry, visited,
local entry = type_name_registry[type_name] local entry = type_name_registry[type_name]
if not entry then return nil end if not entry then return nil end
-- Confident: this entry was already resolved by the propagation pass -- Confident: this entry was already resolved by the propagation pass (e.g., a builtin or a struct whose fields are all resolved).
-- (e.g., a builtin or a struct whose fields are all resolved).
if entry.byte_size ~= nil then return entry.byte_size end if entry.byte_size ~= nil then return entry.byte_size end
-- Chain-following: typedef / TSet_ aliases follow underlying_type. -- Chain-following: typedef / TSet_ aliases follow underlying_type.
if entry.underlying_type then if entry.underlying_type then
return resolve_typedef_byte_size( return resolve_typedef_byte_size(entry.underlying_type, type_name_registry, visited, depth + 1)
entry.underlying_type, type_name_registry, visited, depth + 1)
end end
-- Struct_ entries with unresolved byte_size can still resolve when their fields are all resolved. -- Struct_ entries with unresolved byte_size can still resolve when their fields are all resolved.
@@ -414,7 +418,7 @@ end
-- struct byte_size derives from confident fields; void is invalid and skipped; pointers collapse to 4 bytes at parse time. -- struct byte_size derives from confident fields; void is invalid and skipped; pointers collapse to 4 bytes at parse time.
-- Mutates `out.type_name_registry[name].byte_size` AND each struct's fields' `offset` + `byte_size` in place. -- Mutates `out.type_name_registry[name].byte_size` AND each struct's fields' `offset` + `byte_size` in place.
local function propagate_type_sizes(out) local function propagate_type_sizes(out)
local reg = out.type_name_registry local reg = out.type_name_registry
if not reg then return end if not reg then return end
-- Seed builtin primitives (U1/U2/U4/S1/S2/S4 + __UINT*_TYPE__ family). -- Seed builtin primitives (U1/U2/U4/S1/S2/S4 + __UINT*_TYPE__ family).
@@ -527,25 +531,22 @@ end
-- - a plain register ident: `R_FaceCursor` → reg_name only -- - a plain register ident: `R_FaceCursor` → reg_name only
-- - register + atom_type sub-call: `R_FaceCursor atom_type(V4_S2*)` → reg_name + override entry -- - register + atom_type sub-call: `R_FaceCursor atom_type(V4_S2*)` → reg_name + override entry
-- Returns (reg_name, override_entry_or_nil, malformed_flag). -- Returns (reg_name, override_entry_or_nil, malformed_flag).
-- On malformed `atom_type(...)` (missing close paren, trailing tokens after the close paren, empty type chain, trailing junk inside the -- On malformed `atom_type(...)` (missing close paren, trailing tokens after the close paren, empty type chain, trailing junk inside the parens like `V4_S2*()`),
-- parens like `V4_S2*()`), the function still returns the leading reg_name but sets `malformed_flag = true` and `override_entry = nil` -- the function still returns the leading reg_name but sets `malformed_flag = true` and `override_entry = nil`
-- so the caller can silently drop the override while keeping the register in the reads/writes list -- so the caller can silently drop the override while keeping the register in the reads/writes list.
-- (per the parser-safety contract — "malformed atom_type MUST NOT create any override").
local function parse_atom_info_reg_entry(entry) local function parse_atom_info_reg_entry(entry)
local pos = 1 local pos = 1
pos = duffle.skip_ws_and_cmt(entry, pos) pos = duffle.skip_ws_and_cmt(entry, pos)
if pos > #entry then return nil, nil, false end if pos > #entry then return nil, nil, false end
local reg_name, reg_end = duffle.read_ident(entry, pos) local reg_name, reg_end = duffle.read_ident(entry, pos)
if not reg_name then return nil, nil, false end if not reg_name then return nil, nil, false end
pos = duffle.skip_ws_and_cmt(entry, reg_end) pos = duffle.skip_ws_and_cmt(entry, reg_end)
-- Plain register, no adjacent atom_type — done. -- Plain register, no adjacent atom_type — done.
if pos > #entry then return reg_name, nil, false end if pos > #entry then return reg_name, nil, false end
-- Adjacent ident must be a bare `atom_type` (word-bounded both sides). -- Adjacent ident must be a bare `atom_type` (word-bounded both sides).
local next_ident, next_end = duffle.read_ident(entry, pos) local next_ident, next_end = duffle.read_ident(entry, pos)
if not next_ident or next_ident ~= "atom_type" then if not next_ident or next_ident ~= "atom_type" then return reg_name, nil, false end
return reg_name, nil, false
end
-- Left word-boundary: `_atom_type` should NOT match `atom_type`. -- Left word-boundary: `_atom_type` should NOT match `atom_type`.
if pos > 1 then if pos > 1 then
local prev = entry:byte(pos - 1) local prev = entry:byte(pos - 1)
@@ -559,27 +560,21 @@ local function parse_atom_info_reg_entry(entry)
-- Expect `(` immediately after `atom_type` (whitespace tolerated). -- Expect `(` immediately after `atom_type` (whitespace tolerated).
pos = duffle.skip_ws_and_cmt(entry, next_end) pos = duffle.skip_ws_and_cmt(entry, next_end)
if pos > #entry or entry:sub(pos, pos) ~= "(" then if pos > #entry or entry:sub(pos, pos) ~= "(" then return reg_name, nil, true end
return reg_name, nil, true
end
local sub_inner, sub_after = duffle.read_parens(entry, pos) local sub_inner, sub_after = duffle.read_parens(entry, pos)
-- Reject any trailing tokens (including `;` / `,`) after the close paren. -- Reject any trailing tokens (including `;` / `,`) after the close paren.
-- The outer caller already split on top-level commas, so the only legal terminator here is end-of-entry. -- The outer caller already split on top-level commas, so the only legal terminator here is end-of-entry.
local after_close = duffle.skip_ws_and_cmt(entry, sub_after) local after_close = duffle.skip_ws_and_cmt(entry, sub_after)
if after_close <= #entry then if after_close <= #entry then return reg_name, nil, true end
return reg_name, nil, true
end
-- Parse the type chain inside the parens; require full consumption. -- Parse the type chain inside the parens; require full consumption.
-- parse_type_chain returns (ident, depth, end_pos); -- parse_type_chain returns (ident, depth, end_pos);
-- we reject any non-whitespace residue past end_pos (catches `V4_S2*()` etc.). -- We reject any non-whitespace residue past end_pos (catches `V4_S2*()` etc.).
local type_name, depth, after_chain = parse_type_chain(sub_inner, 1) local type_name, depth, after_chain = parse_type_chain(sub_inner, 1)
if not type_name then return reg_name, nil, true end if not type_name then return reg_name, nil, true end
local end_check = duffle.skip_ws_and_cmt(sub_inner, after_chain) local end_check = duffle.skip_ws_and_cmt(sub_inner, after_chain)
if end_check <= #sub_inner then if end_check <= #sub_inner then return reg_name, nil, true end
return reg_name, nil, true
end
return reg_name, { type_name = type_name, pointer_depth = depth }, false return reg_name, { type_name = type_name, pointer_depth = depth }, false
end end
@@ -601,8 +596,8 @@ local function scan_atom_info_subcalls(info_inner, info_line)
-- Per-subcall handler table. Each handler takes (sub_inner, info_line) and mutates the outer locals above. -- Per-subcall handler table. Each handler takes (sub_inner, info_line) and mutates the outer locals above.
-- atom_reads/atom_writes share a handler (same shape; just different output target). -- atom_reads/atom_writes share a handler (same shape; just different output target).
local function rw_handler(sub_inner, info_line, kind) local function rw_handler(sub_inner, info_line, kind)
-- The reads/writes arrays contain ONLY register idents; -- reads/writes arrays contain ONLY register idents;
-- the `atom_type(...)` sub-entry (when present and well-formed) is recorded as a per-atom reg_type_override. -- `atom_type(...)` sub-entry (when present and well-formed) is recorded as a per-atom reg_type_override.
local entries = duffle.split_top_level_commas(sub_inner) local entries = duffle.split_top_level_commas(sub_inner)
local regs = {} local regs = {}
for _, entry in ipairs(entries) do for _, entry in ipairs(entries) do
@@ -625,7 +620,7 @@ local function scan_atom_info_subcalls(info_inner, info_line)
end end
local function ident_handler(sub_inner, info_line, out_name) local function ident_handler(sub_inner, info_line, out_name)
-- Validates the arg is a single C identifier (no commas / parens / whitespace). -- Validates the arg is a single C identifier (no commas / parens / whitespace).
-- Silently reject malformed args; the DWARF chain treats the field as un-set. -- Silently reject malformed args; DWARF chain treats the field as un-set.
local name = duffle.read_ident(sub_inner, 1) local name = duffle.read_ident(sub_inner, 1)
if name then if name then
local after_id = duffle.skip_ws_and_cmt(sub_inner, 1 + #name) local after_id = duffle.skip_ws_and_cmt(sub_inner, 1 + #name)
@@ -706,7 +701,7 @@ local function scan_skip_qualifiers(source, pos)
end end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Track A helpers: enum / atom_reg / R_*_Code parsing -- enum / atom_reg / R_*_Code parsing
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- --
-- The parser walks `enum { <body> }` declarations and emits one AliasEntry per R_* entry whose value is followed by a bare `atom_reg` token. -- The parser walks `enum { <body> }` declarations and emits one AliasEntry per R_* entry whose value is followed by a bare `atom_reg` token.
@@ -718,6 +713,7 @@ local BYTE_HASH = 0x23 -- '#'
local BYTE_NEWLINE = 0x0A -- '\n' local BYTE_NEWLINE = 0x0A -- '\n'
local BYTE_DASH = 0x2D -- '-' local BYTE_DASH = 0x2D -- '-'
local BYTE_COMMA = 0x2C -- ',' local BYTE_COMMA = 0x2C -- ','
local BYTE_SEMI = 0x3B -- ';'
local BYTE_EQUAL = 0x3D -- '=' local BYTE_EQUAL = 0x3D -- '='
local BYTE_R = 0x52 -- 'R' local BYTE_R = 0x52 -- 'R'
local BYTE_UNDERSCORE = 0x5F -- '_' local BYTE_UNDERSCORE = 0x5F -- '_'
@@ -754,13 +750,16 @@ local function hex_digit_value(b)
return nil return nil
end end
-- Parse a decimal/negative-decimal/hex integer literal starting at byte position `start`. --- Parse a decimal/negative-decimal/hex integer literal starting at byte position `start`.
-- Returns (value, end_pos) on success, or (nil, start) on failure / no match. --- Returns (value, end_pos) on success, or (nil, start) on failure / no match.
-- Accepts: 12, -1, 0, 0x10, 0X1F, -0x10. --- Accepts: 12, -1, 0, 0x10, 0X1F, -0x10.
-- @param text string --- @param text string
-- @param start integer --- @param start integer
-- @return integer|nil, integer --- @return integer|nil, integer
local function parse_enum_int_literal(text, start) --- Implementation note: this is a plain assignment (not `local function`)
--- so the forward declaration at the top of the file is the same upvalue the earlier `parse_enum_body_fields` closure captures.
--- Lua 5.3 / LuaJIT upvalue semantics resolve the assignment at call time.
parse_enum_int_literal = function(text, start)
local pos = start local pos = start
local len = #text local len = #text
if pos > len then return nil, start end if pos > len then return nil, start end
@@ -804,7 +803,7 @@ local function parse_enum_int_literal(text, start)
end end
-- Returns true iff `name` matches the `R_*_Code` ident pattern (R, _, <anything>, _Code). -- Returns true iff `name` matches the `R_*_Code` ident pattern (R, _, <anything>, _Code).
-- Uses byte checks for the fixed bytes per the Track A spec. -- Uses byte checks for the fixed bytes.
local function is_r_code_macro(name) local function is_r_code_macro(name)
if not name or #name < 6 then return false end if not name or #name < 6 then return false end
if name:byte(1) ~= BYTE_R then return false end if name:byte(1) ~= BYTE_R then return false end
@@ -847,10 +846,10 @@ local function resolve_code_macro_value(source, pos, code_macros, code_macro_bod
-- Try integer literal first (decimal / negative / hex). -- Try integer literal first (decimal / negative / hex).
local int_val, int_end = parse_enum_int_literal(source, pos) local int_val, int_end = parse_enum_int_literal(source, pos)
if int_val ~= nil then return int_val end if int_val ~= nil then return int_val end
-- Try symbol reference (must be R_*_Code per the spec). -- Try symbol reference (must be R_*_Code per the spec).
local sym = duffle.read_ident(source, pos) local sym = duffle.read_ident(source, pos)
if not sym then return nil end if not sym then return nil end
if not is_r_code_macro(sym) then return nil end if not is_r_code_macro(sym) then return nil end
@@ -864,7 +863,7 @@ local function resolve_code_macro_value(source, pos, code_macros, code_macro_bod
-- Cross-source fallback: walk the body of the missing symbol if any other source collected its RHS during pass 1a. -- Cross-source fallback: walk the body of the missing symbol if any other source collected its RHS during pass 1a.
-- The body is parsed as a fresh RHS (it's the raw post-`=` text of the original `#define` line), so the chain continues transparently. -- The body is parsed as a fresh RHS (it's the raw post-`=` text of the original `#define` line), so the chain continues transparently.
local body = code_macro_bodies and code_macro_bodies[sym] local body = code_macro_bodies and code_macro_bodies[sym]
if body then if body then
return resolve_code_macro_value(body, 1, code_macros, code_macro_bodies, visited, depth + 1) return resolve_code_macro_value(body, 1, code_macros, code_macro_bodies, visited, depth + 1)
end end
@@ -874,14 +873,14 @@ local function resolve_code_macro_value(source, pos, code_macros, code_macro_bod
return nil return nil
end end
-- Intercept a `#define R_*_Code <RHS>` preprocessor line. --- Intercept a `#define R_*_Code <RHS>` preprocessor line.
-- Always saves the raw RHS text into `code_macro_bodies` (for cross-source fallback during chain resolution), --- Always saves the raw RHS text into `code_macro_bodies` (for cross-source fallback during chain resolution),
-- then (if resolvable) stores the resolved integer code into `code_macros` keyed by the macro name. --- then (if resolvable) stores the resolved integer code into `code_macros` keyed by the macro name.
-- `directive_start` points at the `#` byte. The function is silent on non-matching directives, the caller skips the line in any case. --- `directive_start` points at the `#` byte. The function is silent on non-matching directives, the caller skips the line in any case.
-- @param source string --- @param source string
-- @param directive_start integer -- byte position of `#` --- @param directive_start integer -- byte position of `#`
-- @param code_macros table -- out._code_macros / ctx.shared._code_macros --- @param code_macros table -- out._code_macros / ctx.shared._code_macros
-- @param code_macro_bodies table -- out._code_macro_bodies / ctx.shared._code_macro_bodies --- @param code_macro_bodies table -- out._code_macro_bodies / ctx.shared._code_macro_bodies
local function try_extract_code_macro(source, directive_start, code_macros, code_macro_bodies) local function try_extract_code_macro(source, directive_start, code_macros, code_macro_bodies)
local rest = duffle.skip_ws_and_cmt(source, directive_start + 1) local rest = duffle.skip_ws_and_cmt(source, directive_start + 1)
local kw, kw_end = duffle.read_ident(source, rest) local kw, kw_end = duffle.read_ident(source, rest)
@@ -907,13 +906,13 @@ local function try_extract_code_macro(source, directive_start, code_macros, code
if value ~= nil then code_macros[macro_name] = value end if value ~= nil then code_macros[macro_name] = value end
end end
-- Quick pre-pass: walk the source looking ONLY for `#define R_*_Code` lines. --- Quick pre-pass: walk the source looking ONLY for `#define R_*_Code` lines.
-- Populates `code_macros` with resolved integer codes AND `code_macro_bodies` with raw RHS text --- Populates `code_macros` with resolved integer codes AND `code_macro_bodies` with raw RHS text
-- (used by the chain walker as cross-source fallback during pass 1b in `M.run`); ignores everything else. --- (used by the chain walker as cross-source fallback during pass 1b in `M.run`); ignores everything else.
-- Used by `M.run` pass 1a to build the cross-source `_code_macros` + `_code_macro_bodies` registries before pass 1b resolves chains. --- Used by `M.run` pass 1a to build the cross-source `_code_macros` + `_code_macro_bodies` registries before pass 1b resolves chains.
-- @param source string --- @param source string
-- @param code_macros table --- @param code_macros table
-- @param code_macro_bodies table --- @param code_macro_bodies table
local function scan_source_pre_pass(source, code_macros, code_macro_bodies) local function scan_source_pre_pass(source, code_macros, code_macro_bodies)
local pos = 1 local pos = 1
local src_len = #source local src_len = #source
@@ -1012,7 +1011,7 @@ end
-- --
-- All parsers read source-as-written via the duffle primitives (skip_ws_and_cmt / read_parens / read_braces / read_balanced). -- All parsers read source-as-written via the duffle primitives (skip_ws_and_cmt / read_parens / read_braces / read_balanced).
-- No regex per the no_regex constraint; no hand-rolled depth tracking -- No regex per the no_regex constraint; no hand-rolled depth tracking
-- (the MipsAtomComp_Proc_ brace matcher now uses duffle.read_braces instead of bespoke byte-dispatch). -- The MipsAtomComp_Proc_ brace matcher uses `duffle.read_braces`.
-- --
-- Adding a new construct = 1 row in DECL_PARSERS + 1 parser function. The scan_source() loop never needs editing. -- Adding a new construct = 1 row in DECL_PARSERS + 1 parser function. The scan_source() loop never needs editing.
@@ -1186,7 +1185,7 @@ local function parse_mips_atom_comp_proc(source, pos, ident_end, line_of, out)
if not last_brace_pos then return after_paren end if not last_brace_pos then return after_paren end
-- Use duffle.read_braces to find the matching close brace. -- Use duffle.read_braces to find the matching close brace.
-- Replaces the pre-refactor hand-rolled depth tracker (~25 LOC of `if c == 123 then depth = depth + 1 ...`). -- Uses `read_balanced` for delimiter-depth tracking.
-- If close_pos is past the end of inner, the brace didn't match (malformed input); skip. -- If close_pos is past the end of inner, the brace didn't match (malformed input); skip.
local body, close_pos = duffle.read_braces(inner, last_brace_pos) local body, close_pos = duffle.read_braces(inner, last_brace_pos)
if close_pos > #inner + 1 then return after_paren end if close_pos > #inner + 1 then return after_paren end
@@ -1340,10 +1339,9 @@ local function parse_typedef_binds(source, pos, ident_end, line_of, out)
register_struct_type(body, name, pos, line_of, out) register_struct_type(body, name, pos, line_of, out)
associate_skip_over_marker(out, name, name, "unrelated", line_of(pos), pos) associate_skip_over_marker(out, name, name, "unrelated", line_of(pos), pos)
return after_brace return after_brace
end
-- ── Shape 2: `typedef Enum_(<underlying>, <name>) { <body> } <alias>;` -- ── Shape 2: `typedef Enum_(<underlying>, <name>) { <body> } <alias>;`
if id2 == "Enum_" then elseif id2 == "Enum_" then
local inner, after_paren, open_paren = read_parens_after(source, id2_end, id2_end) local inner, after_paren, open_paren = read_parens_after(source, id2_end, id2_end)
if not inner then return id2_end end if not inner then return id2_end end
-- Split `inner` on the first top-level comma into (<underlying>, <name>). -- Split `inner` on the first top-level comma into (<underlying>, <name>).
@@ -1359,35 +1357,104 @@ local function parse_typedef_binds(source, pos, ident_end, line_of, out)
return after_brace return after_brace
end end
-- ── Shapes 3 + 4: `typedef <type> <alias>;` or -- ── Shapes 3 + 4: `typedef <span> <alias>;` or
-- `typedef <type> TSet_(<name>);` -- `typedef <span> TSet_(<name>);`
-- Read the <type> ident we already have (id2) and the trailing alias. --
local after_id2 = duffle.skip_ws_and_cmt(source, id2_end) -- The <span> between `typedef` and the alias ident may be MULTI-token (e.g. `unsigned char`, `__UINT8_TYPE__`, `const U4`).
local id3, id3_end = duffle.read_ident(source, after_id2) -- The alias is the LAST identifier before `;` (Shape 3), OR the argument of `TSet_(...)` when that wrapper is present (Shape 4).
if not id3 then return ident_end end --
-- Two required exact outcomes:
-- typedef unsigned char UTF8; -> name=UTF8, underlying="unsigned char"
-- typedef __UINT8_TYPE__ TSet_(U1); -> name=U1, underlying="__UINT8_TYPE__"
--
-- Algorithm:
-- 1. Find the terminating `;` (BYTE_SEMI). If absent, abort cleanly.
-- 2. If id2 itself is `TSet_`, capture the parenthesized argument and use it as the alias (the preceding underlying span is empty).
-- 3. Otherwise walk idents forward from id2_end to semi_pos:
-- - If any ident is `TSet_`, capture its argument as the alias and mark the underlying span as everything from id2 to before TSet_.
-- - Otherwise remember the last ident (and its source position) as the alias; the underlying span is everything from id2 to before that ident.
-- 4. Trim the underlying span and call register_typedef_alias.
--
-- Struct_/Enum_ declarations carry their own dedicated parser paths above; this branch only handles non-Struct_/Enum_ typedefs.
-- Shape 4: `typedef <type> TSet_(<name>);` -- Find the terminating `;` (BYTE_SEMI). If absent, abort cleanly.
if id3 == "TSet_" then local semi_pos = duffle.find_byte(source, BYTE_SEMI, id2_end)
local tset_inner, tset_after = read_parens_after(source, id3_end, id3_end) if not semi_pos then return id2_end end
if not tset_inner then return id3_end end
local tset_name = duffle.trim(tset_inner) -- Shape 4 (TSet_ at id2 position): no preceding underlying span.
register_typedef_alias(id2, tset_name, pos, line_of, out) if id2 == "TSet_" then
local inner, after_paren = read_parens_after(source, id2_end, id2_end)
if not inner then return id2_end end
local tset_name = duffle.trim(inner)
-- Empty underlying span is acceptable; the TSet_ wrapper itself
-- encodes the alias identity (per the duffle TSet_ convention).
register_typedef_alias("", tset_name, pos, line_of, out)
associate_skip_over_marker(out, tset_name, tset_name, "unrelated", line_of(pos), pos) associate_skip_over_marker(out, tset_name, tset_name, "unrelated", line_of(pos), pos)
return tset_after return after_paren
end end
-- Shape 3: `typedef <type> <alias>;` -- Walk idents forward to find the alias ident (last ident before `;`), or the TSet_(<arg>) form (capture the arg, use it as the alias).
register_typedef_alias(id2, id3, pos, line_of, out) local last_ident = nil
associate_skip_over_marker(out, id3, id3, "unrelated", line_of(pos), pos) local last_ident_pos = nil
return id3_end local last_ident_end = nil
local tset_arg = nil
local tset_arg_end = nil
local tset_pos = nil
local scan = id2_end
while scan < semi_pos do
scan = duffle.skip_ws_and_cmt(source, scan)
if scan >= semi_pos then break end
local id, id_end = duffle.read_ident(source, scan)
if not id then
scan = scan + 1
elseif id == "TSet_" then
-- Shape 4 (TSet_ at non-id2 position): grab the parenthesized argument.
local inner, after_paren = read_parens_after(source, id_end, id_end)
if inner then
tset_arg = duffle.trim(inner)
tset_arg_end = after_paren
tset_pos = scan
scan = after_paren
else
scan = id_end
end
else
last_ident = id
last_ident_pos = scan
last_ident_end = id_end
scan = id_end
end
end
if tset_arg then
-- Shape 4: alias is the TSet_ argument; the underlying span is the trimmed text from the start of id2 up to (but not including) the TSet_ ident.
local underlying_span = source:sub(after_typedef, tset_pos - 1)
local underlying = duffle.trim(underlying_span)
register_typedef_alias(underlying, tset_arg, pos, line_of, out)
associate_skip_over_marker(out, tset_arg, tset_arg, "unrelated", line_of(pos), pos)
return tset_arg_end or (semi_pos + 1)
end
if last_ident then
-- Shape 3: alias is the last ident before `;`; the underlying span is the trimmed text from the start of id2 up to (but not including) the alias ident.
local underlying_span = source:sub(after_typedef, last_ident_pos - 1)
local underlying = duffle.trim(underlying_span)
register_typedef_alias(underlying, last_ident, pos, line_of, out)
associate_skip_over_marker(out, last_ident, last_ident, "unrelated", line_of(pos), pos)
return last_ident_end
end
-- Malformed: id2 with no following ident before `;`. Skip past the terminating semicolon and let the main loop continue.
return semi_pos + 1
end end
--- Parse: `_Pragma("mac_X tape_atom words=N")` (operator form). --- Parse: `_Pragma("mac_X tape_atom words=N")` (operator form).
--- @param source string --- @param source string
--- @param pos integer --- @param pos integer
--- @param ident_end integer --- @param ident_end integer
--- @param line_of fun(pos: integer): integer --- @param line_of fun(pos: integer): integer
--- @param out SourceScan --- @param out SourceScan
--- @return integer --- @return integer
local function parse_pragma_macro(source, pos, ident_end, line_of, out) local function parse_pragma_macro(source, pos, ident_end, line_of, out)
local str, str_end = read_parens_after(source, ident_end) local str, str_end = read_parens_after(source, ident_end)
@@ -1413,30 +1480,17 @@ local function parse_pragma_macro(source, pos, ident_end, line_of, out)
return str_end return str_end
end end
--- Parse: `pragma` ident (no-op — directive form `#pragma` is handled by `skip_preprocessor_line` upstream).
--- If we reach this parser it means the directive skip didn't fire, which can happen for non-#-prefixed pragma.
--- Just advance past the ident.
--- @param source string
--- @param pos integer
--- @param ident_end integer
--- @param line_of fun(pos: integer): integer
--- @param out SourceScan
--- @return integer
local function parse_pragma_dummy(source, pos, ident_end, line_of, out)
return ident_end
end
-- Parse the value side of an enum entry. -- Parse the value side of an enum entry.
-- Accepts integer literals (decimal/negative/hex), `R_*_Code` symbol references resolved via `out._code_macros`, -- Accepts integer literals (decimal/negative/hex), `R_*_Code` symbol references resolved via `out._code_macros`,
-- AND bare `R_*` symbols that map to an `R_*_Code` variant in the registry. -- AND bare `R_*` symbols that map to an `R_*_Code` variant in the registry.
-- The bare-`R_*` fallback is needed for the lottes_tape.h wave-context aliases whose enum RHS is the bare register ident (e.g. `R_TapePtr = R_T8 atom_reg`); -- The bare-`R_*` fallback is needed for the lottes_tape.h wave-context aliases whose enum RHS is the bare register ident (e.g. `R_TapePtr = R_T8 atom_reg`);
-- the `R_*_Code` form (e.g. `R_T8_Code`) holds the actual GPR code in mips.h and is now resolvable cross-source via the chain walker. -- The `R_*_Code` form holds the GPR code and resolves across sources through the chain walker.
-- Returns (value, end_pos) on success or (nil, pos) if unresolvable. -- Returns (value, end_pos) on success or (nil, pos) if unresolvable.
local function parse_enum_value(body, pos, out) local function parse_enum_value(body, pos, out)
local int_val, int_end = parse_enum_int_literal(body, pos) local int_val, int_end = parse_enum_int_literal(body, pos)
if int_val ~= nil then return int_val, int_end end if int_val ~= nil then return int_val, int_end end
local sym = duffle.read_ident(body, pos) local sym = duffle.read_ident(body, pos)
if not sym then return nil, pos end if not sym then return nil, pos end
-- Bare `R_*` (no `_Code` suffix) → translate to `R_*_Code` and look that up. Non-`R_*` symbols -- Bare `R_*` (no `_Code` suffix) → translate to `R_*_Code` and look that up. Non-`R_*` symbols
@@ -1580,8 +1634,7 @@ local DECL_PARSERS = {
MipsCode = parse_mips_code, MipsCode = parse_mips_code,
typedef = parse_typedef_binds, typedef = parse_typedef_binds,
_Pragma = parse_pragma_macro, _Pragma = parse_pragma_macro,
pragma = parse_pragma_dummy, -- `enum [<tag>] { <body> }` populates `out.register_alias_registry`.
-- Track A: `enum [<tag>] { <body> }` populates `out.register_alias_registry`.
enum = parse_enum, enum = parse_enum,
} }
@@ -1612,20 +1665,20 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
types = {}, types = {},
atom_views = {}, atom_views = {},
line_of = line_of, line_of = line_of,
-- Track A: source-derived register-alias registry (atom_reg opt-in entries). -- Source-derived register-alias registry (atom_reg opt-in entries).
-- Keys are full R_* idents (never stripped); see parse_enum / parse_enum_body. -- Keys are full R_* idents (never stripped); see parse_enum / parse_enum_body.
register_alias_registry = {}, register_alias_registry = {},
-- Track A: source-derived type-name registry. -- Source-derived type-name registry.
-- Populated from `typedef Struct_(...)`, `typedef Enum_(...)`, `typedef <type> <alias>`, and `typedef <type> TSet_(<name>)` declarations. -- Populated from `typedef Struct_(...)`, `typedef Enum_(...)`, `typedef <type> <alias>`, and `typedef <type> TSet_(<name>)` declarations.
-- The propagation pass at the end of `scan_source()` resolves byte_size via the builtin map, -- The propagation pass at the end of `scan_source()` resolves byte_size via the builtin map,
-- typedef chain walking (cycle-guarded, depth <= 8), and struct field sums. -- typedef chain walking (cycle-guarded, depth <= 8), and struct field sums.
-- See `propagate_type_sizes()` below. -- See `propagate_type_sizes()` below.
type_name_registry = {}, type_name_registry = {},
-- Track A: shared `R_*_Code -> integer code` registry -- Shared `R_*_Code -> integer code` registry
-- (passed in from M.run pass 1; same reference so preprocessor intercept writes are visible to the enum-value resolver). -- (passed in from M.run pass 1; same reference so preprocessor intercept writes are visible to the enum-value resolver).
-- Stripped from `src.scan` before return. -- Stripped from `src.scan` before return.
_code_macros = code_macros or {}, _code_macros = code_macros or {},
-- Track A: shared raw RHS body table (passed in from M.run pass 1a; -- Shared raw RHS body table (passed in from M.run pass 1a;
-- same reference so preprocessor intercept writes are visible to the cross-source chain walker in resolve_code_macro_value). -- same reference so preprocessor intercept writes are visible to the cross-source chain walker in resolve_code_macro_value).
-- Stripped from `src.scan` before return. -- Stripped from `src.scan` before return.
_code_macro_bodies = code_macro_bodies or {}, _code_macro_bodies = code_macro_bodies or {},
@@ -1642,8 +1695,7 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
-- _Pragma is an operator (not a directive) — it doesn't start with #. -- _Pragma is an operator (not a directive) — it doesn't start with #.
local pp_pos = duffle.skip_preprocessor_line(source, pos) local pp_pos = duffle.skip_preprocessor_line(source, pos)
if pp_pos then if pp_pos then
-- Track A intercept: resolve `#define R_*_Code <int-or-symbol>` -- Resolve `#define R_*_Code <int-or-symbol>` into the shared `_code_macros` registry before skipping the line.
-- into the shared `_code_macros` registry before skipping the line.
try_extract_code_macro(source, pos, out._code_macros, out._code_macro_bodies) try_extract_code_macro(source, pos, out._code_macros, out._code_macro_bodies)
pos = pp_pos pos = pp_pos
else else
@@ -1656,8 +1708,7 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
if parser then if parser then
pos = parser(source, pos, ident_end, line_of, out) pos = parser(source, pos, ident_end, line_of, out)
else else
-- A component-procedure declaration has an FI_ signature before MipsAtomComp_Proc_; -- A component-procedure declaration has an FI_ signature before MipsAtomComp_Proc_; keep the marker pending across that prelude.
-- keep the marker pending across that prelude.
-- Any other identifier begins an unrelated declaration/construct and consumes the marker so it cannot drift to a later atom. -- Any other identifier begins an unrelated declaration/construct and consumes the marker so it cannot drift to a later atom.
local markers = out.skip_over.markers local markers = out.skip_over.markers
local marker = markers[#markers] local marker = markers[#markers]
@@ -1693,6 +1744,252 @@ local function scan_source(source, source_file, code_macros, code_macro_bodies)
return out return out
end end
-- ════════════════════════════════════════════════════════════════════════════
-- Corpus merge — first-wins lookup identity + typed collisions
-- ════════════════════════════════════════════════════════════════════════════
-- These helpers run ONCE per `M.run` invocation, after every per-source scan has attached `src.scan`.
-- They merge per-source scans into the canonical `ctx.shared.corpus.*` registries.
-- The corpus is the source of truth; `src.scan` keeps the source-local projection for the duration of the run but the cross-source visibility lives on `corpus`.
-- Build a deterministic site record (path + line) from a per-source entry.
-- Falls back to the placeholder when an entry lacks a recorded source file or line.
local function build_site(path, line)
return { path = path or "?", line = line or 0 }
end
-- Compute a deterministic shape signature for an AliasEntry (register_alias_registry).
-- Two alias declarations are "identical" iff they resolve to the same shape:
-- code (integer) + default_type + default_depth + pointer_depth + has_atom_reg.
local function alias_shape(entry)
if type(entry) ~= "table" then return "" end
return string.format("code=%s;default=%s/%s;depth=%s;atom_reg=%s",
tostring(entry.code),
tostring(entry.default_type or ""),
tostring(entry.default_depth or 0),
tostring(entry.pointer_depth or 0),
tostring(entry.has_atom_reg and 1 or 0))
end
-- Compute a deterministic shape signature for a type-name registry entry.
-- struct: serialized fields (name:type:depth, in declaration order)
-- enum: serialized fields (name=value, declaration order)
-- typedef: The underlying_type string
local function type_shape(entry)
if type(entry) ~= "table" then return "" end
if entry.kind == "struct" then
local fields = entry.fields or {}
local parts = {}
for _, f in ipairs(fields) do
parts[#parts + 1] = string.format("%s:%s*%s",
tostring(f.name), tostring(f.type_name), tostring(f.pointer_depth or 0))
end
return "struct[" .. table.concat(parts, ",") .. "]"
elseif entry.kind == "enum" then
local fields = entry.fields or {}
local parts = {}
for _, f in ipairs(fields) do
parts[#parts + 1] = string.format("%s=%s", tostring(f.name), tostring(f.value))
end
return "enum[" .. table.concat(parts, ",") .. "]"
elseif entry.kind == "typedef" then
return "typedef[" .. tostring(entry.underlying_type or "") .. "]"
end
return "?"
end
-- Compute a deterministic shape signature for a Binds_* entry (Struct_ projection).
-- Binds_* entries come from scan.binds[] (per-source array) with `{line, name, fields, body, bytes}`;
-- They share the same `fields` layout as the matching struct entry in `type_name_registry`, so the shape is the struct-field serialization.
-- The `kind` field is absent here, so the struct branch of `type_shape` would misfire; we serialize the fields directly.
local function bind_shape(entry)
if type(entry) ~= "table" then return "" end
local fields = entry.fields or {}
local parts = {}
for _, f in ipairs(fields) do
parts[#parts + 1] = string.format("%s:%s*%s",
tostring(f.name), tostring(f.type_name), tostring(f.pointer_depth or 0))
end
return "struct[" .. table.concat(parts, ",") .. "]"
end
-- Compute a deterministic shape signature for an AtomEntry.
-- The "kind + body" pair uniquely identifies the same declaration when re-encountered.
-- `body` is the brace-delimited body text.
local function atom_shape(entry)
if type(entry) ~= "table" then return "" end
return string.format("kind=%s;body=%s",
tostring(entry.kind or ""), tostring(entry.body or ""))
end
-- Compute a deterministic shape signature for an AtomViewEntry.
-- Two views are identical iff they bind the same Binds_X with the same reg overrides.
local function view_shape(entry)
if type(entry) ~= "table" then return "" end
local overrides = entry.reg_type_overrides or {}
local keys = {}
for k in pairs(overrides) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
local parts = {}
for _, k in ipairs(keys) do
local ov = overrides[k]
parts[#parts + 1] = string.format("%s=%s*%s", tostring(k),
tostring(ov.type_name), tostring(ov.pointer_depth or 0))
end
return string.format("binds=%s;overrides=[%s]",
tostring(entry.binds_name or ""), table.concat(parts, ","))
end
-- Compute a deterministic shape signature for an AtomCtxEntry.
local function ctx_shape(entry)
if type(entry) ~= "table" then return "" end
return "rbind=" .. tostring(entry.rbind_atom or "")
end
-- Compute a deterministic shape signature for an AtomPhaseGroup.
-- The atoms list is compared as a SET (sorted) so that two declarations of `atom_phase(setup)` with atoms = [A] vs atoms = [B]
-- are DIFFERENT shapes (first-wins + collision), while atoms = [A,B] vs atoms = [B,A] are considered identical (and coalesce).
local function phase_shape(entry)
if type(entry) ~= "table" then return "" end
local atoms = entry.atoms or {}
local sorted = {}
for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end
table.sort(sorted)
return "atoms=[" .. table.concat(sorted, ",") .. "]"
end
-- Merge a new declaration site into a registry following the first-wins discipline.
-- * first declaration: Entry becomes the canonical corpus entry (entry.sites initialized).
-- * identical subsequent: Append the new site to entry.sites (no collision).
-- * conflicting shape: Keep first entry, append ONE typed collision record with shape diff.
local function merge_named_with_sites(registry, name, new_entry, site, collisions, kind, shape_fn)
if registry[name] == nil then
registry[name] = new_entry
registry[name].sites = { site }
return
end
local existing = registry[name]
local new_shape = shape_fn(new_entry)
local old_shape = shape_fn(existing)
if new_shape == old_shape and new_shape ~= "" then
-- Identical shape: coalesce by appending the site.
existing.sites = existing.sites or { build_site(existing.source_file, existing.source_line) }
existing.sites[#existing.sites + 1] = site
return
end
-- Conflicting shape: first-wins; record exactly one typed collision.
collisions[#collisions + 1] = {
kind = kind,
name = name,
first_site = existing.sites and existing.sites[1]
or build_site(existing.source_file, existing.source_line),
conflicting_site = site,
first_shape = old_shape,
conflicting_shape = new_shape,
}
end
-- Merge per-source scans into the canonical corpus registries.
-- Iterates `corpus.source_order` (not `ctx.sources`) — the corpus is the source of truth.
-- Each source owns only its `src.scan`; the corpus owns the cross-source lookup tables.
local function merge_corpus_registries(corpus)
-- Ensure every expected corpus table exists (the fixture_ctx seeds most of these,
-- but a barebones corpus from build_ctx should also be safe).
corpus.register_alias_registry = corpus.register_alias_registry or {}
corpus.type_name_registry = corpus.type_name_registry or {}
corpus.binds_by_name = corpus.binds_by_name or {}
corpus.atoms_by_name = corpus.atoms_by_name or {}
corpus.atom_views = corpus.atom_views or {}
corpus.atom_ctxs = corpus.atom_ctxs or {}
corpus.atom_phases = corpus.atom_phases or {}
corpus.atom_infos = corpus.atom_infos or {}
corpus.collisions = corpus.collisions or {}
-- Replace the existing corpus collections with empty tables so a re-run on the same corpus produces identical state (deterministic merge).
-- This is safe because M.run is the only writer to these tables within a single orchestrator invocation.
for _, key in ipairs({
"register_alias_registry", "type_name_registry", "binds_by_name",
"atoms_by_name", "atom_views", "atom_ctxs", "atom_phases",
"atom_infos", "collisions",
}) do
corpus[key] = {}
end
for _, src in ipairs(corpus.source_order or {}) do
local scan = src.scan
if scan then
local path = src.path
-- register_alias_registry: keyed by R_* alias ident.
-- Each AliasEntry carries `source_file` (set by scan_source to the path).
for name, entry in pairs(scan.register_alias_registry or {}) do
local site = build_site(entry.source_file or path, entry.source_line)
merge_named_with_sites(
corpus.register_alias_registry, name, entry, site,
corpus.collisions, "alias", alias_shape)
end
-- type_name_registry: keyed by type ident; covers Struct_/Enum_/typedef.
for name, entry in pairs(scan.type_name_registry or {}) do
local site = build_site(path, entry.source_line)
merge_named_with_sites(
corpus.type_name_registry, name, entry, site,
corpus.collisions, "type", type_shape)
end
-- binds_by_name: the Binds_* projection of Struct_ types.
-- Sources populate scan.binds[] (per-source array) with `{line, name, fields, body}`.
-- We merge by name (Binds_X) so cross-source Struct_(X) declarations can be coalesced or collided.
for _, bind_entry in ipairs(scan.binds or {}) do
local site = build_site(path, bind_entry.line)
merge_named_with_sites(
corpus.binds_by_name, bind_entry.name, bind_entry, site,
corpus.collisions, "binds", bind_shape)
end
-- atoms_by_name: MipsAtom_(name) + MipsAtomComp_(name) + MipsAtomComp_Proc_(name).
-- Each atom carries `{line, name, body, body_off, kind, raw_name, ...}`.
-- Duplicate atom names across sources are first-wins + collision; see the atom_infos block below for the evidence list.
for _, atom_entry in ipairs(scan.atoms or {}) do
local site = build_site(path, atom_entry.line)
merge_named_with_sites(
corpus.atoms_by_name, atom_entry.name, atom_entry, site,
corpus.collisions, "atom", atom_shape)
end
-- atom_views: keyed by atom_name; each carries `binds_name` + per-atom overrides.
for name, entry in pairs(scan.atom_views or {}) do
local site = build_site(path, entry.info_line)
merge_named_with_sites(
corpus.atom_views, name, entry, site,
corpus.collisions, "view", view_shape)
end
-- atom_ctxs: keyed by atom_name; each carries `rbind_atom`.
for name, entry in pairs(scan.atom_ctxs or {}) do
local site = build_site(path, entry.info_line)
merge_named_with_sites(
corpus.atom_ctxs, name, entry, site,
corpus.collisions, "ctx", ctx_shape)
end
-- atom_phases: keyed by phase label; each carries `atoms = [...]`.
-- The phase atoms list is set-shaped for the collision discipline (phase_shape sorts atoms before comparing).
for name, entry in pairs(scan.atom_phases or {}) do
local site = build_site(path, 0)
merge_named_with_sites(
corpus.atom_phases, name, entry, site,
corpus.collisions, "phase", phase_shape)
end
-- atom_infos: ALWAYS append every record in source/declaration order.
-- Duplicates are preserved so the annotation pass can flag them via `check_unique_annotation`;
-- The merge is purely order-preserving.
for _, info in ipairs(scan.atom_infos or {}) do
corpus.atom_infos[#corpus.atom_infos + 1] = info
end
end
end
end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- M — module exports -- M — module exports
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -1704,37 +2001,50 @@ local M = {}
--- Walk each source once and attach the fat SourceScan payload to `src.scan`. --- Walk each source once and attach the fat SourceScan payload to `src.scan`.
--- No output files; this is a pure in-memory pre-processing pass. --- No output files; this is a pure in-memory pre-processing pass.
--- ---
--- Runs in 3 phases. --- Runs in 5 phases.
--- Pass 1a: `scan_source_pre_pass` over every source, populating the cross-source `ctx.shared._code_macros` AND `ctx.shared._code_macro_bodies` registries. --- Resolve: Resolve the canonical source order from `ctx.shared.corpus.source_order`.
--- The bodies table holds the raw post-`=` text of every `#define R_*_Code` line (cross-source) so the chain walker can fall back when a --- The canonical corpus is the SOLE source of truth; no `ctx.sources` alias is consulted and no per-source fallback synthesis is performed.
--- sdefining `#define` lives in a different source than the chain call site. --- Pass 1a: `scan_source_pre_pass` over every source, populating LOCAL `code_macros` AND LOCAL `code_macro_bodies` tables.
--- Pass 1b: Resolve every collected macro's chain using the bodies table as fallback. --- The bodies table holds the raw post-`=` text of every `#define R_*_Code` line (cross-source)
--- This fills in `code_macros` entries whose defining source was scanned AFTER the call site --- so the chain walker can fall back when the defining `#define` lives in a different source than the chain call site.
--- (e.g. lottes_tape.h's `R_TapePtr_Code -> R_T8_Code` chain into mips.h's `R_T8_Code = 24`). --- Pass 1b: Resolve every collected macro's chain using the bodies table as fallback.
--- Pass 2: The full `scan_source(source, source_file, code_macros, code_macro_bodies)` walk, which feeds `out._code_macros = ctx.shared._code_macros` --- This fills in `code_macros` entries whose defining source was scanned AFTER the call site (e.g. lottes_tape.h's `R_TapePtr_Code -> R_T8_Code` chain into mips.h's `R_T8_Code = 24`).
--- (and `out._code_macro_bodies = ctx.shared._code_macro_bodies`) --- Pass 2: The full `scan_source(source, source_file, code_macros, code_macro_bodies)` walk per source. The per-source `src.scan` payload includes the source-local registries
--- so the enum parser can resolve cross-source `R_*_Code` references and bare `R_*` symbols via the `_Code` registry fallback. --- (register_alias_registry, type_name_registry, atom_views, atom_ctxs, atom_phases, binds, atoms, atom_infos, ...).
--- Strip: `src.scan._code_macros` AND `src.scan._code_macro_bodies` are nilled before returning so downstream passes --- Strip: Strip `src.scan._code_macros`, `src.scan._code_macro_bodies`, and the `_source_file` pointer.
--- (annotation, components, offsets, dwarf_injection, etc.) don't see the private parse state. --- The LOCAL tables `code_macros` and `code_macro_bodies` go out of scope here; they MUST NOT appear on `ctx.shared`, `ctx.shared.corpus`, or any `src.scan` after this point.
--- Merge: Iterate `ctx.shared.corpus.source_order` in declared order. For every source's local registry, first-wins lookup identity (entry from the first declaration site becomes the canonical corpus entry);
--- identical shapes coalesce by appending the declaration site; conflicting shapes keep the first lookup entry and append ONE typed collision record with shape diff.
--- Populate `register_alias_registry`, `type_name_registry`, `binds_by_name`, `atoms_by_name`, `atom_views`, `atom_ctxs`, `atom_phases`.
--- `atom_infos` ALWAYS appends every record (preserving source order + duplicates for annotation evidence).
---
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
-- Initialize the cross-source code-macro + body registries in ctx.shared. -- The cross-source _code_macros / _code_macro_bodies tables are LOCAL to this run.
-- (Pass 1a writes into both; pass 1b reads from both; pass 2 reads both.) -- They are shared across source scans ONLY long enough to resolve cross-source R_*_Code chains, then DISCARDED.
ctx.shared = ctx.shared or {} -- They MUST NOT appear on ctx.shared, ctx.shared.corpus, or any src.scan after
ctx.shared._code_macros = ctx.shared._code_macros or {} -- this function returns.
ctx.shared._code_macro_bodies = ctx.shared._code_macro_bodies or {} local code_macros = {}
local code_macros = ctx.shared._code_macros local code_macro_bodies = {}
local code_macro_bodies = ctx.shared._code_macro_bodies
-- Resolve the canonical source list. The corpus owns the authoritative source_order.
-- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus error.
ctx.shared = ctx.shared or {}
local corpus = ctx.shared.corpus
if not corpus or type(corpus.source_order) ~= "table" then
error("scan_source.run requires ctx.shared.corpus.source_order (the canonical corpus is the source of truth; no per-source fallback is supported)", 0)
end
local sources = corpus.source_order
-- Pass 1a: collect `_code_macros` + `_code_macro_bodies` across ALL sources. -- Pass 1a: collect `_code_macros` + `_code_macro_bodies` across ALL sources.
for _, src in ipairs(ctx.sources) do for _, src in ipairs(sources) do
scan_source_pre_pass(src.text, code_macros, code_macro_bodies) scan_source_pre_pass(src.text, code_macros, code_macro_bodies)
end end
-- Pass 1b: resolve every collected macro's chain with cross-source fallback. -- Pass 1b: resolve every collected macro's chain with cross-source fallback.
-- The bodies table was populated for every `#define R_*_Code` line in pass 1a; -- The bodies table was populated for every `#define R_*_Code` line in pass 1a;
-- this iteration finishes the chain even when the chain hops span sources -- This iteration finishes the chain even when the chain hops span sources
-- (e.g. R_TapePtr_Code -> R_T8_Code -> 24 spans lottes_tape.h into mips.h). -- (e.g. R_TapePtr_Code -> R_T8_Code -> 24 spans lottes_tape.h into mips.h).
-- Same `code_macros` table is shared with pass 2 below. -- Same `code_macros` table is shared with pass 2 below.
for macro_name, _ in pairs(code_macro_bodies) do for macro_name, _ in pairs(code_macro_bodies) do
@@ -1746,28 +2056,33 @@ function M.run(ctx)
end end
end end
-- Pass 2: run the full scan with the shared `_code_macros` + `_code_macro_bodies` -- Pass 2: run the full scan with the shared `_code_macros` + `_code_macro_bodies` so the enum parser can resolve cross-source
-- so the enum parser can resolve cross-source `R_*_Code` references and bare `R_*` symbols via the `_Code` registry fallback. -- `R_*_Code` references and bare `R_*` symbols via the `_Code` registry fallback.
for _, src in ipairs(ctx.sources) do -- The private `_code_macros` / `_code_macro_bodies` / `_source_file` strip is midway through `source_order`.
-- Must not leave earlier sources leaking the private parse state, because a separate post-loop would not run when an exception propagates out of pass 2.
for _, src in ipairs(sources) do
src.scan = scan_source(src.text, src.path, code_macros, code_macro_bodies) src.scan = scan_source(src.text, src.path, code_macros, code_macro_bodies)
-- Pre-tokenize each atom body once (plex: single source of truth). -- Strip the three private fields immediately so a later fatal source does not leave this source leaking parse state.
-- Downstream passes (offsets, word-counts, components, static-analysis) read from -- The shared `code_macros` / `code_macro_bodies` locals remain in the outer scope and keep their contents for subsequent sources.
-- `atom.body_tokens` instead of calling `split_top_level_commas` / `tokenize_body` independently.
-- The tokens are memoized in duffle.lua's cache, so re-access is O(1).
for _, atom in ipairs(src.scan.atoms) do atom.body_tokens = duffle.tokenize_body(atom.body) end
for _, atom in ipairs(src.scan.raw_atoms or {}) do atom.body_tokens = duffle.tokenize_body(atom.body) end
end
-- Strip `_code_macros` + `_code_macro_bodies` (and the convenience `_source_file` pointer)
-- before returning so downstream passes don't see private parse state.
for _, src in ipairs(ctx.sources) do
if src.scan then if src.scan then
src.scan._code_macros = nil src.scan._code_macros = nil
src.scan._code_macro_bodies = nil src.scan._code_macro_bodies = nil
src.scan._source_file = nil src.scan._source_file = nil
end end
-- Pre-tokenize each atom body once (plex: single source of truth).
-- Downstream passes (offsets, word-counts, components, static-analysis) read from `atom.body_tokens` instead of calling `split_top_level_commas` / `tokenize_body` independently.
-- The tokens are memoized in duffle.lua's cache, so re-access is O(1).
for _, atom in ipairs(src.scan.atoms) do atom.body_tokens = duffle.tokenize_body(atom.body) end
for _, atom in ipairs(src.scan.raw_atoms or {}) do atom.body_tokens = duffle.tokenize_body(atom.body) end
end end
-- Merge per-source scans into the canonical corpus registries.
-- First-wins lookup identity + collision discipline (see merge_corpus_registries).
-- The corpus is always present; no conditional / fallback path.
merge_corpus_registries(corpus)
-- code_macros and code_macro_bodies go out of scope here; their references are not captured on corpus, ctx.shared, or any src.scan.
-- The Lua GC reclaims them on M.run return.
return { outputs = {}, errors = {}, warnings = {} } return { outputs = {}, errors = {}, warnings = {} }
end end
File diff suppressed because it is too large Load Diff
+44 -91
View File
@@ -1,11 +1,17 @@
--- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram pipeline. --- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram pipeline.
--- ---
--- Three responsibilities: --- Two responsibilities:
--- 1. **Public utilities** (used by `passes/components.lua`, `passes/offsets.lua`, `passes/annotation.lua`): --- 1. **Public utility** `M.count_token_words(token, wc)`: Used by `passes/offsets.lua`, `passes/annotation.lua`, and other passes.
--- - `M.count_token_words(token, wc)` — words emitted by one token --- 2. **Pass entry** `M.run(ctx)`: Loads the authored `word_count.metadata.h` into `ctx.shared.corpus.word_counts` for downstream passes.
--- - `M.scan_dir(dir, suffix)` — glob walk for *.macs.h --- The generated `.macs.h` files are OUTPUT artifacts and are NOT inputs to this pass;
--- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into `ctx.shared.word_counts` for downstream passes. --- Current component counts are owned by `passes/components.lua` (which populates `corpus.word_counts` and `corpus.component_body_index`
--- 3. **Internal helpers** for the body scanner. --- AFTER computing each current count from the just-built body + `corpus.word_counts`).
---
--- **Canonical contract**:
--- * `ctx.shared.corpus.word_counts` is the canonical count table.
--- * `corpus.word_counts` is the sole count table. Consumers read `corpus.word_counts` directly.
--- * `ctx.shared.components` and `ctx.shared.component_body_index` are NOT created by this pass (canonical projections only).
--- * No `.macs.h` recursive discovery (no `scan_dir`, no scan cache, no `_invalidate_scan_cache`).
--- ---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. --- Lua 5.3 compatible.
@@ -14,23 +20,11 @@
-- Module-scope requires + package.path setup -- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Bootstrap: see `ps1_meta.lua` for the rationale.
-- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath).
-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. -- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator.
-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. -- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Required native extension: lfs (LuaFileSystem). Built by `update_deps.ps1` to
-- `toolchain/lfs/lfs.dll` and wired into package.cpath by `scripts/duffle_paths.lua`.
-- If lfs is missing, `require` throws — fail loud per the build-tool convention.
local lfs = require("lfs")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Type declarations -- Type declarations
@@ -49,12 +43,12 @@ local lfs = require("lfs")
--- @field sources SourceFile[] -- all source files in the build --- @field sources SourceFile[] -- all source files in the build
--- @field metadata_path string -- path to word_count.metadata.h --- @field metadata_path string -- path to word_count.metadata.h
--- @field shared table -- cross-pass shared state --- @field shared table -- cross-pass shared state
--- @field shared.word_counts WordCounts -- populated by this pass --- @field shared.corpus table -- canonical corpus (required)
--- @field shared.corpus.word_counts WordCounts -- canonical count table (populated by this pass)
--- @field out_root string -- output root (e.g. "build/gen") --- @field out_root string -- output root (e.g. "build/gen")
--- @field project_root string -- project root (e.g. "code/") --- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs --- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags --- @field flags table -- CLI flags
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info --- @field verbose boolean -- if true, log diagnostic info
--- @class PassResult --- @class PassResult
@@ -76,8 +70,8 @@ local M = {}
--- For most tokens (regular MIPS instructions) this returns 1. --- 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 `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. --- For unknown macros, returns 1 and (optionally) warns.
--- @param token string -- a single token from split_top_level_commas --- @param token string -- a single token from split_top_level_commas
--- @param wc WordCounts -- the shared word-count table --- @param wc WordCounts -- the shared word-count table
--- @return integer --- @return integer
function M.count_token_words(token, wc) function M.count_token_words(token, wc)
local s = duffle.trim(token) local s = duffle.trim(token)
@@ -92,83 +86,42 @@ function M.count_token_words(token, wc)
return 1 return 1
end end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: scan_dir │
-- └────────────────────────────────────────────────────────────────────┘
-- Cache the scan_dir result per (dir, suffix) in package.loaded.
-- The cache persists for the lifetime of the Lua process (cleared when ps1_meta.lua exits).
-- If a build removes/creates .macs.h files mid-process, the caller can invalidate by calling `M._invalidate_scan_cache()`.
local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__"
--- Scan `code/` for files matching `suffix` (e.g. `*.macs.h`).
--- Native directory enumeration via lfs (~2ms). Zero subprocess spawns.
--- @param dir string -- project root directory
--- @param suffix string -- file pattern, e.g. "*.macs.h"
--- @return string[]
function M.scan_dir(dir, suffix)
local key = dir .. "\0" .. suffix
local cache = package.loaded[SCAN_CACHE_KEY]
if cache and cache[key] then return cache[key] end
local results = {}
local code_dir = dir .. "/code"
if lfs.attributes(code_dir, "mode") == "directory" then
for mod_name in lfs.dir(code_dir) do
if mod_name ~= "." and mod_name ~= ".." then
local gen_path = code_dir .. "/" .. mod_name .. "/gen"
if lfs.attributes(gen_path, "mode") == "directory" then
for fname in lfs.dir(gen_path) do
if fname:match("%.macs%.h$") then
results[#results + 1] = gen_path .. "/" .. fname
end
end
end
end
end
end
-- Cache the result (including empty results).
cache = cache or {}
cache[key] = results
package.loaded[SCAN_CACHE_KEY] = cache
return results
end
--- Invalidate the scan cache (call after creating new .macs.h files in the same Lua process — usually not needed).
function M._invalidate_scan_cache() package.loaded[SCAN_CACHE_KEY] = nil end
-- ┌────────────────────────────────────────────────────────────────────┐ -- ┌────────────────────────────────────────────────────────────────────┐
-- │ Pass entry: M.run(ctx) — "word-counts" pass │ -- │ Pass entry: M.run(ctx) — "word-counts" pass │
-- └────────────────────────────────────────────────────────────────────┘ -- └────────────────────────────────────────────────────────────────────┘
--- Load metadata.h + scan for existing *.macs.h files into ctx.shared.word_counts. --- Load the authored `word_count.metadata.h` into `ctx.shared.corpus.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. --- Generated `.macs.h` files are OUTPUT artifacts and are NOT scanned as inputs.
--- Current component counts are computed and inserted by `passes/components.lua`
--- after the components pass iterates `corpus.source_order` and writes each source's `<dir_basename>.macs.h` file.
---
--- Contract:
--- * `ctx.shared.corpus` MUST exist (canonical corpus ownership).
--- * `ctx.metadata_path` MUST be a readable file path to the authored `word_count.metadata.h`.
--- * The pass assigns exactly one table to `corpus.word_counts`.
--- Consumers read the corpus-owned table directly.
--- Consumers must read `corpus.word_counts` directly.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
local wc = {} -- 1. Canonical-corpus ownership gate.
local corpus = ctx.shared and ctx.shared.corpus
-- 1. Load metadata.h (the encoding-macro source of truth). if type(corpus) ~= "table" then
local meta_counts = duffle.load_word_counts(ctx.metadata_path) error("word_count_eval.run requires ctx.shared.corpus (canonical corpus). The fixture must install the corpus before running this pass.", 0)
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(duffle.load_word_counts, macs_path)
if not ok then
io.stderr:write(string.format("[word_count_eval] parse error in '%s': %s\n", macs_path, tostring(mc)))
elseif type(mc) ~= "table" then
io.stderr:write(string.format("[word_count_eval] '%s' did not return a table (got %s)\n", macs_path, type(mc)))
else
for name, count in pairs(mc) do wc[name] = count end
end
end end
ctx.shared.word_counts = wc -- 2. metadata_path gate.
if type(ctx.metadata_path) ~= "string" or ctx.metadata_path == "" then
error("word_count_eval.run requires ctx.metadata_path (path to the authored word_count.metadata.h).", 0)
end
-- 3. Load authored metadata. Generated .macs.h files are NOT scanned
-- (the canonical pass computes their counts from the just-built bodies after disk emission; see passes/components.lua).
local wc = duffle.load_word_counts(ctx.metadata_path)
-- 4. Assign the canonical count table. ONE assignment, no copy. The assignment creates no secondary alias.
corpus.word_counts = wc
return { outputs = {}, errors = {}, warnings = {} } return { outputs = {}, errors = {}, warnings = {} }
end end
+283 -467
View File
File diff suppressed because it is too large Load Diff
+1 -16
View File
@@ -39,13 +39,7 @@ pop-location
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# PCSX-Redux — built via MSBuild (VS2022) # PCSX-Redux — built via MSBuild (VS2022)
#
# Requires: Visual Studio 2022 with the C++ desktop workload. # Requires: Visual Studio 2022 with the C++ desktop workload.
# The .vcxproj files target platform toolset v145, but VS2022 ships v143;
# we pass /p:PlatformToolset=v143 to retarget at build time (no file edits).
# NuGet packages (glfw, luajit.native, libFFmpeg-lite, x64sentry) are
# restored automatically by MSBuild on first build.
#
# Output: toolchain\pcsx-redux\vsprojects\x64\Debug\pcsx-redux.exe # Output: toolchain\pcsx-redux\vsprojects\x64\Debug\pcsx-redux.exe
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@@ -65,8 +59,7 @@ $path_pcsx_sln = join-path $path_pcsx_redux 'vsprojects\pcsx-redux.sln'
& $msbuild_exe $path_pcsx_sln /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /m /v:minimal & $msbuild_exe $path_pcsx_sln /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /m /v:minimal
# Locate luajit via scoop. `luajit.exe` is on PATH via scoop's shim; # Locate luajit via scoop. `luajit.exe` is on PATH via scoop's shim;
# we use `scoop prefix` to find the install root for the include dir # we use `scoop prefix` to find the install root for the include dir (needed to compile lpeg against luajit's headers).
# (needed to compile lpeg against luajit's headers).
# If scoop or luajit is missing, fail fast with an actionable message. # If scoop or luajit is missing, fail fast with an actionable message.
$luajit_prefix = & scoop prefix luajit 2>$null $luajit_prefix = & scoop prefix luajit 2>$null
if (-not $luajit_prefix -or -not (Test-Path (Join-Path $luajit_prefix 'bin/luajit.exe'))) { if (-not $luajit_prefix -or -not (Test-Path (Join-Path $luajit_prefix 'bin/luajit.exe'))) {
@@ -87,7 +80,6 @@ if (-not $lua_inc_dir) {
# Generate lpeg.dll by compiling the 6 source files directly. # Generate lpeg.dll by compiling the 6 source files directly.
# `gcc` is on PATH (scoop's shim puts it there). # `gcc` is on PATH (scoop's shim puts it there).
# The source files: lpcap.c lpcode.c lpcset.c lpprint.c lptree.c lpvm.c # The source files: lpcap.c lpcode.c lpcset.c lpprint.c lptree.c lpvm.c
# (per the lpeg makefile — no `make.lua` template generator in this version).
# Link against luajit's import library (`libluajit-5.1.a`) for the Lua C API symbols (lua_*, luaL_*). # Link against luajit's import library (`libluajit-5.1.a`) for the Lua C API symbols (lua_*, luaL_*).
$luajit_lib_dir = Join-Path $luajit_prefix 'lib' $luajit_lib_dir = Join-Path $luajit_prefix 'lib'
$lpeg_sources = @('lpcap.c', 'lpcode.c', 'lpcset.c', 'lpprint.c', 'lptree.c', 'lpvm.c') $lpeg_sources = @('lpcap.c', 'lpcode.c', 'lpcset.c', 'lpprint.c', 'lptree.c', 'lpvm.c')
@@ -103,8 +95,6 @@ pop-location
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# lfs (LuaFileSystem) — compiled from pcsx-redux's vendored luafilesystem source. # lfs (LuaFileSystem) — compiled from pcsx-redux's vendored luafilesystem source.
# Used by word_count_eval.lua :: scan_dir for native directory enumeration (~2ms)
# instead of spawning `dir /b /s` as a subprocess (~56ms).
# Source: toolchain/pcsx-redux/third_party/luafilesystem/src/lfs.c # Source: toolchain/pcsx-redux/third_party/luafilesystem/src/lfs.c
# Output: toolchain/lfs/lfs.dll # Output: toolchain/lfs/lfs.dll
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@@ -118,11 +108,6 @@ $lfs_dll_import = join-path $luajit_lib_dir 'libluajit-5.1.dll.a'
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# OpenBIOS — built from the PCSX-Redux source tree via make + mipsel-none-elf # OpenBIOS — built from the PCSX-Redux source tree via make + mipsel-none-elf
#
# OpenBIOS is an open-source PS1 BIOS implementation (no retail BIOS dump needed).
# It builds with the MIPS cross-toolchain (`mipsel-none-elf-gcc`, on PATH via the `mips` toolchain installer)
# + `make` (on PATH via scoop).
#
# Output: toolchain\pcsx-redux\src\mips\openbios\openbios.bin # Output: toolchain\pcsx-redux\src\mips\openbios\openbios.bin
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════