Skip to main content

brink_runtime/
program.rs

1//! Immutable linked program.
2
3use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use brink_format::{
8    AliasEntry, CountingFlags, DebugInfoSection, DefinitionId, ListValue, NameId, ShapeId, Value,
9};
10
11use crate::collections::Map as HashMap;
12use crate::error::RuntimeError;
13
14/// A linked, ready-to-execute program.
15///
16/// Created from [`StoryData`](brink_format::StoryData) via [`link()`](crate::link).
17/// Immutable after creation — mutable per-instance state lives in [`Story`](crate::Story).
18pub struct Program {
19    pub(crate) containers: Vec<LinkedContainer>,
20    /// What the linker derived from the symbolic bytecode: see [`LinkTables`].
21    pub(crate) link: LinkTables,
22    /// Unified address map: `id → (container_idx, byte_offset)`.
23    /// Contains both container IDs (offset 0) and intra-container addresses.
24    pub(crate) address_map: HashMap<DefinitionId, (u32, usize)>,
25    /// Scope `DefinitionId` for each entry in the line tables (parallel vec).
26    /// Structural metadata — does not change with locale.
27    pub(crate) scope_ids: Vec<DefinitionId>,
28    /// CRC-32 checksum from the source `.inkb`, used for locale validation.
29    pub(crate) source_checksum: u32,
30    pub(crate) globals: Vec<GlobalSlot>,
31    pub(crate) global_map: HashMap<DefinitionId, u32>,
32    pub(crate) name_table: Vec<String>,
33    /// Map from a knot/stitch path string to its target: the defining
34    /// `DefinitionId` plus the resolved `(container_idx, byte_offset)`.
35    /// Built at link time from named scope containers; lets consumers
36    /// spawn flows at named entry points without needing `DefinitionId`s.
37    pub(crate) address_by_path: HashMap<String, PathTarget>,
38    /// `container_idx → shortest knot/stitch path` for every container that
39    /// is the offset-0 target of an author-facing path — the reverse of
40    /// `address_by_path`, built once at link time. Backs
41    /// [`Program::container_path`] and, through it, the runtime's
42    /// [`current_path`](crate::Story::current_path) query and the
43    /// debugger's location names. Deterministic on collision: the shortest
44    /// path, then the lexicographically smallest.
45    pub(crate) container_paths: HashMap<u32, String>,
46    pub(crate) root_idx: u32,
47    /// List literal values referenced by `PushList(idx)`.
48    pub(crate) list_literals: Vec<ListValue>,
49    /// The T1b literal pool: constant collection values referenced by
50    /// `PushLiteral(idx)` (`docs/format-v4-rfc.md` §2).
51    pub(crate) literal_pool: Vec<Value>,
52    /// Per-item metadata keyed by item `DefinitionId`.
53    pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
54    /// List definitions indexed by position.
55    pub(crate) list_defs: Vec<ListDefEntry>,
56    /// Map from list def `DefinitionId` to index in `list_defs`.
57    pub(crate) list_def_map: HashMap<DefinitionId, usize>,
58    /// External function metadata keyed by the external function's `DefinitionId`.
59    pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
60    /// Compiled flow-private scope defaults for knots/stitches: the
61    /// `(path, id)` of every scope container the compiler marked
62    /// `#@local`, sorted by path so knots seed before their stitches.
63    /// The base layer of `WorldPolicy` resolution
64    /// (`docs/directive-annotations-spec.md`).
65    pub(crate) local_scope_defaults: Vec<(String, DefinitionId)>,
66    /// TM-4 `StructShapes` table (`docs/typed-mode-spec.md` §6), indexed by
67    /// `ShapeId` — every `RecordNew`/`RecordGetDyn`/`RecordSetDyn` opcode
68    /// looks a shape up here for its field count and field-name → offset
69    /// mapping. Empty until a compiler milestone emits `STRUCT`
70    /// declarations.
71    pub(crate) struct_shapes: Vec<StructShapeEntry>,
72    /// M-2b (`docs/modules-spec.md` §4): the set of `#@private` definition
73    /// ids. Used only to refuse host **semantic** access (variable get/set,
74    /// entry lookup, function eval) — the VM and host **persistence** never
75    /// consult it, so private state still executes and still saves/loads.
76    /// Empty (and never consulted) for the all-public pre-modules world.
77    /// Sorted ascending by raw id (the linker sorts it), so membership is a
78    /// `binary_search` — no set type needed for a list that is typically empty
79    /// or tiny, and `no_std`-clean.
80    pub(crate) private_defs: Vec<DefinitionId>,
81    /// M-3 (`docs/modules-spec.md` §5): the compiled `#@was` alias table,
82    /// sorted by `old` — [`Program::resolve_alias`] binary-searches it.
83    /// Empty for every story that uses no `#@was`.
84    pub(crate) alias_table: Vec<AliasEntry>,
85    /// D6's `DebugInfo` section (`docs/debugger-spec.md` §2, `.inkb` tag
86    /// `0x11`), carried through unchanged from `StoryData::debug_info` —
87    /// `None` for a release-exported / non-debug compile (§1.2's ship
88    /// policy, never requested) or any story compiled before D6. Two
89    /// consumers, both reading it lockstep with the `Containers` table
90    /// this `Program` already links (no `DefinitionId` lookup on either
91    /// read path, per the section's own design):
92    ///
93    /// - [`Program::resolve_debug_position`] (D9, #3187) — a running
94    ///   `(container_idx, offset)` position resolves to a source range by
95    ///   direct index into `containers[container_idx]`.
96    /// - [`Program::scope_debug_locals`] (D7, #3185), behind
97    ///   [`crate::debug::DebugFrame::locals`] — §3's per-container
98    ///   `LocalsTable` names a call frame's live temp slots.
99    pub(crate) debug_info: Option<DebugInfoSection>,
100}
101
102/// Runtime metadata for one declared struct shape.
103pub(crate) struct StructShapeEntry {
104    /// The declared `STRUCT` name — the head of the structural display
105    /// default (`Point { x: 1, y: 2 }`, NS-A3 / stdlib-spec §9.6).
106    pub name: NameId,
107    /// Declared field names, in shape order — the same order
108    /// [`brink_format::Value::Record`]'s flat field vector follows.
109    pub fields: Vec<NameId>,
110}
111
112/// A static jump/call target, resolved once by the linker.
113///
114/// `id` is kept alongside the position because the VM still needs the
115/// address identity at run time — visit and turn counts are keyed by it —
116/// and the two rulings behind hot-reload (decision log 2026-03-01) make the
117/// id the stable identity across relinks while `container_idx` is not.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub(crate) struct LinkedTarget {
120    pub container_idx: u32,
121    pub offset: usize,
122    pub id: DefinitionId,
123}
124
125/// The linker's derived layer over the symbolic bytecode.
126///
127/// `.inkb` stores `DefinitionId`s in every jump and call, with no
128/// compile-time indices (decision log 2026-03-01, "`.inkb` stores
129/// `ContainerId`s … resolved to fast internal indices at load time"). Until
130/// this table existed, "at load time" meant a hash lookup on every
131/// `Goto`/`Call`/`BeginChoice` the VM executed — 1.7% of `TheIntercept`'s
132/// instructions in `Program::resolve_target`. Now the linker walks each
133/// container once, interns every resolvable static target into `targets`,
134/// and writes the target's ordinal over the operand bytes in its own copy
135/// of the code (`code[i]` is `containers[i].bytecode` with those operands
136/// rewritten — same length, same offsets). The VM indexes `targets` by
137/// that ordinal; nothing hashes. A global's operand (`GetGlobal`,
138/// `SetGlobal`, `TakeGlobal`) is rewritten the same way to its slot index in
139/// `globals`, which is already dense, so it needs no table of its own.
140///
141/// The symbolic `bytecode` stays untouched on each `LinkedContainer` for
142/// every other decoder (the debugger, `container_bytecode`, tests):
143/// `Opcode::decode` is not defined over the rewritten copy. An operand the
144/// linker cannot resolve is left symbolic in `code` too, so an unresolved
145/// divert still fails exactly where and how it did before — when reached.
146///
147/// Rebuilt on every link, so a hot patch that renumbers containers simply
148/// produces a new table; nothing here is persisted.
149#[derive(Debug, Clone, Default)]
150pub(crate) struct LinkTables {
151    pub code: Vec<Vec<u8>>,
152    pub targets: Vec<LinkedTarget>,
153}
154
155/// The operand at a static site of *linked* code: `Some(n)` — a target's
156/// ordinal into [`LinkTables::targets`], or a global's slot index — when the
157/// linker resolved it, `None` when the bytes still hold a symbolic
158/// `DefinitionId`.
159///
160/// The two are distinguishable by the last byte: an id's top byte is its
161/// `DefinitionTag`, never zero (`brink_format::DefinitionTag` starts at
162/// `0x01`), while an ordinal is written as a little-endian `u64` whose top
163/// four bytes are zero.
164pub(crate) fn linked_ordinal(operand: &[u8]) -> Option<u32> {
165    let raw = u64::from_le_bytes(operand.try_into().ok()?);
166    #[expect(clippy::cast_possible_truncation, reason = "top 32 bits checked zero")]
167    (raw >> 32 == 0).then_some(raw as u32)
168}
169
170/// The linked form of a target ordinal — the inverse of [`linked_ordinal`].
171pub(crate) fn linked_operand(ordinal: u32) -> [u8; 8] {
172    u64::from(ordinal).to_le_bytes()
173}
174
175pub(crate) struct LinkedContainer {
176    pub id: DefinitionId,
177    pub bytecode: Vec<u8>,
178    pub counting_flags: CountingFlags,
179    pub path_hash: i32,
180    /// Number of declared parameters (for arity-checking host-directed entry).
181    pub param_count: u8,
182    /// Per-parameter name/mode metadata, in declared order (T1c, #700).
183    /// Empty for containers the converter produced or that declare no params;
184    /// used by function-value dispatch to validate a rehydrated closure's
185    /// bound env against the current signature.
186    pub params: Vec<brink_format::ParamMeta>,
187    /// Index into `Program.line_tables` for this container's scope line table.
188    pub scope_table_idx: u32,
189    /// The lexical scope this container belongs to (`ContainerDef::
190    /// scope_id`'s own doc: `scope_id == id` for a scope container itself —
191    /// root/knot/stitch — and the enclosing scope's `id` for a child
192    /// container — gather, choice target, sequence branch, etc.). D7
193    /// (`docs/debugger-spec.md` §3, #3185): the key
194    /// [`Program::scope_debug_locals`] groups by, since a call frame's
195    /// `container_stack` can legitimately drop back to a single entry
196    /// mid-frame (`vm::goto_target`'s "target not already on the stack"
197    /// branch clears and replaces it — verified on `origin/main`) while the
198    /// frame's declared locals stay live in its `temps` regardless of which
199    /// child container the current leaf position sits in.
200    pub scope_id: DefinitionId,
201}
202
203pub(crate) struct GlobalSlot {
204    /// The global's `DefinitionId` — used by save/load and by M-2b
205    /// visibility enforcement ([`Program::global_is_private`]).
206    pub id: DefinitionId,
207    pub name: NameId,
208    pub default: Value,
209    /// Compiled flow-private (`#@local`) scope default for this global.
210    pub local: bool,
211}
212
213/// Runtime metadata for a list item.
214pub(crate) struct ListItemEntry {
215    pub name: NameId,
216    pub ordinal: i32,
217    pub origin: DefinitionId,
218}
219
220/// Runtime metadata for a list definition.
221pub(crate) struct ListDefEntry {
222    pub name: NameId,
223    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
224    pub items: Vec<DefinitionId>,
225}
226
227/// Runtime metadata for an external function.
228pub(crate) struct ExternalFnEntry {
229    pub name: NameId,
230    pub fallback: Option<DefinitionId>,
231}
232
233/// Resolved target of a qualified path string: the defining `DefinitionId`
234/// (used for visit counting, exactly as a divert to the same target would
235/// use it) plus the linked `(container_idx, byte_offset)` position.
236#[derive(Debug, Clone, Copy)]
237pub(crate) struct PathTarget {
238    pub id: DefinitionId,
239    pub container_idx: u32,
240    pub byte_offset: usize,
241}
242
243/// Build the `container_idx → path` index [`Program::container_paths`]
244/// holds: offset-0 targets only, shortest path (then lexicographically
245/// smallest) per container — independent of map iteration order.
246pub(crate) fn container_paths_from(
247    address_by_path: &HashMap<String, PathTarget>,
248) -> HashMap<u32, String> {
249    let mut rev: HashMap<u32, String> = HashMap::new();
250    for (path, target) in address_by_path {
251        if target.byte_offset != 0 {
252            continue;
253        }
254        let better = match rev.get(&target.container_idx) {
255            None => true,
256            Some(existing) => {
257                path.len() < existing.len()
258                    || (path.len() == existing.len() && path.as_str() < existing.as_str())
259            }
260        };
261        if better {
262            rev.insert(target.container_idx, path.clone());
263        }
264    }
265    rev
266}
267
268impl Program {
269    /// The knot or `knot.stitch` path a container names, if it names one.
270    /// Exact: an anonymous container (a choice body, gather, sequence
271    /// branch) is `None` — the save format relies on that to write such a
272    /// container's visit entry without a path. For "where is this
273    /// container" see [`Program::scope_path`].
274    #[must_use]
275    pub fn container_path(&self, idx: u32) -> Option<&str> {
276        self.container_paths.get(&idx).map(String::as_str)
277    }
278
279    /// The knot or `knot.stitch` a container sits in: its own path when it
280    /// names one, otherwise its lexical scope's — a choice body, gather, or
281    /// sequence branch reports the knot/stitch that holds it (after
282    /// `choose`, the frame holds only the chosen branch, and the story is
283    /// still in that knot). `None` for the root scope and anything directly
284    /// under it. The runtime's own vocabulary for "where am I" — see
285    /// [`Story::current_path`](crate::Story::current_path).
286    #[must_use]
287    pub fn scope_path(&self, idx: u32) -> Option<&str> {
288        if let Some(path) = self.container_path(idx) {
289            return Some(path);
290        }
291        let scope_id = self.containers.get(usize::try_from(idx).ok()?)?.scope_id;
292        let (scope_idx, _) = self.resolve_target(scope_id)?;
293        if scope_idx == idx {
294            return None;
295        }
296        self.container_path(scope_idx)
297    }
298
299    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
300    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
301        self.address_map.get(&id).copied()
302    }
303
304    /// Whether `id` names a live container/address in the current program
305    /// (a knot/stitch/label or a synthetic child address) — the "does this
306    /// still resolve directly" half of the M-3 rehydration miss-path check
307    /// (`docs/modules-spec.md` §5).
308    pub(crate) fn knows_address(&self, id: DefinitionId) -> bool {
309        self.address_map.contains_key(&id)
310    }
311
312    /// Whether `id` names a live global slot in the current program.
313    pub(crate) fn knows_global(&self, id: DefinitionId) -> bool {
314        self.global_map.contains_key(&id)
315    }
316
317    /// Whether `id` names a live list item in the current program — the
318    /// list-item half of the M-3 rehydration miss-path check (`docs/modules-spec.md`
319    /// §5), mirroring [`knows_address`](Self::knows_address)/[`knows_global`](Self::knows_global)
320    /// for the ids embedded in a saved `Value::List` (active items).
321    pub(crate) fn knows_list_item(&self, id: DefinitionId) -> bool {
322        self.list_item_map.contains_key(&id)
323    }
324
325    /// Whether `id` names a live list definition in the current program —
326    /// the list-origin half of the M-3 rehydration miss-path check, for the
327    /// ids embedded in a saved `Value::List`'s `origins`.
328    pub(crate) fn knows_list_def(&self, id: DefinitionId) -> bool {
329        self.list_def_map.contains_key(&id)
330    }
331
332    /// M-3 rehydration miss-path lookup (`docs/modules-spec.md` §5): given
333    /// an `id` the current program doesn't recognize, consult the compiled
334    /// `#@was` alias table for its current identity. Callers still need to
335    /// check whether the returned id itself resolves — an alias chain is
336    /// never followed (the compiler always emits `old -> new` against the
337    /// definition's *current* id, never `old -> old2`).
338    pub(crate) fn resolve_alias(&self, old: DefinitionId) -> Option<DefinitionId> {
339        self.alias_table
340            .binary_search_by_key(&old, |e| e.old)
341            .ok()
342            .map(|idx| self.alias_table[idx].new)
343    }
344
345    /// Whether this program carries any `#@was`-derived alias-table
346    /// entries at all. Gates `load_state`'s miss-path reporting: an
347    /// ordinary content edit with no rename directive stays exactly as
348    /// silent as it was before M-3.
349    pub(crate) fn has_aliases(&self) -> bool {
350        !self.alias_table.is_empty()
351    }
352
353    /// Resolve a definition ID to `(container_idx, byte_offset)`.
354    ///
355    /// Promoted from `#[cfg(feature = "testing")]` to real public API by
356    /// W2 (#3295): with [`Self::definition_id_for_path`] it is the
357    /// name-based half of source→program addressing ("break on
358    /// `tavern.order`"), which the wasm bridge composes as
359    /// `resolve_path_address`. A pure lookup over the container table —
360    /// nothing here touches the VM hot path, so the `step_once` promotion
361    /// warning (`docs/debugger-spec.md` §1.4) does not apply.
362    #[must_use]
363    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
364        self.resolve_target(id)
365    }
366
367    /// The program→source resolver (D9, issue #3187; wire encoding: D6,
368    /// `docs/debugger-spec.md` §2.2). Resolves a runtime execution position
369    /// — [`crate::DebugPosition`], as reported by
370    /// [`crate::DebugSnapshot::position`]/[`crate::DebugFrame::position`]
371    /// (D4, #3182) — to the source range it was compiled from, via this
372    /// program's `DebugInfo` section.
373    ///
374    /// `None` when:
375    /// - no `DebugInfo` section is present (a release-exported or
376    ///   `--debug-info`-less compile — §1.2 ship policy: this is the
377    ///   expected, non-error case for most builds, not a fault);
378    /// - `container_idx` is out of range for the section's container table
379    ///   (defensive — should not happen for a position this same `Program`
380    ///   produced);
381    /// - `offset` is before the container's first recorded entry (the
382    ///   section's coverage guarantee, §2.2, means this should not happen
383    ///   for a real instruction boundary either, but a reader must not
384    ///   panic on an adversarial/malformed position).
385    ///
386    /// The returned range's `file` is `None` for the reserved synthetic
387    /// sentinel file (index 0, §2.5) — a compiler-synthesized construct
388    /// with no author source to point at — and `Some(path)` (project-root-
389    /// relative) otherwise. This is exactly the `path`/`span` pair the
390    /// studio's `source` Location space needs (`docs/studio-shell-spec.md`
391    /// §6.1) — a caller's `program` resolver wraps this method and returns
392    /// `{ kind: "source", file, span: { start: range_start, end:
393    /// range_start + range_len } }`.
394    ///
395    /// Entries within a container are sorted ascending by
396    /// `bytecode_offset` and cover the container's full address range with
397    /// no gaps (§2.2), so a floor lookup — the last entry whose
398    /// `bytecode_offset` is `<= offset` — always names the instruction's
399    /// own statement, matching how a running VM's `offset` (the *next*
400    /// instruction to execute, always itself a decoded instruction
401    /// boundary) lines up against entries recorded at instruction
402    /// boundaries during codegen's own walk.
403    #[must_use]
404    pub fn resolve_debug_position(
405        &self,
406        position: crate::debug::DebugPosition,
407    ) -> Option<crate::debug::DebugSourceLocation> {
408        let entry = self.debug_entry_at(position)?;
409        let debug_info = self.debug_info.as_ref()?;
410        let file = debug_info.files.get(entry.file_idx as usize)?;
411        let path = match file.surface {
412            brink_format::FileSurface::Synthetic => None,
413            brink_format::FileSurface::Ink | brink_format::FileSurface::Native => {
414                Some(file.path.clone())
415            }
416        };
417        Some(crate::debug::DebugSourceLocation {
418            file: path,
419            range_start: entry.range_start,
420            range_len: entry.range_len,
421        })
422    }
423
424    /// The `DebugInfo` entry covering `position` — the floor lookup both
425    /// [`Self::resolve_debug_position`] and [`Self::debug_line_key`] share.
426    fn debug_entry_at(
427        &self,
428        position: crate::debug::DebugPosition,
429    ) -> Option<&brink_format::DebugEntry> {
430        let debug_info = self.debug_info.as_ref()?;
431        let table = debug_info.containers.get(position.container_idx as usize)?;
432        let target = u32::try_from(position.offset).ok()?;
433        let idx = match table
434            .entries
435            .binary_search_by_key(&target, |e| e.bytecode_offset)
436        {
437            Ok(i) => i,
438            Err(0) => return None,
439            Err(i) => i - 1,
440        };
441        table.entries.get(idx)
442    }
443
444    /// A cheap identity for "which source line is this position on":
445    /// `(file_idx, line_idx)`, both section-local and 0-based (#3264).
446    ///
447    /// Deliberately not `(String, u32)`: line stepping compares this once
448    /// per VM instruction, and cloning a path per instruction to answer
449    /// "same line?" would make the verb's cost scale with path length for
450    /// no benefit. The indices are only ever compared to each other, never
451    /// shown, so they never need resolving to text.
452    ///
453    /// `None` when the artifact carries no `DebugInfo`, the position has no
454    /// covering entry, or that file carries no line index (compiled without
455    /// source text — see `DebugFileEntry::line_starts`).
456    #[cfg(feature = "debug-hooks")]
457    pub(crate) fn debug_line_key(
458        &self,
459        position: crate::debug::DebugPosition,
460    ) -> Option<(u32, u32)> {
461        let entry = self.debug_entry_at(position)?;
462        let file = self
463            .debug_info
464            .as_ref()?
465            .files
466            .get(entry.file_idx as usize)?;
467        let line = Self::line_index_in(file, entry.range_start)?;
468        Some((entry.file_idx, line))
469    }
470}
471
472/// [`Program::resolve_debug_line`]'s answer: where a bytecode position
473/// sits in author-facing source, at both granularities the debugger
474/// serves — the line (the author tier's band/chip) and the covering
475/// entry's byte range (the finer tiers: expression rows, instruction
476/// stepping, step-out's mid-line call-site landing).
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub struct ResolvedDebugLine<'a> {
479    pub file: &'a str,
480    /// 0-based.
481    pub line: u32,
482    /// Byte offset in `file`, as the compiler consumed it.
483    pub range_start: u32,
484    pub range_len: u32,
485}
486
487impl Program {
488    /// The `file:line` — plus the covering entry's exact byte range — of a
489    /// bytecode position (W6/#3299). The line places the execution
490    /// highlight's band and the paused chip; the RANGE rides along so
491    /// finer-than-line consumers need no new seam: expression-level
492    /// entries (D1's unflagged rows, once #3183's `Expr` provenance
493    /// lands), instruction stepping in the editor, and the one
494    /// mid-line case that exists TODAY — a step-out lands at the call
495    /// site, not a line start (`docs/debugger-spec.md` §4's `finish`
496    /// semantics).
497    ///
498    /// 0-based line (UIs showing 1-based convert at their edge). `None`
499    /// when the position doesn't resolve, resolves to the synthetic
500    /// sentinel, or that file carries no line index.
501    #[must_use]
502    pub fn resolve_debug_line(
503        &self,
504        position: crate::debug::DebugPosition,
505    ) -> Option<ResolvedDebugLine<'_>> {
506        let entry = self.debug_entry_at(position)?;
507        let file = self
508            .debug_info
509            .as_ref()?
510            .files
511            .get(entry.file_idx as usize)?;
512        if !matches!(
513            file.surface,
514            brink_format::FileSurface::Ink | brink_format::FileSurface::Native
515        ) {
516            return None;
517        }
518        let line = Self::line_index_in(file, entry.range_start)?;
519        Some(ResolvedDebugLine {
520            file: file.path.as_str(),
521            line,
522            range_start: entry.range_start,
523            range_len: entry.range_len,
524        })
525    }
526
527    /// 0-based line containing `byte` within `file`'s line index, or `None`
528    /// when that file carries no index or the offset precedes its first
529    /// line start (which a well-formed index makes impossible, since it
530    /// always begins at 0).
531    fn line_index_in(file: &brink_format::DebugFileEntry, byte: u32) -> Option<u32> {
532        if file.line_starts.is_empty() {
533            return None;
534        }
535        // `partition_point` gives the count of starts at or before `byte`;
536        // the line is one less. Never underflows for a well-formed index,
537        // whose first start is 0 — but a malformed artifact must degrade to
538        // `None` rather than wrap.
539        let count = file.line_starts.partition_point(|&s| s <= byte);
540        u32::try_from(count.checked_sub(1)?).ok()
541    }
542
543    /// 0-based line containing `byte` in `file` (#3264) — the public form
544    /// of the lookup [`Self::debug_line_key`] uses internally. `None` when
545    /// the file is unknown or carries no line index.
546    #[must_use]
547    pub fn line_at(&self, file: &str, byte: u32) -> Option<u32> {
548        let debug_info = self.debug_info.as_ref()?;
549        let entry = debug_info.files.iter().find(|f| {
550            matches!(
551                f.surface,
552                brink_format::FileSurface::Ink | brink_format::FileSurface::Native
553            ) && f.path == file
554        })?;
555        Self::line_index_in(entry, byte)
556    }
557
558    /// The inverse of [`Self::resolve_debug_position`] (D9/#3187): the
559    /// program address to break on for a span of **source** text — issue
560    /// #3246, the half a breakpoint gutter needs. `BreakpointSet` is keyed
561    /// by `(container_idx, offset)`; an editor speaks in source. This maps
562    /// the latter to the former.
563    ///
564    /// # Why a byte range and not a line number
565    ///
566    /// The `DebugInfo` section records **byte ranges**, and a `Program`
567    /// holds neither source text nor a line table — so it physically
568    /// cannot turn "line 7" into bytes. That conversion belongs where the
569    /// source already lives (the editor, the CLI's own file read), which
570    /// also keeps the UTF-8/UTF-16 question out of the runtime entirely.
571    /// The caller passes the half-open byte range `[start, end)` it
572    /// considers "the line" (or a selection, or any span), and this answers
573    /// where to break within it.
574    ///
575    /// # Which candidate wins
576    ///
577    /// Every entry in every container whose file is `file` and whose
578    /// `range_start` lies in `[start, end)` is a candidate. The winner is
579    /// the minimum by `(range_start, container_idx, bytecode_offset)`:
580    ///
581    /// - **`range_start` first** — the textually earliest construct in the
582    ///   span, which is what "break on this line" means to a person. Note
583    ///   this is deliberately *not* "lowest `container_idx`": containers
584    ///   are independent bytecode streams with no execution order between
585    ///   them, so ordering by container index would be arbitrary dressed
586    ///   up as a rule.
587    /// - **then `container_idx`, then `bytecode_offset`** — pure
588    ///   tie-breaking, so a given span always yields the same address
589    ///   rather than whichever entry iteration happened to reach first
590    ///   (`CLAUDE.md`: determinism matters).
591    ///
592    /// # `None` is a real answer, not a failure
593    ///
594    /// Returns `None` when the span contains no executable code at all — a
595    /// comment, a blank line, a line whose code folded away — and when the
596    /// artifact carries no `DebugInfo` or names no such file. Callers
597    /// **must** surface that: a gutter has to refuse to arm visibly,
598    /// because a breakpoint that silently never hits is worse than no
599    /// breakpoint.
600    ///
601    /// Entries whose file is the reserved synthetic sentinel (§2.5) never
602    /// match, since no author-facing path names it.
603    #[must_use]
604    pub fn resolve_source_range(
605        &self,
606        file: &str,
607        start: u32,
608        end: u32,
609    ) -> Option<crate::debug::DebugPosition> {
610        let debug_info = self.debug_info.as_ref()?;
611
612        // Path -> file table index. Synthetic entries carry no
613        // author-facing path and must never match one.
614        let file_idx = u32::try_from(debug_info.files.iter().position(|f| {
615            matches!(
616                f.surface,
617                brink_format::FileSurface::Ink | brink_format::FileSurface::Native
618            ) && f.path == file
619        })?)
620        .ok()?;
621
622        let mut best: Option<(u32, u32, u32)> = None;
623        for (container_idx, table) in debug_info.containers.iter().enumerate() {
624            let Ok(container_idx) = u32::try_from(container_idx) else {
625                continue;
626            };
627            for entry in &table.entries {
628                if entry.file_idx != file_idx
629                    || entry.range_start < start
630                    || entry.range_start >= end
631                {
632                    continue;
633                }
634                let candidate = (entry.range_start, container_idx, entry.bytecode_offset);
635                if best.is_none_or(|current| candidate < current) {
636                    best = Some(candidate);
637                }
638            }
639        }
640
641        let (_, container_idx, bytecode_offset) = best?;
642        Some(crate::debug::DebugPosition {
643            container_idx,
644            offset: bytecode_offset as usize,
645        })
646    }
647
648    /// Whether this program carries a `DebugInfo` section at all (#3248).
649    ///
650    /// Every other debug accessor returns `None` for two very different
651    /// reasons — "this artifact was compiled without `--debug-info`" and
652    /// "that particular position/line has nothing on it" — and a debugger
653    /// front-end must tell a user which. Reporting "that line has no
654    /// executable code" for a story compiled without the flag sends the
655    /// author hunting a bug in their source when the fix is a compiler
656    /// flag. This is the cheap discriminator that keeps that message
657    /// honest; it says nothing about whether any *particular* lookup will
658    /// succeed.
659    #[must_use]
660    pub fn has_debug_info(&self) -> bool {
661        self.debug_info.is_some()
662    }
663
664    /// The program address to break on for a **line** of source, with no
665    /// source text required (#3261) — the `DebugInfo` file table carries a
666    /// per-file line index, so the engine can answer `file:line` directly.
667    ///
668    /// `line` is **0-based**. Every UI that shows 1-based line numbers
669    /// converts at its own edge; keeping the engine 0-based means the
670    /// fencepost lives in exactly one place per consumer instead of being
671    /// re-decided here.
672    ///
673    /// This is the shape a remote debugger frontend needs — DAP's
674    /// `setBreakpoints` is file + line, and an adapter may hold no source
675    /// at all. It is a thin wrapper over
676    /// [`Self::resolve_source_range`]: the line index turns the line into
677    /// its half-open byte span, and the same "textually earliest construct
678    /// wins" rule picks the address.
679    ///
680    /// `None` when the file is unknown, carries no line index (compiled
681    /// before this data existed, or with no source text supplied), the line
682    /// is past the end of the file, or the line holds no executable code —
683    /// a comment, a blank, a line whose code folded away. Callers must
684    /// surface that: a gutter has to refuse to arm visibly, because a
685    /// breakpoint that silently never hits is worse than no breakpoint.
686    #[must_use]
687    pub fn resolve_source_line(
688        &self,
689        file: &str,
690        line: u32,
691    ) -> Option<crate::debug::DebugPosition> {
692        let (start, end) = self.line_span(file, line)?;
693        self.resolve_source_range(file, start, end)
694    }
695
696    /// The half-open byte span `[start, end)` of a 0-based `line` in `file`,
697    /// from the `DebugInfo` file table's line index (#3261). `None` when the
698    /// file is unknown, has no line index, or the line is past its end.
699    ///
700    /// The last line runs to the end of the file, which the index does not
701    /// record — so it is represented as `u32::MAX`, an end bound no real
702    /// `range_start` can reach. That is deliberate rather than clamping to
703    /// a length the section does not carry.
704    #[must_use]
705    pub fn line_span(&self, file: &str, line: u32) -> Option<(u32, u32)> {
706        let debug_info = self.debug_info.as_ref()?;
707        let entry = debug_info.files.iter().find(|f| {
708            matches!(
709                f.surface,
710                brink_format::FileSurface::Ink | brink_format::FileSurface::Native
711            ) && f.path == file
712        })?;
713        let idx = line as usize;
714        let start = *entry.line_starts.get(idx)?;
715        let end = entry.line_starts.get(idx + 1).copied().unwrap_or(u32::MAX);
716        Some((start, end))
717    }
718
719    /// Whether `text` is byte-identical to the source `file` was compiled
720    /// from, by the `DebugInfo` file table's `source_hash` (#3261).
721    ///
722    /// The problem this exists for: both debug resolvers happily answer
723    /// questions about source they were never built from. Author types, the
724    /// recompile is still debounced, the gutter asks about the *current*
725    /// buffer against the *previous* program — and gets a confidently wrong
726    /// address rather than an error. That applies to byte ranges every bit
727    /// as much as to line numbers; offsets shift on every inserted
728    /// character.
729    ///
730    /// Per-file on purpose: one dirty file degrades debugging in that file
731    /// alone, where a whole-program checksum degrades everything.
732    ///
733    /// `None` — "cannot tell" — when the artifact carries no `DebugInfo`,
734    /// names no such file, or recorded no hash (compiled without source
735    /// text). Deliberately tri-state rather than defaulting to `false`:
736    /// "unknown" and "stale" call for different handling, and collapsing
737    /// them would make every hash-less artifact look permanently stale.
738    ///
739    /// A change **detector**, not a proof — see [`brink_format::content_hash`].
740    #[must_use]
741    pub fn source_matches(&self, file: &str, text: &str) -> Option<bool> {
742        let debug_info = self.debug_info.as_ref()?;
743        let entry = debug_info.files.iter().find(|f| {
744            matches!(
745                f.surface,
746                brink_format::FileSurface::Ink | brink_format::FileSurface::Native
747            ) && f.path == file
748        })?;
749        if entry.source_hash == 0 {
750            return None;
751        }
752        Some(entry.source_hash == brink_format::content_hash(text))
753    }
754
755    /// Get a container by its index.
756    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
757        &self.containers[idx as usize]
758    }
759
760    /// The call-frame temp slots container `idx`'s parameters occupy, in
761    /// declared order — what the VM binds arguments into at entry since
762    /// `.inkb` v10 (`docs/compiler-spec.md` §"Parameter binding"). Not
763    /// necessarily `0 … n-1`: a stitch's parameters continue after its
764    /// knot's.
765    pub(crate) fn container_param_slots(&self, idx: u32) -> Vec<u16> {
766        self.containers[idx as usize]
767            .params
768            .iter()
769            .map(|p| p.slot)
770            .collect()
771    }
772
773    /// The code the VM executes for container `idx`: the linker's rewritten
774    /// copy when it produced one (`LinkTables::code`), else the symbolic
775    /// bytecode — same bytes at every offset except static-target operands.
776    #[inline]
777    pub(crate) fn code(&self, idx: u32) -> &[u8] {
778        self.link.code.get(idx as usize).map_or_else(
779            || self.containers[idx as usize].bytecode.as_slice(),
780            Vec::as_slice,
781        )
782    }
783
784    /// Static target `ordinal` of the linked table.
785    #[inline]
786    pub(crate) fn target(&self, ordinal: u32) -> Option<&LinkedTarget> {
787        self.link.targets.get(ordinal as usize)
788    }
789
790    /// Resolve a symbolic address id to a [`LinkedTarget`] — the slow path
791    /// the VM takes for operands the linker left symbolic and for targets
792    /// that arrive as values (`Value::DivertTarget`).
793    pub(crate) fn resolve(&self, id: DefinitionId) -> Result<LinkedTarget, RuntimeError> {
794        let (container_idx, offset) = self
795            .resolve_target(id)
796            .ok_or(RuntimeError::UnresolvedDefinition(id))?;
797        Ok(LinkedTarget {
798            container_idx,
799            offset,
800            id,
801        })
802    }
803
804    /// Get a container's bytecode by index.
805    #[cfg(feature = "testing")]
806    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
807        &self.containers[idx as usize].bytecode
808    }
809
810    /// Number of containers. Promoted from `#[cfg(feature = "testing")]`
811    /// to real public API for the structural-transcript re-render road
812    /// (RULED 2026-08-30): a transcript saved against an older compile can
813    /// carry container indices this program no longer has, and the caller
814    /// must be able to bounds-filter them before `scope_table_idx` would
815    /// panic.
816    #[expect(
817        clippy::cast_possible_truncation,
818        reason = "container count fits in u32"
819    )]
820    pub fn container_count(&self) -> u32 {
821        self.containers.len() as u32
822    }
823
824    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
825    pub fn source_checksum(&self) -> u32 {
826        self.source_checksum
827    }
828
829    /// Get the scope line table index for a container.
830    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
831        self.containers[container_idx as usize].scope_table_idx
832    }
833
834    /// Look up a name by id.
835    pub(crate) fn name(&self, id: NameId) -> &str {
836        &self.name_table[id.0 as usize]
837    }
838
839    /// Look up a name by id, returning `None` if the id is out of range. Used
840    /// by function-value rehydration (T1c, #700): a closure loaded from a save
841    /// produced against a *different* compile can carry a `NameId` that no
842    /// longer indexes this program's table — treated as a mismatch (fault),
843    /// never a panic.
844    ///
845    /// Public (T1d, `docs/t1d-spec.md` §6): the same "index by id, `None` if
846    /// out of range" contract a host needs to resolve a [`brink_format::Value::Handle`]'s
847    /// `kind` to its manifest-declared name — e.g. for dev-tooling display or
848    /// a host-side capability check. `bevy-brink` re-exports `Program`
849    /// (decision 2026-07-10), so this is reachable from engine code without a
850    /// direct `brink-runtime` dependency.
851    pub fn name_checked(&self, id: NameId) -> Option<&str> {
852        self.name_table.get(id.0 as usize).map(String::as_str)
853    }
854
855    /// Reverse of [`name_checked`](Self::name_checked): look up the
856    /// [`NameId`] a string interns to in this program's name table, if any.
857    ///
858    /// Public (T1d-3, `docs/t1d-spec.md` §4): a host minting a
859    /// [`brink_format::Value::Handle`] from a binding (e.g. `spawn_timer()`
860    /// returning a fresh `Handle<Timer>`) needs the compiled program's
861    /// `NameId` for the manifest-declared kind name (`"Timer"`) to build the
862    /// token — the wire form carries only the interned id, never the string.
863    /// `None` means this compile never interned that name (e.g. no
864    /// `Handle<Timer>`-typed signature or annotation anywhere in the source
865    /// graph), so no token of that kind can be minted against this program.
866    /// Linear scan, same cost class as [`global_index`](Self::global_index).
867    #[must_use]
868    pub fn name_id(&self, name: &str) -> Option<NameId> {
869        self.name_table
870            .iter()
871            .position(|n| n == name)
872            .and_then(|i| u16::try_from(i).ok())
873            .map(NameId)
874    }
875
876    /// Access a container's per-parameter name/mode metadata (T1c, #700).
877    pub(crate) fn container_params(&self, idx: u32) -> &[brink_format::ParamMeta] {
878        &self.containers[idx as usize].params
879    }
880
881    /// Look up a global slot index.
882    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
883        self.global_map.get(&id).copied()
884    }
885
886    /// Get the root container index.
887    pub(crate) fn root_idx(&self) -> u32 {
888        self.root_idx
889    }
890
891    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
892    ///
893    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
894    /// for programs compiled by `brink-compiler`, author labels
895    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
896    /// `address_paths` table (legacy `.inkb` or converter output) resolve
897    /// knot/stitch scope paths only. Use this to spawn flows at named entry
898    /// points:
899    ///
900    /// ```no_run
901    /// # fn example(program: &brink_runtime::Program) {
902    /// use brink_runtime::FlowInstance;
903    ///
904    /// if let Some((idx, _)) = program.find_address("intro_scene") {
905    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
906    /// }
907    /// # }
908    /// ```
909    #[must_use]
910    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
911        self.address_by_path
912            .get(path)
913            .map(|t| (t.container_idx, t.byte_offset))
914    }
915
916    /// Resolve a qualified ink path to the `DefinitionId` of its target.
917    /// Same path grammar as [`find_address`](Self::find_address). Used by
918    /// `choose_path_string`, which needs the id so the jump goes through the
919    /// same divert machinery (and visit counting) as `-> path` would.
920    pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
921        self.address_by_path.get(path).map(|t| t.id)
922    }
923
924    /// Public wrapper on [`find_path_target`](Self::find_path_target): resolve
925    /// a qualified ink path (same grammar as [`find_address`](Self::find_address))
926    /// to the `DefinitionId` of its target. Used by hosts that need the id
927    /// itself — e.g. `bevy-brink`'s wake-condition purity check (issue #995),
928    /// which looks the id up in the story's `EffectRows` table to inspect a
929    /// `FlowSleep` condition's effect row before admitting it into the wake
930    /// contract.
931    #[must_use]
932    pub fn definition_id_for_path(&self, path: &str) -> Option<DefinitionId> {
933        self.find_path_target(path)
934    }
935
936    /// Declared parameter count of the container a `path` targets, for
937    /// arity-checking a host-directed parameterized entry. `None` if the path
938    /// is unknown. (Always `0` for converter-built programs, which don't
939    /// record param counts.)
940    pub(crate) fn path_param_count(&self, path: &str) -> Option<u8> {
941        self.address_by_path
942            .get(path)
943            .map(|t| self.containers[t.container_idx as usize].param_count)
944    }
945
946    // ── Visibility (`#@private` — M-2b, docs/modules-spec.md §4) ────────────
947
948    /// Whether the compiler marked any definition `#@private`. `false` for the
949    /// entire pre-modules / all-public world — the fast path where visibility
950    /// enforcement is a single boolean check that skips every lookup below.
951    pub(crate) fn has_private_defs(&self) -> bool {
952        !self.private_defs.is_empty()
953    }
954
955    /// Whether the definition `id` was declared `#@private`.
956    pub(crate) fn is_private(&self, id: DefinitionId) -> bool {
957        self.private_defs
958            .binary_search_by_key(&id.to_raw(), |d| d.to_raw())
959            .is_ok()
960    }
961
962    /// Whether the global at slot `idx` is `#@private`.
963    pub(crate) fn global_is_private(&self, idx: u32) -> bool {
964        self.globals
965            .get(idx as usize)
966            .is_some_and(|slot| self.is_private(slot.id))
967    }
968
969    /// Whether the named entry point (knot/stitch/function path) is
970    /// `#@private`. Unknown paths are treated as not-private — resolution
971    /// failure is reported by the caller's own "not found" path, not here.
972    pub(crate) fn path_is_private(&self, path: &str) -> bool {
973        self.find_path_target(path)
974            .is_some_and(|id| self.is_private(id))
975    }
976
977    /// Whether the container at `idx` is `#@private`. Used by
978    /// [`FlowInstance::begin_function_eval`](crate::FlowInstance::begin_function_eval)/
979    /// [`begin_function_value_eval`](crate::FlowInstance::begin_function_value_eval),
980    /// which receive an already-resolved `container_idx` rather than a name
981    /// (the caller resolves it, typically via [`find_address`](Self::find_address),
982    /// before entering the VM boundary). Out-of-range indices are not
983    /// private — an invalid index is the caller's bug, reported elsewhere.
984    pub(crate) fn container_is_private(&self, idx: u32) -> bool {
985        self.containers
986            .get(idx as usize)
987            .is_some_and(|c| self.is_private(c.id))
988    }
989
990    /// Build the initial globals vector from slot defaults.
991    pub fn global_defaults(&self) -> Vec<Value> {
992        self.globals.iter().map(|s| s.default.clone()).collect()
993    }
994
995    /// Find the global variable slot index for a variable name, if declared.
996    /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
997    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
998    pub fn global_index(&self, name: &str) -> Option<u32> {
999        self.globals
1000            .iter()
1001            .position(|slot| self.name(slot.name) == name)
1002            .map(|i| i as u32)
1003    }
1004
1005    /// Get a list literal by index.
1006    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
1007        &self.list_literals[idx as usize]
1008    }
1009
1010    /// Get a T1b literal pool entry by index. `None` on an out-of-range
1011    /// index (malformed bytecode) rather than panicking — the VM turns
1012    /// this into a `RuntimeError`, never a crash.
1013    pub(crate) fn literal_pool_entry(&self, idx: u32) -> Option<&Value> {
1014        self.literal_pool.get(idx as usize)
1015    }
1016
1017    /// Look up a `STRUCT` shape's runtime metadata by `ShapeId`. `None` on an
1018    /// out-of-range id (malformed bytecode) rather than panicking — mirrors
1019    /// [`literal_pool_entry`](Self::literal_pool_entry).
1020    pub(crate) fn struct_shape(&self, shape: ShapeId) -> Option<&StructShapeEntry> {
1021        self.struct_shapes.get(shape.0 as usize)
1022    }
1023
1024    /// Look up a list item's metadata.
1025    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
1026        self.list_item_map.get(&id)
1027    }
1028
1029    /// Get a list definition by its `DefinitionId`.
1030    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
1031        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
1032    }
1033
1034    /// Find a list definition by its string name.
1035    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
1036        self.list_defs
1037            .iter()
1038            .find(|def| self.name(def.name) == name)
1039    }
1040
1041    /// Look up an external function by its `DefinitionId`.
1042    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
1043        self.external_fns.get(&id)
1044    }
1045
1046    // ── Public variable introspection (host-facing) ─────────────────────────
1047    // `global_index` (above), `global_name`, and `global_count` form the
1048    // host-facing variable-introspection set used by `Story::variable`/
1049    // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
1050    // previously `testing`-gated; promoted to public per the State View plan.
1051
1052    /// Resolve a global cell's `DefinitionId` to its slot index — the
1053    /// numbering [`ContextAccess::set_global`](crate::ContextAccess) and
1054    /// [`Self::global_index`] use.
1055    ///
1056    /// Public because effect rows (`brink_format::DirectEffects::reads` /
1057    /// `writes`) name global cells by `DefinitionId` while the runtime's
1058    /// world writes are keyed by slot: a host consuming rows for scheduling
1059    /// (bevy-brink's row-directed wake dirtying, issue #1146) needs exactly
1060    /// this bridge. `None` for an id this program declares no global for
1061    /// (a stale row, a `VAR` removed by a story patch).
1062    pub fn global_slot(&self, id: DefinitionId) -> Option<u32> {
1063        self.resolve_global(id)
1064    }
1065
1066    /// Resolve a global slot index to its variable name.
1067    pub fn global_name(&self, idx: u32) -> Option<&str> {
1068        self.globals
1069            .get(idx as usize)
1070            .map(|slot| self.name(slot.name))
1071    }
1072
1073    // ── Compiled scope defaults (`#@local` — directive-annotations spec) ────
1074
1075    /// Whether the compiler marked anything flow-private. When `false`
1076    /// (all existing unannotated ink), policy resolution keeps its
1077    /// all-`World` fast path.
1078    ///
1079    /// Public so the bevy host (`bevy-brink`'s batch driver) can guard
1080    /// against batching a `#@local`-annotated story: batch mode routes only
1081    /// the shared `World`, never a flow's private `FlowLocal`, so a story
1082    /// carrying compiled flow-private defaults must stay on the serial API
1083    /// (`docs/effects-spec.md` §12; bevy-brink #925).
1084    pub fn has_local_defaults(&self) -> bool {
1085        !self.local_scope_defaults.is_empty() || self.globals.iter().any(|g| g.local)
1086    }
1087
1088    /// Compiled flow-private default for a global slot.
1089    pub(crate) fn global_is_local(&self, idx: u32) -> bool {
1090        self.globals.get(idx as usize).is_some_and(|g| g.local)
1091    }
1092
1093    /// Compiled flow-private knot/stitch defaults, sorted by path.
1094    pub(crate) fn local_scope_defaults(&self) -> &[(String, DefinitionId)] {
1095        &self.local_scope_defaults
1096    }
1097
1098    /// Number of global variable slots.
1099    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
1100    pub fn global_count(&self) -> u32 {
1101        self.globals.len() as u32
1102    }
1103
1104    // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
1105
1106    /// Variable name for a global slot index.
1107    pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
1108        self.globals.get(idx).map(|slot| self.name(slot.name))
1109    }
1110
1111    /// D7 (`docs/debugger-spec.md` §3, #3185): every `LocalsTable` row
1112    /// declared anywhere in the same lexical **scope** (`ContainerDef::
1113    /// scope_id`) as the container at `leaf_container_idx` — not just that
1114    /// one container's own table.
1115    ///
1116    /// This is deliberately scope-wide, not container-local: a call frame's
1117    /// `container_stack` can legitimately shrink back to a single entry
1118    /// mid-frame (`vm::goto_target`'s "target not already on the stack"
1119    /// branch `clear()`s and replaces it wholesale — e.g. entering a
1120    /// `{? … }` choice-target body from its enclosing knot is exactly this
1121    /// case), which would silently drop an enclosing container's locals
1122    /// (its own parameters/`~ temp`s) from view the moment the leaf
1123    /// position moves into a *sibling* child container — even though the
1124    /// call frame's `temps` are completely unaffected (they are declared
1125    /// once per **scope root**, `docs/debugger-spec.md` §3: "VM temp slots
1126    /// ... are allocated per active call frame, not lexically nested").
1127    /// Grouping by `scope_id` instead of by whatever happens to be on
1128    /// `container_stack` right now is what keeps a parameter/`~ temp`
1129    /// visible for the frame's entire lifetime, matching the runtime's own
1130    /// slot-allocation model rather than the transient shape of one
1131    /// in-frame navigation stack.
1132    ///
1133    /// Empty when this artifact carries no `DebugInfo` at all (a
1134    /// release-exported story, or one compiled before D6), when
1135    /// `leaf_container_idx` is out of range (malformed/adversarial
1136    /// `.inkb`, not a panic case), or when the scope genuinely declares no
1137    /// locals.
1138    pub(crate) fn scope_debug_locals(
1139        &self,
1140        leaf_container_idx: u32,
1141    ) -> Vec<&brink_format::DebugLocalEntry> {
1142        let Some(debug_info) = self.debug_info.as_ref() else {
1143            return Vec::new();
1144        };
1145        let Some(scope_id) = self
1146            .containers
1147            .get(leaf_container_idx as usize)
1148            .map(|c| c.scope_id)
1149        else {
1150            return Vec::new();
1151        };
1152        self.containers
1153            .iter()
1154            .zip(debug_info.containers.iter())
1155            .filter(|(c, _)| c.scope_id == scope_id)
1156            .flat_map(|(_, table)| table.locals.iter())
1157            .collect()
1158    }
1159
1160    /// Compiled `DefinitionId` for a global slot index — the identity
1161    /// `save_state` round-trips into `SaveState::global_ids` so the M-3
1162    /// rehydration miss path (`docs/modules-spec.md` §5) can recover a
1163    /// renamed VAR/CONST/LIST global's *save-time* id (declared-module
1164    /// identity is `(module, name)`-hashed, so the bare name alone can't
1165    /// reconstruct it) and look it up in the compiled alias table.
1166    pub(crate) fn global_id(&self, idx: usize) -> Option<DefinitionId> {
1167        self.globals.get(idx).map(|slot| slot.id)
1168    }
1169
1170    /// Variable name for a global's defining `DefinitionId` (e.g. a
1171    /// `VariablePointer` target, or a T1e projection's root cell). `pub`
1172    /// (not `pub(crate)`) since `brink-web`'s program-model/speculation
1173    /// disassembly needs it to render a projection's root name at the wasm
1174    /// boundary, the same way `divert_target_path` already resolves a
1175    /// divert's `DefinitionId` for that consumer.
1176    pub fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
1177        let slot = self.resolve_global(id)?;
1178        self.global_slot_name(slot as usize)
1179    }
1180
1181    /// Display name for a list item by its `DefinitionId`.
1182    pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
1183        self.list_item(id).map(|item| self.name(item.name))
1184    }
1185
1186    // ── Host-facing structured value display (F4.3 web binding) ─────────────
1187    // `list_members`/`divert_target_path` give a host (e.g. brink-web's wasm
1188    // marshaling) the same name resolution `value_ops::stringify_list` and
1189    // `debug::NameResolver` already do internally, but structured rather than
1190    // pre-joined into a display string — a host may want to render a list's
1191    // members or a divert's destination as distinct fields rather than text.
1192    // On-demand only (not on any hot path), like `debug::NameResolver`.
1193
1194    /// Resolve the active members of a list value for host-facing display:
1195    /// each member's origin list name, unqualified item name, and ordinal.
1196    /// Sorted the same way in-story list stringification orders them
1197    /// (ordinal, then origin name) so the two presentations agree.
1198    #[must_use]
1199    pub fn list_members(&self, list: &ListValue) -> Vec<ListMember> {
1200        let mut entries: Vec<ListMember> = list
1201            .items
1202            .iter()
1203            .filter_map(|&id| {
1204                self.list_item(id).map(|entry| {
1205                    let origin = self
1206                        .list_def(entry.origin)
1207                        .map_or_else(String::new, |def| self.name(def.name).to_owned());
1208                    let full_name = self.name(entry.name);
1209                    let name = full_name
1210                        .split_once('.')
1211                        .map_or_else(|| full_name.to_owned(), |(_, item)| item.to_owned());
1212                    ListMember {
1213                        origin,
1214                        name,
1215                        ordinal: entry.ordinal,
1216                    }
1217                })
1218            })
1219            .collect();
1220        entries.sort_by(|a, b| {
1221            a.ordinal
1222                .cmp(&b.ordinal)
1223                .then_with(|| a.origin.cmp(&b.origin))
1224        });
1225        entries
1226    }
1227
1228    /// The qualified knot/stitch path a `DefinitionId` names, if it resolves
1229    /// to a named scope entry (offset-0 in `address_by_path`) — the
1230    /// destination of a `Value::DivertTarget` for host-facing display.
1231    /// Deterministic on collision: shortest path, then lexicographically
1232    /// smallest, independent of the map's iteration order (mirrors
1233    /// `debug::NameResolver`'s reverse lookup).
1234    #[must_use]
1235    pub fn divert_target_path(&self, id: DefinitionId) -> Option<String> {
1236        let (container_idx, _) = self.resolve_target(id)?;
1237        let mut best: Option<&str> = None;
1238        for (path, target) in &self.address_by_path {
1239            if target.byte_offset != 0 || target.container_idx != container_idx {
1240                continue;
1241            }
1242            best = Some(match best {
1243                None => path.as_str(),
1244                Some(existing) => {
1245                    if path.len() < existing.len()
1246                        || (path.len() == existing.len() && path.as_str() < existing)
1247                    {
1248                        path.as_str()
1249                    } else {
1250                        existing
1251                    }
1252                }
1253            });
1254        }
1255        best.map(ToOwned::to_owned)
1256    }
1257}
1258
1259/// One active member of a list value, resolved for host-facing display. See
1260/// [`Program::list_members`].
1261#[derive(Debug, Clone, PartialEq, Eq)]
1262pub struct ListMember {
1263    /// The origin list's declared name (e.g. `"Weekday"`).
1264    pub origin: String,
1265    /// The item's unqualified display name (e.g. `"Monday"`).
1266    pub name: String,
1267    /// The item's ordinal within its origin list.
1268    pub ordinal: i32,
1269}
1270
1271#[cfg(test)]
1272mod find_address_tests {
1273    use super::*;
1274
1275    fn make_program_with_named_containers(names: &[&str]) -> Program {
1276        // Build a minimal Program where each name maps to a unique
1277        // container_idx. Used to exercise find_address without going
1278        // through the full link path.
1279        let mut address_by_path = HashMap::new();
1280        for (i, name) in names.iter().enumerate() {
1281            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
1282            address_by_path.insert(
1283                (*name).to_string(),
1284                PathTarget {
1285                    id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
1286                    container_idx: i as u32,
1287                    byte_offset: 0,
1288                },
1289            );
1290        }
1291        Program {
1292            link: crate::program::LinkTables::default(),
1293            containers: Vec::new(),
1294            address_map: HashMap::new(),
1295            scope_ids: Vec::new(),
1296            source_checksum: 0,
1297            globals: Vec::new(),
1298            global_map: HashMap::new(),
1299            name_table: Vec::new(),
1300            container_paths: container_paths_from(&address_by_path),
1301            address_by_path,
1302            root_idx: 0,
1303            list_literals: Vec::new(),
1304            literal_pool: Vec::new(),
1305            list_item_map: HashMap::new(),
1306            list_defs: Vec::new(),
1307            list_def_map: HashMap::new(),
1308            external_fns: HashMap::new(),
1309            local_scope_defaults: Vec::new(),
1310            struct_shapes: Vec::new(),
1311            private_defs: Vec::new(),
1312            alias_table: Vec::new(),
1313            debug_info: None,
1314        }
1315    }
1316
1317    #[test]
1318    fn finds_known_knot() {
1319        let program = make_program_with_named_containers(&["intro", "outro"]);
1320        assert_eq!(program.find_address("intro"), Some((0, 0)));
1321        assert_eq!(program.find_address("outro"), Some((1, 0)));
1322    }
1323
1324    #[test]
1325    fn returns_none_for_unknown_knot() {
1326        let program = make_program_with_named_containers(&["intro"]);
1327        assert_eq!(program.find_address("nope"), None);
1328    }
1329
1330    #[test]
1331    fn empty_program_returns_none() {
1332        let program = make_program_with_named_containers(&[]);
1333        assert_eq!(program.find_address("anything"), None);
1334    }
1335}