brink_runtime/debug.rs
1//! Read-only debug introspection for the studio State View.
2//!
3//! [`Story::debug_snapshot`](crate::Story::debug_snapshot) produces a
4//! [`DebugSnapshot`] — a name-resolved, structured view of the runtime's
5//! current state (location, globals, call stack, visit counts, pending
6//! choices, rng). Unlike the VM internals, everything here is resolved to
7//! author-facing knot/stitch paths and variable names.
8//!
9//! This is built on demand and is not on any hot path.
10
11use std::collections::HashMap;
12
13use brink_format::{DefinitionId, Value};
14
15use crate::program::Program;
16
17/// A structured, read-only snapshot of the runtime's current state.
18pub struct DebugSnapshot {
19 /// Execution status: `active` / `waiting_for_choice` / `done` / `ended`.
20 pub status: &'static str,
21 /// Nearest named knot/stitch the cursor is currently in, if resolvable.
22 pub current_location: Option<String>,
23 /// Current turn index.
24 pub turn_index: u32,
25 /// Global variables and their current values (display strings).
26 pub globals: Vec<DebugGlobal>,
27 /// Active call frames, innermost (current) first.
28 pub call_stack: Vec<DebugFrame>,
29 /// Per-knot/stitch visit counts, sorted by path.
30 pub visit_counts: Vec<DebugVisit>,
31 /// Choices currently offered to the player.
32 pub pending_choices: Vec<DebugChoice>,
33 /// Story RNG state.
34 pub rng: DebugRng,
35}
36
37/// A global variable and its current value.
38pub struct DebugGlobal {
39 pub name: String,
40 pub value: String,
41}
42
43/// One call frame, resolved to a knot/stitch path.
44pub struct DebugFrame {
45 /// Frame kind: `root` / `function` / `tunnel` / `thread` / `external` / `eval`.
46 pub kind: &'static str,
47 /// Nearest named container for this frame, if resolvable.
48 pub location: Option<String>,
49 /// Number of temporary (local) variables in this frame.
50 pub temps: usize,
51}
52
53/// A visit count for a named knot/stitch.
54pub struct DebugVisit {
55 pub path: String,
56 pub count: u32,
57}
58
59/// A pending choice and the knot it targets.
60pub struct DebugChoice {
61 pub text: String,
62 pub target: Option<String>,
63 /// The raw `flow.pending_choices` index — the same pre-filter position
64 /// the visible [`Choice`](crate::story::Choice)'s `index` carries and
65 /// that `select_choice`/`choose` expects. Not a post-filter enumeration
66 /// position: invisible-default choices are filtered out of what's shown
67 /// but still occupy a slot in `pending_choices`, so this can skip values.
68 pub index: usize,
69}
70
71/// Story RNG state.
72pub struct DebugRng {
73 pub seed: i32,
74 pub previous: i32,
75}
76
77/// Resolves container indices / definition ids to author-facing paths and
78/// formats values for display. Holds a one-time reverse map of the program's
79/// `address_by_path` table.
80pub(crate) struct NameResolver<'p> {
81 program: &'p Program,
82 /// `container_idx → shortest knot/stitch path` (offset-0 scope entries).
83 rev: HashMap<u32, String>,
84}
85
86impl<'p> NameResolver<'p> {
87 pub(crate) fn new(program: &'p Program) -> Self {
88 let mut rev: HashMap<u32, String> = HashMap::new();
89 for (path, target) in &program.address_by_path {
90 if target.byte_offset != 0 {
91 continue;
92 }
93 let idx = &target.container_idx;
94 // Deterministic on collision: shortest path, then lexicographically
95 // smallest — independent of HashMap iteration order.
96 let better = match rev.get(idx) {
97 None => true,
98 Some(existing) => {
99 path.len() < existing.len()
100 || (path.len() == existing.len() && path.as_str() < existing.as_str())
101 }
102 };
103 if better {
104 rev.insert(*idx, path.clone());
105 }
106 }
107 Self { program, rev }
108 }
109
110 /// The knot/stitch path for a container, if it names a scope.
111 pub(crate) fn container_path(&self, idx: u32) -> Option<&str> {
112 self.rev.get(&idx).map(String::as_str)
113 }
114
115 /// The knot/stitch path a definition id lives in, if resolvable.
116 pub(crate) fn def_path(&self, id: DefinitionId) -> Option<&str> {
117 let (idx, _) = self.program.resolve_target(id)?;
118 self.container_path(idx)
119 }
120
121 /// Format a runtime value for display, resolving names where possible.
122 pub(crate) fn format_value(&self, value: &Value) -> String {
123 match value {
124 Value::Int(i) => i.to_string(),
125 Value::Float(f) => f.to_string(),
126 Value::Bool(b) => b.to_string(),
127 Value::String(s) => format!("\"{s}\""),
128 Value::Null => "null".to_owned(),
129 Value::List(list) => {
130 let members: Vec<&str> = list
131 .items
132 .iter()
133 .filter_map(|id| self.program.list_item_name(*id))
134 .collect();
135 format!("({})", members.join(", "))
136 }
137 Value::DivertTarget(id) => match self.def_path(*id) {
138 Some(p) => format!("-> {p}"),
139 None => "-> ?".to_owned(),
140 },
141 Value::VariablePointer(id) => match self.program.global_var_name(*id) {
142 Some(n) => format!("ref {n}"),
143 None => "ref ?".to_owned(),
144 },
145 Value::TempPointer { slot, frame_depth } => {
146 format!("temp[{slot}]@{frame_depth}")
147 }
148 Value::FragmentRef(idx) => format!("<fragment {idx}>"),
149 }
150 }
151}