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