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    /// Index into `Program.line_tables` for this container's scope line table.
48    pub scope_table_idx: u32,
49}
50
51pub(crate) struct GlobalSlot {
52    #[expect(dead_code, reason = "needed for save/load serialization and debugging")]
53    pub id: DefinitionId,
54    pub name: NameId,
55    pub default: Value,
56}
57
58/// Runtime metadata for a list item.
59pub(crate) struct ListItemEntry {
60    pub name: NameId,
61    pub ordinal: i32,
62    pub origin: DefinitionId,
63}
64
65/// Runtime metadata for a list definition.
66pub(crate) struct ListDefEntry {
67    pub name: NameId,
68    /// All item `DefinitionId`s belonging to this list, sorted by ordinal.
69    pub items: Vec<DefinitionId>,
70}
71
72/// Runtime metadata for an external function.
73pub(crate) struct ExternalFnEntry {
74    pub name: NameId,
75    pub fallback: Option<DefinitionId>,
76}
77
78/// Resolved target of a qualified path string: the defining `DefinitionId`
79/// (used for visit counting, exactly as a divert to the same target would
80/// use it) plus the linked `(container_idx, byte_offset)` position.
81#[derive(Debug, Clone, Copy)]
82pub(crate) struct PathTarget {
83    pub id: DefinitionId,
84    pub container_idx: u32,
85    pub byte_offset: usize,
86}
87
88impl Program {
89    /// Resolve any target (container or address) to `(container_idx, byte_offset)`.
90    pub(crate) fn resolve_target(&self, id: DefinitionId) -> Option<(u32, usize)> {
91        self.address_map.get(&id).copied()
92    }
93
94    /// Resolve a definition ID to `(container_idx, byte_offset)`.
95    #[cfg(feature = "testing")]
96    pub fn resolve_address(&self, id: DefinitionId) -> Option<(u32, usize)> {
97        self.resolve_target(id)
98    }
99
100    /// Get a container by its index.
101    pub(crate) fn container(&self, idx: u32) -> &LinkedContainer {
102        &self.containers[idx as usize]
103    }
104
105    /// Get a container's bytecode by index.
106    #[cfg(feature = "testing")]
107    pub fn container_bytecode(&self, idx: u32) -> &[u8] {
108        &self.containers[idx as usize].bytecode
109    }
110
111    /// Number of containers.
112    #[cfg(feature = "testing")]
113    #[expect(
114        clippy::cast_possible_truncation,
115        reason = "container count fits in u32"
116    )]
117    pub fn container_count(&self) -> u32 {
118        self.containers.len() as u32
119    }
120
121    /// CRC-32 checksum from the source `.inkb`, used for transcript validation.
122    pub fn source_checksum(&self) -> u32 {
123        self.source_checksum
124    }
125
126    /// Get the scope line table index for a container.
127    pub(crate) fn scope_table_idx(&self, container_idx: u32) -> u32 {
128        self.containers[container_idx as usize].scope_table_idx
129    }
130
131    /// Look up a name by id.
132    pub(crate) fn name(&self, id: NameId) -> &str {
133        &self.name_table[id.0 as usize]
134    }
135
136    /// Look up a global slot index.
137    pub(crate) fn resolve_global(&self, id: DefinitionId) -> Option<u32> {
138        self.global_map.get(&id).copied()
139    }
140
141    /// Get the root container index.
142    pub(crate) fn root_idx(&self) -> u32 {
143        self.root_idx
144    }
145
146    /// Resolve a qualified ink path to its `(container_idx, byte_offset)`.
147    ///
148    /// Supports knot names (`intro`), qualified stitches (`knot.stitch`), and,
149    /// for programs compiled by `brink-compiler`, author labels
150    /// (`knot.label`, `knot.stitch.label`). Programs without the compiler's
151    /// `address_paths` table (legacy `.inkb` or converter output) resolve
152    /// knot/stitch scope paths only. Use this to spawn flows at named entry
153    /// points:
154    ///
155    /// ```ignore
156    /// if let Some((idx, _)) = program.find_address("intro_scene") {
157    ///     let (flow, ctx) = FlowInstance::new_at(program, idx);
158    /// }
159    /// ```
160    #[must_use]
161    pub fn find_address(&self, path: &str) -> Option<(u32, usize)> {
162        self.address_by_path
163            .get(path)
164            .map(|t| (t.container_idx, t.byte_offset))
165    }
166
167    /// Resolve a qualified ink path to the `DefinitionId` of its target.
168    /// Same path grammar as [`find_address`](Self::find_address). Used by
169    /// `choose_path_string`, which needs the id so the jump goes through the
170    /// same divert machinery (and visit counting) as `-> path` would.
171    pub(crate) fn find_path_target(&self, path: &str) -> Option<DefinitionId> {
172        self.address_by_path.get(path).map(|t| t.id)
173    }
174
175    /// Build the initial globals vector from slot defaults.
176    pub fn global_defaults(&self) -> Vec<Value> {
177        self.globals.iter().map(|s| s.default.clone()).collect()
178    }
179
180    /// Find the global variable slot index for a variable name, if declared.
181    /// Used by host-facing variable get/set (`Story::variable`/`set_variable`).
182    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
183    pub fn global_index(&self, name: &str) -> Option<u32> {
184        self.globals
185            .iter()
186            .position(|slot| self.name(slot.name) == name)
187            .map(|i| i as u32)
188    }
189
190    /// Get a list literal by index.
191    pub(crate) fn list_literal(&self, idx: u16) -> &ListValue {
192        &self.list_literals[idx as usize]
193    }
194
195    /// Look up a list item's metadata.
196    pub(crate) fn list_item(&self, id: DefinitionId) -> Option<&ListItemEntry> {
197        self.list_item_map.get(&id)
198    }
199
200    /// Get a list definition by its `DefinitionId`.
201    pub(crate) fn list_def(&self, id: DefinitionId) -> Option<&ListDefEntry> {
202        self.list_def_map.get(&id).map(|&idx| &self.list_defs[idx])
203    }
204
205    /// Find a list definition by its string name.
206    pub(crate) fn list_def_by_name(&self, name: &str) -> Option<&ListDefEntry> {
207        self.list_defs
208            .iter()
209            .find(|def| self.name(def.name) == name)
210    }
211
212    /// Look up an external function by its `DefinitionId`.
213    pub(crate) fn external_fn(&self, id: DefinitionId) -> Option<&ExternalFnEntry> {
214        self.external_fns.get(&id)
215    }
216
217    // ── Public variable introspection (host-facing) ─────────────────────────
218    // `global_index` (above), `global_name`, and `global_count` form the
219    // host-facing variable-introspection set used by `Story::variable`/
220    // `set_variable` and consumers like the RMMZ var↔switch mapping. They were
221    // previously `testing`-gated; promoted to public per the State View plan.
222
223    /// Resolve a global slot index to its variable name.
224    pub fn global_name(&self, idx: u32) -> Option<&str> {
225        self.globals
226            .get(idx as usize)
227            .map(|slot| self.name(slot.name))
228    }
229
230    /// Number of global variable slots.
231    #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
232    pub fn global_count(&self) -> u32 {
233        self.globals.len() as u32
234    }
235
236    // ── Debug introspection name lookups (used by `debug_snapshot`) ──────────
237
238    /// Variable name for a global slot index.
239    pub(crate) fn global_slot_name(&self, idx: usize) -> Option<&str> {
240        self.globals.get(idx).map(|slot| self.name(slot.name))
241    }
242
243    /// Variable name for a global's defining `DefinitionId` (e.g. a
244    /// `VariablePointer` target).
245    pub(crate) fn global_var_name(&self, id: DefinitionId) -> Option<&str> {
246        let slot = self.resolve_global(id)?;
247        self.global_slot_name(slot as usize)
248    }
249
250    /// Display name for a list item by its `DefinitionId`.
251    pub(crate) fn list_item_name(&self, id: DefinitionId) -> Option<&str> {
252        self.list_item(id).map(|item| self.name(item.name))
253    }
254}
255
256#[cfg(test)]
257mod find_address_tests {
258    use super::*;
259
260    fn make_program_with_named_containers(names: &[&str]) -> Program {
261        // Build a minimal Program where each name maps to a unique
262        // container_idx. Used to exercise find_address without going
263        // through the full link path.
264        let mut address_by_path = HashMap::new();
265        for (i, name) in names.iter().enumerate() {
266            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
267            address_by_path.insert(
268                (*name).to_string(),
269                PathTarget {
270                    id: DefinitionId::new(brink_format::DefinitionTag::Address, i as u64),
271                    container_idx: i as u32,
272                    byte_offset: 0,
273                },
274            );
275        }
276        Program {
277            containers: Vec::new(),
278            address_map: HashMap::new(),
279            scope_ids: Vec::new(),
280            source_checksum: 0,
281            globals: Vec::new(),
282            global_map: HashMap::new(),
283            name_table: Vec::new(),
284            address_by_path,
285            root_idx: 0,
286            list_literals: Vec::new(),
287            list_item_map: HashMap::new(),
288            list_defs: Vec::new(),
289            list_def_map: HashMap::new(),
290            external_fns: HashMap::new(),
291        }
292    }
293
294    #[test]
295    fn finds_known_knot() {
296        let program = make_program_with_named_containers(&["intro", "outro"]);
297        assert_eq!(program.find_address("intro"), Some((0, 0)));
298        assert_eq!(program.find_address("outro"), Some((1, 0)));
299    }
300
301    #[test]
302    fn returns_none_for_unknown_knot() {
303        let program = make_program_with_named_containers(&["intro"]);
304        assert_eq!(program.find_address("nope"), None);
305    }
306
307    #[test]
308    fn empty_program_returns_none() {
309        let program = make_program_with_named_containers(&[]);
310        assert_eq!(program.find_address("anything"), None);
311    }
312}