Better static analysis for C0 <-> C2 data race hazards.

This commit is contained in:
ed
2026-07-25 04:09:48 -04:00
parent d56adab38f
commit 9ffd6592bc
17 changed files with 4264 additions and 1820 deletions
+1
View File
@@ -104,6 +104,7 @@ FI_ Slice_MipsCode tb_slice(TapeBuilder tb) { return (Sl
* ---------------------------------------------------------------------------*/ * ---------------------------------------------------------------------------*/
// 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)),
+2 -2
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"
+1 -1
View File
@@ -103,8 +103,8 @@ MipsAtom_(rbind_floor_f3_face) atom_info(atom_bind(Binds_FloorTri), atom_phase(f
mac_yield() mac_yield()
}; };
internal
// atom_dbg_skip_over() // atom_dbg_skip_over()
internal
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_FaceCursr) , atom_writes(R_PrimCursor, R_FaceCursr)
+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"
} }
} }
+1429 -145
View File
File diff suppressed because it is too large Load Diff
+94 -40
View File
@@ -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
@@ -108,12 +108,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 +143,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 +159,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 +187,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 +239,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 +270,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,14 +384,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 are the context aliases opted in via `#define atom_reg` in lottes_tape.h. --- 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.
--- Any source referencing an R_X that's NOT in the registry will trip the new check; a single pass-level info entry --- A source referencing an unregistered R_X emits one pass-level info entry
--- (emitted only when at least one such rejection lands in this source) tells users where to look. --- (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)
@@ -445,14 +444,65 @@ 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 -- built once per pass from corpus registries
--- @return AnnotatedResult --- @return AnnotatedResult
local function validate(ctx, src) local function validate(ctx, src, corpus_pipe_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.
@@ -478,9 +528,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
@@ -493,25 +545,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.
@@ -543,8 +590,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 {}
@@ -555,7 +602,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
@@ -571,8 +618,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 {
@@ -650,9 +697,16 @@ 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
@@ -663,7 +717,7 @@ function M.run(ctx)
ctx.flags = ctx.flags or {} ctx.flags = ctx.flags or {}
ctx.flags._annot_source_results = ctx.flags._annot_source_results 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() 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
+136 -279
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,90 @@ 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 -- identity alias of `corpus.word_counts`
--- @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 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 +149,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 +169,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 +189,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 +225,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
@@ -652,57 +514,52 @@ 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 (and memoized at `ctx.shared.component_body_index`) 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).
-- 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.
-- Superseded by `duffle.get_component_body_index` so the same index is shared with `static_analysis.lua`
-- and any future dependency-check pass (sourcemap/provenance byte-identical because the new entry only ADDS fields).
local comp_body_index = duffle.get_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.paths then has_projection = true; break end
if n_atoms + n_raw_atoms > 0 then end
local basename = duffle.basename_no_ext(src.path) if not has_projection then
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do
-- (1) atoms.sourcemap.txt — per-.word line map (unchanged contract). if atom.paths then has_projection = true; break end
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 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)
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
-- Optionally emit the gdb-runtime form (post-link, one file per build). -- Optionally emit the gdb-runtime form (post-link, one file per build).
+140 -76
View File
@@ -108,8 +108,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,7 +149,7 @@ 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
@@ -266,12 +266,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
@@ -282,6 +282,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,
@@ -339,11 +340,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
@@ -352,13 +353,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)
@@ -397,7 +398,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 = {}
@@ -470,9 +471,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 = {}
@@ -504,10 +505,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.
@@ -529,13 +530,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,10 +546,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. --- 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
@@ -577,41 +578,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 legacy 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
@@ -622,24 +668,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
+164 -237
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,19 +37,15 @@ 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_uleb128_at = elf_dwarf.read_uleb128_at
local read_sleb128_at = elf_dwarf.read_sleb128_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
@@ -97,7 +93,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 +146,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
@@ -341,7 +337,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 +363,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 +393,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 +468,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.
@@ -686,162 +680,94 @@ 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 local atoms_by_name = corpus.atoms_by_name or {}
-- Source-map path: convention matches the α MVP's emission location.
-- writes `<out_root>/<basename>.atoms.sourcemap.txt` (e.g. `build/gen/hello_gte_tape.atoms.sourcemap.txt`).
-- 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. -- Cross-ref: keep only the atoms that exist in BOTH the nm symbol table AND the canonical corpus projection.
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) -- Address-ascending sort + lexical Stable tie-breaker: declaration order, then symbol address.
local merged = {}
for _, sm_path in ipairs(sm_files) do
local sm = elf_dwarf.parse_source_map_file(sm_path, 1)
for name, sm_data in pairs(sm) do
merged[name] = sm_data
end
end
-- Also read *.atoms.provenance.txt to extract per-component invocations.
-- Files are merged by atom name; entries carry the original {pos, call_file, call_line, comp_name, comp_file, comp_line} shape.
local prov_files = duffle.list_dir(ctx.out_root, "%.atoms.provenance%.txt$")
local prov_merged = {}
for _, prov_path in ipairs(prov_files) do
local prov = elf_dwarf.parse_provenance_file(prov_path, 1)
for name, prov_data in pairs(prov) do
prov_merged[name] = prov_data
end
end
-- Build a per-source component index keyed by the bare component name (e.g. `gte_load_tri_verts`, NOT `ac_gte_load_tri_verts`).
-- The bare name matches the provenance row's `comp_name` field (which is `strip_mac_prefix_from_token(tok)` — strips `mac_`, leaves the rest).
-- Each entry holds the data we need to walk the component body's tokens in lockstep with their word counts:
-- 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.
-- 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
end
end
-- 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
return lines
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_record = atoms_by_name[name]
if sm then if atom_record then
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` is the 0-based `.word` position;
-- `call_line` is 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
local atom = { local atom = {
name = name, name = name,
addr = info[1], addr = info[1],
size_bytes = info[2], size_bytes = info[2],
words = sm.total, words = #word_events,
entries = sm.words, entries = entries,
skip_over = skip_over.atoms[name] ~= nil, 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. -- Group consecutive `word_events` rows whose outermost invocation
-- Two consecutive rows with the same (comp_name, call_file, call_line, comp_file, comp_line) are part of the same invocation. -- is the SAME format-1 invocation into a single `atom.invocations`
local prov_data = prov_merged[name] -- entry. Keep entries grouped by outermost invocation
if prov_data and prov_data.words then -- (two consecutive rows with the same comp_name/call_file/call_line/
-- comp_file/comp_line are part of the same invocation).
if #invocations_proj > 0 then
local invocations = {} local invocations = {}
local cur_inv = nil local cur_inv = nil
for _, w in ipairs(prov_data.words) do for _, ev in ipairs(word_events) do
if w.comp_name then local outer_id = ev.outermost_invocation_id
local inv_key = w.comp_name .. "|" .. w.call_file .. "|" .. w.call_line .. "|" .. w.comp_file .. "|" .. w.comp_line local outer_inv = outer_id and invocations_proj[outer_id] or nil
if outer_inv and outer_inv.component_name then
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 if cur_inv and cur_inv.key == inv_key then
-- Same invocation as the previous word — extend its range. cur_inv.end_pos = ev_pos
cur_inv.end_pos = w.pos cur_inv.body_lines[#cur_inv.body_lines + 1] = ev.body_line or 0
else else
-- New invocation: flush the previous one and start fresh.
if cur_inv then invocations[#invocations + 1] = cur_inv end if cur_inv then invocations[#invocations + 1] = cur_inv end
cur_inv = { cur_inv = {
key = inv_key, key = inv_key,
comp_name = w.comp_name, comp_name = outer_inv.component_name,
call_file = w.call_file, call_file = outer_inv.call_path or "",
call_line = w.call_line, call_line = outer_inv.call_line or 0,
comp_file = w.comp_file, comp_file = outer_inv.def_path or "",
comp_line = w.comp_line, comp_line = outer_inv.def_line or 0,
start_pos = w.pos, start_pos = ev_pos,
end_pos = w.pos, end_pos = ev_pos,
-- component_skip_key: case-insensitive Windows path + "\0" separator + exact component-name. skip_over = skip_over.components[normalize_debug_path(outer_inv.def_path or ""):lower()
-- (Inlined from `component_skip_key`; the lookup is in skip_over.components keyed by the result.) .. "\0" .. outer_inv.component_name] ~= nil,
skip_over = skip_over.components[normalize_debug_path(w.comp_file):lower() .. "\0" .. w.comp_name] ~= nil, body_lines = { ev.body_line or 0 },
} }
-- 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 end
else else
-- RAW row: flush the current invocation. -- RAW row: flush the current invocation.
@@ -889,7 +815,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.
@@ -905,8 +830,8 @@ 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).
--- @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 = {}
@@ -938,9 +863,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}}
@@ -949,22 +872,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
@@ -985,7 +908,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
@@ -995,7 +918,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
@@ -1013,9 +936,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)
@@ -1041,12 +964,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)
@@ -1094,7 +1017,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)
@@ -1132,7 +1055,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)
@@ -1451,7 +1374,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
@@ -1561,8 +1484,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).
--- ---
@@ -1596,7 +1519,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.
@@ -1673,7 +1596,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.
@@ -1874,9 +1797,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.
@@ -1937,7 +1860,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,7 +1946,7 @@ local function build_inserted_children(main_cu_offset, main_cu_end_excl, atom_ta
-- 5-step precedence chain. 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 = {
@@ -2076,10 +1999,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).
@@ -2094,7 +2015,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
@@ -2108,8 +2029,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
@@ -2126,7 +2047,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
@@ -2148,7 +2069,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)
@@ -2167,7 +2088,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)
@@ -2213,29 +2134,28 @@ 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 -- local function build_debug_loc_section() return string.char(0x00) end
local SECTION_BUILDERS = { local SECTION_BUILDERS = {
@@ -2351,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",
@@ -2367,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).
@@ -2445,7 +2372,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
+194
View File
@@ -0,0 +1,194 @@
--- 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, dry_run, ... }
--- @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
-- Walk every source in canonical source order; for each source, iterate atoms.
-- Atom declarations (`kind == "atom"` / `"raw_atom"`) receive the canonical `atom.paths` projection; component declarations
-- (`comp_bare` / `comp_proc`) are recursively expanded by atom projections and do not get an independent projection themselves.
-- Test-only fixtures that need per-component word events may still consume `duffle.expand_word_events`
-- (which remains available; emission-model owns the canonical per-atom projection).
for _, src in ipairs(corpus.source_order) do
local scan = src.scan or {}
for _, atom in ipairs(scan.atoms or {}) do
if atom and atom.body and (atom.kind == "atom" or atom.kind == "raw_atom") then
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
end
for _, atom in ipairs(scan.raw_atoms or {}) do
if atom and atom.body then
local proj = project_atom(atom, src, corpus)
for _, e in ipairs(proj.errors) do
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
end
end
return {
outputs = outputs,
errors = errors,
warnings = warnings,
}
end
return M
+87 -193
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,11 @@ 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 -- compatibility alias to corpus.word_counts
--- @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 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 +58,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 +69,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 +168,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 +193,43 @@ 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 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, atoms_data)) duffle.write_file(out_path, generate_header(src.path:gsub("/", "\\"), atoms_data))
end 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 +237,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 }
+29 -26
View File
@@ -138,31 +138,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
@@ -377,13 +377,13 @@ end
-- Orchestration helpers -- Orchestration helpers
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- (internal) Pull per-source validate() results from the annotation pass's stash. --- (internal) Pull per-source validate() results from the annotation pass's stash.
-- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`; --- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`;
-- we read from there instead of re-validating each source. --- we read from there instead of re-validating each source.
-- Returns the list of module results + the flat list of all results (for the project-wide summary). --- Returns the list of module results + the flat list of all results (for the project-wide summary).
-- @param ctx PassCtx --- @param ctx PassCtx
-- @param dir_sources SourceFile[] --- @param dir_sources SourceFile[]
-- @return AnnotationResult[], AnnotationResult[] --- @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 src_cache = (ctx.flags and ctx.flags._annot_source_results) or {}
local module_results = {} local module_results = {}
@@ -399,9 +399,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 +412,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, ...))
@@ -436,7 +436,10 @@ function M.run(ctx)
local warnings = {} local warnings = {}
local module_entries = (ctx.flags and ctx.flags._annot_results) or {} local module_entries = (ctx.flags and ctx.flags._annot_results) or {}
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources) -- Read module grouping from `corpus.sources_by_dir` (the canonical projection).
-- Module grouping comes from `corpus.sources_by_dir`.
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 if not ctx.dry_run then duffle.ensure_dir(ctx.out_root) end
+457 -140
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
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -286,13 +293,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 +335,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 +392,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 +419,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 +532,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 +561,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 +597,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 +621,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)
@@ -718,6 +714,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 +751,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
@@ -847,10 +847,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 +864,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 +874,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 +907,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 +1012,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 +1186,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 +1340,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 +1358,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 +1481,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,7 +1635,6 @@ 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`. -- `enum [<tag>] { <body> }` populates `out.register_alias_registry`.
enum = parse_enum, enum = parse_enum,
} }
@@ -1655,8 +1709,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]
@@ -1692,6 +1745,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
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -1703,37 +2002,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; no legacy alias is consulted and no per-source fallback synthesis is performed.
-- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message so callers migrate to the canonical context (no production compatibility layer).
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
@@ -1745,28 +2057,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 -90
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,7 +43,8 @@ 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
@@ -76,8 +71,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 +87,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
+254 -151
View File
@@ -4,8 +4,8 @@
--- ---
--- **Architecture**: --- **Architecture**:
--- - **PASSES table** — declarative dep graph (data, not code). --- - **PASSES table** — declarative dep graph (data, not code).
--- - **FLAG_HANDLERS table** — per-flag CLI dispatchers (handler-map pattern; replaces an 8-way if/elseif chain). --- - **FLAG_HANDLERS table** — maps CLI flags to handlers.
--- - **parse_args** → **build_ctx** (just opens + reads source files; no inline scanning) → **topo_sort** → **dispatch_passes**. --- - **parse_args** → **build_ctx** (resolves unity/direct includes or exact sources; no semantic scanning) → **topo_sort** → **dispatch_passes**.
--- - The first pass in the dep graph is `scan-source` (see `passes/scan_source.lua`). --- - The first pass in the dep graph is `scan-source` (see `passes/scan_source.lua`).
--- It calls `duffle.scan_source` once per source to produce the fat `SourceScan` payload, which is attached to each `src.scan`. --- It calls `duffle.scan_source` once per source to produce the fat `SourceScan` payload, which is attached to each `src.scan`.
--- Every other pass that reads source structure depends on `scan-source` and consumes `src.scan` as a read-only. --- Every other pass that reads source structure depends on `scan-source` and consumes `src.scan` as a read-only.
@@ -18,13 +18,9 @@
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Bootstrap: load `duffle_paths.lua` via this script's own path. -- Bootstrap: load `duffle_paths.lua` via this script's own path.
-- Use `arg[0]` when this file is the entry script (`arg[0]` ends in -- Use `arg[0]` when this file is the entry script (`arg[0]` ends in "ps1_meta.lua");
-- "ps1_meta.lua"); fall back to `debug.getinfo(1, "S").source` when this -- fall back to `debug.getinfo(1, "S").source` when this file is being dofile()'d or require()'d (in which case `arg[0]` is the *caller's* path, not ours).
-- file is being dofile()'d or require()'d (in which case `arg[0]` is the -- That single statement: (a) sets `package.path` + `package.cpath` (via cached `git rev-parse`), (b) at the bottom returns `require("duffle")`.
-- *caller's* path, not ours).
--
-- That single statement: (a) sets `package.path` + `package.cpath`
-- (via cached `git rev-parse`), (b) at the bottom returns `require("duffle")`.
-- So the dofile's return value is the duffle module. -- So the dofile's return value is the duffle module.
local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil
local _bootstrap_src local _bootstrap_src
@@ -81,12 +77,11 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field basename string -- filename without extension --- @field basename string -- filename without extension
--- @class PassCtx --- @class PassCtx
--- @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 table<string, integer> -- populated by word-counts pass --- @field shared.corpus table -- canonical authored-source/project projection
--- @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 -- PS1 repository root
--- @field upstream table<string, table> -- per-pass output accumulator --- @field upstream table<string, table> -- per-pass output accumulator
--- @field flags table -- CLI flags + per-pass stash --- @field flags table -- CLI flags + per-pass stash
--- @field dry_run boolean -- if true, compute but don't write --- @field dry_run boolean -- if true, compute but don't write
@@ -106,13 +101,14 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field warnings Finding[] -- informational --- @field warnings Finding[] -- informational
--- @class ParsedArgs --- @class ParsedArgs
--- @field requested_set string[] -- pass names to run (explicit --all expanded) --- @field requested_set string[] -- pass names to run (explicit --all expanded)
--- @field sources string[] -- --source values --- @field sources string[] -- exact --source values, retained in CLI order
--- @field metadata string -- --metadata value --- @field unity_root string|nil -- --unity-root value; mutually exclusive with sources
--- @field out_root string -- --out-root value (default "build/gen") --- @field metadata string -- --metadata value
--- @field project_root string -- --project-root value (default dirname(metadata)) --- @field out_root string -- --out-root value (default "build/gen")
--- @field dry_run boolean -- if true, compute but don't write --- @field project_root string -- PS1 repository root (derived from metadata by default)
--- @field verbose boolean -- if true, log diagnostic info --- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- PASSES Table -- PASSES Table
@@ -145,6 +141,13 @@ local PASSES = {
desc = "Emit mac_X macros from MipsAtomComp_ declarations", desc = "Emit mac_X macros from MipsAtomComp_ declarations",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.macs.h" } }, out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.macs.h" } },
}, },
["emission-model"] = {
module = "passes.emission_model",
kind = "validation",
deps = {"components"},
desc = "Build canonical per-atom words, markers, and invocation ancestry",
out = {},
},
annotation = { annotation = {
module = "passes.annotation", module = "passes.annotation",
kind = "validation", kind = "validation",
@@ -158,7 +161,7 @@ local PASSES = {
offsets = { offsets = {
module = "passes.offsets", module = "passes.offsets",
kind = "header-output", kind = "header-output",
deps = {"scan-source", "word-counts", "components"}, deps = {"scan-source", "word-counts", "components", "emission-model"},
groups = { "pre-link" }, groups = { "pre-link" },
desc = "Compute branch offsets for atom_label / atom_offset", desc = "Compute branch offsets for atom_label / atom_offset",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.offsets.h" } }, out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.offsets.h" } },
@@ -169,14 +172,14 @@ local PASSES = {
-- the orchestrator does NOT exit non-zero on these findings (see PASS_KIND_STOP_ON_ERROR). -- the orchestrator does NOT exit non-zero on these findings (see PASS_KIND_STOP_ON_ERROR).
-- Report severity is independent from process exit policy. -- Report severity is independent from process exit policy.
kind = "diagnostic", kind = "diagnostic",
deps = {"scan-source", "word-counts", "components"}, deps = {"scan-source", "word-counts", "components", "emission-model"},
desc = "Static analysis: GTE pipeline-fill, mac_yield uniformity, ABI handoff, GPU port-store shape, per-atom cycle budget, type consistency", desc = "Static analysis: GTE pipeline-fill, mac_yield uniformity, ABI handoff, GPU port-store shape, per-atom cycle budget, type consistency",
out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } }, out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } },
}, },
["atoms-source-map"] = { ["atoms-source-map"] = {
module = "passes.atoms_source_map", module = "passes.atoms_source_map",
kind = "header-output", kind = "header-output",
deps = {"word-counts", "components"}, deps = {"word-counts", "components", "emission-model"},
desc = "Emit gen/<basename>.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/<basename>.atoms.provenance.txt (per-.word provenance; each word tagged with its call-site file:line and, when emitted by a mac_X(...) component invocation, 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.", desc = "Emit gen/<basename>.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/<basename>.atoms.provenance.txt (per-.word provenance; each word tagged with its call-site file:line and, when emitted by a mac_X(...) component invocation, 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.",
out = { out = {
{ kind = "report", path_template = "<out_root>/<basename>.atoms.sourcemap.txt" }, { kind = "report", path_template = "<out_root>/<basename>.atoms.sourcemap.txt" },
@@ -241,10 +244,8 @@ end
local function request_roots_for_group(args, group_name) local function request_roots_for_group(args, group_name)
local roots = roots_for_group(group_name) local roots = roots_for_group(group_name)
if #roots == 0 then if #roots == 0 then
error(string.format( error(string.format("ps1_meta: build-phase group %q has zero roots in PASSES; check PASSES rows for a `groups = { %q }` field"
"ps1_meta: build-phase group %q has zero roots in PASSES; " , group_name, group_name))
.. "check PASSES rows for a `groups = { %q }` field",
group_name, group_name))
end end
for _, name in ipairs(roots) do for _, name in ipairs(roots) do
args.requested_set[#args.requested_set + 1] = name args.requested_set[#args.requested_set + 1] = name
@@ -255,8 +256,7 @@ end
-- --
-- Report severity is independent from process exit policy. A "diagnostic" pass still writes every `error`/`warning` finding into its report file, -- Report severity is independent from process exit policy. A "diagnostic" pass still writes every `error`/`warning` finding into its report file,
-- but `report_validation_errors` returns early for non-stopping kinds, so nothing is printed to stderr and the orchestrator does not exit non-zero. -- but `report_validation_errors` returns early for non-stopping kinds, so nothing is printed to stderr and the orchestrator does not exit non-zero.
-- -- Adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true".
-- Closed-set discipline: adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true".
local PASS_KIND_STOP_ON_ERROR = { local PASS_KIND_STOP_ON_ERROR = {
["shared"] = false, ["shared"] = false,
["header-output"] = true, ["header-output"] = true,
@@ -268,9 +268,8 @@ local PASS_KIND_STOP_ON_ERROR = {
-- Closed set of CLI flags -> pass names. -- Closed set of CLI flags -> pass names.
-- Per-pass flags (e.g. --word-counts) live here; phase flags (--pre-link, --post-link, --all) -- Per-pass flags (e.g. --word-counts) live here; phase flags (--pre-link, --post-link, --all)
-- live in FLAG_HANDLERS because they own side effects or invoke group-derivation logic. -- live in FLAG_HANDLERS because they own side effects or invoke group-derivation logic.
-- dwarf-injection is *also* a per-pass opt-in flag, but its selection + opt-in state are both owned by the -- dwarf-injection is *also* a per-pass opt-in flag, but its selection + opt-in state are both owned by the explicit FLAG_HANDLERS entry below
-- explicit FLAG_HANDLERS entry below (it sets args.flags.dwarf_injection and appends "dwarf-injection" to requested_set), -- (it sets args.flags.dwarf_injection and appends "dwarf-injection" to requested_set), so it is intentionally absent from this table.
-- so it is intentionally absent from this table.
local PASS_FLAG_TO_NAME = { local PASS_FLAG_TO_NAME = {
["--word-counts"] = "word-counts", ["--word-counts"] = "word-counts",
["--components"] = "components", ["--components"] = "components",
@@ -297,7 +296,7 @@ end
-- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). -- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it).
-- Returning nil + os.exit() handles termination flags (--help). -- Returning nil + os.exit() handles termination flags (--help).
-- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan.
local FLAG_HANDLERS = {} local FLAG_HANDLERS = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -336,10 +335,14 @@ PASS_FLAGS:
--report Render per-project summary --report Render per-project summary
COMMON_FLAGS: COMMON_FLAGS:
--source FILE Source file to process (repeatable) --unity-root FILE Unity source root: load root + direct quoted authored
includes only. Mutually exclusive with --source.
--source FILE Exact source file to process (repeatable, never expands
includes). Mutually exclusive with --unity-root.
--metadata PATH Path to metadata.h (required) --metadata PATH Path to metadata.h (required)
--out-root DIR Output root for reports (default: build/gen) --out-root DIR Output root for reports (default: build/gen)
--project-root DIR Project root for .macs.h scan (default: dirname(metadata)) --project-root DIR PS1 repository root (default: derived from
<repo>/code/duffle/word_count.metadata.h)
--gdb-runtime Also emit <out_root>/gdb_tape_atoms_runtime.gdb (post-link, requires --elf) --gdb-runtime Also emit <out_root>/gdb_tape_atoms_runtime.gdb (post-link, requires --elf)
--elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection) --elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection)
--dry-run Print dep order (alphabetical); exit 0 without running --dry-run Print dep order (alphabetical); exit 0 without running
@@ -351,17 +354,36 @@ EXIT CODES:
1 Validation errors found 1 Validation errors found
2 Metaprogram internal error 2 Metaprogram internal error
EXAMPLE: EXAMPLES:
ps1_meta.lua --pre-link --metadata metadata.h --source code/foo.c --source code/bar.c ps1_meta.lua --pre-link --metadata code/duffle/word_count.metadata.h --unity-root code/gte_hello/hello_gte.c
ps1_meta.lua --post-link --metadata metadata.h --source code/foo.c --source code/bar.c --elf build/hello_gte.elf ps1_meta.lua --post-link --metadata code/duffle/word_count.metadata.h --unity-root code/gte_hello/hello_gte.c --elf build/hello_gte.elf
ps1_meta.lua --all --metadata metadata.h --source code/foo.c --source code/bar.c ps1_meta.lua --all --metadata metadata.h --source code/foo.c --source code/bar.c
]]) ]])
end end
local FLAG_VALUE_NAMES = {
["--source"] = "FILE",
["--unity-root"] = "FILE",
["--metadata"] = "PATH",
["--out-root"] = "DIR",
["--project-root"] = "DIR",
["--elf"] = "PATH",
}
local function require_flag_value(argv, arg_idx, flag)
local value = argv[arg_idx + 1]
local next_known = type(value) == "string"
and (FLAG_HANDLERS[value] ~= nil or PASS_FLAG_TO_NAME[value] ~= nil)
if value == nil or next_known then
io.stderr:write("ps1_meta: " .. flag .. " requires "
.. FLAG_VALUE_NAMES[flag] .. "\n")
os.exit(EXIT_INTERNAL_ERROR)
end
return value, arg_idx + 1
end
-- Per-flag handlers. Each takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). -- Per-flag handlers. Each takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it).
-- Termination flags like --help call os.exit() instead. -- Termination flags like --help call os.exit() instead.
-- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan.
--
-- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time, -- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time,
-- but if the closure is defined before the local, it falls back to _G). -- but if the closure is defined before the local, it falls back to _G).
FLAG_HANDLERS["--help"] = function(args) FLAG_HANDLERS["--help"] = function(args)
@@ -369,17 +391,46 @@ FLAG_HANDLERS["--help"] = function(args)
os.exit(0) os.exit(0)
end end
FLAG_HANDLERS["--dry-run"] = function(args) args.dry_run = true end FLAG_HANDLERS["--dry-run"] = function(args) args.dry_run = true end
FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end
FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) args.sources[#args.sources + 1] = argv[arg_idx + 1]; return arg_idx + 1 end FLAG_HANDLERS["--source"] = function(args, argv, arg_idx)
FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) args.metadata = argv[arg_idx + 1]; return arg_idx + 1 end local value, value_idx = require_flag_value(argv, arg_idx, "--source")
FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) args.out_root = argv[arg_idx + 1]; return arg_idx + 1 end args.sources[#args.sources + 1] = value
FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_root = argv[arg_idx + 1]; return arg_idx + 1 end return value_idx
end
FLAG_HANDLERS["--unity-root"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--unity-root")
args.unity_root = value
return value_idx
end
FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--metadata")
args.metadata = value
return value_idx
end
FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--out-root")
args.out_root = value
return value_idx
end
FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--project-root")
args.project_root = value
return value_idx
end
-- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the post-link gdb-runtime emission. -- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the post-link gdb-runtime emission.
-- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`). -- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`).
FLAG_HANDLERS["--gdb-runtime"] = function(args) args.flags = args.flags or {}; args.flags.gdb_runtime = true end FLAG_HANDLERS["--gdb-runtime"] = function(args)
FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx) args.flags = args.flags or {}; args.flags.elf_path = argv[arg_idx + 1]; return arg_idx + 1 end args.flags = args.flags or {}
args.flags.gdb_runtime = true
end
FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--elf")
args.flags = args.flags or {}
args.flags.elf_path = value
return value_idx
end
-- Enable DWARF injection (default OFF). Opts in to the post-link pass and sets the flag in one shot. -- Enable DWARF injection (default OFF). Opts in to the post-link pass and sets the flag in one shot.
-- The explicit handler below owns both selection and opt-in state, so --dwarf-injection is intentionally absent from PASS_FLAG_TO_NAME. -- The explicit handler below owns both selection and opt-in state, so --dwarf-injection is intentionally absent from PASS_FLAG_TO_NAME.
FLAG_HANDLERS["--dwarf-injection"] = function(args) FLAG_HANDLERS["--dwarf-injection"] = function(args)
@@ -402,7 +453,7 @@ FLAG_HANDLERS["--post-link"] = function(args)
request_roots_for_group(args, "post-link") request_roots_for_group(args, "post-link")
end end
-- (atom locals) is now consolidated into --dwarf-injection; no separate flag. -- `--dwarf-injection` also emits atom-local debug data.
-- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set. -- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set.
FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a) FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a)
@@ -421,6 +472,7 @@ local function parse_args(argv)
local args = { local args = {
requested_set = {}, requested_set = {},
sources = {}, sources = {},
unity_root = nil,
metadata = nil, metadata = nil,
out_root = DEFAULT_OUT_ROOT, out_root = DEFAULT_OUT_ROOT,
project_root = nil, project_root = nil,
@@ -448,21 +500,28 @@ local function parse_args(argv)
-- The first invocation of a build is always pre-link, so this avoids silently also invoking post-link work in builds without an ELF artifact. -- The first invocation of a build is always pre-link, so this avoids silently also invoking post-link work in builds without an ELF artifact.
if #args.requested_set == 0 then request_roots_for_group(args, "pre-link") end if #args.requested_set == 0 then request_roots_for_group(args, "pre-link") end
-- Defaults: project_root = dirname(metadata).
if args.metadata and not args.project_root then
local d = duffle.dirname(args.metadata)
if #d > 0 and (d:sub(-1) == "/" or d:sub(-1) == "\\") then
d = d:sub(1, -2)
end
args.project_root = duffle.dirname(d)
end
if not args.metadata then if not args.metadata then
io.stderr:write("ps1_meta: --metadata PATH is required\n") io.stderr:write("ps1_meta: --metadata PATH is required\n")
os.exit(EXIT_INTERNAL_ERROR) os.exit(EXIT_INTERNAL_ERROR)
end end
if #args.sources == 0 then
io.stderr:write("ps1_meta: at least one --source FILE is required\n") -- `<repo>/code/duffle/word_count.metadata.h` is the canonical metadata location.
-- `project_root` names `<repo>`; the resolver derives `<project_root>/code` separately.
if not args.project_root then
local metadata_dir = duffle.dirname(duffle.normalize_path(args.metadata))
local code_root = duffle.dirname(metadata_dir)
args.project_root = duffle.dirname(code_root)
else
args.project_root = duffle.normalize_path(args.project_root)
end
local has_unity = type(args.unity_root) == "string" and args.unity_root ~= ""
if has_unity and #args.sources > 0 then
io.stderr:write("ps1_meta: --unity-root FILE and --source FILE are mutually exclusive\n")
os.exit(EXIT_INTERNAL_ERROR)
end
if not has_unity and #args.sources == 0 then
io.stderr:write("ps1_meta: either --unity-root FILE or at least one --source FILE is required\n")
os.exit(EXIT_INTERNAL_ERROR) os.exit(EXIT_INTERNAL_ERROR)
end end
@@ -485,56 +544,128 @@ end
-- Build ctx from parsed args -- Build ctx from parsed args
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Build the PassCtx from parsed args. Reads each source file once at startup; --- Build the PassCtx from parsed args. Exact mode opens only the repeated `--source` inputs;
--- passes consume `src.text`, not the path (path is preserved for error reporting). --- unity mode delegates direct-include resolution to duffle.resolve_source_corpus`.
--- Scanning remains pass-owned (`src.scan`).
--- @param args ParsedArgs --- @param args ParsedArgs
--- @return PassCtx --- @return PassCtx
local function build_ctx(args) local function build_ctx(args)
local sources = {} local normalized_project_root = duffle.normalize_path(args.project_root)
for _, path in ipairs(args.sources) do local project_root = normalized_project_root
-- lfs handles path metadata and directories, not file-content streams. local project_root_is_absolute = normalized_project_root:match("^%a:/")
-- Keep this io.open local so this entry point preserves its tailored diagnostic and exit path below. or normalized_project_root:sub(1, 2) == "//"
local f = io.open(path, "r") or normalized_project_root:sub(1, 1) == "/"
if not f then if not project_root_is_absolute then
io.stderr:write("ps1_meta: cannot open --source " .. path .. "\n") -- canonical_path_key validates ordinary relative paths and rejects
-- drive-relative paths before the legacy display-path helper is used.
duffle.canonical_path_key(normalized_project_root)
project_root = duffle.normalize_path(duffle.to_absolute_path(normalized_project_root))
else
-- Do not route POSIX/UNC/drive-absolute paths through to_absolute_path.
duffle.canonical_path_key(project_root)
end
local resolution
if args.unity_root then
local ok_resolve, resolved = pcall(duffle.resolve_source_corpus, {
unity_root = args.unity_root,
project_root = project_root,
})
if not ok_resolve then
io.stderr:write("ps1_meta: cannot resolve --unity-root "
.. tostring(args.unity_root) .. ": " .. tostring(resolved) .. "\n")
os.exit(EXIT_INTERNAL_ERROR) os.exit(EXIT_INTERNAL_ERROR)
end end
local text = f:read("*a") resolution = resolved
f:close() else
local source_order = {}
local sources_by_path = {}
local resolver = {
resolved = {},
skipped = {},
shadowed = {},
}
for _, input_path in ipairs(args.sources) do
local path = duffle.normalize_path(input_path)
local key_ok, key_or_error = pcall(duffle.canonical_path_key, path)
if not key_ok then
error("ps1_meta: invalid --source " .. input_path .. ": "
.. tostring(key_or_error), 0)
end
local file = io.open(path, "r")
if not file then
io.stderr:write("ps1_meta: cannot open --source " .. input_path .. "\n")
os.exit(EXIT_INTERNAL_ERROR)
end
local text = file:read("*a")
file:close()
local dir = duffle.dirname(path) local source = {
local basename = duffle.basename_no_ext(path) path = path,
if #dir > 0 and (dir:sub(-1) == "/" or dir:sub(-1) == "\\") then text = text,
dir = dir:sub(1, -2) dir = duffle.dirname(path),
basename = duffle.basename_no_ext(path),
}
source_order[#source_order + 1] = source
local key = key_or_error
if not sources_by_path[key] then sources_by_path[key] = source end
resolver.resolved[#resolver.resolved + 1] = {
include_path = path,
include_text = nil,
root_source = nil,
root_line = nil,
candidate_a = path,
candidate_b = nil,
selected_path = path,
disposition = "exact",
}
end end
resolution = {
-- src.scan is populated by the "scan-source" pass (the first pass in the dep graph). unity_root = nil,
-- build_ctx just opens + reads the files; the scan itself happens in the pass module, not inline in the orchestrator. project_root = project_root,
sources[#sources + 1] = { code_root = duffle.normalize_path(project_root .. "/code"),
path = path, source_order = source_order,
text = text, sources_by_path = sources_by_path,
dir = dir, sources_by_dir = duffle.group_sources_by_dir(source_order),
basename = basename, resolver = resolver,
} }
end end
-- Pre-compute the per-directory grouping once (Fleury: expose structure). local corpus = {
-- Three passes (annotation, report, static-analysis) call group_sources_by_dir with the same ctx.sources; unity_root = resolution.unity_root,
-- computing it here and stashing on ctx.by_dir eliminates 2 redundant calls. project_root = resolution.project_root,
local by_dir = duffle.group_sources_by_dir(sources) code_root = resolution.code_root,
source_order = resolution.source_order,
return { sources_by_path = resolution.sources_by_path,
sources = sources, sources_by_dir = resolution.sources_by_dir,
by_dir = by_dir, atoms_by_name = {},
binds_by_name = {},
atom_infos = {},
register_alias_registry = {},
type_name_registry = {},
atom_views = {},
atom_ctxs = {},
atom_phases = {},
word_counts = {},
components = {},
component_body_index = {},
collisions = {},
resolver = resolution.resolver,
}
local ctx = {
metadata_path = args.metadata, metadata_path = args.metadata,
shared = {}, shared = { corpus = corpus },
upstream = {}, upstream = {},
out_root = args.out_root, out_root = args.out_root,
project_root = args.project_root, project_root = corpus.project_root,
flags = args.flags or {}, flags = args.flags or {},
dry_run = args.dry_run, dry_run = args.dry_run,
verbose = args.verbose, verbose = args.verbose,
} }
-- Source records and directory buckets are owned by the corpus.
-- Consumers read `corpus.source_order` and `corpus.sources_by_dir` directly.
-- The corpus is the sole source of truth for source records and module grouping; `ctx` only holds per-pass execution state.
return ctx
end end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -543,14 +674,14 @@ end
--- Topologically sort the requested pass set, augmented with all transitive deps. --- Topologically sort the requested pass set, augmented with all transitive deps.
--- Detects cycles and errors out with details. --- Detects cycles and errors out with details.
--- @param passes table<string, PassDescriptor> --- @param passes table<string, PassDescriptor>
--- @param requested_set string[] --- @param requested_set string[]
--- @return string[] -- execution order --- @return string[] -- execution order
--- ---
--- Implementation note: the 4 algorithm phases (dep-closure, in-degree, ready-queue, sort) are inlined as 3 small blocks within this function. --- Dependency closure, in-degree calculation, queue seeding, and sorting are local blocks.
--- Each was a 1-caller helper; the 2-caller rule doesn't apply, so inlining produces a single readable function --- Keeping these blocks local makes the topological sort self-contained.
local function topo_sort(passes, requested_set) local function topo_sort(passes, requested_set)
-- Phase 1: dep-closure. Include every pass name transitively required by `requested_set`. -- Dependency closure: include every pass transitively required by `requested_set`.
local needed = {} local needed = {}
for _, name in ipairs(requested_set) do needed[name] = true end for _, name in ipairs(requested_set) do needed[name] = true end
local changed = true local changed = true
@@ -570,7 +701,7 @@ local function topo_sort(passes, requested_set)
end end
end end
-- Phase 2: in-degrees for Kahn's algorithm. For each pass in `needed`, the number of its deps that are also in `needed`. -- In-degree calculation: count each needed pass's needed dependencies.
local in_degree = {} local in_degree = {}
for name, _ in pairs(needed) do in_degree[name] = 0 end for name, _ in pairs(needed) do in_degree[name] = 0 end
for name, _ in pairs(needed) do for name, _ in pairs(needed) do
@@ -581,14 +712,14 @@ local function topo_sort(passes, requested_set)
end end
end end
-- Phase 3: seed the ready queue with passes whose in-degree is 0, sorted alphabetically for deterministic order. -- Ready-queue seeding: add zero-in-degree passes in deterministic order.
local ready = {} local ready = {}
for name, deg in pairs(in_degree) do for name, deg in pairs(in_degree) do
if deg == 0 then ready[#ready + 1] = name end if deg == 0 then ready[#ready + 1] = name end
end end
table.sort(ready) table.sort(ready)
-- Phase 4: drain the ready queue. For each popped pass, decrement the in-degree of every remaining pass that depended on it. -- Ready-queue drain: decrement dependents when each pass is emitted.
-- Newly-zero-degree passes are inserted back into the ready queue (kept sorted). -- Newly-zero-degree passes are inserted back into the ready queue (kept sorted).
local order = {} local order = {}
while #ready > 0 do while #ready > 0 do
@@ -629,29 +760,8 @@ end
-- ASCII dep graph renderer (Decision 6 in the spec) -- ASCII dep graph renderer (Decision 6 in the spec)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Topological dep-order printer (used by --dry-run).
---
--- ASCII art graph rendering was removed from this file.
--- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual;
--- the canonical ASCII view there is regenerated by hand whenever PASSES rows change.
-- ════════════════════════════════════════════════════════════════════════════
local function render_dep_order(passes, closed)
local lines = {}
lines[#lines + 1] = "[ps1_meta] Resolved dependency order (closed under deps):"
for pass_idx, name in ipairs(closed) do
local p = passes[name]
local deps_str = (#p.deps == 0) and "(no deps)" or
"(deps: " .. table.concat(p.deps, ", ") .. ")"
lines[#lines + 1] = string.format(" %d. %-22s %-45s [%s]",
pass_idx, name, deps_str, p.kind)
end
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Topological dep-order printer (used by --dry-run). -- Topological dep-order printer (used by --dry-run).
--
-- ASCII art graph rendering was removed from this file.
-- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual; -- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual;
-- The canonical ASCII view there is regenerated by hand whenever PASSES rows change. -- The canonical ASCII view there is regenerated by hand whenever PASSES rows change.
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -669,54 +779,46 @@ local function render_dep_order(passes, closed)
end end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Main orchestrator -- Main Orchestrator
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` for downstream passes to consume. --- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` for downstream passes to consume.
-- @param ctx PassCtx --- @param ctx PassCtx
-- @param pass_name string --- @param pass_name string
-- @param result PassResult --- @param result PassResult
local function accumulate_pass_result(ctx, pass_name, result) local function accumulate_pass_result(ctx, pass_name, result)
ctx.upstream[pass_name] = ctx.upstream[pass_name] or {} ctx.upstream[pass_name] = ctx.upstream[pass_name] or {}
for _, out in ipairs(result.outputs or {}) do for _, out in ipairs(result.outputs or {}) do table.insert(ctx.upstream[pass_name], out) end
table.insert(ctx.upstream[pass_name], out) for _, warn in ipairs(result.warnings or {}) do table.insert(ctx.upstream[pass_name], warn) end
end
for _, warn in ipairs(result.warnings or {}) do
table.insert(ctx.upstream[pass_name], warn)
end
end end
-- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it reported errors, write each error to stderr. --- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it reported errors, write each error to stderr.
-- Returns true if any validation errors were reported. --- Returns true if any validation errors were reported.
-- @param pass_name string --- @param pass_name string
-- @param pass PassDescriptor --- @param pass PassDescriptor
-- @param result PassResult --- @param result PassResult
-- @return boolean --- @return boolean
local function report_validation_errors(pass_name, pass, result) local function report_validation_errors(pass_name, pass, result)
local has_errors = result.errors and #result.errors > 0 local has_errors = result.errors and #result.errors > 0
if not (has_errors and PASS_KIND_STOP_ON_ERROR[pass.kind]) then if not (has_errors and PASS_KIND_STOP_ON_ERROR[pass.kind]) then return false end
return false
end
for _, e in ipairs(result.errors) do for _, e in ipairs(result.errors) do
io.stderr:write(string.format("[%s] line %d: %s\n", io.stderr:write(string.format("[%s] line %d: %s\n", pass_name, e.line or 0, e.msg or ""))
pass_name, e.line or 0, e.msg or ""))
end end
return true return true
end end
-- (internal) Run each pass in `order` in topological sequence. --- (internal) Run each pass in `order` in topological sequence.
-- @param ctx PassCtx --- @param ctx PassCtx
-- @param order string[] --- @param order string[]
-- @return boolean -- true if any validation errors were reported --- @return boolean -- true if any validation errors were reported
local function dispatch_passes(ctx, order) local function dispatch_passes(ctx, order)
ctx.shared = {} ctx.shared = ctx.shared or {}
local had_errors = false local had_errors = false
for _, pass_name in ipairs(order) do for _, pass_name in ipairs(order) do
local pass = PASSES[pass_name] local pass = PASSES[pass_name]
io.stderr:write(string.format("[ps1_meta] %-22s running\n", pass_name)) -- io.stderr:write(string.format("[ps1_meta] %-22s running\n", pass_name))
local mod = require(pass.module) local mod = require(pass.module)
local result = mod.run(ctx) local result = mod.run(ctx)
accumulate_pass_result(ctx, pass_name, result) accumulate_pass_result(ctx, pass_name, result)
if report_validation_errors(pass_name, pass, result) then if report_validation_errors(pass_name, pass, result) then
had_errors = true had_errors = true
@@ -755,14 +857,15 @@ local function main(argv)
end end
-- Module export for in-process consumers (tests that dofile this script). -- Module export for in-process consumers (tests that dofile this script).
-- The closed dep-order printer + the canonical `PASSES` table are exposed so a test -- The closed dep-order printer + the `PASSES` table are exposed so a test can observe the resolved dep order for synthetic PASSES tables without spawning a subprocess.
-- can observe the resolved dep order for synthetic PASSES tables without spawning a subprocess.
-- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua"); -- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua");
-- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller. -- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller.
local _M = { local _M = {
render_dep_order = render_dep_order, render_dep_order = render_dep_order,
PASSES = PASSES, PASSES = PASSES,
PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR, PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR,
parse_args = parse_args,
build_ctx = build_ctx,
} }
if arg and arg[0] and arg[0]:match("ps1_meta%.lua$") then if arg and arg[0] and arg[0]:match("ps1_meta%.lua$") then
+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
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════