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 alloc::borrow::ToOwned;
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16use brink_format::{DefinitionId, Value};
17
18use crate::collections::Map as HashMap;
19use crate::program::Program;
20use crate::value_ops;
21
22/// A structured, read-only snapshot of the runtime's current state.
23pub struct DebugSnapshot {
24 /// Execution status: `active` / `waiting_for_choice` / `done` / `ended`.
25 pub status: &'static str,
26 /// Nearest named knot/stitch the cursor is currently in, if resolvable.
27 pub current_location: Option<String>,
28 /// Current turn index.
29 pub turn_index: u32,
30 /// Global variables and their current values (display strings).
31 pub globals: Vec<DebugGlobal>,
32 /// Active call frames, innermost (current) first.
33 pub call_stack: Vec<DebugFrame>,
34 /// Per-knot/stitch visit counts, sorted by path.
35 pub visit_counts: Vec<DebugVisit>,
36 /// Choices currently offered to the player.
37 pub pending_choices: Vec<DebugChoice>,
38 /// Story RNG state.
39 pub rng: DebugRng,
40}
41
42/// A global variable and its current value.
43pub struct DebugGlobal {
44 pub name: String,
45 pub value: String,
46}
47
48/// One call frame, resolved to a knot/stitch path.
49pub struct DebugFrame {
50 /// Frame kind: `root` / `function` / `tunnel` / `thread` / `external` / `eval`.
51 pub kind: &'static str,
52 /// Nearest named container for this frame, if resolvable.
53 pub location: Option<String>,
54 /// Number of temporary (local) variables in this frame.
55 pub temps: usize,
56}
57
58/// A visit count for a named knot/stitch.
59pub struct DebugVisit {
60 pub path: String,
61 pub count: u32,
62}
63
64/// A pending choice and the knot it targets.
65pub struct DebugChoice {
66 pub text: String,
67 pub target: Option<String>,
68 /// The raw `flow.pending_choices` index — the same pre-filter position
69 /// the visible [`Choice`](crate::story::Choice)'s `index` carries and
70 /// that `select_choice`/`choose` expects. Not a post-filter enumeration
71 /// position: invisible-default choices are filtered out of what's shown
72 /// but still occupy a slot in `pending_choices`, so this can skip values.
73 pub index: usize,
74}
75
76/// Story RNG state.
77pub struct DebugRng {
78 pub seed: i32,
79 pub previous: i32,
80}
81
82/// Resolves container indices / definition ids to author-facing paths and
83/// formats values for display. Holds a one-time reverse map of the program's
84/// `address_by_path` table.
85pub(crate) struct NameResolver<'p> {
86 program: &'p Program,
87 /// `container_idx → shortest knot/stitch path` (offset-0 scope entries).
88 rev: HashMap<u32, String>,
89}
90
91impl<'p> NameResolver<'p> {
92 pub(crate) fn new(program: &'p Program) -> Self {
93 let mut rev: HashMap<u32, String> = HashMap::new();
94 for (path, target) in &program.address_by_path {
95 if target.byte_offset != 0 {
96 continue;
97 }
98 let idx = &target.container_idx;
99 // Deterministic on collision: shortest path, then lexicographically
100 // smallest — independent of HashMap iteration order.
101 let better = match rev.get(idx) {
102 None => true,
103 Some(existing) => {
104 path.len() < existing.len()
105 || (path.len() == existing.len() && path.as_str() < existing.as_str())
106 }
107 };
108 if better {
109 rev.insert(*idx, path.clone());
110 }
111 }
112 Self { program, rev }
113 }
114
115 /// The knot/stitch path for a container, if it names a scope.
116 pub(crate) fn container_path(&self, idx: u32) -> Option<&str> {
117 self.rev.get(&idx).map(String::as_str)
118 }
119
120 /// The knot/stitch path a definition id lives in, if resolvable.
121 pub(crate) fn def_path(&self, id: DefinitionId) -> Option<&str> {
122 let (idx, _) = self.program.resolve_target(id)?;
123 self.container_path(idx)
124 }
125
126 /// Format a runtime value for display, resolving names where possible.
127 pub(crate) fn format_value(&self, value: &Value) -> String {
128 match value {
129 Value::Int(i) => i.to_string(),
130 Value::Float(f) => f.to_string(),
131 Value::Bool(b) => b.to_string(),
132 Value::String(s) => format!("\"{s}\""),
133 Value::Null => "null".to_owned(),
134 Value::List(list) => {
135 let members: Vec<&str> = list
136 .items
137 .iter()
138 .filter_map(|id| self.program.list_item_name(*id))
139 .collect();
140 format!("({})", members.join(", "))
141 }
142 Value::DivertTarget(id) => match self.def_path(*id) {
143 Some(p) => format!("-> {p}"),
144 None => "-> ?".to_owned(),
145 },
146 Value::VariablePointer(id) => match self.program.global_var_name(*id) {
147 Some(n) => format!("ref {n}"),
148 None => "ref ?".to_owned(),
149 },
150 Value::TempPointer { slot, frame_depth } => {
151 format!("temp[{slot}]@{frame_depth}")
152 }
153 Value::FragmentRef(idx) => format!("<fragment {idx}>"),
154 Value::Array(items) => {
155 let parts: Vec<String> = items.iter().map(|v| self.format_value(v)).collect();
156 format!("[{}]", parts.join(", "))
157 }
158 Value::Map(map) => {
159 let parts: Vec<String> = map
160 .iter()
161 .map(|(k, v)| format!("{}: {}", format_map_key(k), self.format_value(v)))
162 .collect();
163 format!("{{{}}}", parts.join(", "))
164 }
165 // Weighted tables (NS-A7): mirror the construction literal,
166 // entries in construction order.
167 Value::Weighted(w) => {
168 let parts: Vec<String> = w
169 .entries
170 .iter()
171 .map(|(weight, v)| format!("{weight}: {}", self.format_value(v)))
172 .collect();
173 format!("Weighted {{ {} }}", parts.join(", "))
174 }
175 Value::Record { shape, fields } => {
176 let parts: Vec<String> = fields.iter().map(|v| self.format_value(v)).collect();
177 format!("Record#{}{{{}}}", shape.0, parts.join(", "))
178 }
179 // Function values (T1c, #700). Debug rendering resolves the target
180 // path where possible and shows the bound env; the author-facing
181 // `string(f)` display form (spec §5) lands in T1c-3.
182 Value::FnRef(target) => match self.def_path(*target) {
183 Some(p) => format!("fn {p}"),
184 None => "fn ?".to_owned(),
185 },
186 Value::Closure(c) => {
187 let name = self.def_path(c.target).unwrap_or("?");
188 let parts: Vec<String> = c
189 .env
190 .iter()
191 .map(|e| {
192 let mode = if e.is_ref { "ref" } else { "val" };
193 format!("{mode} {}", self.format_value(&e.payload))
194 })
195 .collect();
196 format!("fn {name}({})", parts.join(", "))
197 }
198 // Handle values (T1d, `docs/t1d-spec.md` §6). Same display form
199 // as the runtime's authoritative `string(h)` (`value_ops::stringify`):
200 // `handle <Kind>#<id>`, resolved via the program's name table.
201 Value::Handle { kind, id } => {
202 let kind_name = self.program.name_checked(*kind).unwrap_or("?");
203 format!("handle {kind_name}#{id}")
204 }
205 // Projection values (T1e, `docs/t1e-spec.md` §4). Same display
206 // form as the runtime's authoritative `string(p)`
207 // (`value_ops::stringify`).
208 // Range values (NS-A5, F7) share the authoritative display too:
209 // the written `0..10` / `1..=6` form.
210 // Tower values (NS-A8): same display form as the runtime's
211 // authoritative `string(v)` (`value_ops::stringify`).
212 Value::Projection(_)
213 | Value::OptionVal(_)
214 | Value::Range { .. }
215 | Value::Vec2(_)
216 | Value::Vec3(_)
217 | Value::Vec4(_)
218 | Value::Quat(_)
219 | Value::Mat2(_)
220 | Value::Mat3(_)
221 | Value::Mat4(_) => value_ops::stringify(value, self.program),
222 }
223 }
224}
225
226/// Format a map key for debug display.
227fn format_map_key(key: &brink_format::MapKey) -> String {
228 match key {
229 brink_format::MapKey::Int(n) => n.to_string(),
230 brink_format::MapKey::Str(s) => format!("\"{s}\""),
231 brink_format::MapKey::Bool(b) => b.to_string(),
232 }
233}