Skip to main content

brink_runtime/
program.rs

1//! Immutable linked program.
2
3use std::collections::HashMap;
4
5use brink_format::{CountingFlags, DefinitionId, ListValue, NameId, Value};
6
7/// A linked, ready-to-execute program.
8///
9/// Created from [`StoryData`](brink_format::StoryData) via [`link()`](crate::link).
10/// Immutable after creation — mutable per-instance state lives in [`Story`](crate::Story).
11pub struct Program {
12    pub(crate) containers: Vec<LinkedContainer>,
13    /// Unified address map: `id → (container_idx, byte_offset)`.
14    /// Contains both container IDs (offset 0) and intra-container addresses.
15    pub(crate) address_map: HashMap<DefinitionId, (u32, usize)>,
16    /// Scope `DefinitionId` for each entry in the line tables (parallel vec).
17    /// Structural metadata — does not change with locale.
18    pub(crate) scope_ids: Vec<DefinitionId>,
19    /// CRC-32 checksum from the source `.inkb`, used for locale validation.
20    pub(crate) source_checksum: u32,
21    pub(crate) globals: Vec<GlobalSlot>,
22    pub(crate) global_map: HashMap<DefinitionId, u32>,
23    pub(crate) name_table: Vec<String>,
24    /// Map from a knot/stitch path string to its target: the defining
25    /// `DefinitionId` plus the resolved `(container_idx, byte_offset)`.
26    /// Built at link time from named scope containers; lets consumers
27    /// spawn flows at named entry points without needing `DefinitionId`s.
28    pub(crate) address_by_path: HashMap<String, PathTarget>,
29    pub(crate) root_idx: u32,
30    /// List literal values referenced by `PushList(idx)`.
31    pub(crate) list_literals: Vec<ListValue>,
32    /// Per-item metadata keyed by item `DefinitionId`.
33    pub(crate) list_item_map: HashMap<DefinitionId, ListItemEntry>,
34    /// List definitions indexed by position.
35    pub(crate) list_defs: Vec<ListDefEntry>,
36    /// Map from list def `DefinitionId` to index in `list_defs`.
37    pub(crate) list_def_map: HashMap<DefinitionId, usize>,
38    /// External function metadata keyed by the external function's `DefinitionId`.
39    pub(crate) external_fns: HashMap<DefinitionId, ExternalFnEntry>,
40}
41
42pub(crate) struct LinkedContainer {
43    pub id: DefinitionId,
44    pub bytecode: Vec<u8>,
45    pub counting_flags: CountingFlags,
46    pub path_hash: i32,
47    /// Number of declared parameters (for arity-checking host-directed entry).
48    pub param_count: u8,
49    /// Index into `Program.line_tables` for this container's scope line table.
50    pub scope_table_idx: u32,
51}
52
53pub(crate) struct GlobalSlot {
54    #[expect(dead_code, reason = "needed for save/load serialization and debugging")]
55    pub id: DefinitionId,
56    pub name: NameId,
57    pub default: Value,
58}
59
60/// Runtime metadata for a list item.
61pub(crate) struct ListItemEntry {
62    pub name: NameId,
63    pub ordinal: i32,
64    pub origin: DefinitionId,
65}
66
67/// Runtime metadata for a list definition.
68pub(crate) struct ListDefEntry {
69    pub name: NameId,
70    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
71    pub items: Vec<DefinitionId>,
72}
73
74/// Runtime metadata for an external function.
75pub(crate) struct ExternalFnEntry {
76    pub name: NameId,
77    pub fallback: Option<DefinitionId>,
78}
79
80/// Resolved target of a qualified path string: the defining `DefinitionId`
81/// (used for visit counting, exactly as a divert to the same target would
82/// use it) plus the linked `(container_idx, byte_offset)` position.
83#[derive(Debug, Clone, Copy)]
84pub(crate) struct PathTarget {
85    pub id: DefinitionId,
86    pub container_idx: u32,
87    pub byte_offset: usize,
88}
89
90impl Program {
91    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
92    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
93        self.address_map.get(&id).copied()
94    }
95
96    /// Resolve a definition ID to `(container_idx, byte_offset)`.
97    #[cfg(feature = "testing")]
98    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
99        self.resolve_target(id)
100    }
101
102    /// Get a container by its index.
103    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
104        &self.containers[idx as usize]
105    }
106
107    /// Get a container's bytecode by index.
108    #[cfg(feature = "testing")]
109    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
110        &self.containers[idx as usize].bytecode
111    }
112
113    /// Number of containers.
114    #[cfg(feature = "testing")]
115    #[expect(
116        clippy::cast_possible_truncation,
117        reason = "container count fits in u32"
118    )]
119    pub fn container_count(&self) -> u32 {
120        self.containers.len() as u32
121    }
122
123    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
124    pub fn source_checksum(&self) -> u32 {
125        self.source_checksum
126    }
127
128    /// Get the scope line table index for a container.
129    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
130        self.containers[container_idx as usize].scope_table_idx
131    }
132
133    /// Look up a name by id.
134    pub(crate) fn name(&self, id: NameId) -> &str {
135        &self.name_table[id.0 as usize]
136    }
137
138    /// Look up a global slot index.
139    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
140        self.global_map.get(&id).copied()
141    }
142
143    /// Get the root container index.
144    pub(crate) fn root_idx(&self) -> u32 {
145        self.root_idx
146    }
147
148    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
149    ///
150    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
151    /// for programs compiled by `brink-compiler`, author labels
152    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
153    /// `address_paths` table (legacy `.inkb` or converter output) resolve
154    /// knot/stitch scope paths only. Use this to spawn flows at named entry
155    /// points:
156    ///
157    /// ```ignore
158    /// if let Some((idx, _)) = program.find_address("intro_scene") {
159    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
160    /// }
161    /// ```
162    #[must_use]
163    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
164        self.address_by_path
165            .get(path)
166            .map(|t| (t.container_idx, t.byte_offset))
167    }
168
169    /// Resolve a qualified ink path to the `DefinitionId` of its target.
170    /// Same path grammar as [`find_address`](Self::find_address). Used by
171    /// `choose_path_string`, which needs the id so the jump goes through the
172    /// same divert machinery (and visit counting) as `-> path` would.
173    pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
174        self.address_by_path.get(path).map(|t| t.id)
175    }
176
177    /// Declared parameter count of the container a `path` targets, for
178    /// arity-checking a host-directed parameterized entry. `None` if the path
179    /// is unknown. (Always `0` for converter-built programs, which don't
180    /// record param counts.)
181    pub(crate) fn path_param_count(&self, path: &str) -> Option<u8> {
182        self.address_by_path
183            .get(path)
184            .map(|t| self.containers[t.container_idx as usize].param_count)
185    }
186
187    /// Build the initial globals vector from slot defaults.
188    pub fn global_defaults(&self) -> Vec<Value> {
189        self.globals.iter().map(|s| s.default.clone()).collect()
190    }
191
192    /// Find the global variable slot index for a variable name, if declared.
193    /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
194    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
195    pub fn global_index(&self, name: &str) -> Option<u32> {
196        self.globals
197            .iter()
198            .position(|slot| self.name(slot.name) == name)
199            .map(|i| i as u32)
200    }
201
202    /// Get a list literal by index.
203    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
204        &self.list_literals[idx as usize]
205    }
206
207    /// Look up a list item's metadata.
208    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
209        self.list_item_map.get(&id)
210    }
211
212    /// Get a list definition by its `DefinitionId`.
213    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
214        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
215    }
216
217    /// Find a list definition by its string name.
218    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
219        self.list_defs
220            .iter()
221            .find(|def| self.name(def.name) == name)
222    }
223
224    /// Look up an external function by its `DefinitionId`.
225    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
226        self.external_fns.get(&id)
227    }
228
229    // ── Public variable introspection (host-facing) ─────────────────────────
230    // `global_index` (above), `global_name`, and `global_count` form the
231    // host-facing variable-introspection set used by `Story::variable`/
232    // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
233    // previously `testing`-gated; promoted to public per the State View plan.
234
235    /// Resolve a global slot index to its variable name.
236    pub fn global_name(&self, idx: u32) -> Option<&str> {
237        self.globals
238            .get(idx as usize)
239            .map(|slot| self.name(slot.name))
240    }
241
242    /// Number of global variable slots.
243    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
244    pub fn global_count(&self) -> u32 {
245        self.globals.len() as u32
246    }
247
248    // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
249
250    /// Variable name for a global slot index.
251    pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
252        self.globals.get(idx).map(|slot| self.name(slot.name))
253    }
254
255    /// Variable name for a global's defining `DefinitionId` (e.g. a
256    /// `VariablePointer` target).
257    pub(crate) fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
258        let slot = self.resolve_global(id)?;
259        self.global_slot_name(slot as usize)
260    }
261
262    /// Display name for a list item by its `DefinitionId`.
263    pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
264        self.list_item(id).map(|item| self.name(item.name))
265    }
266
267    // ── Host-facing structured value display (F4.3 web binding) ─────────────
268    // `list_members`/`divert_target_path` give a host (e.g. brink-web's wasm
269    // marshaling) the same name resolution `value_ops::stringify_list` and
270    // `debug::NameResolver` already do internally, but structured rather than
271    // pre-joined into a display string — a host may want to render a list's
272    // members or a divert's destination as distinct fields rather than text.
273    // On-demand only (not on any hot path), like `debug::NameResolver`.
274
275    /// Resolve the active members of a list value for host-facing display:
276    /// each member's origin list name, unqualified item name, and ordinal.
277    /// Sorted the same way in-story list stringification orders them
278    /// (ordinal, then origin name) so the two presentations agree.
279    #[must_use]
280    pub fn list_members(&self, list: &ListValue) -> Vec<ListMember> {
281        let mut entries: Vec<ListMember> = list
282            .items
283            .iter()
284            .filter_map(|&id| {
285                self.list_item(id).map(|entry| {
286                    let origin = self
287                        .list_def(entry.origin)
288                        .map_or_else(String::new, |def| self.name(def.name).to_owned());
289                    let full_name = self.name(entry.name);
290                    let name = full_name
291                        .split_once('.')
292                        .map_or_else(|| full_name.to_owned(), |(_, item)| item.to_owned());
293                    ListMember {
294                        origin,
295                        name,
296                        ordinal: entry.ordinal,
297                    }
298                })
299            })
300            .collect();
301        entries.sort_by(|a, b| {
302            a.ordinal
303                .cmp(&b.ordinal)
304                .then_with(|| a.origin.cmp(&b.origin))
305        });
306        entries
307    }
308
309    /// The qualified knot/stitch path a `DefinitionId` names, if it resolves
310    /// to a named scope entry (offset-0 in `address_by_path`) — the
311    /// destination of a `Value::DivertTarget` for host-facing display.
312    /// Deterministic on collision: shortest path, then lexicographically
313    /// smallest, independent of the map's iteration order (mirrors
314    /// `debug::NameResolver`'s reverse lookup).
315    #[must_use]
316    pub fn divert_target_path(&self, id: DefinitionId) -> Option<String> {
317        let (container_idx, _) = self.resolve_target(id)?;
318        let mut best: Option<&str> = None;
319        for (path, target) in &self.address_by_path {
320            if target.byte_offset != 0 || target.container_idx != container_idx {
321                continue;
322            }
323            best = Some(match best {
324                None => path.as_str(),
325                Some(existing) => {
326                    if path.len() < existing.len()
327                        || (path.len() == existing.len() && path.as_str() < existing)
328                    {
329                        path.as_str()
330                    } else {
331                        existing
332                    }
333                }
334            });
335        }
336        best.map(ToOwned::to_owned)
337    }
338}
339
340/// One active member of a list value, resolved for host-facing display. See
341/// [`Program::list_members`].
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct ListMember {
344    /// The origin list's declared name (e.g. `"Weekday"`).
345    pub origin: String,
346    /// The item's unqualified display name (e.g. `"Monday"`).
347    pub name: String,
348    /// The item's ordinal within its origin list.
349    pub ordinal: i32,
350}
351
352#[cfg(test)]
353mod find_address_tests {
354    use super::*;
355
356    fn make_program_with_named_containers(names: &[&str]) -> Program {
357        // Build a minimal Program where each name maps to a unique
358        // container_idx. Used to exercise find_address without going
359        // through the full link path.
360        let mut address_by_path = HashMap::new();
361        for (i, name) in names.iter().enumerate() {
362            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
363            address_by_path.insert(
364                (*name).to_string(),
365                PathTarget {
366                    id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
367                    container_idx: i as u32,
368                    byte_offset: 0,
369                },
370            );
371        }
372        Program {
373            containers: Vec::new(),
374            address_map: HashMap::new(),
375            scope_ids: Vec::new(),
376            source_checksum: 0,
377            globals: Vec::new(),
378            global_map: HashMap::new(),
379            name_table: Vec::new(),
380            address_by_path,
381            root_idx: 0,
382            list_literals: Vec::new(),
383            list_item_map: HashMap::new(),
384            list_defs: Vec::new(),
385            list_def_map: HashMap::new(),
386            external_fns: HashMap::new(),
387        }
388    }
389
390    #[test]
391    fn finds_known_knot() {
392        let program = make_program_with_named_containers(&["intro", "outro"]);
393        assert_eq!(program.find_address("intro"), Some((0, 0)));
394        assert_eq!(program.find_address("outro"), Some((1, 0)));
395    }
396
397    #[test]
398    fn returns_none_for_unknown_knot() {
399        let program = make_program_with_named_containers(&["intro"]);
400        assert_eq!(program.find_address("nope"), None);
401    }
402
403    #[test]
404    fn empty_program_returns_none() {
405        let program = make_program_with_named_containers(&[]);
406        assert_eq!(program.find_address("anything"), None);
407    }
408}