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}