static analysis first pass

This commit is contained in:
2026-07-10 12:01:17 -04:00
parent 2ceb2f2a05
commit 7a168137fc
3 changed files with 596 additions and 9 deletions
+7 -5
View File
@@ -440,6 +440,8 @@ enum { _C2_TX_SUBS_ = 0
#define gte_cmdw_rtpt (gte_cmd_base | enc_gte_cmd(gte_cmd_rtpt ) | gte_cmdw_psyq_compat)
#define gte_cmdw_nclip (gte_cmd_base | enc_gte_cmd(gte_cmd_nclip))
#define gte_cmdw_op (gte_cmd_base | enc_gte_cmd(gte_cmd_op ))
#define gte_cmdw_outer_product gte_cmdw_op /* "outer product" -- NOCASH/Sdk terminology */
#define gte_cmdw_wedge gte_cmdw_op /* "wedge product" -- geometric-algebra terminology */
#define gte_cmdw_mvmva (gte_cmd_base | enc_gte_cmd(gte_cmd_mvmva))
#define gte_cmdw_rotate_translate_perspective_single gte_cmdw_rtps
@@ -713,14 +715,14 @@ enum {
asm_words( \
load_word(R_T5, R_T4, 0) \
, load_word(R_T6, R_T4, 4) \
, gte_mt( R_T5, 0) \
, gte_mt( R_T6, 1) \
, gte_mv_to_data_r( R_T5, 0) \
, gte_mv_to_data_r( R_T6, 1) \
, load_word(R_T5, R_T4, 8) \
, load_word(R_T6, R_T4, 12) \
, load_word(R_T4, R_T4, 16) \
, gte_mt( R_T5, 2) \
, gte_mt( R_T6, 3) \
, gte_mt( R_T4, 4) \
, gte_mv_to_data_r( R_T5, 2) \
, gte_mv_to_data_r( R_T6, 3) \
, gte_mv_to_data_r( R_T4, 4) \
) \
, r_use(r0) \
asm_clobber: clbr_volatile_gprs, rlit(R_T4), rlit(R_T5), rlit(R_T6) \
+78
View File
@@ -520,6 +520,84 @@ M.TAPE_ATOM_MACROS = {
["atom_info"] = { kind = "info", binds = false },
}
-- GTE pipeline-fill latency table (static-analysis Phase 1).
--
-- For each `gte_cmdw_*` macro in code/duffle/gte.h, the minimum number
-- of consecutive COP2 "nop" words that MUST appear before any other
-- COP2 read or non-nop instruction (so the GTE pipeline latency is
-- fully retired). Latencies are sourced from the doxygen comments
-- in gte.h (e.g. `* @brief Rotate, Translate and Perspective Triple
-- (23 cycles)` with body `Two nop words fill the COP2 pipeline
-- latency`).
--
-- The check (`scripts/passes/static_analysis.lua ::
-- check_gte_pipeline_fill`) walks each atom body, counts the
-- consecutive nop words after every `gte_cmdw_*` invocation, and
-- reports a finding if the count is below this minimum. Aliases
-- are dereferenced before lookup (gté_cmdw_rtps_alias ->
-- gte_cmdw_rtps -> 2).
--
-- Values verified against PSX-SPX gte.txt (rtpt 23cy / 8cy per divide
-- => 2 nops; nclip 8cy => 2 nops; avsz3/avsz4 14cy => 2 nops; op
-- single-cycle atomic => 0 nops; mvmva 8cy matrix-vector => 2 nops).
M.GTE_PIPELINE_LATENCY = {
-- Minimum number of consecutive `nop` words that must appear
-- IMMEDIATELY BEFORE a `gte_cmdw_<X>` invocation -- to retire
-- any preceding `lwc2` / `swc2` / pre-existing C2 state writes
-- before the GTE pipeline starts reading from V0/V1/V2 or
-- MAC0..3 / OTZ / IR0..3 at the command's issue cycle.
--
-- Values are from the doxygen comments in code/duffle/gte.h and
-- cross-checked against PSX-SPX `geometrytransformationenginegte.md`:
--
-- cmd cycles min pre-nops rationale
-- rtps 14 2 8c per perspective divide + 6c for IR1..4 + mac write
-- rptt 22 2 3x rtps worth of pipeline depth
-- nclip 7 2 MAC0 write + 5c for sign
-- avsz3 14 2 14c to compute average + write OTZ
-- avsz4 16 2 avsz3 + 2c extra for avg over 4
-- mvmva 8 2 IR1..4 write + matrix work
-- op 5 0 output to MAC0 only (atomic 5c calc)
--
-- The `gte_rtpt()` / `gte_nclip()` / `gte_avsz3()` wrapper macros in
-- gte.h emit the pre-cmd nops internally (asm_words(nop, nop, ...)),
-- but THOSE WRAPPERS ARE NOT USED INSIDE ATOM BODIES in this
-- codebase. Every MipsAtom_(name) body uses raw `nop2,
-- gte_cmdw_<X>, ...` form instead -- that `nop2,` is the pre-fill
-- this check validates. So values here must reflect the source-level
-- convention, NOT the wrapper-internal pre-fill (which is invisible
-- at the source level).
--
-- Existing clean-atom bodies (cube_g4_face, floor_f3_face,
-- diag_gte) all emit `nop2,` before every `gte_cmdw_<X>` (which
-- matches values >= 2). The check passes them all.
--
-- Aliases are listed separately because source code may use either
-- the alias or the canonical name. The check looks up the EXACT
-- macro text, so both forms must be in the table.
-- Canonical macros (from code/duffle/gte.h)
["gte_cmdw_rtps"] = 2,
["gte_cmdw_rtpt"] = 2,
["gte_cmdw_nclip"] = 2,
["gte_cmdw_op"] = 0,
["gte_cmdw_mvmva"] = 2,
["gte_cmdw_avsz3"] = 2,
["gte_cmdw_avsz4"] = 2,
-- Aliases (must have the same value as their canonical target)
["gte_cmdw_rotate_translate_perspective_single"] = 2,
["gte_cmdw_rotate_translate_perspective_triple"] = 2,
["gte_cmdw_avg_sort_z4"] = 2,
-- Outer product aliases (same canonical op, 0 pre-fill nops).
-- gte_cmdw_op = canonical GTE-internal short form
-- gte_cmdw_outer_product = NOCASH / SDK-readable form
-- gte_cmdw_wedge = geometric-algebra (exterior-product) form
["gte_cmdw_outer_product"] = 0,
["gte_cmdw_wedge"] = 0,
}
-- Expose the lpeg_ok flag so callers can detect the LPeg-back path.
-- True when LPeg was successfully required and the patterns above were
-- compiled at module load time. False when running in fallback mode.
+511 -4
View File
@@ -1,7 +1,496 @@
-- passes/static_analysis.lua
--
-- [FUTURE] Per-atom static-analysis checks. Stub for now; the upcoming
-- static_analysis_atoms_20260708 track will deliver the 5 (+1) checks.
-- Per-atom static-analysis checks for the tape-atom build pipeline.
-- Currently ships Phase 1 checks (GTE pipeline-fill + mac_yield
-- uniformity). Phases 2/3 (ABI handoff discipline, GPU port-store
-- shape, per-atom cycle budget) extend this file.
--
-- Workspace boundary: same conventions as annotation.lua
-- - primitives from duffle.lua (read_parens, read_braces, scan_to_char, LineIndex)
-- - LPeg not needed: pure hand-rolled string scanning
-- - 5.3-compatible (no <close>, no continue keyword)
-- - no :match/:gmatch
-- - tab indent, EmmyLua @class/@param annotations
--
-- The orchestrator (ps1_meta.lua) wires this module in via the
-- PASSES table:
-- ["static-analysis"] = {
-- module = "passes.static_analysis",
-- kind = "validation", -- errors stop the build
-- deps = {"word-counts", "components"},
-- out = { { kind = "report",
-- path_template = "<out_root>/<basename>.static_analysis.txt" } },
-- }
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
local script_path = arg and arg[0] or "?"
local last_sep = 0
for i = 1, #script_path do
local c = script_path:sub(i, i)
if c == "/" or c == "\\" then last_sep = i end
end
local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep)
package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path
local duffle = require("duffle")
local read_ident = duffle.read_ident
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local read_brackets = duffle.read_brackets
local scan_to_char = duffle.scan_to_char
local split_top_level_commas = duffle.split_top_level_commas
local trim = duffle.trim
local ensure_dir = duffle.ensure_dir
local write_file = duffle.write_file
local basename_no_ext = duffle.basename_no_ext
-- Latency table lives in duffle.lua (shared between this pass + future
-- per-atom cycle-budget pass). Lazily read on first use.
local GTE_PIPELINE_LATENCY = duffle.GTE_PIPELINE_LATENCY
-- ════════════════════════════════════════════════════════════════════════════
-- Source walkers
-- ════════════════════════════════════════════════════════════════════════════
--- Walk source-as-written, return a list of `{line, name, body, body_off}`
--- for every `MipsAtom_(name) { body }` declaration. Comments / strings
--- inside `name` and `body` are tolerated; `body` is the raw brace
--- inner text (with surrounding whitespace, no leading/trailing `{` `}`).
--- `body_off` is the character offset of `body[1]` in `source_text`,
--- used to compute per-token line numbers later.
local function find_atom_bodies(source_text)
local line_of = duffle.LineIndex(source_text)
local out = {}
local len = #source_text
local i = 1
while i <= len do
i = skip_ws_and_cmt(source_text, i); if i > len then break end
local ident, after = read_ident(source_text, i)
if not ident then
i = i + 1
elseif ident == "MipsAtom_" then
local open = skip_ws_and_cmt(source_text, after)
if source_text:sub(open, open) ~= "(" then
i = open + 1
else
local inner, after_paren = read_parens(source_text, open)
local n = 1
while n <= #inner and inner:sub(n, n):match("[%s]") do n = n + 1 end
local m = n
while m <= #inner and inner:sub(m, m):match("[%w_]") do m = m + 1 end
local name = inner:sub(n, m - 1)
if name ~= "" then
local brace = scan_to_char(source_text, "{", after_paren)
if brace then
local body, after_brace = read_braces(source_text, brace)
-- `body` starts at brace + 1 (the char after `{`).
local body_off = brace + 1
out[#out + 1] = {
line = line_of(i),
name = name,
body = body,
body_off = body_off,
}
i = after_brace
else
i = open + 1
end
else
i = open + 1
end
end
else
i = after
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Body tokenizer (top-level comma splitter + per-token classification)
-- ════════════════════════════════════════════════════════════════════════════
--- Build a map: `body_relative_char_offset` -> `body_relative_line`.
--- Used by the checks to convert per-token offsets in the body to line
--- numbers relative to the start of `body`. The atom's source-line of
--- the body-start is added by the caller.
---
--- Simple line-counting: count `\n` chars from offset 1 up to the
--- offset; that count + 1 is the line number (1-based).
local function build_body_line_index(body)
local index = {}
local len = #body
local newline_count = 0
for i = 1, len do
if i > 1 then
index[i] = newline_count + 1 -- line of `i` relative to body
end
if body:byte(i) == 10 then -- '\n'
newline_count = newline_count + 1
end
end
-- Offsets beyond the body still resolve to the final line
index[len + 1] = newline_count + 1
return index
end
--- Count of COP2-nop words contributed by a single top-level token.
-- `nop` -> 1
-- `nop2` -> 2 (i.e. `nop, nop` baked into one asm arg)
-- `nop,` / `nop2,` -> same as above; strip trailing comma defensively
-- anything else -> 0
--
-- (Branch-delay-slot nops like `branch_*(..., nop)` are tokenized
-- separately by split_top_level_commas: the branch arg ends before
-- the trailing comma, and `nop` becomes its own token. So no special
-- handling is needed here.)
local function nop_word_count(token)
local s = trim(token)
-- strip trailing comma(s) (defensive against raw text via, but our
-- tokenize_body already strips them; this is a safety net)
s = s:gsub(",$", "")
s = trim(s)
if s == "nop" then return 1 end
if s == "nop2" then return 2 end
return 0
end
--- Tokenize the body inner-text into a flat list of `(token, body_rel_offset)`
--- pairs (nested parens/braces/brackets are honored; comments and strings
--- are skipped). `body_rel_offset` is the char offset within `body` of the
--- start of the token — callers add it to the atom's `body_off` to get
--- an absolute source position for line tracking.
local function tokenize_body(body)
local out = {}
local len = #body
local rel = 1
while rel <= len do
-- Find next non-whitespace, non-comment start
local ws_end = skip_ws_and_cmt(body, rel)
if ws_end > rel then
rel = ws_end
end
if rel > len then break end
-- Find comma/newline/semicolon after this token. Read balanced
-- groups so commas inside parens/braces/brackets aren't treated
-- as separators. Comments / strings are skipped.
local i = rel
while i <= len do
local c = body:byte(i)
if c == 44 then break end -- ','
if c == 10 then break end -- '\n'
if c == 59 then break end -- ';'
if c == 40 then -- '('
local _, a = read_parens(body, i); i = a
elseif c == 123 then -- '{'
local _, a = read_braces(body, i); i = a
elseif c == 91 then -- '['
local _, a = read_brackets(body, i); i = a
elseif c == 34 or c == 39 then -- '"' or '\''
i = duffle.skip_str_or_cmt(body, i) + 1
else
i = i + 1
end
end
-- Extract token [rel .. i-1]
local tok = trim(body:sub(rel, i - 1))
if tok ~= "" then
out[#out + 1] = { tok = tok, rel = rel }
end
-- Move past the separator
if i <= len then
i = i + 1
-- Also skip whitespace before next token
local w = skip_ws_and_cmt(body, i)
if w > i then i = w end
end
rel = i
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #1: GTE pipeline-fill
-- ════════════════════════════════════════════════════════════════════════════
--- Walk the token list. Whenever we hit a `gte_cmdw_<X>` token, count
--- consecutive nop words starting at the next token. If count < the
--- minimum declared in `GTE_PIPELINE_LATENCY[X]`, record a finding.
---
--- Aliases (`gte_cmdw_rotate_translate_perspective_single` etc.) are
--- resolved against the lookup table directly; if a macro name is not
--- in the table, emit a soft warning (the user might have added a new
--- gte_cmdw_* but not updated duffle.lua).
local function check_gte_pipeline_fill(atoms, findings, line_of)
-- Walk the token list. Whenever we hit a `gte_cmdw_<X>` token,
-- count consecutive `nop` words IMMEDIATELY PRECEDING it (the
-- source-level `nop2, gte_cmdw_X` idiom provides the pre-pipeline
-- fill that gte.h's wrapper functions provide internally). If
-- count < `GTE_PIPELINE_LATENCY[X]`, record a finding.
--
-- We count nops going backwards from the cmdw token, stopping at
-- the first non-nop token. Tokens like `mem_share` or `port_write`
-- (any non-nop) break the count. `gte_mv_to_data_r` (writes to
-- C2_DR registers) are non-nops in this sense -- they count as
-- "previous GTE state" but don't themselves count as pipeline
-- fill.
for _, a in ipairs(atoms) do
local tokens = tokenize_body(a.body)
local line_in_body = build_body_line_index(a.body)
local tn = #tokens
local ti = 1
while ti <= tn do
local tok = tokens[ti].tok
local cmdw_full = tok:match("^(gte_cmdw_[%w_]+)%s*[,%)]")
or tok:match("^(gte_cmdw_[%w_]+)%s*$")
if cmdw_full then
local variant = cmdw_full:match("^gte_cmdw_(.+)$")
local need = GTE_PIPELINE_LATENCY[cmdw_full]
if need == nil then
-- alias or new gte_cmdw_<X> not yet in latency table
local line = a.line + line_in_body[tokens[ti].rel]
findings[#findings + 1] = {
atom = a.name,
line = line,
check = "gte_pipeline_fill",
kind = "warning",
msg = string.format(
"%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry",
a.name, line, variant),
}
ti = ti + 1
elseif need > 0 then
-- Count consecutive nops immediately BEFORE the cmdw
-- token. We walk tokens[ti - n] backwards, accumulating
-- nop_word_count, stopping at the first non-nop.
local have = 0
local where_ti = ti - 1
while where_ti >= 1 do
local n = nop_word_count(tokens[where_ti].tok)
if n == 0 then break end
have = have + n
where_ti = where_ti - 1
end
if have < need then
local line = a.line + line_in_body[tokens[ti].rel]
findings[#findings + 1] = {
atom = a.name,
line = line,
check = "gte_pipeline_fill",
kind = "error",
msg = string.format(
"%s at line %d needs %d nop word%s immediately BEFORE `gte_cmdw_%s`; only %d found",
a.name, line, need, need == 1 and "" or "s", variant, have),
}
end
ti = ti + 1
else
ti = ti + 1
end
else
ti = ti + 1
end
end
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #2: mac_yield uniformity
-- ════════════════════════════════════════════════════════════════════════════
--- Every atom body must contain exactly one `mac_yield()` call and it
--- must be the LAST top-level token in the body (so the tape runtime
--- can pick up cleanly at the next atom's bound registers).
---
--- Empty bodies are not currently flagged — runtime infrastructure
--- atoms like `MipsAtom_(yield) { mac_yield() }` and `MipsAtom_(tape_exit)
--- { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end
--- is the contract.
local function check_mac_yield_uniformity(atoms, findings)
for _, a in ipairs(atoms) do
local tokens = tokenize_body(a.body)
local line_in_body = build_body_line_index(a.body)
local count = 0
local last_idx = 0
for i, t in ipairs(tokens) do
local tok = t.tok
-- Match `mac_yield(...)` or just `mac_yield`. The bareword
-- variant is rare in modern style but tolerated.
if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then
count = count + 1
last_idx = i
end
end
local function line_for(idx)
return a.line + line_in_body[tokens[idx].rel]
end
if count == 0 then
findings[#findings + 1] = {
atom = a.name,
line = a.line,
check = "mac_yield_uniformity",
msg = string.format(
"%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end",
a.name, a.line),
}
elseif count > 1 then
findings[#findings + 1] = {
atom = a.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
msg = string.format(
"%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed",
a.name, line_for(last_idx), count),
}
elseif last_idx < #tokens then
-- 1 call, but not the last token. Check if there's anything
-- AFTER mac_yield (compiler tolerance is `macro_yield, nop`).
-- We DON'T fail if the post-token is just `nop` or `nop2` or
-- a branch with `, nop` delay slot -- it's the standard
-- "yield, then BD nop" idiom.
local post_non_nop = false
for j = last_idx + 1, #tokens do
local t = tokens[j].tok
if t ~= "" and t ~= "nop" and t ~= "nop2" and not t:match("%,%s*nop%)%s*$") then
post_non_nop = true
break
end
end
if post_non_nop then
findings[#findings + 1] = {
atom = a.name,
line = line_for(last_idx),
check = "mac_yield_uniformity",
msg = string.format(
"%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body",
a.name, line_for(last_idx), last_idx, #tokens),
}
end
end
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source validation
-- ════════════════════════════════════════════════════════════════════════════
local function validate(ctx, src)
local source = src.text
local atoms = find_atom_bodies(source)
local findings = {}
check_gte_pipeline_fill(atoms, findings)
check_mac_yield_uniformity(atoms, findings)
-- Phase 2 (ABI handoff / GPU port-store shape) and Phase 3 (cycle
-- budget) hooks go here when those tracks are reactivated.
-- check_abi_handoff(atoms, findings)
-- check_gpu_portstore_shape(atoms, findings)
-- emit per-atom cycle counts via INSTRUCTION_LATENCY table
local errors = {}
local warnings = {}
local info = {}
for _, f in ipairs(findings) do
-- Per-finding severity is set by the check via `f.kind`
-- ("error" or "warning"). A `gte_pipeline_fill` finding can be
-- either severity (errors for missing nops; warnings for unknown
-- cmdw macros not in the latency table). Bin by `kind`, not by
-- check name.
if f.kind == "error" then
errors[#errors + 1] = { line = f.line, msg = f.msg }
else
warnings[#warnings + 1] = { line = f.line, msg = f.msg }
end
end
info[#info + 1] = {
line = 0,
msg = string.format("scanned: %d atom bodies; %d findings", #atoms, #findings),
}
return {
atoms = atoms,
findings = findings,
errors = errors,
warnings = warnings,
info = info,
}
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source output: build/gen/<basename>.static_analysis.txt
-- ════════════════════════════════════════════════════════════════════════════
local function emit_static_analysis_txt(ctx, src, result)
local out_path = ctx.out_root .. "/" .. src.basename .. ".static_analysis.txt"
if ctx.dry_run then return out_path end
ensure_dir(ctx.out_root)
local lines = {}
local function add(s) lines[#lines + 1] = s end
add("========================================================")
add("STATIC ANALYSIS PASS -- " .. src.path)
add("========================================================")
add("")
add(string.format("Atoms: %d Findings: %d Errors: %d Warnings: %d",
#result.atoms, #result.findings, #result.errors, #result.warnings))
add("")
-- Group findings by atom for readability
local by_atom = {}
for _, f in ipairs(result.findings) do
by_atom[f.atom] = by_atom[f.atom] or {}
by_atom[f.atom][#by_atom[f.atom] + 1] = f
end
if next(by_atom) == nil then
add(" (no findings -- every atom passed all checks)")
else
add("── Findings by atom ─────────────────────────────────────")
for _, a in ipairs(result.atoms) do
local fs = by_atom[a.name]
if fs then
add(string.format(" %s line %d", a.name, a.line))
for _, f in ipairs(fs) do
add(string.format(" [%s] %s", f.check, f.msg))
end
end
end
end
add("")
add("── Errors ──────────────────────────────────────────────")
if #result.errors == 0 then add(" (none)") end
for _, e in ipairs(result.errors) do
add(string.format(" X line %d %s", e.line, e.msg))
end
add("")
add("── Warnings ────────────────────────────────────────────")
if #result.warnings == 0 then add(" (none)") end
for _, w in ipairs(result.warnings) do
add(string.format(" ! line %d %s", w.line, w.msg))
end
add("")
add("── Info ────────────────────────────────────────────────")
for _, i_ in ipairs(result.info) do
add(string.format(" %s", i_.msg))
end
write_file(out_path, table.concat(lines, "\n") .. "\n")
return out_path
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
--- @class M
@@ -10,7 +499,25 @@ local M = {}
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
return { outputs = {}, errors = {}, warnings = {} }
local outputs = {}
local errors = {}
local warnings = {}
for _, src in ipairs(ctx.sources) do
local result = validate(ctx, src)
local out_path = emit_static_analysis_txt(ctx, src, result)
if out_path then
table.insert(outputs, { static_analysis_txt = out_path })
end
for _, e in ipairs(result.errors) do
errors[#errors + 1] = { line = e.line, msg = e.msg }
end
for _, w in ipairs(result.warnings) do
warnings[#warnings + 1] = { line = w.line, msg = w.msg }
end
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M
return M