--- passes/scan_source.lua — Source pre-scan pass (the "mega entity" pass). --- --- Single source-walk pass that produces the fat `SourceScan` payload consumed by all downstream passes. Walks each `ctx.sources` entry once, --- extracting every construct type the metaprograms need: --- --- MipsAtom_ (kind = "atom", with optional atom_info inner) --- MipsAtomComp_ (kind = "comp_bare") --- MipsAtomComp_Proc_ (kind = "comp_proc", body inside last {}) --- atom_dbg_skip_over (whole-atom/component debug-step marker; following declaration disambiguates) --- MipsCode code_ (kind = "raw_atom", offsets pass only) --- typedef Struct_(Binds_X) { fields } --- #pragma mac_X tape_atom words=N + _Pragma("...") --- --- The result is attached to each `src.scan` so downstream passes can read from `src.scan.atoms` / `src.scan.binds` / etc. without re-walking the source. --- This is the first pass in the dep graph (no deps). --- Every other pass that reads source structure depends on this one — see `ps1_meta.lua :: PASSES`. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible -- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale. -- 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. -- 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. local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -- ════════════════════════════════════════════════════════════════════════════ -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- @class SourceScan --- @field atoms AtomEntry[] -- MipsAtom_ + MipsAtomComp_ + MipsAtomComp_Proc_ --- @field raw_atoms AtomEntry[] -- MipsCode code_ { body } (offsets pass only) --- @field binds BindsEntry[] -- typedef Struct_(Binds_X) { fields } (fields pre-parsed) --- @field atom_infos AtomInfoEntry[] -- MipsAtom_(name) atom_info(...) (sub-calls pre-parsed) --- @field macros MacroEntry[] -- #pragma mac_X tape_atom words=N + _Pragma("...") --- @field skip_over SkipOverScan -- atom/component debug-step markers + resolved declaration associations --- @field types table -- atom_dbg_reg_default(R_X, ) declarations --- @field atom_views table -- MipsAtom_(name) -> {binds_name, reg_type_overrides, info_line} --- @field atom_ctxs table -- MipsAtom_(name) -> {rbind_atom, info_line, source} (atom_ctx(...) call sites) --- @field atom_phases table -- phase_label -> {atoms = {atom_name1, atom_name2, ...}} (atom_phase(...) tags) --- @field line_of fun(pos: integer): integer -- shared LineIndex closure --- @class SkipOverScan --- @field atoms table --- @field components table --- @field markers SkipOverMarker[] --- @class SkipOverMarker --- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over") --- @field marker_line integer --- @field marker_pos integer --- @field after_paren integer --- @field args string|nil -- trimmed marker args"" --- @field has_parens boolean --- @field pending boolean --- @field superseded_by_marker_line integer|nil --- @field target_name string|nil -- stripped declaration name once observed --- @field target_raw_name string|nil -- source-written declaration name once observed --- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed --- @field declaration_line integer|nil --- @field declaration_pos integer|nil --- @field proc_prelude boolean|nil -- marker has crossed FI_ and awaits MipsAtomComp_Proc_ --- @class RegTypeDefault --- @field name string -- "R_TapePtr" (the register ident; without the value part) --- @field type_name string -- "U4" / "V3_S2" / "void" (the pointer/struct base name) --- @field pointer_depth integer -- 0 for `U4`, 1 for `U4*`, 2 for `U4**` --- @field source_line integer -- 1-based source line of the declaration --- @class RegTypeOverride --- @field reg string -- "R_T0" --- @field type_name string --- @field pointer_depth integer --- @field source_line integer -- line of the call site (callsite or enum-site) --- @class AtomCtxEntry --- @field rbind_atom string -- the rbind atom ident that this consumer should propagate types from --- @field info_line integer --- @field source string -- absolute path of the source file --- @class AtomPhaseGroup --- @field atoms string[] -- atom names tagged with this phase label (source-order) --- @class AtomViewEntry --- @field atom_name string -- e.g. "red_cube_g4_face" --- @field binds_name string|nil -- "Binds_CubeTri" if attached --- @field reg_type_overrides table -- "R_T0" -> override --- @field info_line integer -- line of the atom_info call --- @class SkipOverAssociation --- @field marker_line integer --- @field declaration_line integer --- @field kind string --- @field marker SkipOverMarker --- @class SourceFile --- @field path string -- absolute path to the source file --- @field text string -- the full source text --- @field dir string -- the directory containing the source --- @field basename string -- filename without extension --- @field scan table -- pre-scanned SourceScan payload (set by this pass) --- @class PassCtx --- @field sources SourceFile[] --- @field metadata_path string --- @field shared table --- @field out_root string --- @field project_root string --- @field upstream table --- @field flags table --- @field dry_run boolean --- @field verbose boolean --- @class PassResult --- @field outputs table[] --- @field errors table[] --- @field warnings table[] --- @class AtomEntry --- @field line integer --- @field name string -- atom name (for components: without ac_ prefix) --- @field body string -- brace-delimited body (without the braces) --- @field body_off integer -- char offset of body[1] in source --- @field kind string -- "atom" | "comp_bare" | "comp_proc" | "raw_atom" --- @field raw_name string -- un-stripped name (for components: with ac_ prefix) --- @field ident_pos integer -- position of the MipsAtom_/MipsAtomComp_ ident start --- @field after_paren integer -- position past the closing paren --- @field args string|nil -- populated by components pass (backward lookup) --- @field comment string|nil -- populated by components pass (backward lookup) -- ════════════════════════════════════════════════════════════════════════════ -- Local helpers (shared by per-form parsers) -- ════════════════════════════════════════════════════════════════════════════ -- C qualifier keywords that may precede a MipsAtom_ / MipsCode declaration. -- (typedef is NOT a qualifier here — it's a separate construct (`typedef Struct_(Binds_X) { ... };`) -- and must be read as an ident so the typedef check below can match it.) local QUALIFIER_KEYWORDS = { ["static"] = true, ["const"] = true, ["volatile"] = true, ["extern"] = true, ["register"] = true, ["auto"] = true, ["inline"] = true, ["internal"] = true, ["LP_"] = true, ["global"] = true, ["gkknown"] = true, } -- "ac_" prefix length on component names (e.g., `MipsAtomComp_(ac_X, ...)`). -- The components pass strips this prefix to derive the macro name (e.g., `mac_X`). local AC_PREFIX = "ac_" local AC_PREFIX_LEN = 3 -- Strip the "ac_" prefix from a component name. -- Returns the input unchanged if it doesn't start with the prefix. -- @param raw_name string -- @return string local function strip_ac_prefix(raw_name) if #raw_name > AC_PREFIX_LEN and raw_name:sub(1, AC_PREFIX_LEN) == AC_PREFIX then return raw_name:sub(AC_PREFIX_LEN + 1) end return raw_name end -- Preserve a source marker until the following declaration parser observes it. local function push_skip_over_marker(out, marker) local markers = out.skip_over.markers local prior = markers[#markers] if prior and prior.pending then prior.pending = false prior.superseded_by_marker_line = marker.marker_line end marker.pending = true markers[#markers + 1] = marker end -- Attach the pending marker to the next declaration. -- The declaration form disambiguates whole atoms from components; -- unsupported declarations retain placement evidence for annotation.lua and populate neither lookup table. local function associate_skip_over_marker(out, target_name, target_raw_name, target_kind, declaration_line, declaration_pos) local markers = out.skip_over.markers local marker = markers[#markers] if not (marker and marker.pending) then return end marker.pending = false marker.target_name = target_name marker.target_raw_name = target_raw_name marker.target_kind = target_kind marker.declaration_line = declaration_line marker.declaration_pos = declaration_pos if not (marker.has_parens and marker.args == "") then return end local association = { marker_line = marker.marker_line, declaration_line = declaration_line, kind = target_kind, marker = marker, } if target_kind == "atom" then out.skip_over.atoms[target_name] = association elseif target_kind == "comp_bare" or target_kind == "comp_proc" then out.skip_over.components[target_name] = association end end -- Read a top-level comma-delimited argument list. Mirrors split_top_level_commas in shape; -- kept inline so scan_source can remain dependency-free. local function read_top_level_args(text, pos) local args = {} pos = duffle.skip_ws_and_cmt(text, pos) while pos <= #text do local buf, level = {}, 0 while pos <= #text do local c = text:sub(pos, pos) if c == "(" or c == "[" or c == "{" then level = level + 1 elseif c == ")" or c == "]" or c == "}" then if level == 0 then break end level = level - 1 elseif c == "," and level == 0 then break end buf[#buf + 1] = c pos = pos + 1 end local arg = duffle.trim(table.concat(buf)) if arg ~= "" then args[#args + 1] = arg end if pos > #text then break end if text:sub(pos, pos) == "," then pos = pos + 1; pos = duffle.skip_ws_and_cmt(text, pos) end if not text:sub(pos, pos) or text:sub(pos, pos) == ")" then break end end return args end -- Parse a `Type*` chain (zero or more `*` separated by optional whitespace) followed by the type ident. -- Returns (type_name, pointer_depth) or nil. local function parse_type_chain(text, pos) if pos > #text then return nil end -- Skip leading whitespace before the type ident. local start = duffle.skip_ws_and_cmt(text, pos) local ident, after = duffle.read_ident(text, start) if not ident then return nil end local depth = 0 local cursor = duffle.skip_ws_and_cmt(text, after) while cursor <= #text and text:sub(cursor, cursor) == "*" do depth = depth + 1 cursor = cursor + 1 cursor = duffle.skip_ws_and_cmt(text, cursor) end return ident, depth, cursor end -- Byte-size lookup for builtin C primitives + the GCC __UINT*_TYPE__ family. -- Returns a confident byte_size (positive integer) or nil if `type_name` is not a known builtin primitive. -- Builtin primitive map; used by the byte-size propagation pass to seed confident byte_size values for typedef chains that bottom out at a builtin. local BUILTIN_BYTE_SIZES = { ["U1"] = 1, ["U2"] = 2, ["U4"] = 4, ["S1"] = 1, ["S2"] = 2, ["S4"] = 4, -- GCC __UINT*_TYPE__ family (used by the duffle TSet_ convention). TODO(Ed): Do we really need these, they shouldn't be directly used... ["__UINT8_TYPE__"] = 1, ["__UINT16_TYPE__"] = 2, ["__UINT32_TYPE__"] = 4, ["__UINT64_TYPE__"] = 8, ["__INT8_TYPE__"] = 1, ["__INT16_TYPE__"] = 2, ["__INT32_TYPE__"] = 4, ["__INT64_TYPE__"] = 8, } -- Pointer fields collapse to 4 bytes on MIPS32 (PS1). local POINTER_BYTE_SIZE = 4 -- Maximum chain depth when resolving typedef / TSet_ chains (cycle guard). local TYPE_CHAIN_MAX_DEPTH = 8 -- Parse the ` ;` declarations from a Struct_ body. -- Returns the raw fields array with `{name, type_name, pointer_depth}` only (NO offset / byte_size). -- The propagation pass `resolve_struct_field_sizes` walks each struct's fields AFTER type resolution and populates offset + byte_size in place. -- Returns (fields). The aggregate byte_count is computed in the propagation pass (it depends on whether every field's type resolved). local function parse_struct_body_fields(body) local fields = {} local body_pos = 1 local body_len = #body while body_pos <= body_len do body_pos = duffle.skip_ws_and_cmt(body, body_pos) if body_pos > body_len then break end local type_ident, type_end = duffle.read_ident(body, body_pos) if not type_ident then body_pos = body_pos + 1 else -- Parse the trailing `*` chain to derive pointer_depth. local depth = 0 local cursor = duffle.skip_ws_and_cmt(body, type_end) while cursor <= body_len and body:sub(cursor, cursor) == "*" do depth = depth + 1 cursor = cursor + 1 cursor = duffle.skip_ws_and_cmt(body, cursor) end -- Read the field ident immediately after the type chain. local field_ident, field_end = duffle.read_ident(body, cursor) if not field_ident then body_pos = type_end + 1 else fields[#fields + 1] = { name = field_ident, type_name = type_ident, pointer_depth = depth, -- offset + byte_size filled by resolve_struct_field_sizes offset = nil, byte_size = nil, } body_pos = field_end end end end return fields end -- Parse the `Enum_(, ) { }` body for entries. -- Captures one field per named enumerator with the shape { name, value } value is the integer literal parsed from the source. -- Returns the fields array (may be empty for `enum { }` shapes). -- Self-contained integer parser (no upvalue on parse_enum_int_literal which lives further down in the file). local function parse_enum_body_fields(body) local fields = {} local body_pos = 1 local body_len = #body while body_pos <= body_len do body_pos = duffle.skip_ws_and_cmt(body, body_pos) if body_pos > body_len then break end local entry_name, name_end = duffle.read_ident(body, body_pos) if not entry_name then body_pos = body_pos + 1 else local after_name = duffle.skip_ws_and_cmt(body, name_end) local value if body:sub(after_name, after_name) == "=" then local val_pos = duffle.skip_ws_and_cmt(body, after_name + 1) -- Self-contained integer literal parse (decimal, negative, hex). -- Mirrors parse_enum_int_literal for the subset of shapes that appear in `typedef Enum_(...)` bodies. local sign = 1 local p = val_pos if p <= body_len and body:sub(p, p) == "-" then sign = -1 p = p + 1 end local start_digit = p -- Hex form `0xNN` / `0XNN`. local hex_value if p + 1 <= body_len and body:sub(p, p) == "0" and (body:sub(p + 1, p + 1) == "x" or body:sub(p + 1, p + 1) == "X") then p = p + 2 hex_value = 0 local any_hex = false while p <= body_len do local c = body:byte(p) local d if c >= 0x30 and c <= 0x39 then d = c - 0x30 elseif c >= 0x61 and c <= 0x66 then d = c - 0x61 + 10 elseif c >= 0x41 and c <= 0x46 then d = c - 0x41 + 10 else break end hex_value = hex_value * 16 + d p = p + 1 any_hex = true end if any_hex then value = sign * hex_value end elseif start_digit <= body_len and body:sub(start_digit, start_digit):match("[%d]") then -- Decimal form. local dec_value = 0 local any_digit = false while p <= body_len do local c = body:byte(p) if c < 0x30 or c > 0x39 then break end dec_value = dec_value * 10 + (c - 0x30) p = p + 1 any_digit = true end if any_digit then value = sign * dec_value end end if value ~= nil then body_pos = p else body_pos = after_name + 1 end else body_pos = name_end end fields[#fields + 1] = { name = entry_name, value = value } end -- Skip past any trailing comma. body_pos = duffle.skip_ws_and_cmt(body, body_pos) if body_pos <= body_len and body:sub(body_pos, body_pos) == "," then body_pos = body_pos + 1 end end return fields end -- Resolve a typedef chain's `byte_size` via repeated underlying_type walks. -- Returns a positive integer byte_size when the chain bottoms out at a builtin, or nil if the chain is broken, exceeds TYPE_CHAIN_MAX_DEPTH, or contains a cycle. local function resolve_typedef_byte_size(type_name, type_name_registry, visited, depth) if depth > TYPE_CHAIN_MAX_DEPTH then return nil end if visited[type_name] then return nil end visited[type_name] = true -- Check the builtin primitive map FIRST. -- This handles undeclared builtin idents (e.g. `__UINT32_TYPE__` appears as underlying_type in `typedef __UINT32_TYPE__ TSet_(V4_S2);` -- even though the fixture never declares `__UINT32_TYPE__` itself). local builtin = BUILTIN_BYTE_SIZES[type_name] if builtin ~= nil then return builtin end local entry = type_name_registry[type_name] if not entry then return nil end -- Confident: this entry was already resolved by the propagation pass -- (e.g., a builtin or a struct whose fields are all resolved). if entry.byte_size ~= nil then return entry.byte_size end -- Chain-following: typedef / TSet_ aliases follow underlying_type. if entry.underlying_type then return resolve_typedef_byte_size( entry.underlying_type, type_name_registry, visited, depth + 1) end -- Struct_ entries with unresolved byte_size can still resolve when their fields are all resolved. if entry.kind == "struct" and entry.fields then local sum = 0 local all_have = true for _, f in ipairs(entry.fields) do if f.byte_size == nil then all_have = false; break end sum = sum + f.byte_size end if all_have and #entry.fields > 0 then return sum end end return nil end -- Propagation pass: resolve every entry's `byte_size` field by walking typedef chains, struct field sums, and the builtin primitive map. -- Builtins seed confident values; chains follow underlying_type recursively with a per-chain cycle guard (depth <= 8); -- 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. local function propagate_type_sizes(out) local reg = out.type_name_registry if not reg then return end -- Phase 1: seed builtin primitives (U1/U2/U4/S1/S2/S4 + __UINT*_TYPE__ family). for name, size in pairs(BUILTIN_BYTE_SIZES) do if reg[name] and reg[name].byte_size == nil then reg[name].byte_size = size end end -- Resolve typedef / TSet_ chains. Iterate to a fixed point (max TYPE_CHAIN_MAX_DEPTH iterations) -- since chains may span multiple hops and the resolution order isn't guaranteed by declaration order. -- The visited map is empty when handed to the resolver. -- The resolver marks visited as it enters each node, so the cycle guard fires only on RECURSIVE re-entry (not on the initial call). for _ = 1, TYPE_CHAIN_MAX_DEPTH do local any_change = false for name, entry in pairs(reg) do if entry.byte_size == nil then local resolved = resolve_typedef_byte_size(name, reg, {}, 1) if resolved ~= nil then entry.byte_size = resolved any_change = true end end end if not any_change then break end end -- Resolve struct field byte_sizes + offsets, then aggregate the struct's own byte_size. -- Iterate to a fixed point: struct A may reference struct B which hasn't been resolved yet on the first pass. -- Each pass updates as many fields + aggregates as possible; the loop terminates when no struct's byte_size changes between passes. for _ = 1, TYPE_CHAIN_MAX_DEPTH do local any_change = false for _, entry in pairs(reg) do if entry.kind == "struct" and entry.fields then local byte_off = 0 local gap_seen = false local sum = 0 local all_have = true for _, f in ipairs(entry.fields) do -- Resolve field byte_size (pointer / builtin / typedef chain). if f.byte_size == nil then if f.pointer_depth > 0 then f.byte_size = POINTER_BYTE_SIZE elseif BUILTIN_BYTE_SIZES[f.type_name] then f.byte_size = BUILTIN_BYTE_SIZES[f.type_name] elseif reg[f.type_name] and reg[f.type_name].byte_size ~= nil then f.byte_size = reg[f.type_name].byte_size end end -- Set offset (clean prefix rule). if not gap_seen then if f.byte_size ~= nil then f.offset = byte_off byte_off = byte_off + f.byte_size else f.offset = nil gap_seen = true end else f.offset = nil end if f.byte_size ~= nil then sum = sum + f.byte_size else all_have = false end end if all_have and #entry.fields > 0 then if entry.byte_size ~= sum then entry.byte_size = sum any_change = true end end end end if not any_change then break end end -- Mirror aggregate byte_size onto the Binds_* entries in out.binds (Binds_* structs are shared via type_name_registry fields; -- Only the aggregate bytes needs a mirror write since binds.bytes is a separate field from type_name_registry[name].byte_size). for _, bind_entry in ipairs(out.binds or {}) do local reg_entry = reg[bind_entry.name] if reg_entry then bind_entry.bytes = reg_entry.byte_size end end end -- Parse the register list from inside `atom_reads(...)` or `atom_writes(...)`. local function scan_reg_list(sub_inner) local regs = {} local sub_inner_pos = 1 while sub_inner_pos <= #sub_inner do sub_inner_pos = duffle.skip_ws_and_cmt(sub_inner, sub_inner_pos) if sub_inner_pos > #sub_inner then break end local reg_ident, reg_end = duffle.read_ident(sub_inner, sub_inner_pos) if reg_ident then regs[#regs + 1] = duffle.trim(reg_ident) sub_inner_pos = reg_end else sub_inner_pos = sub_inner_pos + 1 end if sub_inner_pos > #sub_inner then break end if sub_inner:sub(sub_inner_pos, sub_inner_pos) == "," then sub_inner_pos = sub_inner_pos + 1 end end return regs end -- Parse one entry inside `atom_reads(...)` or `atom_writes(...)`. -- Each top-level comma-separated entry is either: -- - a plain register ident: `R_FaceCursor` → reg_name only -- - register + atom_type sub-call: `R_FaceCursor atom_type(V4_S2*)` → reg_name + override entry -- 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 -- parens like `V4_S2*()`), 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 -- (per the parser-safety contract — "malformed atom_type MUST NOT create any override"). -- -- The legacy `atom_rtype(...)` spelling is accepted as a transparent alias so test fixtures -- authored against the pre-Track-A grammar continue to parse. Canonical: `atom_type`. local function parse_atom_info_reg_entry(entry) local pos = 1 pos = duffle.skip_ws_and_cmt(entry, pos) if pos > #entry then return nil, nil, false end local reg_name, reg_end = duffle.read_ident(entry, pos) if not reg_name then return nil, nil, false end pos = duffle.skip_ws_and_cmt(entry, reg_end) -- Plain register, no adjacent atom_type — done. if pos > #entry then return reg_name, nil, false end -- Adjacent ident must be a bare `atom_type` (word-bounded both sides). -- `atom_rtype` is the legacy alias; matches silently. local next_ident, next_end = duffle.read_ident(entry, pos) if not next_ident or (next_ident ~= "atom_type" and next_ident ~= "atom_rtype") then return reg_name, nil, false end -- Left word-boundary: `_atom_type` should NOT match `atom_type`. if pos > 1 then local prev = entry:byte(pos - 1) if duffle.is_alnum_byte(prev) then return reg_name, nil, false end end -- Right word-boundary: `atom_type_foo` should NOT match `atom_type`. if next_end <= #entry then local nxt = entry:byte(next_end) if duffle.is_alnum_byte(nxt) then return reg_name, nil, false end end -- Expect `(` immediately after `atom_type` (whitespace tolerated). pos = duffle.skip_ws_and_cmt(entry, next_end) if pos > #entry or entry:sub(pos, pos) ~= "(" then return reg_name, nil, true end local sub_inner, sub_after = duffle.read_parens(entry, pos) -- 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. local after_close = duffle.skip_ws_and_cmt(entry, sub_after) if after_close <= #entry then return reg_name, nil, true end -- Parse the type chain inside the parens; require full consumption. -- parse_type_chain returns (ident, depth, end_pos); -- 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) if not type_name then return reg_name, nil, true end local end_check = duffle.skip_ws_and_cmt(sub_inner, after_chain) if end_check <= #sub_inner then return reg_name, nil, true end return reg_name, { type_name = type_name, pointer_depth = depth }, false end -- Parse the sub-calls inside `atom_info(atom_bind(...), atom_reads(...), atom_writes(...), atom_view(...), atom_reg_types(...), atom_ctx(...), atom_phase(...))`. -- Returns (binds, reads, writes, view_binds, reg_overrides, ctx_atom_name, phase_label). -- `view_binds` is the Binds_X ident from `atom_view(Binds_X)`. -- `reg_overrides` is a {reg_name = {reg, type_name, pointer_depth, source_line}} -- table populated from BOTH `atom_reg_types(R_X, )` (legacy) and `atom_type(...)` sub-entries inside `atom_reads(...)` / `atom_writes(...)`. -- `ctx_atom_name` is the rbind atom ident from `atom_ctx()` (singular; last-write-wins). -- `phase_label` is the user-authored C-ident label from `atom_phase(